从SSTI到命令执行:Python模板注入的完整对象链与排查指南

📅 发布时间:2026/10/12 4:12:17
从SSTI到命令执行:Python模板注入的完整对象链与排查指南
最近带人刷靶场的时候我经常会推荐一道名叫 python_template_injection 的题。它被很多人叫成“胎教版”意思是说难度门槛真的很低低到哪怕是刚接触 Web 安全、连 Python 类长什么样都不知道的零基础同学只要愿意一步步跟着走也能把 flag 拿下来。可奇怪的是不少人在这一题上反而卡得最久。卡住的点往往不是题目本身而是上来就搜了一段“模板注入万能 payload”直接粘贴结果换一道题、换一个环境就立刻失效连哪里出问题都看不明白。所以这篇 writeup 我打算换一种讲法先不急着堆 payload而是把“输入一个名字”到“读出一个 flag”的整条链路拆开讲清楚模板注入到底是怎么回事对象链的每一环在干什么以及当 payload 不生效的时候该从哪里排查。内容尽量照顾零基础也顺带给我自己留一份以后复查用的速查手册。1. 先别急着拿 flag这题到底在考什么1.1 从题目页面能看到什么这类题目打开之后页面上一般会有一个输入框或者直接在 URL 里带一个name之类的参数。随便输入一个名字页面会返回类似Hello xxx的欢迎语。如果你只看到这一步会觉得它就是个普通到不能再普通的 Web 题。真正的测试点是当你往参数里塞进去的不是普通文字而是一段模板语法时页面是什么反应。试着提交{{7*7}}正常的传统写法大概率会原样输出{{7*7}}或者干脆报错但模板注入的题目会直接回显49。这个现象非常典型看到它基本就可以确定目标后端使用了模板引擎并且把用户输入直接当成了模板源码的一部分来渲染。题目名字里的python_template_injection已经说明了一切Python 语言、模板、注入。1.2 “胎教”不是说题简单是说解法可以很笨为什么管这道题叫“胎教版”因为这道题几乎不做任何过滤不挡花括号不挡引号不挡点号也不挡关键字。你不需要学什么绕过技巧只需要把“从模板语法通向系统命令”这条路走通就行。也就是说这一题的重点不是炫技而是建立概念。我见过很多人一上来就去背什么__mro__[2]、__subclasses__()的固定套路背完之后能打出来但稍微改一下环境就懵了。真正“胎教”级别的理解应该是模板注入能让你在模板里执行表达式而 Python 里几乎所有东西都是对象对象和对象之间可以通过属性一层一层摸过去最终摸到能执行系统命令的地方。理解了这条链后面不管题目怎么加过滤你都有思路而不是靠猜。1.3 入口就一句话输入即模板为了后面流程清晰我把这道题的攻击模型简化一下。后端代码往往是这样的逻辑接收用户传入的name参数把它拼进一段模板字符串再用模板引擎渲染。比如代码里写着render_template_string(Hello name)。这里的问题非常大name不是作为“值”传给模板的而是作为“模板源码”直接塞了进去。如果你的输入里有{{7*7}}渲染引擎看到花括号里的表达式就会按模板语法把它求值于是 49 出现在页面上。用户本来只能提供数据现在却意外地获得了“编辑模板源码”的能力这就是服务端模板注入缩写也就是 SSTI。理解了这句“输入即模板”后续的所有姿势都只是从这句话延伸出去的动作。2. 原理课把用户输入当代码执行是什么概念2.1 模板引擎是把“填空卡”变成 HTML 的机器很多人一听到“模板引擎”就觉得高深其实打个比方就很好懂。模板引擎就像一张带空格的答题卡空格之外的部分是固定的版式空格里的内容由程序在运行时填充。比如一个页面的结构是Hello, {用户名}用户名每次请求都不一样模板引擎的工作就是把{用户名}这个占位符替换成实际值最后输出 HTML。在 Python 的生态里最常见的模板引擎是 Jinja2一般是配 Flask 用的。它定义了两套核心语法{{ 表达式 }}负责输出某个表达式的值{% 语句 %}负责控制逻辑比如if、for。正常情况下模板里的这些语法是开发者写好的用户只能给出里面某个变量的值。可一旦用户输入成了模板的一部分那用户就能自己写{{ }}和{% %}了相当于把笔交给了考生让他往答题卡上再加一排自己想要的题目。2.2 为什么7*7能算出 49先别觉得这个问题蠢很多卡壳的新手就是在这里没转过弯来。在 Jinja2 模板里{{7*7}}不是一个字符串而是一条表达式引擎会把它解析成“计算 7 乘以 7并输出结果”所以回显是 49而不是7*7这五个字符。这说明什么说明模板引擎能读懂里面的运算符号。既然它能读懂乘法那它也能读懂函数调用、属性访问、下标取值。于是攻击面就打开了你不再局限于传一个字符串而是能在模板里写各种 Python 表达式。模板引擎虽然本身是在“渲染页面”但渲染表达式的能力让你有办法把系统命令的结果也渲染出来。后面所有 payload本质上都是在回答一个问题怎么在模板表达式里找到一块可以执行命令的跳板。2.3 漏洞根因字符串拼接模板实际开发里为什么会出这种漏洞大部分情况是因为偷懒或者图省事用了字符串拼接的方式动态生成模板。比如某段代码想做一个欢迎页面正常写法是构造一个固定模板把用户名作为变量传进去。但有人为了少写两行代码直接写成字符串加法把用户输入怼进模板源码里。这种写法一旦上线用户就能控制模板语法这是最典型的 SSTI 成因。CTF 题目只是把这个脆弱写法原封不动地变成了一道题方便大家低成本地看到它的破坏力。也就是说这一题不是凭空发明的漏洞而是现实世界某类开发失误的浓缩演示。这也是为什么我强调做完题别只记得 payload更要记住“用户输入拼接模板”这个危险信号。2.4 对象链从“任意对象”一路摸到系统命令Python 里的“一切皆对象”这句话平时写业务代码的时候你可能没太大感觉但在 SSTI 里它就是你最趁手的武器。字符串是对象列表是对象函数是对象类本身也是对象。对象上有属性和方法其中有一批“双下划线”属性比如__class__能拿到一个对象所属的类__bases__能拿到一个类的父类__subclasses__()能拿到一个类的所有子类__globals__能拿到一个函数所在模块的全局命名空间。把这些串起来就能实现这样的跳跃一个字符串对象 - 它所属的类 - 这个类的父类通常是 object- object 的所有子类 - 在子类里找到某个和系统模块相关的类 - 通过它的函数拿到全局命名空间 - 摸到 os 模块或内建函数 - 执行命令。整个过程像顺着自家水管一路摸到整栋楼的总阀很绕但每一环在语法上都有合法的依据。模板注入只是给了你一个入口让你能把第一脚踩进去。3. 胎教版流水账一步一步把 flag“要”出来3.1 第一步确认注入点与引擎做题第一步不是急着读 flag而是确认模板到底有没有执行。我常规做法是先输入{{7*7}}回显 49 就说明模板执行了再输入{{config}}如果页面输出了一个配置字典那就说明后端十有八九是 Flask Jinja2并且模板上下文中默认带了一个叫config的变量。这一步非常有价值因为config是 Flask 封装好的全局对象。我们后面可以直接拿它当跳板省去从字符串到 object 的一长段路程。如果输入{{config}}没有输出或者页面直接报变量未定义也别慌说明环境不是 Flask或者模板上下文里没有暴露这个变量那就走备用路线用字符串对象一路摸到 object 再找子类。3.2 第二步拿到 object 类拿不到 config 也没关系还有一条更通用的路。在 Jinja2 模板里我们可以这样写{{ .__class__ }}这里的是一个空字符串__class__拿到的是字符串的类型也就是str。然后继续{{ .__class__.__bases__[0] }}str的直接父类是object。这里我建议用__bases__[0]而不是网上很多 payload 里写的__mro__[2]因为__mro__在不同 Python 版本下索引位置会变写死[2]很容易在 Python 3 环境里直接越界报错而__bases__[0]取的永远是第一个直接父类兼容性更好这个习惯我一直保持到现在。拿到 object 之后再执行{{ .__class__.__bases__[0].__subclasses__() }}这一步会返回一个长长的列表里面是当前进程里加载的几乎所有 Python 类。列表很长但别被吓到我们不需要记住它只需要在里面找到能帮我们执行命令的工具类。3.3 第三步从子类列表里捞出工具类拿到长长的子类列表以后怎么定位目标我习惯用浏览器的响应查看功能返回原始 HTML然后直接按CtrlF搜索os._wrap_close。这个类是 Python 的 os 模块内部定义的一个类它之所以好用是因为它定义在 os 模块里所以它的__init__函数的__globals__里直接就能看到整个 os 模块的全局命名空间。搜索到之后会看到列表里它的前面有一个数字比如[155]那个数字就是它在整个子类列表里的下标。把这个数字填进 payload{{ .__class__.__bases__[0].__subclasses__()[155].__init__.__globals__[os].popen(ls).read() }}这里每个环节对应起来__subclasses__()[数字]选中目标类__init__拿到构造函数__globals__拿到这个函数所在模块的全局字典[os]从字典里取出 os 模块popen(ls)执行命令read()把命令输出读出来并交给模板渲染。整条链终于跑通了。3.4 第四步执行命令读 flag看到ls的回显以后基本就到了收尾阶段。flag 文件一般叫flag或flag.txt也可能在某个子目录里。我一般直接执行{{ .__class__.__bases__[0].__subclasses__()[155].__init__.__globals__[os].popen(cat fl*).read() }}用通配符fl*而不是赌死文件名这个小习惯能在不同题目里省不少事。如果ls先显示文件在一个目录里那就把路径补全比如cat /tmp/flag。命令执行成功flag 就会直接显示在页面上。这题到这一步就算通关了非常简单没有过滤也没有二次校验。如果你用的环境和我的索引不一样只需要把[155]换成你实际搜出来的数字即可。索引不是固定的它取决于当前进程里加载了哪些库、什么版本这也是背固定 payload 最容易翻车的原因。提示所有命令执行类 payload都只建议在 CTF 靶场和自己搭建的实验环境里使用。不要对任何你没有获得授权的系统进行测试这是底线。4. 排查手册payload 不生效的时候到底哪里断了4.1 先判断是模板没执行还是执行了但没回显SSTI 写题的时候最烦的不是不会构造而是构造完了一批 payload 全部石沉大海这时候就要分情况排查了。我先确认一个问题{{7*7}}有没有回显 49如果没有那说明参数名不对或者入口根本不是模板注入得回头重新看题。如果 49 能出说明问题出在后面的对象链上或者是特定字符被过滤了。在这个环节我还有一个很笨但很实用的动作回显里看到的结果太杂的时候直接在浏览器里看源代码而不是只看渲染后的页面。因为页面渲染会把尖括号、引号之类的内容转义很多对象名看起来会缺胳膊少腿源码视图里反而干干净净方便搜索关键字。4.2 引擎不是 Jinja2 怎么办有些题目表面叫 Python 模板注入但实际后端可能用了别的模板引擎比如 PHP 的 Twig、Python 的 Tornado。不同引擎语法有差异不能拿 Jinja2 的 payload 硬套。一个简单的判断技巧是输入{{7*7}}在 Jinja2 里字符串和整数相乘会把字符串重复 7 遍也就是输出7777777。但如果是 Twig 这类会把两边都转换成数字再运算的引擎可能输出49。这个技巧虽然不能覆盖所有引擎但能帮你快速排除常见选项。一般来说题目名写了 Python 模板注入就优先按 Jinja2 的思路来如果报错信息里出现TemplateNotFound、jinja2之类的字样那就更确定了。4.3 命令执行类工具不止一种搜索子类列表时不一定每次都能找到os._wrap_close。不同 Python 版本、不同第三方库加载情况会导致列表内容差异很大。找不到的时候不要死磕同一个关键字可以再试试搜索subprocess.Popen或者warnings.catch_warnings。后面这两个类同样能帮你通过__init__.__globals__摸到可以执行命令的模块或者内建函数。还有一个更通用的终极后备不再依赖某个具体类而是想办法拿到__builtins__。几乎任何类的某个函数全局命名空间里都会出现它而__builtins__里就有__import__。拿到__import__之后想导入什么模块都行这算是对象链思路的兜底方案。不过这属于进阶内容胎教版题目里一般用不上。4.4 常用 Payload 速查对照目标Payload 思路探测模板执行{{7*7}}回显 49 说明存在判断 Flask 环境{{config}}有回显则可用从 config 直接摸 os{{ config.__class__.__init__.__globals__[os] }}获取 object 类{{ .__class__.__bases__[0] }}获取所有子类{{ .__class__.__bases__[0].__subclasses__() }}通过 os._wrap_close 执行命令...__subclasses__()[索引].__init__.__globals__[os].popen(命令).read()通过内建函数导入模块...__init__.__globals__[__builtins__][__import__](os)把这个表存在自己笔记里比背一长串完整 payload 实用得多。你要记住的是每个环节的取法而不是死记某一条字符串。4.5 过滤场景的思路预留这题不过滤但很多后续练手题会过滤_、[、{{这类关键字。新手先不用急着全部学会但至少要有一个意识过滤不等于无解。比如单引号被过滤时可以借助request.args这个模板全局对象把危险字符放到 URL 的请求参数里去绕开对引号的过滤过滤[时可以尝试用点号访问字典键。把这些思路写在笔记里等遇到过滤题型再去逐条钻研会比临时搜索有效率。5. 从这道题看向真实场景SSTI 为什么值得记5.1 真实项目里怎么会出现这种漏洞CTF 题虽然是个靶场但 SSTI 在现实开发中绝不是纸面威胁。最常见的场景就是用户需要自定义邮件模板、页面模板或者代码里图省事用字符串拼接组了一个模板。开发者可能觉得“我就拼一个用户名进去能出什么问题”问题在于用户名一旦可以被用户随意修改那它就不再是普通数据而是模板源码的一部分。我见过一个极端的案例某系统的通知功能允许管理员自定义通知模板模板里用了拼接方式把通知对象的标题塞进去结果造成任意用户都能通过请求参数篡改模板结构最终变成服务器命令执行。这个案例不是特例而是“输入拼模板”这种写法被滥用后的必然结果。CTF 里练出来的对象链放到真实代码审计里同样成立这也是为什么 Web 安全方向不会绕过 SSTI。5.2 修复思路别把用户输入当模板源码最有效的修复方式说穿了就是一句话用户输入只能当数据不能当模板源码。正常代码应该把用户输入作为变量传给模板引擎而不是直接拼进模板字符串。拿 Flask 举例安全的写法是这样return render_template_string(Hello, {{ name }}, nameuser_input)模板里写死的name才是渲染变量用户输入被当作值传进去。这样即使输入里有什么{{7*7}}也只是一个字符串值引擎不会把它当表达式求值。如果确实需要用户自定义模板那就要加沙箱环境、做权限隔离、限制可访问对象还要对模板语法做白名单约束而不是完全放任。5.3 自查清单我自己做代码审计的时候遇到模板渲染相关代码通常会快速过一遍这几个问题模板字符串里有没有出现用户可控的拼接内容用户输入有没有可能变成{{ }}或{% %}的一部分渲染结果返回给用户之后用户能不能通过输入改变模板执行路径只要这些问题里有任何一个答“是”就必须立刻改掉字符串拼接的写法。开发的时候我用另一个更简单的习惯搜索代码里render_template_string、Template(这类调用看它们第一个参数到底是硬编码字面量还是拼接出来的字符串。只要看到拼接就直接标记成高风险点。这个习惯已经帮我提前发现过不少问题在这里也分享给所有做后端的朋友。做这道“胎教版”题目我最大的体会是模板注入考察的不是背功而是对“数据与代码边界”的理解。凡是用户输入能接触到代码解释器的地方都要问一句它是被当成数据还是被当成代码把这句记住比记住任何一条 payload 都值。