[WesternCTF2018] shrine(Flask模板注入绕过config过滤)
题目描述
访问题目得到一个 Flask 应用,根路径返回完整的源码:
1 | import flask |
FLAG 存储在 app.config['FLAG'] 中,由环境变量注入。
代码分析
逐行分析源码
1 | import flask |
引入 Flask Web 框架和 os 模块。
1 | app = flask.Flask(__name__) |
创建 Flask 应用实例。__name__ 传入当前模块名,用于 Flask 定位模板和静态文件等资源。
1 | app.config['FLAG'] = os.environ.pop('FLAG') |
核心目标:从系统环境变量读取 FLAG 并存入 app.config 字典中。
os.environ.pop('FLAG') 会取出并删除环境变量 FLAG——这样做的好处是,即使服务器被进一步攻破,通过 /proc 或其他方式读取环境变量也无法再获取 FLAG。
1 |
|
根路由 / 返回当前文件的源码。__file__ 是当前 Python 脚本的路径,open(__file__).read() 读取自身内容并返回。CTF 中常见的设计,目的是让选手拿到完整代码进行分析。
1 |
|
定义 /shrine/ 路由。<path:shrine> 是 Flask 路由转换器:
<path:表示匹配任意路径(包括斜杠/,而默认的string转换器不匹配斜杠)shrine是变量名,匹配到的内容会作为参数传入函数- 因此访问
/shrine/任意内容,任意内容就会进入shrine函数
1 | def safe_jinja(s): |
safe_jinja 是内嵌的安全过滤函数,做了两件事:
| 防护 | 代码 | 效果 |
|---|---|---|
| 去括号 | s.replace('(', '').replace(')', '') |
删除字符串中所有 ( 和 ),使 __subclasses__()、attr('x')、popen('ls') 等所有需要括号的表达式失效 |
| 黑名单变量置空 | {{% set {}=None%}} 拼接 |
对 blacklist 中的每个名字,在用户输入前插入 {% set config=None %} 和 {% set self=None %},将模板变量 config 和 self 覆盖为 None |
注意 Python 的字符串格式化中 {{ 会被转义为 {,所以 '{{% set {}=None%}}'.format('config') 实际输出 {% set config=None %}。
最终拼接结果:{% set config=None %}{% set self=None %} + 用户输入。
1 | return flask.render_template_string(safe_jinja(shrine)) |
漏洞核心行:
- 用户输入的
shrine先经过safe_jinja处理 - 处理后传入
render_template_string—— 这个函数将字符串作为 Jinja2 模板直接渲染 - 模板中
{{...}}内的表达式会被 Jinja2 引擎执行
这就构成了典型的 SSTI(服务端模板注入)漏洞——用户输入被直接拼入模板并执行。
1 | if __name__ == '__main__': |
debug=True 开启 Flask 调试模式。当模板渲染出错时,会返回详细的错误堆栈和交互式调试器,可能在调试过程中泄露额外信息。
路由与输入点
1 |
|
<path:shrine> 表示 URL 路径中 /shrine/ 后面的所有内容都会作为变量 shrine 传入函数。这就是用户输入入口。
安全防护
safe_jinja 函数做了两层防护:
| 防护 | 实现 | 目的 |
|---|---|---|
| 去括号 | s.replace('(', '').replace(')', '') |
阻止函数调用,如 __subclasses__()、attr("x") 等经典 SSTI 手段全部失效 |
| 黑名单变量 | {% set config=None %}{% set self=None %} |
模板变量 config 和 self 被设为 None,无法直接通过 {{config['FLAG']}} 获取 flag |
最终渲染的模板等价于:
1 | {% set config=None %}{% set self=None %}{{用户输入}} |
render_template_string
1 | return flask.render_template_string(safe_jinja(shrine)) |
用户输入经过 safe_jinja 处理后,直接被 Flask 作为 Jinja2 模板渲染——典型的 SSTI(服务端模板注入) 漏洞。
寻找可利用的模板变量
目标:绕过 config 被设为 None,以其他方式访问 app.config['FLAG']。
Flask 的 render_template_string 渲染时,模板上下文中可用以下变量:
| 变量 | 可用性 | 能否获取 Flask app |
|---|---|---|
config |
❌ 被设为 None |
本身就是 config 但被禁了 |
self |
❌ 被设为 None |
指向模板对象,不能拿 app |
request |
✅ | 可以,但需要绕到 Flask 模块的 globals |
session |
✅ | 类似 |
url_for |
✅ | 最佳路径:函数 __globals__ 直接包含 current_app |
g |
✅ | 需要绕 |
get_flashed_messages |
✅ | 类似 |
url_for 是最佳入口,它是 flask.helpers 模块中定义的函数,其 __globals__ 直接包含了 current_app 和 _app_ctx_stack。
攻击链推导
链式调用
全程只需要使用点号属性访问 . 和方括号下标访问 []——完全不使用括号:
1 | url_for |
对应的 payload:
1 | {{url_for.__globals__['_app_ctx_stack'].top.app.config['FLAG']}} |
更简洁的路径
url_for.__globals__ 中不仅有 _app_ctx_stack,还有直接的 current_app:
1 | {{url_for.__globals__['current_app'].config['FLAG']}} |
比上面的链少了两步,推荐使用。
为什么这个 payload 能绕过
| 绕过点 | 解释 |
|---|---|
| 绕过括号过滤 | 全程只用 .属性 和 ['键'],没有任何 () |
| 绕过 config=None | {% set config=None %} 只挡住了模板变量 config;payload 中访问的是 app.config(Flask 应用对象的 config 属性),和模板变量 config 是两个东西 |
另一种思路(通过 request)
1 | {{request.__class__.__init__.__globals__['_app_ctx_stack'].top.app.config['FLAG']}} |
也可以用 request 做入口,但 request.__class__.__init__ 继承自 Werkzeug 的 BaseRequest,它的 __globals__ 是 werkzeug.wrappers.base_request 模块的命名空间——不包含 _app_ctx_stack。
因此 url_for 才是最短路径。
攻击过程

得到 Flag
源码阅读技巧
这类题目的突破口在于从代码逆向推导利用方式:
- 找输入点:
<path:shrine>→ URL 路径传参 - 找漏洞函数:
render_template_string→ SSTI - 看防护:去括号 + 黑名单 → 不能有
()、不能用config/self - 找可用变量:
url_for、request、session、g… - 选最短路径:
url_for.__globals__直达 Flask app 模块
总结
| 要点 | 说明 |
|---|---|
| 漏洞类型 | Flask SSTI(服务端模板注入) |
| 防御手段 | 去除括号 + 关键模板变量置空 |
| 绕过方式 | Jinja2 点号属性访问 + 方括号下标访问,全程无括号 |
| 关键入口 | url_for.__globals__ 直通 Flask 模块全局变量 |
| 核心 payload | {{url_for.__globals__['current_app'].config['FLAG']}} |
