1. 关卡全景与通关思路总览sqli-labs 这个靶场前 28 关基本是把 SQL 注入的常规类型轮着过了一遍数字型、字符型、报错注入、布尔盲注、时间盲注、堆叠注入、双查询注入该见的基础姿势都有了。但凡你能把前 28 关平推掉说明对注入的判别和手工流程已经有了肌肉记忆。但从第 29 关开始sqli-labs 拐了个弯进入了一个完全不同的维度如何绕过防护逻辑本身。29、30、31 三关是一组主题是 HTTP 参数污染HPPHTTP Parameter Pollution结合 WAF 绕过的经典场景第 32 关单独开了一组讲的是宽字节注入如何击穿addslashes的转义防线。之所以把这四关放在一起讲是因为它们共同指向一个核心命题当后端做了简单过滤时注入语句还能不能打进去以及为什么能打进去。我的建议是把 29-31 当作一个整体来刷。三关的注入点、闭合方式、解法思路几乎是同一个模板的三种变体连源码里的作弊逻辑都是同一套。你只要把 29 关的源码吃透了30、31 基本就是改个参数位置和闭合符号的事。而 32 关的宽字节则稍微独立一点但它的源码解析价值我认为是这四关里最高的——因为addslashes转义GBK 编码这种组合在真实环境中曾经是烂大街的配置理解它背后的字节级原理能帮你应对不少老旧系统里的同类问题。这篇解析我按照“源码逻辑→攻击面分析→payload 构造思路”的顺序来拆每个关卡都会把源码里的关键行拎出来讲而不是光丢一个 payload 让你去抄。只有知道每一行代码为什么能绕过你才能在换了个参数名、换了个闭合方式的变体题目里照样打穿。2. 29 关源码级拆解HTTP 参数污染HPP绕过 WAF2.1 这关到底模拟了什么场景29 关的页面很简单一个?id1的 GET 参数传进去后页面正常显示用户信息。但如果你按照之前的老套路直接上id1去试探会发现请求被一个“WAF”拦了——实际上这个所谓的 WAF 是写在源码里的一个判断逻辑它在转发请求之前先做了一次过滤检查。先看源码的核心结构我简化掉无关部分?php // 模拟 WAF 层 if (mysqli_real_escape_string($con1, $_GET[id]) $_GET[id]) { // 放行到后端 $id $_GET[id]; } else { die(The used parameter is not numeric.); } // 模拟 Tomcat 取参逻辑 $id $_GET[id]; // 后端查询 $sql SELECT * FROM users WHERE id$id LIMIT 0,1; ?这里有个关键细节代码里前后取了两次$_GET[id]第一次用于 WAF 校验模拟 Java 应用层拿到参数第二次才是真正带入查询的参数模拟 PHP 后端拿到参数。而mysqli_real_escape_string在这里起的作用是如果传入的参数是 1 或者 1 这种转义后和原值不同说明里面有特殊字符于是把你拦截掉。问题来了——$_GET[id]在 PHP 里取的是同名参数的最后一个值。也就是说如果你提交?id1id1 union select 1,2,3--WAF 拿到的$_GET[id]是1转义后还是1校验通过放行而真正带入查询的$_GET[id]同样也是1 union select 1,2,3--——这里注意第二次取参也是在同一个 PHP 进程里取的依然是最后一个值WAF 校验时取的也是$_GET[id]那两个用的是同一个值这个疑问非常关键。我拆开讲。正确的理解是在真实的多层架构里WAF 层如 Java Filter和业务层PHP各自独立解析一次 HTTP 参数。Tomcat 默认取同名参数的第一个PHP 取最后一个。代码里为了模拟这个行为实际上是通过$_GET[id]和某种方式在“两个层”分别取值。我这里把源码逻辑还原准确一点?php // 模拟 Tomcat/JSP 层取第一个同名参数 $id $_GET[id]; // 实际第一个值 // WAF 过滤只对第一个参数做转义检查 if (mysqli_real_escape_string($con1, $id) $id) { // 放行 $qs $_SERVER[QUERY_STRING]; // 模拟 PHP 层取最后一个同名参数 parse_str($qs, $params); $id $params[id]; } else { die(The used parameter is not numeric.); } ?这个版本才符合 HPP 的原始语义第一层WAF只检查第一个id第二层后端从原始查询字符串里重新解析取到最后一个id。于是只要第一个参数是干净的就能绕过 WAF真正的 payload 藏在第二个参数里。2.2 注入点探测与闭合方式确认如果你第一次刷这关不要上来就id1id1 union select...一把梭。建议还是按手工注入的标准流程走先正常访问?id1页面显示Your Login name:Dumb和Your Password:1说明查询成立。然后测试闭合?id1id1 ?id1id1 -- ?id1id1 -- -id1当传入id1时报错id1 --时页面恢复正常说明闭合方式是单引号字符型注入点就在这个单引号内。由于是 union 注入接下来就是用 order by 确认列数?id1id1 order by 3--页面正常返回再试order by 4报错确认 3 列。后面的流程就和基础关一样了只是记得所有带 payload 的参数都放在第二个id上。2.3 完整 payload 与绕过原理拿库名和表名我用的 payload 如下?id1id1 union select 1,database(),3--执行后页面回显第二个字段位置显示security。继续爆表名?id1id1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase()--回显emails,referers,uagents,users。再爆字段、爆数据常规流程我就不重复贴了。核心 payload 就一条?id1id1 union select 1,2,3--绕过生效的关键在于第一个id1通过了 WAF 的转义检测第二个id1 union select...才是后端真正执行的内容。如果只提交单个带 payload 的idmysqli_real_escape_string会发现有特殊字符从而拦截。这是 HPP 最典型的使用场景——利用中间件对同名参数解析结果的差异让 WAF 看到的是一个干净的请求让后端拿到的是恶意数据。2.4 配套源码逐行说明29 关的完整源码里还有一段判断参数是否数字的函数我贴关键片段function java_impl($arr) { // 模拟 Java 取第一个同名参数 return $arr[0]; } $params explode(, $qs); foreach ($params as $param) { // 解析出所有 id 参数的值放入数组 } // WAF 检查 $id java_impl($arr); // 取第一个值 if (mysqli_real_escape_string($con1, $id) $id) { // 取最后一个值作为真正查询参数 $id end($arr); }这里的java_impl和end就是整个 HPP 绕过的开关。理解了这个你甚至可以自己改着玩把end改成$arr[0]这关就变成不管怎么传都只取第一个参数HPP 就失效了。靶场的学习价值就在这里——你可以动手改源码观察行为变化比单纯背 payload 有用得多。提示29 关在部分版本的 sqli-labs 里如果你用 Burp Suite 重放注意 URL 里两个 id 的顺序不要搞反第一个是诱饵第二个是攻击载荷。顺序反了会被 WAF 直接拦下来。3. 30 关 POST 版 HPP从 GET 到 POST 的迁移思路3.1 源码差异与参数位置变化30 关基本复刻了 29 关的 HPP 逻辑区别在于两点一是请求方式从 GET 变成了 POST二是闭合方式从单引号变成了双引号。看源码里的取参逻辑?php // 模拟 WAF 层 $id $_POST[id]; if (mysqli_real_escape_string($con1, $id) $id) { // 放行后取同名参数的最后一个 $id end($_POST[id]); // 实际写法是数组形式 } else { die(The used parameter is not numeric.); } ?注意这里的写法。如果用原生 POSTid1id1 union select...这种方式提交PHP 解析$_POST[id]时得到的确实是一个数组end()取到的是最后一个值。WAF 层检查的$_POST[id]如果是数组mysqli_real_escape_string会直接报警告所以在源码里它实际是把第一个元素取出来做检查后面的逻辑和 29 关如出一辙。但这里有个实操中的坑直接用 Burp 的 body 里写两行idxxx某些场景下 PHP 对 POST 同名参数的解析结果是只保留最后一个值并不会自动组成数组除非你在参数名后面加[]即id[]1id[]1 union...。sqli-labs 这关的源码实际上在解析时用了自定义逻辑不是所有环境都按数组处理。我实测下来的稳定玩法是这样直接在 Burp Repeater 里把请求体写成id1id1 union select 1,2,3--Content-Type 设成application/x-www-form-urlencoded就能稳定触发。如果你用浏览器插件直接改 POST 参数反而容易因为插件自身处理同名参数的方式不对而失败。3.2 闭合方式确认与 payload 构造这关源码里的查询语句是$sql SELECT * FROM users WHERE id\$id\ LIMIT 0,1;双引号包裹。传id1时如果报错说明闭合生效。确认列数POST /sqli-labs/Less-30/ id1id1 order by 3--页面正常order by 4报错3 列。直接上全套id1id1 union select 1,database(),3-- id1id1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase()-- id1id1 union select 1,group_concat(username,0x3a,password),3 from security.users--回显点依然在第二列和 29 关保持一致方便你对照。3.3 与 29 关的解题思路对比两关相比29 关的 WAF 逻辑和 30 关完全一致核心差异就是注入点的转移方式和闭合符号。我在实际刷题过程中的体会是从 29 到 30 的迁移真正的考验不是技术难度而是你有没有形成“同一种绕过原理可以平移到不同位置”的意识。很多人 29 关过了到 30 关就懵了主要是因为思维还停留在固定 payload 上没有把 payload 后面的原理剥离出来。如果你把原理抽出来会发现 30 关和 29 关其实共用同一个做题模板无非就是把参数从 URL 挪到请求体把单引号换成双引号。我自己的习惯是每过一关在笔记里写下“这关的注入点是什么、闭合是什么、防护是怎么绕的”三行总结。29 关写的是“GET-HPP-单引号”30 关写的是“POST-HPP-双引号”到 31 关就很好推了。4. 31 关双引号括号闭合 HPP闭合方式的细节差异4.1 源码与实际闭合规则31 关依然是 HPP但查询语句变成了$sql SELECT * FROM users WHERE id(\$id\) LIMIT 0,1;注意这里多了个圆括号。闭合方式需要同时闭合引号和括号也就是 POST 提交的 payload 结尾得是)。源码的 WAF 检查逻辑还是那套没有变化所以绕过原理依然是第一个参数诱饵、第二个参数载荷// 源码关键行 if (mysqli_real_escape_string($con1, $id) $id) { $id end($arr); }4.2 payload 全套示例先说最基础的闭合测试id1id1)如果页面报错接着上注释符验证id1id1) --发现页面恢复说明闭合正确。列数探测id1id1) order by 3-- // 正常 id1id1) order by 4-- // 报错然后直接一把梭id1id1) union select 1,database(),3-- id1id1) union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase()-- id1id1) union select 1,group_concat(username,0x3a,password),3 from security.users--4.3 不同闭合情况下的 payload 变形规律刷到这一关建议顺带把之前所有闭合方式做一个横向对比以后遇到任何字符型注入都能快速识别闭合规则闭合符号代表性关卡闭合 payload完整 union payload 开头单引号29 关1--1 union select 1,2,3--双引号30 关1--1 union select 1,2,3--双引号括号)31 关1)--1) union select 1,2,3--单引号括号)常见变体1)--1) union select 1,2,3--这个表我建议你保存下来后面刷其他靶场或者做 CTF 题时非常有用。判断闭合方式的方法就一句话先传一个特殊符号看报不报错再在尾部加--看能不能把注释生效能恢复就是闭合成功。多试几次形成肌肉记忆之后你看到报错信息里的 SQL 语句基本就能直接判断闭合字符了。5. 32 关宽字节注入编码问题如何击穿转义防线5.1 addslashes 的转义逻辑与漏洞成因如果说 29-31 关的核心是“利用中间件解析差异”那 32 关的核心就是“利用字符集编码差异”。先看源码?php $id $_GET[id]; // 转义特殊字符 $id addslashes($id); // 设置数据库连接编码为 GBK mysql_query(SET NAMES gbk); $sql SELECT * FROM users WHERE id$id LIMIT 0,1; ?addslashes的作用是在引号、反斜杠、NULL 等字符前面加上反斜杠比如输入1会变成1\这在 UTF-8 等编码下是安全的——因为反斜杠就是转义符单引号变成了普通字符失去了闭合语义。问题出在后面那行SET NAMES gbk。这句话把数据库连接的字符集设置成了 GBK而 GBK 是一个多字节编码部分汉字由两个字节组成并且第二字节的范围是 0x40-0xFE这个范围覆盖了 ASCII 中的\0x5C和单引号0x27。这就产生了一个经典的字节拼接问题你传入的某个“字符”的第一个字节加上addslashes插入的反斜杠\0x5C恰好凑成了一个合法的 GBK 双字节汉字反斜杠被“吃掉”了不再具有转义功能。于是原本被转义的单引号就裸奔了出来。5.2 宽字节注入的完整原理推导实际操作中最常见的绕过 payload 是id%df%27%df是 GBK 编码里的一个汉字實的首字节%27就是单引号的 URL 编码。当后端执行addslashes时会在前面加上反斜杠变成%df%5c%27其中%5c就是反斜杠。而 GBK 编码的规则是%df和%5c会被解析成一个完整的汉字——%df%5c恰好是“連”字的 GBK 编码。于是真正的字符串在数据库层变成了%df%5c%27 → (連) 反斜杠已经消失在汉字里单引号成功逃逸。这就是宽字节注入的全部秘密不是绕过了 addslashes而是让 addslashes 插入的反斜杠成为另一个字符的一部分从而失去转义作用。用生活化的类比来说addslashes就像一个给危险字符穿防弹衣的安检员他在单引号前面放了一个保安反斜杠。但 GBK 编码有个规律汉字“連”的编码恰好要求必须吃掉一个字符来组成完整汉字于是“保安”被汉字拽走了单引号前面的保护就真空了。5.3 32 关手工注入实操记录我在刷这关的时候第一步直接用id1页面显示正常因为被转义后查询不到任何数据但没报错这就提示我存在转义。然后改传id1%df%27页面报错说明单引号逃逸成功。这里有一个细节报错信息不会直接显示完整的 SQL给的提示是You have an error in your SQL syntax可以断定注入点存在但需要手工盲打。由于这关页面有回显点还是优先考虑 union 注入。闭合字符用%df%27完整 payloadid1%df%27 union select 1,2,3--页面正常回显1,2,3。继续id-1%df%27 union select 1,database(),3--拿到库名security。后面爆表、爆字段的老流程我就不重复了但有个技巧值得说一下在 URL 里直接写%df%27是可以的但在 Burp 或某些自动工具里如果遇到%df被二次 URL 编码成%25df%27的情况注入会失败。解决办法是关掉工具的自动 URL 编码或者在实测时直接用原生请求发送。5.4 常见 bypass 变体与适用边界宽字节注入不止%df%27这一种用法实际测试中可以根据不同的转义函数选择不同的首字节首字节转义后拼接结果是否形成合法 GBK 字符%df%df%5c是連%bf%bf%5c是%e4%e4%5c是%aa%aa%5c是判断依据很简单只要首字节加0x5C能组成一个 GBK 双字节字符就能用。所以理论上从%81到%FE的很多字节都可以用来尝试%df只是最经典的一个。但要特别注意适用范围。宽字节注入有三个前提缺一不可数据库连接编码是 GBK 或 GB2312 等双字节编码源码里的SET NAMES gbk就是干这个的应用层没有先做 UTF-8 编码转换转义函数只是简单加反斜杠没有使用mysql_real_escape_string之类针对编码的增强转义如果你在自己搭的环境里测宽字节失败优先检查这三点。尤其是现在很多环境的默认字符集是 UTF-8宽字节注入基本天然免疫——这也是为什么我说这关最值得研究源码因为它逼着你去理解编码层的问题而不是死记%df%27这个 payload。6. 实战中这些技术的价值与迁移6.1 HPP 在真实 Web 架构中的危害很多人觉得 HPP 是个“靶场玩具”但如果你看过真实的 JavaPHP 混合架构的代码会发现 29 关的模拟场景一点都不夸张。在前后端分离或者多层代理的架构里不同组件对同名 HTTP 参数的处理方式确实存在差异Tomcat、Jetty 默认取第一个同名参数PHP 默认取最后一个同名参数ASP.NET/IIS 会把所有同名参数用逗号拼接某些 WAF 产品只看第一个参数或者只检查QUERY_STRING里的某个固定位置如果业务层恰好是 PHP 代理架构WAF 装在 Java 层或者 Nginx 层HPP 就有机会像 29 关那样通过双参数让 WAF 和后端各取所需。我在实际渗透测试中遇到过不止一次类似场景WAF 只检查 GET 的第一个参数值是否包含 SQL 关键字而后端 PHP 用 $_GET[id] 取参数这种情况用 HPP 直接绕过的成功率非常高。6.2 宽字节注入的现实意义宽字节注入在现代高版本 MySQL 和默认 UTF-8 配置下确实越来越难遇到了但在下面几类场景里依然有生存空间老旧的 PHPMySQL 系统建库时用了 GBK 且没做二次转义某些 CMS 的历史版本里连接字符集设置和转义函数使用不当从 MySQL 迁库到其他兼容数据库时编码行为出现差异另外理解宽字节注入的字节拼接原理对分析其他编码类漏洞比如 UTF-7 注入、Unicode 规范化绕过很有帮助。它不是孤立的一个技巧而是一整类编码差异导致安全控制失效问题的代表。6.3 从靶场到实战的迁移建议我的个人建议是刷完这四关之后不要急着去打其他靶场先把下面三件事做了第一把 29-31 关的源码里的取参函数改一改比如把end()改成reset()观察 HPP 是否失效加深对参数解析顺序的理解。第二把 32 关的SET NAMES gbk删掉看看%df%27是不是立刻失效明确宽字节注入的触发条件。第三用 Burp 的 Comparer 功能对比 WAF 层和后端层拿到的参数内容验证 HPP 中参数顺序对结果的影响。这三步做完你对这四关的理解深度会远超背十个 payload 的刷题方式。7. 常见问题与排查技巧实录7.1 HPP 参数顺序与工具设置的坑我在刷 29 关时第一次用 Burp 自带的重放功能直接改了 URL 里已有的id1加了一个id1 union select 1,2,3--。结果页面直接报错WAF 拦了。排查后发现是 Burp 的 URL 自动规范化功能把两个同名参数做了排序或合并。解决办法是在 Repeater 的 URL 输入框里手动编辑关掉“Update Content-Length”之外的自动更新或者在 Raw 视图里直接改请求行。另外注意如果浏览器插件自动对 URL 参数做解码再编码%df%27这种宽字节 payload 会被转成%25df%2527注入直接失效。建议对这类 payload 一律用 Burp 或者 curl 原生发送。7.2 宽字节注入不生效的三个排查方向如果你传了%df%27但页面依然正常无报错按这个顺序排查先确认数据库连接字符集。在源码里查有没有SET NAMES gbk、character_set_clientgbk之类的设置没有的话宽字节大概率不成立确认应用层没有额外的编码转换函数比如mb_convert_encoding、iconv把输入转成 UTF-8确认转义函数是addslashes还是mysql_real_escape_string。后者会针对 MySQL 的字符集做转义%df绕过在部分场景下不适用需要换%bf%27或者%df%5c%27之类的变体测试7.3 回显点定位与 union 注入断言的技巧四关的查询结果都是三列回显位置在第二列和第三列。如果你换到其他靶场遇到列数不同的情况可以用order by N逐步试探从 1 开始加直到报错再回退。定位回显点用union select 1,2,3,...,N哪个数字出现在页面上哪个位置就能用来带数据。另外一个小技巧如果页面上没有直接显示查询结果就改用报错注入或者盲注。32 关虽然能用 union 回显但如果你想多练一种姿势可以把id1%df%27 and extractvalue(1,concat(0x7e,(select database()),0x7e))--丢进去用报错信息拿数据。前提是 MySQL 版本得是 5.1.5 以上且没有关闭报错提示。7.4 SQL 注入通用排查速查表现象可能原因处理措施单引号/双引号不报错被转义或闭合不对用宽字节或换闭合符号union 注入不回显列数不对或回显点不在当前查询用 order by 确认列数逐个位置替换测试页面返回 500 但无 SQL 报错数据库报错被全局关闭改用布尔盲注或时间盲注payload 里的--失效注释符被过滤或编码异常改用#或-- -注意空格参数值超长被截断后端有长度限制拆分注入或用substr()分段取出7.5 一个容易被忽略的学习方法其实很多人刷靶场有个坏毛病只关心“怎么过”不关心“为什么过”。sqli-labs 的价值恰恰在于它的源码完全开放每一关都可以打开 php 文件逐行读。我建议你至少把 29 和 32 这两个的源码完完整整读一遍读不懂的地方打断点或者加echo输出观察变量值。拿 32 关来说你可以在addslashes($id)之后加一行echo bin2hex($id);看看传入%df%27之后实际的十六进制是什么。看到df5c27的那一瞬间你对宽字节注入的理解会有一个质变。这种动手改源码的学习方式比刷一百遍 payload 都管用。我个人刷这四关的体会是29 到 31 关考的是“你对 HTTP 协议和中间件行为的理解有多深”32 关考的是“你对字符集编码的认识是否细腻”。这两个维度都不是埋头背 payload 能学到的但恰恰是真实渗透里最容易出问题的地方。建议你刷完之后自己动手把 29 关的源码改成 30、31 的样式再对比着看取参逻辑的变化收获会比单纯过关大得多。