题目描述

访问题目得到一个 Flask 应用,根路径返回完整的源码:

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
import flask
import os

app = flask.Flask(__name__)

app.config['FLAG'] = os.environ.pop('FLAG')


@app.route('/')
def index():
return open(__file__).read()


@app.route('/shrine/<path:shrine>')
def shrine(shrine):

def safe_jinja(s):
s = s.replace('(', '').replace(')', '')
blacklist = ['config', 'self']
return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist]) + s

return flask.render_template_string(safe_jinja(shrine))


if __name__ == '__main__':
app.run(debug=True)

FLAG 存储在 app.config['FLAG'] 中,由环境变量注入。

代码分析

逐行分析源码

1
2
import flask
import os

引入 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
2
3
@app.route('/')
def index():
return open(__file__).read()

根路由 / 返回当前文件的源码__file__ 是当前 Python 脚本的路径,open(__file__).read() 读取自身内容并返回。CTF 中常见的设计,目的是让选手拿到完整代码进行分析。


1
2
@app.route('/shrine/<path:shrine>')
def shrine(shrine):

定义 /shrine/ 路由。<path:shrine> 是 Flask 路由转换器:

  • <path: 表示匹配任意路径(包括斜杠 /,而默认的 string 转换器不匹配斜杠)
  • shrine 是变量名,匹配到的内容会作为参数传入函数
  • 因此访问 /shrine/任意内容任意内容 就会进入 shrine 函数

1
2
3
4
def safe_jinja(s):
s = s.replace('(', '').replace(')', '')
blacklist = ['config', 'self']
return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist]) + s

safe_jinja 是内嵌的安全过滤函数,做了两件事:

防护 代码 效果
去括号 s.replace('(', '').replace(')', '') 删除字符串中所有 (),使 __subclasses__()attr('x')popen('ls') 等所有需要括号的表达式失效
黑名单变量置空 {{% set {}=None%}} 拼接 对 blacklist 中的每个名字,在用户输入前插入 {% set config=None %}{% set self=None %},将模板变量 configself 覆盖为 None

注意 Python 的字符串格式化中 {{ 会被转义为 {,所以 '{{% set {}=None%}}'.format('config') 实际输出 {% set config=None %}

最终拼接结果:{% set config=None %}{% set self=None %} + 用户输入。


1
return flask.render_template_string(safe_jinja(shrine))

漏洞核心行

  1. 用户输入的 shrine 先经过 safe_jinja 处理
  2. 处理后传入 render_template_string —— 这个函数将字符串作为 Jinja2 模板直接渲染
  3. 模板中 {{...}} 内的表达式会被 Jinja2 引擎执行

这就构成了典型的 SSTI(服务端模板注入)漏洞——用户输入被直接拼入模板并执行。


1
2
if __name__ == '__main__':
app.run(debug=True)

debug=True 开启 Flask 调试模式。当模板渲染出错时,会返回详细的错误堆栈和交互式调试器,可能在调试过程中泄露额外信息。

路由与输入点

1
2
@app.route('/shrine/<path:shrine>')
def shrine(shrine):

<path:shrine> 表示 URL 路径中 /shrine/ 后面的所有内容都会作为变量 shrine 传入函数。这就是用户输入入口。

安全防护

safe_jinja 函数做了两层防护:

防护 实现 目的
去括号 s.replace('(', '').replace(')', '') 阻止函数调用,如 __subclasses__()attr("x") 等经典 SSTI 手段全部失效
黑名单变量 {% set config=None %}{% set self=None %} 模板变量 configself 被设为 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
2
3
4
5
6
7
url_for
→ .__globals__ # 获取 flask.helpers 模块的全局命名空间(dict)
→ ['_app_ctx_stack'] # 获取应用上下文栈
→ .top # 获取栈顶的当前应用上下文
→ .app # 获取 Flask 应用对象
→ .config # 获取应用配置(app.config,不是模板变量 config!)
→ ['FLAG'] # 获取 flag

对应的 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 才是最短路径

攻击过程

666
得到 Flag

源码阅读技巧

这类题目的突破口在于从代码逆向推导利用方式

  1. 找输入点<path:shrine> → URL 路径传参
  2. 找漏洞函数render_template_string → SSTI
  3. 看防护:去括号 + 黑名单 → 不能有 ()、不能用 config/self
  4. 找可用变量url_forrequestsessiong
  5. 选最短路径url_for.__globals__ 直达 Flask app 模块

总结

要点 说明
漏洞类型 Flask SSTI(服务端模板注入)
防御手段 去除括号 + 关键模板变量置空
绕过方式 Jinja2 点号属性访问 + 方括号下标访问,全程无括号
关键入口 url_for.__globals__ 直通 Flask 模块全局变量
核心 payload {{url_for.__globals__['current_app'].config['FLAG']}}