XYCTF 中碰到的 Bottle 框架,当时做题根本没想到通过审计框架代码找漏洞点,也是学到新东西了


Bottle 简介

Bottle 是一个 Python 的轻量级 Web 框架,完全依赖标准库(除了 wsgi 的部分),体积非常小(一个 .py 文件就能运行),适合写小型 Web 应用、API 或原型系统。
它的设计理念是 “单文件、零依赖”,所以部署非常方便。

主要特点

特点 说明
单文件 框架代码就是一个 bottle.py 文件,方便直接打包或拷贝
零依赖 除了 Python 标准库外不需要额外安装其它库
内置开发服务器 run() 就能启动 HTTP 服务
支持多模板引擎 默认内置 SimpleTemplate,也支持 Jinja2、Mako 等
路由简洁 使用装饰器 @route() 定义 URL 对应的处理函数
WSGI兼容 可以在任何 WSGI 服务器(如 gunicorn、uWSGI)上部署
内置常用工具 请求/响应处理、静态文件服务、Cookie、表单解析等

基本结构示例

1
2
3
4
5
6
7
from bottle import route, run

@route('/')
def hello():
return "Hello Bottle!"

run(host='localhost', port=8080, debug=True)

执行后访问 http://localhost:8080 就能看到页面内容啦 (≧▽≦)

常见功能

  • 路由映射@route('/path') 定义 URL 和处理函数
  • URL 参数
1
2
3
@route('/user/<name>')
def greet(name):
return f"Hello {name}!"
  • 静态文件static_file(filename, root='/path')
  • 表单与请求数据
1
2
from bottle import request
name = request.forms.get('name')
  • 模板渲染
1
2
from bottle import template
return template('Hello {{name}}!', name='学长')
  • JSON 输出:直接 return {'key': 'value'} 会自动转成 JSON

基于 Bottle 库的 SSTI 注入

产生原因

Bottle 默认的模板引擎是 SimpleTemplate(扩展名 .tpl),语法类似 Python 表达式,如果开发者直接把用户输入拼进模板字符串里渲染,就会导致 SSTI(Server-Side Template Injection)

危险示例

1
2
3
4
5
6
7
8
from bottle import route, run, request, template

@route('/')
def vuln():
user_input = request.query.get('tpl', 'guest')
return template(user_input) # 直接渲染用户输入

run(host='0.0.0.0', port=8080)

利用:

1
http://127.0.0.1:8080/?tpl={{__import__('os').popen('ls').read()}}

有一个博客说是不止{{}}可以用来 SSTI

虽说{{ }}是唯一默认语法,但是我看到一篇博客说<%%>%也可以使用,但是我自己实测下来发现就%可以使用(我猜测跟bottle.template()有关系跟进代码去看了一下检测 \n{%$ 字符自动判断输入类型但是没有<%%>所以我感觉挺奇怪的估计跟版本问题有关系,实际做题时如果{{}}被禁用了可以尝试看看)

漏洞利用

和 flask 框架下的 SSTI 其实差不多,就不列举了


以上都是无关紧要的前置知识,接下来就是一些有意思的新东西了

bottle框架中由斜体字引发的模板注入(SSTI)waf bypass

参考自https://www.cnblogs.com/LAMENTXU/articles/18805019

斜体字符集

斜体字符集指的是Decomposition后为同一个字符的字符集

https://www.compart.com/en/unicode/ 可以查看的到字符集(这里用a来做示例)

这些字符分解后都指向a,例如:á (U+00E1)分解为a(U+0065)+´(U+0301),á分解后指向a

这些字符共同组成了a这个基础字符的斜体字符集。

具体原理我们就先不管了,注意一下这里的字符集并不是所有的都可用

这是因为沟槽的URL编码。这些特殊字符经过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正确解析。而**ª** (U+00AA),**º** (U+00BA)执行的时候等效于字符**a**,**o**,别的字符RCE根本用不上。

这个问题大大的限制了这种利用方式,但是我们也不难推知,以下payload成立:

1
return bottle.template('{{𝒶𝒷𝓈(-1)}}')

因此我们所有的问题都聚焦在如何将斜体字符传入template中,因为get(post)传参特殊字符必须进行URL编码的原因,我们无法传入这种斜体字符。但是假设靶机提供了一种可以不使用URL编码的方式将可控输入传入template(如:上传文件,再渲染文件中的内容形成的SSTI)那就意味着所有的字符可以全部用各种斜体替换

就例如刚刚结束的 LilCTF2025,里面那道题理论上就可以用斜体字绕过过滤(不过我没试)