手把手系列:DVWA SQL 注入 medium / high 难度实战 day03
手把手系列:DVWA SQL 注入 medium / high 难度实战 day03
day02(下)打的是 low,说白了就是靶场把门开着让你进。
这一篇打 medium 和 high。难度提升的方向,不是拦得更严,而是输入进来的路子变了——这个认知比几条 payload 重要得多。最后再回头看一遍 impossible 是怎么写的,打完漏洞回头看正确写法,这个顺序不能省。
特别声明:本文所有实验操作均在本地隔离虚拟靶场环境完成,文中技术仅供网络安全学习研究使用。严禁在未获得书面授权的情况下对任何非自有系统实施扫描、渗透、测试等行为,违规操作带来的一切法律责任由行为人自行承担。
一、先看源码再动手
左侧菜单 DVWA Security 切成 medium,然后点右下角 View Source:

然后就会弹出以下内容

两处变化:输入从 GET 变成了 POST,并且加了一层 mysqli_real_escape_string。
还有一处变化源码里看不出来,得看 HTML——输入的形态从输入框变成了下拉框:
<select name="id">
<option value="1">1</option>
<option value="2">2</option>
...
</select>

下拉框这个东西很容易骗人。它只是限制了浏览器的提交值,拦不住任何东西。 用 Yakit 改包或者直接构造 POST 请求,想传什么就传什么。
1.1 这个转义为什么没用
先把概念分清楚,这块特别容易混:
| 概念 | 在干什么 | 防护效果 |
|---|---|---|
| 转义(escaping) | 给特殊字符加反斜杠,让它们失去语法含义,变成普通字符 | 取决于用在哪,用错了等于没加 |
| 参数化(prepared statement) | 先把 SQL 结构编译定型,参数只能当值填进去 | 不依赖开发者记得过滤 |
mysqli_real_escape_string 属于第一种,而且是做得比较正确的转义函数,它认识当前连接的字符集,能把 '、"、\、NULL 这些字符都转义掉。
问题出在它被用在了哪里。 看拼出来的 SQL:
SELECT first_name, last_name FROM users WHERE user_id = $id;
注意:$id 外面没有引号。
这是个对比——low 的写法是:
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
// ↑ ↑
// 这里是有引号的
low 里有引号,所以你把 $id 输入成 1' or '1'='1,那个单引号会闭合掉 SQL 里的引号,从而改变语句结构——这就是注入。
而 medium 里没有引号,你输入 1' or '1'='1,转义函数会把单引号变成 \',拼进去是:
SELECT first_name, last_name FROM users WHERE user_id = 1\' or \'1\'=\'1;
一个数字后面跟个反斜杠,SQL 直接语法报错。
但是——既然这里根本没有引号给你闭合,那你为什么非要输入引号?
数字型注入里,你压根不需要引号。所以
1 or 1=1就够了。
这就是这个转义的破绽所在:它守的是「引号」这个门,而这道题根本不从这扇门进。
💡 这里有个概念上的问题:「转义了」不等于「安全了」。 转义的防护范围是「引号边界」,一旦开发者在拼接时漏了引号、或者用了数字字段,转义就完全失去了作用。判断一段代码安不安全,不能看它调没调转义函数,要看用户输入最终是以「值」还是以「语法」的身份进入 SQL。
1.2 先把请求抓下来
medium 是 POST 提交,地址栏改不了,必须在工具里做。
Yakit 的 手工测试 → MITM交互式劫持 → 免配置启动,在打开的浏览器里进 DVWA 的 SQL Injection 模块,随便选个 1 点 Submit。
回到 Yakit 的 History,能看到这样一条:

右键 → 发送到 Web Fuzzer,后面所有 payload 都在这里改。
1.3 从 1 开始试
先确认注入还在不在。把 id=1 改成 id=1 or 1=1:
id=1 or 1=1&Submit=Submit

页面把所有用户都列出来了。 注入成立,而且完全没被拦。
再做个真假对比确认一下:
id=1 or 1=1 → 返回全部用户
id=1 or 1=2 → 只返回 id=1 那一条

两次结果不一样,说明 or 后面的条件被执行了。
1.4 确定列数
还是 ORDER BY,但排骨这次不从 1 开始试了——day02 已经知道这张表是 2 列,直接拿 3 来验证,一次就够:
id=1 order by 3

报错。换成 2:
id=1 order by 2

正常返回。列数确认是 2。
同day02,从大向小试试。
1.5 找显示位
id=0 union select 1,2
id=0 是个不存在的值,这样前半段查询返回空,页面上就只剩我们注入的那部分,看着干净。
页面上出现 1 和 2,两个位置都能回显。
关于注释
排骨顺手打了个 id=0 union select 1,2--,想用 -- 把后面多余的字符注释掉,结果页面报错。
原因:-- 注释符后面必须跟一个空格(MySQL 的规矩),写成 -- 直接贴内容是不认的。正确写法是 -- 或者干脆用 #。
换成 # 之后,在 Yakit 里直接把 body 改成 id=0 union select 1,2#&Submit=Submit 还是报错,得写成 id=0 union select 1,2%23&Submit=Submit。
因为 # 在 URL 里是锚点分隔符,而表单提交的 body 是要做 URL 编码的——# 必须先编码成 %23 才能作为数据被送出去,这是 day02 提过的老问题。
⚠️ 这里有个很多人(包括排骨)会遇到的问题:
%23不是万能写法。它的本质是「URL 里的
#」。如果你是在表单框里直接打字、或者用工具发原始请求,%23反而会被当成三个普通字符%、2、3送进数据库,一个字都注释不掉。判断标准是:这段内容最终以什么形式传输。 走 URL 编码的(地址栏、表单 body)要用
%23;直接当原始字符串的(表单输入框、原始报文)就用#。
1.6 按老套路脱库
路径和 day02 完全一样:当前库 → 表 → 字段 → 数据。
当前库名:

dvwa。
表名:

能看到 guestbook 和 users。
字段名:
id=0 union select 1,group_concat(column_name) from information_schema.columns where table_schema=database() and table_name=0x7573657273%23
结果:

0x7573657273 是什么? 就是 users 的十六进制。这里本来应该写 table_name='users',但那两个单引号会被 mysqli_real_escape_string 转义掉,所以得换个写法绕过去。
users → 0x 75 73 65 72 73
u=75、s=73、e=65、r=72、s=73,拼起来就是 0x7573657273。MySQL 见到 0x 开头的十六进制字面量会当成字符串处理,一路上一个引号都不需要。
手算太麻烦,Yakit 的 Codec 功能里可以直接做十六进制编码,CyberChef 也行。思路记住就行:凡是引号被转义、又必须传字符串的地方,都可以试试十六进制。
把用户名和密码拖出来:
id=0 union select group_concat(user,0x3a,password),2 from users%23
0x3a 是冒号,作用同 day02,把用户名和密码拼在一行里显示。
二、medium 的过滤漏洞到底在哪
medium 内容:
| 做了什么 | 有用吗 |
|---|---|
| GET 改 POST | ❌ 只是换了提交方式,改包就绕过了 |
| 下拉框限制取值 | ❌ 只限制浏览器,不限制请求 |
mysqli_real_escape_string 转义 |
⚠️ 函数本身是对的,但用错了地方 |
第三行是这节的结论。它转义了一个不需要转义的位置。
真正拦住数字型注入的办法只有一个:要么把输入强制转成数字类型,要么用参数化查询把它当值绑进去。 转义在这里从原理上就不对症。
三、high 难度:思路又变了
切成 high,先看这一关长什么样——页面结构变了。
这一关的输入不在 index.php 上,而是先进一个单独的页面填 ID,提交后再跳回 index。
所以请求分两步:
第一步:POST /vulnerabilities/sqli/session-input.php → 在这里填 ID
第二步:GET /vulnerabilities/sqli/ → 这里显示结果
这个变化很关键。 因为第二步的请求里,压根没有你输入的 ID 了。
那 ID 去哪了?看源码:

输入被存进了 session,然后从 session 里取出来拼进 SQL。
3.1 LIMIT 1 不是防护
很多人看到 LIMIT 1 会以为这是安全措施。不是。
它是一行代码写顺手了,作用是「只返回一条记录」。它能影响的只有输出数量,拦不住注入甚至可以说它让注入变得更好玩了。
真正变化的是另外两点:
| 变化 | 后果 |
|---|---|
| 输入走 session | 必须打两步请求,不能一条 payload 走完 |
| 拼进 SQL 时重新加上了引号 | 引号回来了,注入点也跟着回来了 |
die() 里的报错信息被清空 |
or die('<pre>Something went wrong.</pre>'),报错注入没了 |
注意最后一行:high 不再把 MySQL 的报错吐给你。没有报错回显,就只能靠真假的差异来判断——我们被逼向盲注。
3.2 先做个最简单的是非题
注入点在哪,得先确认。payload 在 session-input.php 提交。
一个反直觉的地方。 high 的 SQL 里 $id 是被单引号包着的,但下面这个 payload 一个引号都没有:
1 or 1=1
拼进去是:
SELECT first_name, last_name FROM users WHERE user_id = '1 or 1=1' LIMIT 1;
'1 or 1=1' 整个被当成字符串,MySQL 拿它跟 user_id 做隐式类型转换,转换结果是 1——查询出结果了,但那是 id=1 本来该有的结果,跟我们的 or 没有半点关系。
换成 1 or 1=2 也一样:
SELECT first_name, last_name FROM users WHERE user_id = '1 or 1=2' LIMIT 1;
字符串照样被转成 1。两个 payload 结果完全一样,都返回同一个用户。
如果只看「页面有数据返回」就下结论,这里会误判成「注入被防住了」。
真正能说明问题的是带上引号的版本:
1' or '1'='1 → 显示用户
1' or '1'='2 → 什么也不显示

第一个 payload 里那个 ' 把 SQL 语句中的引号闭合了,后面的 or '1'='1 才真正作为条件被执行;第二个因为条件恒假,查询不返回任何东西。
两次结果不一样,注入才算坐实。
💡 这个对比很有价值:同一个输入点,加不加引号,结果完全不同。
判断注入不能只看「页面有没有返回数据」,必须构造一对真假条件,看两次结果是否不同。否则你会把
1 or 1=1那种看起来「成功」的结果当成证据,而它其实什么都没证明。这也是 day02 讲真假条件对比的原因——它是唯一不依赖报错回显的判断方法,而 high 恰好把报错回显去掉了(
or die('<pre>Something went wrong.</pre>'))。
页面只告诉我们「是」或「不是」,不告诉我们数据是什么。这就是布尔盲注的起点。
3.3 UNION 还能用,但一次只能吐一个值
虽然被 LIMIT 1 卡着,UNION 还没死——因为它可以被「聚合」成一行。
1' union select 1,group_concat(user,0x3a,password) from users#

group_concat 把所有用户名和密码揉成一行,正好满足 LIMIT 1 只取一行的要求。一次性把数据全拿走。
💡 这里能看出一个思路:
LIMIT 1限制的是「行数」,不是「信息量」。 把多行压成一行,限制就形同虚设。但如果目标数据本身拿不到、或者被压成一行也不行的时候,就得靠盲注一个字符一个字符地抠了。
⚠️ 注意
#的写法:这里是直接填在表单框里提交的,写#就行;如果你是在地址栏或者 POST body 里拼参数,#会被当成锚点,那就得写成%23。看输入方式决定,别死记一个。
3.4 一个字符一个字符地抠
假设 UNION 那条路走不通(真实环境里,很多点根本没有回显可看),我们就得靠「是 / 不是」来问问题。
思路:把问题拆成一个个能回答「是 / 不是」的小问题。
先问长度:
1' and length(database())=4#

页面显示了用户——说明条件为真,database() 的长度是 4(dvwa 正好 4 个字符)。
再问第一个字符:
1' and substr(database(),1,1)='d'#

显示用户 → 第一个字符是 d。
然后 substr(database(),2,1) 问第二个……一直问下去。库名、表名、字段名、数据,全都这么问。
这个过程就是布尔盲注。
为什么这么慢还要讲它: 因为真实环境里,报错回显和 UNION 回显经常都没有。盲注是最后一条路,也是必须会的一条路。
手工一个个猜确实不现实。下一篇会讲怎么用 sqlmap 把这件事自动化——但前提是你知道它在替你做什么。 不知道的话,你连它跑出来的结果对不对都判断不了。
3.5 high 这里还有点“小惊喜”
这一关填 ID 的那个页面(session-input.php),会把上一次提交的 ID 原样显示回来:
你提交什么,页面上就回显什么
也就是说,在那个框里输入:
<script>alert(1)</script>
它会被当 HTML 执行。
这是个 XSS,跟 SQL 注入是两码事——但它是同一个输入点。 DVWA 这里也是没做处理。
排骨把这个提出来,是因为它说明一件事:一个输入点可以同时是好几种漏洞。
你在纠结「这里到底是 SQL 注入还是 XSS」的时候,正确答案往往是「两个都是」。同一份输入,被存进 session 又拼进 SQL 是注入,被回显到页面就是 XSS。防守方只看住一处,攻击方就从另一处进去。
四、回顾 impossible 是怎么写的
打完两关,切到 impossible,看正确写法:
if(is_numeric( $id )) {
$id = intval ($id);
$data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );
$data->bindParam( ':id', $id, PDO::PARAM_INT );
$data->execute();
}
四道防线,缺一不可:
| 防线 | 作用 |
|---|---|
is_numeric() |
先判断「是不是数字」,不是就直接不处理 |
intval() 强制转型 |
把输入变成真正的整数,脏东西直接被砍掉 |
prepare() + bindParam() |
⭐ 参数化查询,SQL 结构先定型,值只能是值 |
PDO::PARAM_INT |
明确告诉数据库「这是个整数」,连类型都不给含糊的余地 |
checkToken() |
顺带把 CSRF 也防了——SQL 注入和 CSRF 常出现在同一个点 |
注意 is_numeric() 和 intval() 是两道,不是一道。 前者是判断,后者是转换,两个都要有。光判断不转换,你还是把原始字符串拼进去了;光转换不判断,一些奇奇怪怪的输入可能被悄悄转成数字,行为不符合预期。
4.1 转义和参数化,差在哪
打个比方:
- 转义像是给门上的把手缠了层胶带——你预设了攻击者只会从门进,所以只加固了门。
- 参数化是把整栋楼的结构先浇筑好,值只能填进预留的孔位,压根没有「改结构」这个选项。
再具体点,两者的差别是这样:
| 转义 | 参数化 | |
|---|---|---|
| 工作时机 | 拼接之前,处理字符串 | 语句编译时,确定结构 |
| 依赖什么 | 开发者记得用、用对位置 | 数据库引擎强制执行 |
| 能不能被绕过 | 位置用错就失效(就像 medium) | 结构不可变,没有绕过的可能 |
| 字符集问题 | 有(老版本的宽字节注入就是打这个) | 无 |
所以「过滤特殊字符」和「转义」都是治标,参数化才是治本。 理解了这一点,才算真正理解了 SQL 注入。
medium 那一关就是这句话最好的证据:它用了正确版本的转义函数,照样被打穿。
五、总结一下这一篇
从 low 到 impossible,四关的差别其实就一条线:
low : 输入没引号包裹,没有任何处理 → 一个单引号就打穿
medium : 输入没引号包裹,加了转义 → 转义守错了门,照样穿
high : 输入走 session,有引号,没有报错回显 → 得靠真假对比判断,照样穿
impossible : 参数化 + 强制类型 → 打不穿
这四行是整个 SQL 注入的核心。 记住这四行,比记住任何一条 payload 都有用——payload 是会随版本变的,这条规律不会。
排骨这次遇到的问题
| 问题 | 教训 |
|---|---|
打了 -- 没带空格,报错 |
MySQL 的 -- 后面必须有空格,否则不生效 |
# 和 %23 混用 |
看输入方式定:走 URL 编码的用 %23,原始字符串用 #,反了就一个字都注释不掉 |
| 以为 medium 是「过滤单引号」 | 官方版本实际用的是 mysqli_real_escape_string,看源码,别看教程 |
以为 LIMIT 1 是防护 |
它只是限制返回行数,group_concat 一样能一次全拿走 |
最后一条多说一句:DVWA 的源码是会更新的。 排骨照着网上一些老教程打的时候,发现写的过滤方式跟实际源码对不上——老版本确实用过别的写法。所以这个系列的源码部分,都是排骨照着当前版本扒下来核对过的。你打的时候如果和本文对不上,先去看自己的 View Source。
浙公网安备 33010602011771号