手把手系列:DVWA SQL 注入 medium / high 难度实战 day03

手把手系列:DVWA SQL 注入 medium / high 难度实战 day03

day02(下)打的是 low,说白了就是靶场把门开着让你进。

这一篇打 medium 和 high。难度提升的方向,不是拦得更严,而是输入进来的路子变了——这个认知比几条 payload 重要得多。最后再回头看一遍 impossible 是怎么写的,打完漏洞回头看正确写法,这个顺序不能省。

特别声明:本文所有实验操作均在本地隔离虚拟靶场环境完成,文中技术仅供网络安全学习研究使用。严禁在未获得书面授权的情况下对任何非自有系统实施扫描、渗透、测试等行为,违规操作带来的一切法律责任由行为人自行承担。

一、先看源码再动手

左侧菜单 DVWA Security 切成 medium,然后点右下角 View Source:

屏幕截图 2026-10-14 184926

然后就会弹出以下内容

image

两处变化:输入从 GET 变成了 POST,并且加了一层 mysqli_real_escape_string。

还有一处变化源码里看不出来,得看 HTML——输入的形态从输入框变成了下拉框:

<select name="id">
    <option value="1">1</option>
    <option value="2">2</option>
    ...
</select>

image

下拉框这个东西很容易骗人。它只是限制了浏览器的提交值,拦不住任何东西。 用 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,能看到这样一条:

屏幕截图 2026-10-14 191347

右键 → 发送到 Web Fuzzer,后面所有 payload 都在这里改。

1.3 从 1 开始试

先确认注入还在不在。把 id=1 改成 id=1 or 1=1:

id=1 or 1=1&Submit=Submit

屏幕截图 2026-10-14 191600

页面把所有用户都列出来了。 注入成立,而且完全没被拦。

再做个真假对比确认一下:

id=1 or 1=1      → 返回全部用户
id=1 or 1=2      → 只返回 id=1 那一条

屏幕截图 2026-10-14 191635

两次结果不一样,说明 or 后面的条件被执行了。

1.4 确定列数

还是 ORDER BY,但排骨这次不从 1 开始试了——day02 已经知道这张表是 2 列,直接拿 3 来验证,一次就够:

id=1 order by 3

屏幕截图 2026-10-14 191736

报错。换成 2:

id=1 order by 2

屏幕截图 2026-10-14 191755

正常返回。列数确认是 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 完全一样:当前库 → 表 → 字段 → 数据。

当前库名:

屏幕截图 2026-10-14 192402

dvwa。

表名:

屏幕截图 2026-10-14 193012

能看到 guestbook 和 users。

字段名:

id=0 union select 1,group_concat(column_name) from information_schema.columns where table_schema=database() and table_name=0x7573657273%23

结果:

屏幕截图 2026-10-14 200239

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 去哪了?看源码:

image

输入被存进了 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      → 什么也不显示

image

第一个 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#

image

group_concat 把所有用户名和密码揉成一行,正好满足 LIMIT 1 只取一行的要求。一次性把数据全拿走。

💡 这里能看出一个思路:LIMIT 1 限制的是「行数」,不是「信息量」。 把多行压成一行,限制就形同虚设。

但如果目标数据本身拿不到、或者被压成一行也不行的时候,就得靠盲注一个字符一个字符地抠了。

⚠️ 注意 # 的写法:这里是直接填在表单框里提交的,写 # 就行;如果你是在地址栏或者 POST body 里拼参数,# 会被当成锚点,那就得写成 %23。看输入方式决定,别死记一个。

3.4 一个字符一个字符地抠

假设 UNION 那条路走不通(真实环境里,很多点根本没有回显可看),我们就得靠「是 / 不是」来问问题。

思路:把问题拆成一个个能回答「是 / 不是」的小问题。

先问长度:

1' and length(database())=4#

image

页面显示了用户——说明条件为真,database() 的长度是 4(dvwa 正好 4 个字符)。

再问第一个字符:

1' and substr(database(),1,1)='d'#

image

显示用户 → 第一个字符是 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。

参考

posted @ 2026-10-14 20:55  半扇排骨  阅读(6)  评论(0)    收藏  举报