PHP : Winning the race against PHP (alternative way to easy_php @ N1CTF2018)

This weekend I and @shrimpgo decided to try some CTF, noticed that N1CTF2018 are running. Quickly joined and there's a lot of challenges, but this unsolved easy php called our attention.

I doubt it was easy, several hours of CTF had already passed and it remained unsolved.

Challenge details

Not racing, just enjoying the slow pace of life :), Mirror:  


FROM andreisamuilik/php5.5.9-apache2.4-mysql5.5

ADD nu1lctf.tar.gz /app/  
RUN apt-get update  
RUN a2enmod rewrite  
COPY sql.sql /tmp/sql.sql  
COPY /  
RUN mkdir /home/nu1lctf  
COPY /home/nu1lctf/

RUN chmod +x /  
RUN chmod 777 /tmp/sql.sql  
RUN chmod 555 /home/nu1lctf/

CMD ["/"]


Starting the enumeration of provided web service.

There's a login page, If you try to log in with any credential you will receive the code error message. And apparently the username/password check is not even executed.

Ok, they are leaking this sentence Code(substr(md5(?), 0, 5) === b5152), looks like part of code checking function.. checking only the first 5 bytes of md5("code"). And this 5 bytes changes on every refresh.

You need to solve a Proof-Of-Work, send the result with the login request. I believe they did this to minimize brute-force abuse. Very common in CTF challenges.

So, I leave this aside and continued my enumeration...

Source code leaking

By brute-forcing common filenames and directories we was able to leak the entire source code of the web application:

# Web application files & scripts

# Backup files leaking the source code

# PHP scripts without .php extension leaking the views source code

# "Useless" phpinfo() running over command line: <?php system("php -r \"phpinfo();\"") ?>  

From the config.php source code we are able to extract the mysql user password.

  • MySQL user password: Nu1L / Nu1Lpassword233334.

Local File Inclusion (LFI)

Analyzing the leaked source code we found that are vulnerable to LFI.

Now we can read any file on the system that our user have permission w/ this payload:

The provided Dockerfile are showing us some interesting info and file paths that we can read using the LFI.

Starting by the FROM andreisamuilik/php5.5.9-apache2.4-mysql5.5 docker container confirming the version of php, apache and mysql.

/ script

chown www-data:www-data /app -R

if [ "$ALLOW_OVERRIDE" = "**False**" ]; then  
    sed -i "s/AllowOverride None/AllowOverride All/g" /etc/apache2/apache2.conf
    a2enmod rewrite

# initialize database
mysqld_safe --skip-grant-tables&  
sleep 5  
## change root password
mysql -uroot -e "use mysql;UPDATE user SET password=PASSWORD('Nu1Lctf%#~:p') WHERE user='root';FLUSH PRIVILEGES;"  
## restart mysql
service mysql restart  
## execute sql file
mysql -uroot -pNu1Lctf\%\#~\:p < /tmp/sql.sql

## crontab
(while true;do rm -rf /tmp/*;sleep 2;done)&

## rm sql
cd /tmp/  
rm sql.sql  
rm /var/www/phpinfo  
source /etc/apache2/envvars  
tail -F /var/log/apache2/* &  
exec apache2 -D FOREGROUND  

Found MySQL user password and some system info.

  • MySQL root password: root / Nu1Lctf%#~:p

We also tried to fuzz common paths, /proc's, file descriptors etc.. to get more information about the system.

Not much found, just confirmed the user are running apache2, cmdline, some log paths that we do not have permission to access and Linux header Linux df551128a261 3.13.0-30-generic #54-Ubuntu SMP Mon Jun 9 22:45:01 UTC 2014 x86_64.

The PHP Filters & wrappers are also enabled.

Registered PHP Streams => https, ftps, compress.zlib, compress.bzip2, php, file, glob, data, http, ftp, phar, zip  
Registered Stream Socket Transports => tcp, udp, unix, udg, ssl, sslv3, tls  
Registered Stream Filters => zlib.*, bzip2.*, convert.iconv.*, string.rot13, string.toupper, string.tolower, string.strip_tags, convert.*, consumed, dechunk, mcrypt.*, mdecrypt.*  

But we cannot find a way to use it because the require_once 'views/'.$_GET['action']; this views/ prefix we need to escape with ../ in order to get a LFI and it not works with the php://. (if u know a way, plz tell me)

MD5 collision PoW

With all enumerated information about the system and even without a RCE we decided to solve the md5 collision to start attacking the login system.

So, the user.php~ are leaking all the login process details.

Breaking the code into pieces, this if(substr(md5($_POST['code']),0, 5)!==$_SESSION['code']) is mandatory to solve in order to poke the register/login credentials checking functions.

This code is stored in session cookie we leak the content but cannot control it (yet).

If the system return the Invalid user name means that we have sent the correct code.

MD5 first 5 bytes hash collision generator

We quickly wrote this code to get a valid collision.

## MD5 first 5 bytes hash collision generator - solution to easy_php @ N1CTF2018  
# solved by intrd & shrimpgo - p4f team

## 1st create a wordlist: crunch 4 4 1234567890abcdefghijklmnopqrstuvwxyz_ -o file.txt

echo "** searching for: ".$_SESSION['code']."\n";

if ($file = fopen("file.txt", "r")) {  
    while(!feof($file)) {
        $line = trim(fgets($file));
        $_POST['code'] = $line;
        if(substr(md5($_POST['code']),0, 5)===$_SESSION['code']){
            echo "yeah!\n".md5($line);
            echo ":\n".$line."\n";

Gaining System Access

Now using the pre-generated code aklhic we are able to create a new user browsing to

Nice, logged in.

Browsing the web application features, we can publish some content.. and set the Allow different ip to login flag to current user. Nothing more..

..nothing more unless you have the is_admin=1 flag set on your cookie.

As you can see, this flag enables the file sending option, a good way to get our RCE.

How to set the is_admin=1?

Code set this flag to 1 if the login/register POST are from the correct IP, I bet it was

The get_ip() function is strictly reading the REMOTE_ADDR, I think i cannot spoof this value.

Trying session cookie code injection

First idea is inject some <?php code ?> on cookie session file that I can launch from LFI.

Now we have more information stored on the cookie file, but only username I can control. And this is properly filtered.

This check is blocking invalid usernames. We cannot create a new user with some code on it.

Trying SQL injection

So, our next idea is checking for some SQL injection.

The queries are very simple and the Db() controller are not properly filtering this. But the code are.

Sometimes it is converting the type to (int) before sending to query. It breaks our SQLi attempts.

In other cases like insert() it is filtering using the preg_match().

I think it is possible to bypass the (int) sending an array[] or something like this, and I'm sure this is vulnerable to SQLi by other ways, but no success on my tries. (if you know how to do this plz tell me)

Object Injection? POP Chain?

We know the unserialize + PHP are ALWAYS VULNERABLE.

This unserialize() of the mood object is very suspicious. Mood class does not have any php magic function that we can abuse, but the Db{} has.

Ok, but how trigger this?

I created this serialized object locally to a deep inspection.

class Mood{

    public $mood, $ip, $date;

    public function __construct($mood, $ip) {
        $this->mood = $mood;
        $this->ip  = $ip;
        $this->date = time();

    public function getcountry()
        $ip = @file_get_contents("".$this->ip);
        $ip = json_decode($ip,true);
        return $ip['data']['country'];

    public function getsubtime()
        $now_date = time();
        $sub_date = (int)$now_date - (int)$this->date;
        $days = (int)($sub_date/86400);
        $hours = (int)($sub_date%86400/3600);
        $minutes = (int)($sub_date%86400%3600/60);
        $res = ($days>0)?"$days days $hours hours $minutes minutes ago":(($hours>0)?"$hours hours $minutes minutes ago":"$minutes minutes ago");
        return $res;


$_POST['mood'] = 1;
$mood = addslashes(serialize(new Mood((int)$_POST['mood'],"")));
#$mood = serialize(new Mood((int)$_POST['mood'],""));
echo $mood;  
// $mood = unserialize($mood);

// $country = $mood->getcountry();
// print $country;

..and confirmed that I only can control the mood id, that one converted to integer before to get inside the serialized object. Again, no injection because the conversion.


Maybe there a PhantomJS or some script browsing to my publications?

If we are able to trigger a SSRF, I was able to craft a POST and set my user as is_admin=1!

Nope, we have the Dockerfile showing every system changes, and leaked a lot of things that indicate this is not happening. Also there's a htmlentities() and other things filtering our XSS tries, and javascript: didn't work outside a <tag>.

Emulating locally the remote environment

After a lot of frustrated tries, we decided to move back to enumeration searching another attack vector.

So, Dockerfile shows us that they used a public repository to create the base system for this challenge.

I pulled this to my machine, sync'd everything, got a rootshell on it and started the Container enumeration.

The only things that I cannot sync was of course the ADD nu1lctf.tar.gz /app/ and
COPY sql.sql /tmp/sql.sql containing the challenge data.

Unintend way to RCE/Flag

Checking the environment we noticed that the challenger made a mistake(intentionally probably) while removing the /var/www/phpinfo folder on / script.

He missed the -r and it will leave the folder on environment w/ all its contents!

Nice! Different from that useless /app/views/phpinfo that are running over command-line, now we have this phpinfo(); that we can reach directly from web server and interprets our GET and POST requests!

And, why this phpinfo() is dangerous?

Remember the challenge description:

Not racing, just enjoying the slow pace of life :)  

I do not like the slow pace of life and decided to try a well known race condition exploit on it.

Gynvael Coldwind wrote this awesome paper about a Race Condition that can be exploited abusing the PHP File Upload function. Btw our php5.5.9 is vulnerable to this issue.

In order to exploit this we need to launch a multi-thread script to flood the PHP Job queue w/ junk and we have a little time window to access this temporary created files before it was automatically deleted.

The random_value is later written as 6 digits of k=62 (A-Za-z0-9 charset) numeric system, and appended to the "/tmp/php" prefix (unless another directory is set),
e.g. /tmp/phpUs7MxA. -- Gynvael

Also found this another paper from

I tried to use the Insomniasec PoC described on paper but no success, maybe because some chinese server conditions and settings, I don't know what happened.

Instead of troubleshooting that PoC and to learn a new thing, following the Gynvael and Insomniasec papers we decided to write a new exploit from scratch troubleshooting every step on my local docker environment.

Final exploit

The idea behind this code is generate a lot of junk on headers, cookies, uri and POST all the shit including your payload.txt to the phpinfo endpoint.

If the File Upload work, the phpinfo() will respond with the temporary file path.

You aren't fast enough to access this file before it was processed/deleted by PHP. But the multi-thread script are!

This is the payload that will be executed if some thread are fast enought to hit.

<?php $c=fopen('/app/intrd','w');fwrite($c,'<?php passthru($_GET["f"]);?>');?>  

It will create /app/intrd, a webshell that we have access though LFI!

I choose this path because I'm sure this is writable:

But remember..
We are not at an advantage in this race.

There are a fucking rm -rf /tmp/*; running every 2 seconds on the system.

We have the worst scenario possible:

  • This job deleting everything on /tmp every 2s;
  • PHP deleting temporary files after processing.
  • Chinese server - Other side of the world for me (Brazil);
  • Lot of players bruteforcing the application turning the response insanely slow.

Anyway, why not give a try?

So, I launched my exploit locally.

While the Race Condition exploit are running w/ 50 threads, I keep checking the existence of my webshell at /app/intrd.

And, after a few minutes, it worked like a charm! We got our RCE at the controlled environment.

China number one

So, when I tried the exploit remote I have not had the same luck :(.

Of course the Chinese server are too far from me, and the brazilian ISP sucks a lot, tracert indicates that there's a single Embratel node sucking more than 200ms, ending w/ the total ping response 650ms+.

But it become personal, we would not give up at this point.

So we decided to shorten the distance and travel (virtually) near to China!

Thanks DigitalOcean!

350ms now i'm ok to launch my exploit from a VPS hosted on Bagalore!

And after about 1 hour trying, finally got my webshell written to the /app folder.

I think the players loading the server's CPU with bruteforce shit helped me a lot slow-ling the php queue this time.

I quickly upgrade this RCE to a reverse shell.

Where's the flag?

So, knowing the docker environment, and excluding the nu1lctf.tar.gz content, that at this point we had already been digging into everything. My bet was the MySQL database.

Remember the mysql root password leaked on the beginning? I used this to dump all the databases to a file and greped for the flag prefix.

mysqldump -uroot -pNu1Lctf\%\#~\:p --all-databases > /app/intdbs.sql  

A HUGE win!
Also, the flag text confirmed the intended way was that first path we were following.

PHP unserialize + SSRF + CRLF Injection, Jesus, we have no time to learn this today. Hey, Easy? :p

Learned a lot in a single chall.

Awesome CTF Nu1L .Cyberpeace, unfortunately we did not have time to try the other challenges but I'm sure they were as well developed as this one!

And thanks @shrimpgo, awesome team up and brainstorms!

UPDATE: Expected solution (PHP unserialize + SSRF + CRLF Injection)