1. 从两道经典题看Web安全里的信息泄露与反序列化绕过做Web安全练习的人迟早会撞上两类题一类是看起来什么都没给其实线索全在明面上另一类是代码逻辑看着天衣无缝但语言本身的特性留了后门。前者以Tornado模板注入为代表后者以PHP反序列化绕过为代表。这两类题之所以经典不是因为它们有多难而是因为它们精准地戳中了两个真实开发中极易被忽视的盲区模板引擎的渲染边界和对象序列化/反序列化过程中的属性控制。我最初接触这两道题的时候第一道卡了很久因为习惯了找输入点、找回显、找注入的套路面对一个只有几个链接的页面完全不知道从哪下手。第二道题则是另一种困境——源码摆在面前逻辑读懂了但就是想不到绕过点在哪。后来复盘才发现这两道题真正考的不是工具使用而是对框架行为和语言特性的理解深度。这篇文章会从这两道题出发把Tornado的模板注入原理、Cookie签名机制、PHP反序列化的属性覆盖绕过、__wakeup的触发条件等核心知识点拆开讲透。不管你是刚入门CTF Web方向的新手还是想补一补框架安全基础的老手应该都能从中拿到一些能直接用的东西。2. easy_tornado当模板引擎成为攻击面2.1 题目入口与信息收集的切入点拿到这道题首页通常是一个简单的HTML页面上面挂着几个链接比如/file?filename/flag.txt、/file?filename/welcome.txt之类。点进去会发现页面提示文件不存在或者展示了一段文字。同时URL里往往带了一个filename参数和一个cookie_secret相关的签名参数。很多人第一步就卡住了页面没有明显的输入框没有表单没有搜索功能怎么打关键在于观察URL结构和页面源码。通常页面源码里会有一段注释或者提示告诉你flag在/fllllllllllllag这个路径下注意拼写这是出题人故意设置的干扰。而直接访问/file?filename/fllllllllllllag会被拒绝因为缺少正确的签名。这里就引出了第一个核心问题为什么需要签名签名是怎么算的Tornado框架在处理文件读取这类操作时为了防止用户随意构造路径通常会用一个cookie_secret配合hashlib.md5来生成签名。典型的代码逻辑是这样的import hashlib def get_sign(filename, cookie_secret): return hashlib.md5((cookie_secret filename).encode()).hexdigest()URL里的签名参数通常叫sign或msg就是通过这个方式算出来的。你访问/file?filename/welcome.txtsignxxxx服务端会重新计算一遍签名比对一致才返回文件内容。那么问题来了cookie_secret是什么怎么拿到2.2 模板注入的发现从render到SSTI这道题的关键突破口在于Tornado的模板渲染机制。Tornado使用自己的模板引擎语法和Jinja2类似但有关键区别。当用户输入被直接拼接到模板中渲染时就会产生服务端模板注入SSTI。题目中通常会有一个/error?msgxxx或者类似的接口页面会把msg参数的内容渲染到模板里。如果你输入{{11}}页面返回2那就说明模板注入存在。Tornado模板注入的利用方式和Jinja2有所不同。Jinja2里常用的{{config}}、{{.__class__.__mro__}}这套在Tornado里需要调整。Tornado模板中可以直接访问一些内置对象比如handler、request、application等。一个经典的payload是这样的{{handler.settings}}这个表达式会输出当前handler的所有settings其中就包含cookie_secret。为什么能这样访问因为Tornado在渲染模板时会把当前的RequestHandler实例作为上下文传入而handler.settings正好指向了应用的全局配置字典。拿到cookie_secret之后事情就简单了。你可以自己算签名import hashlib cookie_secret 拿到的secret值 filename /fllllllllllllag sign hashlib.md5((cookie_secret filename).encode()).hexdigest() print(sign)然后构造URL访问flag就到手了。2.3 为什么Tornado的SSTI和Jinja2不一样这里值得展开说一下。很多人学SSTI是从Jinja2入门的形成了固定思维遇到Tornado就不知道怎么打了。两者的核心差异在于上下文对象的暴露方式。Jinja2的模板上下文通常只包含开发者显式传入的变量但可以通过.__class__.__mro__[1].__subclasses__()这条链去遍历Python对象体系最终找到os模块或者subprocess来执行命令。这条链很长但在很多CTF题里屡试不爽。Tornado则不同。Tornado的模板引擎在设计上就把handler、request、application等对象直接暴露在模板命名空间里。这意味着你不需要走那么长的利用链直接访问这些对象就能拿到敏感信息。handler.settings是最常用的一个因为它包含了cookie_secret、debug模式等关键配置。除了handler.settings还有一些常用的Tornado模板注入payloadPayload作用{{handler.settings}}获取应用配置包含cookie_secret{{handler.request}}获取当前请求对象{{handler.application}}获取应用实例{{handler.get_argument(xxx)}}获取请求参数如果题目环境允许执行系统命令还可以尝试通过handler.application进一步访问更底层的对象。不过在easy_tornado这道题里拿到cookie_secret就已经足够了。2.4 签名机制的设计缺陷与防御思路从防御角度看这道题暴露的问题很典型把敏感配置暴露在模板上下文中同时用可预测的签名算法保护资源访问。cookie_secret作为签名密钥本不应该出现在任何用户可达的输出里。但Tornado的模板上下文默认包含了handler对象而handler.settings又直接指向全局配置。这就形成了一个信息泄露链模板注入 → 访问handler → 读取settings → 获取secret → 伪造签名。正确的做法应该是第一永远不要将用户输入直接渲染到模板中如果必须渲染使用{% raw %}或者对输入进行严格转义第二敏感配置不应该放在模板可访问的上下文中可以通过自定义模板命名空间来限制暴露的对象第三签名算法本身可以考虑加盐或者使用HMAC增加伪造难度。在实际开发中Tornado的render_string和render方法如果传入了用户可控的模板内容就是高风险的。很多开发者觉得我只是渲染一个错误信息能有什么问题但恰恰是这种看似无害的场景给了攻击者入口。3. PHP反序列化CVE-2016-7124的绕过逻辑3.1 题目源码的典型结构与阅读方法这道PHP题通常会给出完整的源码一般包含三个文件index.php、class.php、flag.php。源码逻辑大致是这样的// class.php class Name { private $username nonono; private $password yesyes; public function __construct($username, $password) { $this-username $username; $this-password $password; } public function __wakeup() { $this-username guest; } public function __destruct() { if ($this-password ! 100) { echo NO!!!; exit(); } // 输出flag include flag.php; echo $flag; } }// index.php if (isset($_GET[select])) { $select $_GET[select]; $res unserialize($select); }阅读这类源码的核心方法是先找魔术方法再找可控入口最后看属性条件。魔术方法是PHP反序列化题的核心。__wakeup在反序列化时自动调用__destruct在对象销毁时自动调用__construct在创建对象时调用反序列化不触发。这道题里__wakeup会把username重置为guest而__destruct检查password是否等于100等于才输出flag。正常构造序列化字符串的话__wakeup会把username改掉但password不受影响。所以理论上只要让password100就行了但问题是password是私有属性序列化后的字符串里属性名会带有类名和空字节手动构造比较麻烦。3.2 构造序列化payload的完整过程先写一个PHP脚本来生成序列化字符串?php class Name { private $username nonono; private $password yesyes; } $obj new Name(); echo serialize($obj); ?输出大概是这样的O:4:Name:2:{s:14:Nameusername;s:6:nonono;s:14:Namepassword;s:6:yesyes;}注意私有属性序列化后属性名前面会有\0Name\0的前缀在输出中可能显示为不可见字符。我们需要把password的值改成100O:4:Name:2:{s:14:Nameusername;s:6:nonono;s:14:Namepassword;s:3:100;}但这样直接提交__wakeup会把username改成guest而__destruct检查的是password所以理论上应该能通过实际上很多题目的__destruct还会检查username或者__wakeup的逻辑更复杂。这道题的核心考点是CVE-2016-7124当序列化字符串中声明的属性个数大于实际属性个数时__wakeup方法会被绕过不被执行。3.3 CVE-2016-7124的原理与利用条件这个漏洞的本质是PHP在反序列化时的一个逻辑缺陷。正常情况下unserialize会先读取属性个数然后逐个读取属性。当读取完所有属性后如果对象有__wakeup方法就会调用它。但如果声明的属性个数大于实际提供的属性个数PHP在解析过程中会因为找不到足够的属性而提前结束导致__wakeup没有被触发。这个行为在PHP 5.6.25之前的版本和PHP 7.0.10之前的版本中存在。利用方式很简单把序列化字符串里的属性个数改大。比如原来是O:4:Name:2:{...}改成O:4:Name:3:{...}。这样__wakeup就不会执行username保持我们设置的值。完整的payloadO:4:Name:3:{s:14:Nameusername;s:6:nonono;s:14:Namepassword;s:3:100;}注意属性个数写的是3但实际只提供了2个属性。这就是绕过__wakeup的关键。3.4 私有属性在序列化中的表示与常见坑私有属性在序列化字符串里的表示是一个容易踩坑的地方。PHP中私有属性的名称会被编码为\0类名\0属性名。在URL传递时这些空字节需要被正确编码。比如\0Name\0username在URL中应该写成%00Name%00username。如果你直接复制粘贴空字节可能会丢失导致反序列化失败。另外如果属性是protected的前缀是\0*\0属性名。public属性则没有前缀。这三种情况在构造payload时都要注意区分。我在实际做题时遇到过一个问题用浏览器直接访问URL空字节被浏览器或服务器过滤了。解决办法是用Burp Suite或者curl来发送请求确保空字节完整传递。curl http://target/index.php?selectO:4:%22Name%22:3:{s:14:%22%00Name%00username%22;s:6:%22nonono%22;s:14:%22%00Name%00password%22;s:3:%22100%22;}3.5 魔术方法的触发顺序与利用链构建理解魔术方法的触发顺序是构建反序列化利用链的基础。在PHP中常见的魔术方法触发时机如下魔术方法触发时机__construct创建对象时__destruct对象被销毁时__wakeup反序列化时__toString对象被当作字符串使用时__call调用不存在的方法时__get访问不存在的属性时__set设置不存在的属性时__invoke对象被当作函数调用时在反序列化题目中__wakeup和__destruct是最常出现的两个。__wakeup通常用于重置敏感属性__destruct通常用于执行最终操作输出flag、写文件等。构建利用链的思路是找到一个入口点通常是unserialize的参数然后沿着魔术方法的调用链逐步控制关键属性最终触发目标操作。CVE-2016-7124提供了一种绕过__wakeup的手段使得我们可以保留在序列化字符串中设置的属性值。4. 两道题背后的通用方法论4.1 信息收集阶段的异常点思维这两道题在信息收集阶段有一个共同点关键线索都藏在容易被忽略的地方。easy_tornado的线索在URL参数和页面源码的注释里PHP题的线索在源码的属性命名和魔术方法逻辑里。很多人做题时习惯性地扫一眼就动手结果漏掉了最关键的提示。我的习惯是拿到题目后先把所有可见的输入点列出来包括URL参数、表单字段、Cookie、HTTP头。然后逐个测试观察响应变化。对于源码题先把所有类、方法、属性列出来画出调用关系图再找可控点和条件判断。4.2 从能打到知道为什么能打CTF题做多了容易形成条件反射看到Tornado就想handler.settings看到PHP反序列化就想改属性个数。但如果不理解背后的原理遇到变种题就会卡住。比如Tornado的模板注入如果题目把handler从上下文中移除了怎么办那就需要找其他对象。如果cookie_secret不在settings里怎么办那就需要找其他泄露点。理解原理才能灵活应变。PHP反序列化也是一样。CVE-2016-7124只是绕过__wakeup的一种方式还有其他绕过手段比如利用__destruct和__wakeup的触发顺序差异、利用引用传递等。掌握原理比记住payload更重要。4.3 防御视角开发中如何避免这类问题从防御角度看这两道题给出的教训是对于模板注入永远不要信任用户输入不要将用户输入直接拼接到模板中。如果必须动态渲染使用白名单机制限制可渲染的内容。敏感配置不要放在模板可访问的上下文中。对于反序列化尽量不要使用unserialize处理用户输入。如果必须使用对输入进行严格校验确保序列化字符串的结构符合预期。升级PHP版本可以修复CVE-2016-7124但更重要的是在代码层面做好防护。另外签名机制的设计要注意密钥要足够随机不要硬编码在代码里签名算法要使用HMAC等更安全的方式签名验证要在服务端严格进行不要依赖客户端。5. 实操中的几个关键细节与避坑经验5.1 Tornado模板注入的空格与编码问题Tornado模板语法对空格比较敏感。{{handler.settings}}和{{ handler.settings }}在某些版本下行为可能不同。如果payload不生效先检查空格和特殊字符是否被过滤或转义。另外URL传递时{{和}}需要编码为%7B%7B和%7D%7D。有些服务器会拦截未编码的花括号导致请求被拒绝。5.2 PHP序列化字符串的长度计算序列化字符串中每个部分的长度必须精确匹配。比如s:14:Nameusername中的14是字符串长度如果算错了反序列化会失败。计算长度时要注意私有属性的前缀\0Name\0是6个字符两个空字节加Name四个字母加上属性名本身的长度。比如username是8个字符总共就是14。这个计算很容易出错建议用PHP脚本自动生成不要手动拼。5.3 不同PHP版本的差异CVE-2016-7124在不同PHP版本中的表现可能不同。有些版本在属性个数不匹配时会直接报错而不是静默绕过。做题时如果payload不生效先确认目标环境的PHP版本。另外PHP 7.4之后对序列化的处理更加严格一些老的绕过手法可能失效。但在CTF环境中出题人通常会选择有漏洞的版本。5.4 工具的使用与限制Burp Suite是处理这类题目的常用工具特别是在需要修改请求、处理编码的时候。但要注意Burp的Repeater在显示空字节时可能不直观建议开启Hex视图来确认字节是否正确。对于Tornado模板注入有时候需要反复调整payload来探测可用的对象。可以写一个简单的脚本来自动化测试import requests url http://target/error payloads [ {{11}}, {{handler.settings}}, {{handler.request}}, {{handler.application}}, ] for p in payloads: r requests.get(url, params{msg: p}) print(fPayload: {p}) print(fResponse length: {len(r.text)}) print(r.text[:200]) print(- * 50)这种批量测试可以快速定位可用的注入点。5.5 从题目到实战的思维迁移CTF题目是高度简化的场景但背后的漏洞原理在真实系统中同样存在。Tornado模板注入对应的是SSTI漏洞在真实应用中可能导致远程代码执行。PHP反序列化对应的是对象注入漏洞在真实应用中可能导致权限绕过、文件读写等。把CTF中学到的原理迁移到实战中关键是要理解漏洞的触发条件和利用路径。比如SSTI的触发条件是用户输入被拼接到模板中那么在实际代码审计时就要重点关注所有使用模板渲染的地方检查是否有用户输入参与。PHP反序列化的触发条件是unserialize的参数可控那么审计时就要搜索所有unserialize调用检查参数来源和后续的魔术方法链。这两道题虽然难度不高但覆盖的知识点很典型。把它们的原理吃透再遇到类似的题目或者真实漏洞就能快速定位和利用。