ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SSTI模板注入入门:原理、引擎判断、绕过与代码审计防御

SSTI模板注入入门:原理、引擎判断、绕过与代码审计防御

SSTI 这几个字母我第一次看到的时候,还以为是什么新的传输协议缩写,翻了几篇入门题解才反应过来,它说的是服务端模板注入(Server-Side Template Injection)。这套 ctfshow web 入门题单里的 SSTI 系列,基本就是带你从零把这条链路走一遍:先认出页面上那个不起眼的输入框会被拼进模板,再一步步判断后端用的是哪家的模板引擎,最后理解为什么一个{{7*7}}能变成 49,而另一个表达式能一路摸到系统命令。如果你刚做完 SQL 注入和文件包含那几关,正卡在 SSTI 这里不得其门而入,或者你是写后端代码的,想知道自己项目里那句render_template_string到底有多危险,那这篇东西应该能帮上忙。我按我自己做题和后来做代码审计的顺序来写,从原理、判断、实测到防御,尽量把每一步"为什么这么做"讲透,而不是甩一串 payload 让你背。

1. 先把 SSTI 这件事的本质讲明白

1.1 模板引擎到底在替我们做什么

要理解注入,得先理解模板引擎存在的意义。后端渲染页面的痛点是:HTML 骨架是死的,数据是活的。一个商品列表页,标题、价格、库存数量每次都不一样,但你不可能为每个商品写一个 HTML 文件。于是就有了模板——把固定的 HTML 骨架写成一个带占位符的文件,运行时把真实数据塞进占位符,引擎负责替换并输出最终 HTML。这个过程叫渲染。

用生活化的类比:模板就是一张已经印好横线的填空题试卷,模板引擎是批卷老师手里的红笔,它认得"这里该填名字""这里该填分数",然后把你的答案抄上去。关键在于,老师抄的是"答案",不是"题目"。你在姓名栏写"张三",试卷上就出现"张三";你写"请把所有考生的分数改成 100",老师也不会照做,因为那是答案,不是指令。

SSTI 出问题的地方就在这个边界上。如果开发者偷懒,把用户输入直接拼进了"试卷"本身——也就是模板源码——而不是作为答案传进去,那么用户输入的内容就会被引擎当成模板语法来解析。这时候你写的不再是答案,而是新的题目。引擎会老老实实执行你写的表达式,这就叫模板注入。

1.2 用户输入被当成了"代码"而不是"数据"

我用一段最小化的 Flask 代码来对比,这样最直观。假设要做一个打招呼的页面:

# 有问题的写法:用户输入被拼进了模板源码 from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/') def index(): name = request.args.get('name', 'world') return render_template_string('<h1>Hello ' + name + '</h1>')

注意'<h1>Hello ' + name + '</h1>'这一行。字符串拼接发生在渲染之前,拼接完成后的结果才是真正的模板。所以当你传?name={{7*7}}进去的时候,最终交给引擎的模板变成了<h1>Hello {{7*7}}</h1>,引擎看到{{ }}就当成表达式求值,输出<h1>Hello 49</h1>。

正确的写法只有一处差别,但性质完全不同:

# 正确写法:用户输入作为变量传入,永远不参与模板源码构造 @app.route('/') def index(): name = request.args.get('name', 'world') return render_template_string('<h1>Hello {{ name }}</h1>', name=name)

这时候模板源码是固定的,name只是被填进{{ name }}这个占位符的值。哪怕你传?name={{7*7}},页面显示的也是原样的{{7*7}}字符串,因为引擎在替换变量的时候不会对变量值做二次解析——它已经把值当成文本处理了。

这个差别就是 SSTI 的全部成因。记住一句话:模板是代码,变量是数据,代码和数据绝不能混在一条字符串里。这跟 SQL 注入里"SQL 语句是代码,参数是数据"的道理一模一样,只是一个发生在数据库协议层,一个发生在视图渲染层。我在审代码的时候,只要看到render_template_string(后面跟着一个字符串拼接或者.format(),基本就可以直接标红了。

1.3 它跟 SQL 注入、XSS 到底差在哪

新手最容易混的就是这三个。它们的共同点是"把不可信数据当成了可执行内容",但执行的位置和危害边界完全不同:

维度SQL 注入SSTIXSS
执行位置数据库服务进程应用服务端进程受害者浏览器
执行主体数据库引擎模板引擎JavaScript 引擎
危害边界数据库及文件系统权限应用进程权限(常常等于服务器权限)当前用户会话
典型探测' OR 1=1--{{7*7}}<script>alert(1)</script>
影响面数据泄露、篡改数据泄露、命令执行、内网横向会话劫持、钓鱼

这张表里最值得盯的是"执行位置"那一行。XSS 再严重,执行的也是浏览器里的 JS,攻击者拿到的是用户会话;SSTI 是在服务端进程里执行,跑在什么用户下就是什么权限,很多时候 Web 服务以非 root 用户运行,但哪怕这样,读配置文件、连内网数据库、横向探测都已经够用了。

我在做代码审计时有个习惯:先把所有"用户可控输入 → 字符串拼接 → 进入解释器"的路径梳理一遍。数据库驱动、模板引擎、表达式引擎(比如某些规则引擎、SpEL)、Shell 调用,全都算。SSTI 之所以在真实业务里比 SQL 注入少见但危害更大,就是因为一旦模板里能打通到 Python 的内置对象体系,等于直接把解释器的一部分能力暴露给了外部,后面的路太宽了。

2. 入门题的题型结构拆解

2.1 这类题目通常长什么样

Web 入门题单里的 SSTI 系列,页面形态一般都很朴素:一个输入框,或者 URL 上一个参数,提交之后页面回显"Hello xxx"之类的问候语。有少数会包装成一个"名片生成器""公告预览"之类的场景,本质一样——你输入的内容被渲染到了页面上。

有意思的是,题目难度是递进的。前几关通常不做任何过滤,就是让你确认{{7*7}}会变成 49;中间几关开始过滤关键字,比如把{{、}}、_、os、class这些词拉黑,逼你去想怎么用字符串拼接、编码、过滤器来绕;后面几关可能干脆不回显了,只能靠布尔条件盲注,或者把回显藏进了某个看不到的地方。

我个人的破题习惯是先不打 payload,先看页面。原因很简单:题目如果给了源码提示(有些会在注释里写用的是哪个框架),能省掉一半的判断时间;如果页面有报错回显,报错信息里往往直接带着引擎名称和版本号,这是最快的指纹。新手常犯的错是上来就贴一段从别处抄来的长 payload,页面没反应就开始怀疑人生,其实连引擎是哪家都还没确认。

2.2 破题顺序:先判断有没有,再判断是哪一种

我给自己定了一个固定的三步走,做题和真实评估都能用。

第一步,确认注入点存不存在。用最无害的算术表达式探,因为算术表达式在任何模板引擎里的解析逻辑都一致,不会有副作用,也不会被业务逻辑干扰。常用的几个探针是{{7*7}}、${7*7}、<%= 7*7 %>、#{7*7}、{7*7}、${{7*7}}。如果页面输出49,基本上就实锤了;如果原样输出{{7*7}},说明输入被当成了纯文本,这条路径大概率是安全的;如果页面报 500,那可能是语法错误,反而说明内容被解析了,值得继续挖。

第二步,识别是哪家引擎。不同引擎的语法、可用的对象链完全不同,判断错了后面全是白费功夫。这里有个很省事的小技巧:用{{7*'7'}}。在 Jinja2(Python)里,7乘以字符串'7'会得到重复七次的字符串7777777;而在 Twig(PHP)里,7*'7'会被当成数值运算,输出49。一个表达式就能区分这两家最容易搞混的引擎,比翻报错快多了。

第三步,摸清过滤规则和回显方式。这一步决定你后面能走多远。过滤可能是黑名单(禁止某些子串)、白名单(只允许字母数字)、长度限制,也可能是在 WAF 层做的。回显可能直接渲染在页面上,也可能被转义了,还可能根本不回显只能盲猜。我在纸上会把每一步的"试了什么、返回什么、推断什么"记下来,做到第十关的时候回头看,规律就出来了。

2.3 常见引擎的指纹对照表

这张表我贴在笔记里很久了,入门阶段够用:

模板引擎主要语言探测表达式预期回显备注
Jinja2Python{{7*7}}49配合{{7*'7'}}输出7777777
TwigPHP{{7*7}}49配合{{7*'7'}}输出49
SmartyPHP{$smarty.version}版本号字符串支持{7*7}输出 49
VelocityJava#set($x=7*7)$x49语法带#指令
FreeMarkerJava${7*7}49部分配置下直接报错
ThymeleafJava[[${7*7}]]49需要内联表达式开启
EJSNode.js<%= 7*7 %>49语法与 ERB 一致
HandlebarsNode.js{{7*7}}原样输出或报错默认不支持任意表达式
ERBRuby<%= 7*7 %>49Ruby 生态常见

注意:这张表只用于本地靶场和无授权的自查环境。任何真实系统上的探测都必须拿到书面授权,且探测行为本身要控制在不影响业务的最小范围内,我不建议在任何非授权环境里试第一个表达式。

表里没有覆盖到的引擎还有不少,但做题的时候遇到陌生语法,最快的办法是搜一下报错原文里的模板文件路径或者类名,比猜快得多。

3. 从探测到出结果的完整实操链路

3.1 第一轮探针:只用算术表达式

我拿一道典型的无过滤入门题演示。页面是一个输入框,提交后回显"你的名字是:xxx"。我第一次输入的是abc,页面回显你的名字是:abc,说明输入确实被渲染了,但还不能判断是否被模板解析。

第二轮输入{{7*7}}。页面回显你的名字是:49。到这里可以确定两点:一是字符串被拼进了模板源码,二是后端用的是支持{{ }}语法的引擎。

第三轮输入{{7*'7'}}。页面回显你的名字是:7777777,确认是 Jinja2,排除了 Twig。

这三轮下来一共花了不到一分钟,全程没有触发任何危险操作。我在给别人讲的时候会反复强调这个顺序的意义——先确定"有没有",再确定"是哪家",最后才谈利用。很多人跳步,结果在 Twig 环境里用 Jinja2 的对象链,怎么调都是空,白白浪费两小时。

顺手补一句,如果是 Java 环境,探针要换成${7*7}和#{7*7},因为这俩在 Spring EL 和部分表达式引擎里表现不同;遇到 Thymeleaf 得用[[${7*7}]],因为它有自己的内联规则。这类差异只能靠平时积累,没什么捷径。

3.2 第二轮:确认上下文和可用对象

确认了 Jinja2 之后,下一步是摸清"我能碰到什么"。Jinja2 在渲染时会往模板上下文里塞一些全局对象,比如config(Flask 应用的配置)、request(当前请求对象)、url_for、get_flashed_messages、lipsum、cycler、joiner等等。这些对象本身是给模板开发用的便利工具,但在注入场景下,它们就是通往底层 Python 运行时的跳板。

最温和的一步是尝试{{config}}。在 Flask 环境里,这会直接打印出应用的配置字典。很多入门题的第二问就是藏在config里的——比如SECRET_KEY泄露之后,可以伪造 Flask 的 session 拿到管理员权限,这是 CTF 里非常经典的一条组合拳。我在真实项目里见过更离谱的,配置里直接放着数据库连接串和第三方 API 密钥,一个 SSTI 直接把整条供应链都交出去了。

如果没有config(比如不是 Flask 环境,或者被过滤了),就试{{request}}看能不能拿到请求对象,或者试{{lipsum}}看看有没有被沙箱限制。这一步的产出是:知道自己能不能访问__globals__、能不能调用函数、有没有沙箱拦截。有了这些信息,后面的路才好规划。

这里要特别提醒一句:{{config}}之所以危险,是因为它绕过了一切业务逻辑,直接读取运行时配置。你自己写代码时如果用到render_template_string,哪怕是"内部可信输入",也得掂量一下这个口子值不值得开。

3.3 第三轮:属性访问链的构造逻辑

Jinja2 里从"一个表达式"走到"能执行命令",中间的桥梁是 Python 的对象模型。我把这条链的原理拆开讲,理解了原理,payload 就不用背了。

Jinja2 表达式支持属性访问和方法调用。Python 里所有东西都是对象,字符串''是str类的实例,而每个类都有__class__属性指向它的类,类又有__mro__(方法解析顺序)指向它的继承链,object是所有类的根,object.__subclasses__()能列出当前进程里所有已加载的object子类。这是一张巨大的网,只要能在里面找到某个能执行命令的类,就能打通。

比如subprocess.Popen是object的子类,在__subclasses__()返回的列表里通常能按索引找到。找到之后通过.__init__.__globals__可以访问到该函数所在模块的全局命名空间,里面往往就有os、sys、__builtins__这些模块,而这些模块里的popen、system、__import__就是执行入口。

用生活化的话说:__class__是"我从哪个类来",__mro__是"我的祖谱",__subclasses__()是"全城所有人的花名册",__init__.__globals__是"这个人的办公室钥匙串"。你从自己身上出发,拿到祖谱,翻到全城花名册,找到一个办公室有钥匙的人,然后拿到钥匙串。这条路径之所以存在,是因为 Python 的对象模型本身在设计上就是开放内省的,这不是模板引擎的锅,是语言特性带来的双刃剑。

在入门题里,最常见的几条捷径是:

{{lipsum.__globals__['os'].popen('ls').read()}} {{cycler.__init__.__globals__.os.popen('id').read()}} {{request.application.__globals__.__builtins__.__import__('os').popen('whoami').read()}}

三条链的原理一样,都是从上下文里一个现成对象出发,通过__globals__拿到模块字典,再找到os。为什么选这三个对象?因为它们默认就在 Jinja2 的全局上下文里,不需要沙箱突破,也不需要猜索引,最省事。做题时优先试这三条,成功率最高。

如果这些被过滤了,再退回到__subclasses__()那条经典路径,用索引去找。找索引的办法是先输出整个列表,然后肉眼或者写脚本找subprocess.Popen、os._wrap_close这些名字。我一般会在本地起个一样的 Python 版本做对照,因为不同版本__subclasses__()的顺序不一样,直接抄别人的索引号经常不灵——这是新手最常见的坑之一。

3.4 过滤绕过的几种常见思路

中间几关开始上过滤,我总结了几类,基本覆盖入门题的难度。

引号被过滤。如果单双引号都被干掉,{{''.__class__}}就写不出来了。这时候可以用request.args把字符串从 URL 参数里带进来,比如{{request.args.a}},然后 URL 上传?a=__class__。也可以用 Jinja2 的chr拼接,或者干脆用dict()、()|list之类的构造出字符串。

下划线被过滤。__class__里的下划线没了,写法要变。最常用的是attr过滤器:{{''|attr('__class__')}},因为过滤器参数里的字符串可以绕开对下划线的直接匹配。或者用十六进制、八进制转义,比如{{''["\x5f\x5fclass\x5f\x5f"]}}。还有一种是用request.args传参,把危险字符藏在参数里。

点号被过滤。属性访问除了.还能用中括号,{{''['__class__']['__mro__']}},中括号里可以放字符串,灵活性更高。Jinja2 里|attr也是替代方案。

关键字被过滤。os、system、popen被拉黑时,可以用字符串拼接'o'+'s',或者用|join、|reverse这些过滤器把字符拼出来。比如{{''|attr('__cla'+'ss__')}}。再狠一点可以用 Jinja2 的{% set %}先赋值再拼接。

整套花括号被过滤。这个比较少见,但如果{{被过滤了,可以试试{%、{#或者其他引擎特有的语法,有时候能用{% print %}绕。也有题是通过头部、请求方法或者其他参数位置把 payload 送进去。

我个人的经验是,绕过这关的关键不在技巧多,而在信息足。知道过滤规则是什么,绕过就是一道填空题。所以卡住的时候别急着换 payload,先去测一下到底哪些字符被拦了,通常试两三轮就能把黑名单摸出来。

3.5 本地复现环境:自己搭一个比刷题更值

我强烈建议每个学 SSTI 的人都自己搭一遍环境。原因很实在:题目里的报错你看不懂,但本地环境里你可以直接看源码、打断点、加日志,理解速度快十倍。

最小可复现环境就一个文件:

from flask import Flask, request, render_template_string app = Flask(__name__) app.config['SECRET_KEY'] = 'local-test-key' @app.route('/') def index(): name = request.args.get('name', 'world') # 故意写成拼接,只用于本地复现 return render_template_string('<h1>Hello ' + name + '</h1>') if __name__ == '__main__': app.run(host='127.0.0.1', port=5000, debug=True)

装好 Flask 之后python app.py,浏览器访问http://127.0.0.1:5000/?name={{7*7}},看到 49 就对了。然后你可以把render_template_string换成render_template,或者把拼接改成变量传参,对比一下行为差异,这个对比比看十篇文章都管用。

再进一步,你可以自己加一个黑名单,模拟题目里的过滤,然后练习绕过。这种"出题人视角"的练习特别有价值,因为你出题的时候会想"我怎么防",而防守思路和攻击思路是互通的。我第一次自己写过滤规则的时候才发现,很多我以为能防住的写法其实一绕就过,这个认知直接改变了我后来写代码的习惯。

4. 把做题经验搬到真实代码审计里

4.1 真实业务里的 SSTI 长什么样

CTF 题目是浓缩的、刻意的,真实业务里的 SSTI 往往藏得很深。我复盘过几类典型场景。

第一类是"模板片段拼接"。比如做一个邮件模板系统,允许运营在后台配置邮件正文,正文里支持变量占位符,后端为了灵活,直接render_template_string(user_config + data)。看起来是内部人员操作,风险可控,但后台账号一旦被盗,或者某个接口存在越权,这条链就直接通了。

第二类是"报表和规则引擎"。有些业务需要让用户配置简单的计算规则,比如"满 100 减 20",后端用表达式引擎求值。表达式引擎和模板引擎在原理上是近亲,用户可控的表达式字符串一旦直接求值,效果和 SSTI 一样。

第三类是"文件名、错误信息渲染"。这个最隐蔽,有时候模板里拼的只是错误提示,看起来无害,但错误提示里包含了用户输入的用户名或者路径,拼进模板后照样能注入。我在一次自查里就发现过一个 500 页面,把请求路径直接渲染进了错误模板,虽然没被利用,但性质是明确的。

第四类是"第三方组件默认行为"。某些脚手架或者低代码平台,为了"让用户自定义页面",把模板编辑能力暴露给了前端,这是把整台服务器的钥匙交出去。这类问题在做技术选型的时候就要警惕——任何允许用户提交模板源码的功能,都必须假定用户是恶意的。

4.2 从代码到配置的加固清单

防御 SSTI 这件事,分层做比单点做靠谱。我按优先级列一下我自己项目里会做的:

层级措施说明
编码规范禁止字符串拼接构造模板模板源码必须是常量,用户输入只走变量传参
编码规范禁用render_template_string处理外部输入该函数应仅用于完全可控的静态片段
运行时使用沙箱环境Jinja2 提供SandboxedEnvironment,会拦截危险属性访问
运行时限制模板可用全局对象清理__globals__、__builtins__等上下文对象
配置关闭 debug 和生产环境报错回显报错信息是最快的指纹来源
配置敏感配置不放在可被模板读取的对象里减少config泄露的损失
运维Web 进程最小权限运行即使被突破,也限制影响范围
边界WAF 规则补充只能作为兜底,不能作为主要防线

表格里我特意把"编码规范"放在最前面,因为后面所有措施都是补救。模板拼接这个问题,只要写代码的时候守住"模板源码是常量"这条线,压根不会有 SSTI。沙箱这层要单独说一下:Jinja2 的SandboxedEnvironment不是万能的,历史上出过多次绕过,所以它是纵深防御的一层,不是免死金牌。我在需要让用户自定义模板的场景里,做法是沙箱 + 白名单变量 + 独立低权限进程,三道一起上。

还有一个容易被忽略的点:日志和报错。生产环境把堆栈信息暴露给前端,等于把引擎版本、模板路径、甚至部分源码直接送给对方。这个成本极低,收益极高,但很多项目就是懒得关。

5. 卡住时的排查思路和速查表

5.1 报错信息怎么读

做题时页面报 500,很多人第一反应是"payload 错了",其实报错信息里藏着大量信息,值得逐字看。

如果报错里出现jinja2.exceptions.TemplateSyntaxError,说明语法层面就错了,可能是括号不匹配,也可能是花括号被转义了。如果出现UndefinedError,说明表达式语法没问题,但引用的变量在上下文里不存在,这时候要换个对象试。如果出现SecurityError或者类似的沙箱拦截提示,说明沙箱生效了,得换绕过思路。如果报错里直接带出了模板文件的绝对路径,那说明回显控制没做好,这本身就是个信息泄露。

我的习惯是把报错原文复制下来,去掉自己 payload 的部分,剩下的就是引擎的"自白"。入门阶段这么做能快速建立对引擎行为的直觉,比看文档快。

5.2 常见问题速查

现象可能原因排查方向
输入{{7*7}}原样输出输入被当文本处理,未拼进模板换参数位置,检查是否有二次编码
输入后页面空白或 500语法错误或表达式被拦截看报错原文,简化表达式重试
{{7*7}}出 49 但{{7*'7'}}也出 49后端是 Twig 而非 Jinja2换 PHP 侧的思路
{{config}}报未定义非 Flask 环境或上下文被清理换lipsum、request等对象试探
属性链能读不能执行沙箱拦截了函数调用找沙箱允许的方法,或绕过滤器
抄来的索引号无效Python 版本不同,子类顺序不同本地起同版本环境重新查索引
过滤了引号导致无法构造字符串黑名单命中用request.args传参或字符拼接
页面完全无回显盲注场景用条件表达式配合布尔差异判断

这张表我自己用了很久,卡住的时候对着过一遍,八成能定位到问题在哪一层。真正难的从来不是 payload 本身,而是判断现在卡在"没注入"、"注入了但被过滤"还是"执行了但没回显"这三个状态中的哪一个。

5.3 几个我踩过的坑

第一个坑是在错误的引擎上套模板。有一关我怎么试都不对,最后发现后端是 Twig,我一直在用 Jinja2 的__class__链,PHP 里根本没有这套东西。从那以后我养成了先跑指纹再动手的习惯,多花十秒,省下两小时。

第二个坑是忽略了 URL 编码。花括号、下划线在某些位置会被中间层处理掉,尤其是经过多次跳转或者被前端 JS 处理过的参数。我现在的做法是抓包看原始请求,确认发出去的内容和我想的一致,再判断是不是过滤。

第三个坑是字符串拼接里少了个空格或者引号。这类低级错误在调试阶段特别浪费时间,因为报错信息往往指向模板内部,不指向你的输入。我的解决办法是把 payload 拆短,一段一段加,每加一段验证一次,出问题立刻知道是哪一段。

第四个坑是以为过滤了关键字就没戏了。实际上黑名单几乎永远可以被绕,因为模板语法的表达能力太强了。真正有效的防御只有白名单和沙箱,黑名单只是提高门槛。这个认知对做题和写代码都适用。

6. 一点个人体会

这套入门题我从头到尾刷了两遍,第一遍是照着题解抄 payload,刷完其实什么都没记住;第二遍是自己搭环境、自己出题、自己绕,才算真正入门。SSTI 这个知识点最值得学的地方,不是那几个固定的对象链,而是它背后那个通用的思维方式——凡是"数据被当成代码执行"的地方,就有注入。SQL 是这样,模板是这样,表达式引擎、命令拼接、反序列化,全都是同一个道理。

我现在审代码的时候,看到任何eval、exec、render_template_string、format拼接、动态import,脑子里会自动亮红灯。这个直觉不是背书背出来的,是把这类题目一道道啃下来之后长出来的。如果你正在学这块,我给的建议就一条:别急着找现成的长 payload,先把"为什么这个表达式能返回 49"和"为什么那个属性能摸到系统命令"这两件事想透,剩下的都是排列组合。环境一定自己搭,报错一定自己读,绕不过去的时候先测过滤规则而不是换 payload。这三件事做到了,SSTI 这关基本就过了。

返回列表