DVWA中级SQL注入绕过转义:数字型拼接、UNION与information_schema实战
老实说DVWA 这个靶场我刷过好几遍每次刷都有新体会。低级 SQL 注入基本就是“送分题”直接拼字符串就完事很多人做到中级就卡住了——因为前端突然从可以随便输入的文本框变成了一个下拉框后端也加了转义函数。这篇就是把 SQL Injection 中等级别的边界、绕过思路和常见坑彻底聊透顺便补上几个网上很少写清楚的技巧适合已经做过低级注入、想搞清楚“转义之后到底怎么玩”的读者。先说明一点DVWA 这类靶场只允许放在本地环境做实验别乱搬到公网或对着真实目标试这是底线。1. 中等难度的防护思路前端锁输入、后台做转义、查询却不带引号1.1 三层防护各自的“表面功能”和真实杀伤力从上往下捋一遍中等难度相比低级一共多了三道东西前端交互变了、后端过滤函数加了、查询语句构造方式也完全不同。很多人一上来就只盯着“转义函数”看其实真正的变数在最后一行。第一道是前端。低级是文本框随便你写什么字符都能提交。中级换成了select下拉菜单选项就 1 到 5 几个值。这就把大部分人的手工输入卡掉了——你根本没法在页面上输入或者OR 11。但这个限制只存在于浏览器界面抓包改 POST 参数就能轻松绕过去。所以前端限制永远只是“防君子不防小人”它不是安全边界只是降低了非技术人员乱点的概率。第二道是后端转义。典型写法是mysqli_real_escape_string它会把进入查询的单引号、双引号、反斜杠、NULL 字节等统一转义让这些字符在 SQL 语句里变成“字符串内容”而不是“语法符号”。如果你输入的 ID 被包裹在单引号里比如WHERE user_id $id那么输入1会被变成1\放进去之后就变成WHERE user_id 1\语法被中和掉了这就是为什么很多人觉得“转义之后注不进去”。第三道才是重点——查询语句本身。低级的写法是参数直接被单向引号包裹转义之后确实很安全。但中等级别的代码通常会写成$id $_POST[id]; $id mysqli_real_escape_string($GLOBALS[___mysqli_ston], $id); $query SELECT first_name, last_name FROM users WHERE user_id $id;;你看到问题了吗参数拼接进了 SQL但外面没有引号。转义函数处理的是“字符串上下文”里的特殊字符当参数被放在裸数字位置上时转义根本起不到拦截作用。这就像你给行李箱上了锁结果箱子压根没装东西——防御对象和攻击路径完全错位了。1.2 从代码层面理解“数值型拼接”和“字符串拼接”的本质差异要彻底搞懂为什么这门课卡人必须看懂这两种构造方式的区别。把查询字符串想象成一句话字符串拼接相当于把用户的输入当成“书名”放进书名号里转义函数就是给你的书名加上一层保护让你不能写出书名号之外的文字而数值型拼接相当于用户输入直接当作页码写在书签上你写什么查询就按什么执行。具体到 SQL 语法里WHERE user_id $id用户输入1 UNION SELECT ...转义后变成1\ UNION SELECT ...整个\被当成字符串内容后面内容全部作废注入失败。WHERE user_id $id用户输入1 UNION SELECT user, password FROM users转义函数检查这一整串发现里面没有需要在字符串上下文中处理的裸引号于是原样放行。于是 SQL 变成WHERE user_id 1 UNION SELECT user, password FROM users查询直接执行没有任何阻碍。正是因为中等级别走了数值型拼接这条路转义函数才形同虚设。这也解释了为什么实际测试时你根本不需要去闭合引号、注释掉尾巴只要把整条 payload 当作“不包含单引号的纯语法注入”来写就行了。很多教程上来就教“单引号被转义了要用宽字节注入”在这里是舍近求远。2. 确认无引号数字型注入抓包、运算探测与返回差异分析2.1 第一步为什么必须是抓包改请求因为页面上的下拉框只能选预设值你没法在浏览器里直接输入测试 payload。别想着右键检查改 DOM不如直接开抓包工具。把页面正常提交一次把 POST 请求拦下来然后手动改id参数的值再放行。只要抓包工具能正常拿到请求前端限制就等于不存在了。我习惯的处理方式是先在 Burp 里把 DVWA 的会话 Cookie 配置好然后直接构造原始 POST 请求发送不用每次都在浏览器里点来点去。这里有个小技巧请求里除了id参数通常还带一个SubmitSubmit的字段Python 或抓包工具重放时不能漏漏了后端会认为没有提交返回空页面。2.2 四个测试值确认“数值型、无引号包裹”的完整链路一套干净利落的确认流程可以这样走。先把id1作为基线看正常返回再看id1 AND 11接着id1 AND 12最后用id2-1做运算探测。四个值对照下来结论就非常明确了。测试值页面表现含义1正常显示第一个用户基线值1 AND 11正常显示第一个用户条件为真时不受影响1 AND 12没有返回任何数据条件为假时被过滤2-1显示第一个用户参数位置参与算术运算关键就在最后一条。如果查询把参数当字符串处理并加了引号劫持2-1会被当成普通字典文本根本不会触发减法运算返回的会是一个不存在的用户或者直接为空。现在它老老实实给你算出了 1说明这段输入被直接塞进了 SQL 表达式裸数字拼接毫无疑问。通过这套流程你不需要去猜什么“单引号有没有被过滤”直接用业务逻辑本身说话。2.3 想再省事一点一次请求同时验证多个条件的思路有时候为了省流量或者减少日志痕迹可以尝试把条件压缩到一条请求里验证。比如id1 AND (SELECT 1 FROM users WHERE user_id1)1整个表达式不出现单引号同样能判断查询是否能执行多个条件。不过这种压缩日常测试用不太到真正有价值的是培养“最小验证”的思维——每次改动只验证一个变量结果才能归因到具体因素上。我在测试这类问题时踩过一个小坑把1 AND 11缩写成1 AND 1DBA 环境可能没问题但某些 PHP 版本下会返回语法错误或产生奇怪回显反而干扰判断。老老实实写完整的11、12每一步都留出可复现性比什么都重要。3. 引号禁区里怎么拖数据UNION注入、十六进制与CHAR函数的组合拳3.1 为什么 UNION SELECT 在这种场景下特别好用确认了数字型注入之后下一步就是判断回显位和字段数。目标查询返回两列first_name和last_name。所以测试字段数时直接1 ORDER BY 2看是否正常再1 ORDER BY 3看是否报错因为中等级别的错误信息通常会被原样吐到页面上报错本身就是线索。一旦定位了字段数量1 UNION SELECT 1,2就能看到数字映射到哪个位置。这里有一个容易被忽略的关键点UNION 构造出的所有内容都不需要引号包裹表名、列名直接裸写数字直接写函数直接调。转义函数在这条链路里几乎完全没有插手的机会。换句话说只要你能找到一个不依赖字符串字面量的注入路径转义就是一张废纸。实际拖数据时可以直接构造1 UNION SELECT user, password FROM usersusers表里有user和password两个字段正好对应回显的两个位置。返回页面上第一列是用户名第二列是密码的 MD5 哈希。MD5 只是一串 32 位的十六进制字符里面既没有空格也没有单引号所以拖出来之后再送去在线工具还是本地字典爆破都是后面的事注入本身已经成功了。3.2 字符串字面量怎么绕过十六进制、CHAR 函数与列名裸写规则如果你需要收紧查询范围比如只想提取某个用户的密码问题就来了WHERE user admin里的admin带有单引号一旦写进参数转义函数会把它变成\admin\放进数字型拼接的 SQL 后直接破坏语法。这是中等难度里最容易被绕晕的地方——不是不能带字符串而是要换一种“不依赖引号”的写法。方案一十六进制字面量。admin的十六进制是0x61646d696e在 MySQL 里0x61646d696e会被解析为二进制的admin和普通字符串比较时行为几乎一致。于是可以写1 UNION SELECT user, password FROM users WHERE user 0x61646d696e全程没有出现一个单引号。方案二CHAR 函数。CHAR(97,100,109,105,110)也能拼出admin适合你记忆十进制 ASCII 码的场景。方案三最省事——直接用列名本身当比较对象或者利用information_schema里的表名列名裸写不涉及任何字符串字面量。优先掌握十六进制和 CHAR 两个技巧因为信息收集时这两个是刚需。3.3 提取指定数据的完整 payload 示例目标payload探测字段数1 ORDER BY 2/1 ORDER BY 3判断回显位1 UNION SELECT 1,2拖全部用户1 UNION SELECT user, password FROM users拖指定用户1 UNION SELECT user, password FROM users WHERE user 0x61646d696e提取当前库名1 UNION SELECT database(), 2提取版本1 UNION SELECT version(), 2我看过不少人卡在version()上因为在某些环境里version()函数名是大写也无所谓但偶尔会因为参数形式多写了括号导致 SQL 语法差异。真遇到这个问题就试试1 UNION SELECT version, 2这类变量形式在某些数据库上可比函数稳定得多。4. GROUP_CONCAT与information_schema一次请求批量枚举表和字段4.1 GROUP_CONCAT 解决“多行数据只能回显一行”的痛点手动一条条拖字段名太累了。最直接的思路就是把多行结果合并到一行里输出而 MySQL 自带的GROUP_CONCAT正好干这事。以获取当前库所有表名为例1 UNION SELECT GROUP_CONCAT(table_name), 2 FROM information_schema.tables WHERE table_schemadatabase()database()不需要引号table_name、table_schema都是裸列名GROUP_CONCAT会自动把返回的多个表名用逗号拼接成一个字符串页面一次就能显示完。没有GROUP_CONCAT时逐条查询既费流量又容易被页面截断有了它就是一行代码的事。需要特别注意一个小坑GROUP_CONCAT有默认长度限制默认大概 1024 字节超过的部分会被静默截断。DVWA 的环境里表不多不容易触发但如果你把这个思路搬到真实练习环境结果经常不明不白少一半。稳妥的做法是通过1 UNION SELECT group_concat_max_len, 2先看一眼当前值心里有数。4.2 用 information_schema.columns 一次性抓取目标表的全字段清单拿到表名后下一步是拿字段清单。假设已经知道有一个users表直接构造1 UNION SELECT GROUP_CONCAT(column_name), 2 FROM information_schema.columns WHERE table_name0x7573657273这段代码里唯一需要字符串比较的table_nameusers直接用十六进制0x7573657273替代绕过了引号转义。实际执行后页面会输出类似user_id, first_name, last_name, user, password, avatar, last_login, failed_login这样一串字段名。这个结果告诉你两件事第一users表的字段结构完全暴露了第二想拖整个表的所有字段可以用1 UNION SELECT user_id, password FROM users或者更通用地根据字段清单手动拼 SELECT。如果你想要一次性枚举出当前数据库里面所有表的所有字段也没问题把 WHERE 条件放宽到table_schemadatabase()就行。输出会很长甚至被截断但思路是一致的把GROUP_CONCAT(column_name)和GROUP_CONCAT(table_name)同时列出来逐表对应。这种做法在平时测试时价值很大因为很多时候你只知道有个目标表却不知道有没有password、email、phone这类敏感字段跑一遍清单就能迅速定位。4.3 用脚本替代手工重放请求构造与结果解析手动在抓包工具里改参数提交十几次是可行的但从经验来看这种重复劳动没有任何收益。把固定请求模板写成脚本每次只修改变量是更高效也更接近真实测试流程的做法。下面这个例子可以直接在本地 DVWA 环境里改改 Cookie 和 URL 使用import requests url http://127.0.0.1/dvwa/vulnerabilities/sqli/ cookies {PHPSESSID: 你的会话ID, security: medium} payload { id: 1 UNION SELECT GROUP_CONCAT(column_name), 2 FROM information_schema.columns WHERE table_name0x7573657273, Submit: Submit, } r requests.post(url, datapayload, cookiescookies) print(r.text)运行前记得把PHPSESSID换成你自己登录 DVWA 之后的会话值。跑完如果页面返回空或者只看到第一个用户的原始记录需要重点排查两件事一是users表名是否正确先用1 UNION SELECT GROUP_CONCAT(table_name), 2 FROM information_schema.tables WHERE table_schemadatabase()确认一下二是字段数是否匹配构造的 UNION 查询必须跟原始查询的列数保持一致多一列少一列都会报The used SELECT statements have a different number of columns。脚本的好处是便于反复调整 payload也容易把结果用正则表达式从 HTML 里抠出来。但注意自动化是为了提高实验效率不是为了拿脚本去扫任何未经授权的系统。本地靶场随便玩出了靶场这条线就断了。4.4 实际重跑流程中的两个典型异常与排查链路这个实验最常遇到的异常就是“明明语句没问题页面就是不显示第二行”。我的排查链路是先看后端报错有没有被原样输出如果输出从错误信息直接定位是语法问题还是列数不匹配。如果没有报错则大概率是回显位置不对这时用1 UNION SELECT AAAA, BBBB这种带特殊字符串的 payload 在页面源码里搜AAAA、BBBB看哪个位置能命中。再不行就检查GROUP_CONCAT返回结果是不是太长被截断或者被页面标签截断了显示范围。第二个异常是十六进制字符串写错。0x7573657273对应的是users如果少一位或多一位查询不会有明显语义错误但 WHERE 条件匹配不上任何记录返回空。这种问题最隐蔽因为它不报错。我现在的习惯是涉及十六进制的地方先在本地数据库客户端跑一遍SELECT 0x7573657273确认转换结果再粘贴到注入 payload 里。一步验证能省掉半小时瞎猜。结尾中级 SQL 注入最值得带走的东西不是“绕过转义”我把这套过程完整跑过几遍之后最大的感受是中级难度其实不是在教你怎么绕过滤函数而是在教你理解“过滤函数究竟在什么上下文中才有效”。mysqli_real_escape_string本身没问题问题在于开发者在数值型拼接场景错误地信任了它。这就是为什么防御侧真正推荐的永远是参数化查询或预编译语句——不是因为它天下无敌而是因为它从语法层面就不允许用户输入改变查询结构彻底绕开了“该转义什么、不该转义什么”这种争论。对初学者来说我的建议是不要把精力全花在背 payload 上而是把“无引号字符串表达”这套思路练熟。十六进制、CHAR 函数、列名裸写、GROUP_CONCAT这几个工具组合起来足以让你在没有单引号可用的情况下完成百分之八十的信息提取。以后再遇到其他靶场或 CTF 题看到数字型拼接加转义函数的搭配脑子里会自动浮现这条链路而不是傻乎乎地试宽字节。这个能力才是中级难度真正值得“补充”的内容。