ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DVWA SQL注入全等级实战:从手工注入到参数化防御

DVWA SQL注入全等级实战:从手工注入到参数化防御

1. 为什么SQL注入至今仍是绕不开的必修课

但凡接触过Web安全方向的人,对DVWA这个靶场都不会陌生。它全称Damn Vulnerable Web Application,是一套刻意设计得千疮百孔的练习环境,专门用来让安全学习者在合法可控的条件下,亲手体验各种经典漏洞的利用过程。而SQL Injection,也就是SQL注入,几乎是所有人打开DVWA后第一个真正上手实战的模块。原因很简单:它原理直观、危害巨大、利用手法层次分明,从最低难度的直接拼接,到高难度的盲注与绕过,一条线走下来,能把数据库交互这件事的里里外外摸个通透。

这篇文章要聊的,就是DVWA里SQL Injection这个模块的完整拆解。我会从它背后的代码逻辑讲起,把Low、Medium、High、Impossible四个安全等级的实现差异逐一剖开,再配合手工注入的完整实操流程、参数构造的推导过程、以及我在反复练习中踩过的坑和总结出来的排查技巧。不管你是刚入门、连union select都还没写利索的新手,还是已经能盲注跑数据、想回头把底层逻辑再夯实一遍的老手,这篇内容都能给你一些能直接抄作业的东西。

需要先明确一点:本文所有操作都只在本地搭建的DVWA靶场环境中进行,目的是理解漏洞成因、掌握防御思路。任何脱离授权环境、针对真实系统的测试都是违规的,这个边界必须守住。下面进入正题。

2. DVWA靶场环境与SQL注入模块的整体设计

2.1 DVWA的定位与安全等级机制

DVWA最巧妙的设计,是它的安全等级切换功能。同一个功能模块,在Low、Medium、High、Impossible四个等级下,后端代码的防护强度完全不同。Low几乎不设防,Medium加了基础过滤,High上了更严格的限制,Impossible则是教科书级别的正确写法。这种递进式设计的好处在于,你能直观看到"同一个漏洞,防护每加强一层,攻击手法就要相应升级",从而真正理解防御措施到底在防什么。

SQL Injection模块的功能本身很简单:页面上有一个输入框,你输入一个用户ID,它去数据库里查这个ID对应的用户名,然后把结果返回给你。就这么一个看似无害的查询功能,在Low等级下却能被玩出花来——因为它的SQL语句是直接字符串拼接的。

2.2 数据库结构与查询逻辑

DVWA默认使用MySQL数据库,SQL Injection模块涉及的核心表是users表,里面有两个关键字段:user_id和first_name、last_name。正常查询的SQL语句长这样:

SELECT first_name, last_name FROM users WHERE user_id = '$id';

这里的$id就是你在输入框里填的内容,被原封不动地塞进了SQL语句。理解这一点是理解整个注入攻击的钥匙——你的输入不再是"数据",而是变成了"代码"的一部分。当你输入1,语句变成WHERE user_id = '1',正常返回。当你输入1' OR '1'='1,语句就变成了WHERE user_id = '1' OR '1'='1',条件恒真,于是返回所有用户。这就是注入的本质:让用户输入突破数据边界,篡改SQL语义。

2.3 四个等级的实现差异概览

在动手之前,先把四个等级的代码逻辑差异理清楚,这样你在实操时才知道自己面对的是什么。

安全等级核心防护手段可利用性
Low无任何过滤,直接拼接完全可利用,任意注入
Mediummysqli_real_escape_string转义 + 下拉框限制输入数字型注入仍可利用
High限制输入长度 + 会话校验 + 分页查询需借助其他注入点或二次注入
Impossible参数化查询(预处理语句)无法注入

这张表先放在这里,后面每个等级我都会展开讲。你会发现,Medium和High的防护看似严密,但都有各自的破绽,而这些破绽恰恰是真实世界里很多系统翻车的地方。

3. Low等级:从手工注入到拖库的完整实操

3.1 判断注入类型与闭合方式

进入SQL Injection页面,把安全等级调到Low。第一步不是急着写payload,而是先判断注入类型。在输入框里填1,正常返回。填1',如果报错,说明存在字符型注入,且闭合符是单引号。DVWA的Low等级正是这种情况,填1'会抛出SQL语法错误。

这一步的判断逻辑很关键。SQL注入按数据类型分,有数字型和字符型。数字型不需要闭合符,直接1 and 1=1就行;字符型则需要先闭合前面的引号。DVWA这里是字符型,所以payload要围绕单引号来构造。判断闭合方式的方法就是逐个尝试'、"、)等,看哪个能造成语法错误,哪个能让语句恢复正常。

3.2 确定字段数量:ORDER BY的妙用

闭合方式确定后,下一步是确定查询返回了几个字段。因为后面要用UNION SELECT联合查询,而UNION要求前后两个SELECT的字段数必须一致。确定字段数最常用的方法是ORDER BY。

依次输入:

1' ORDER BY 1 # 1' ORDER BY 2 # 1' ORDER BY 3 #

#是MySQL的注释符,用来把后面的原始SQL注释掉,避免语法错误。当ORDER BY 2正常返回、ORDER BY 3报错时,说明查询结果有2个字段。DVWA这里就是2个字段。这个推导过程不能跳,字段数错了,后面的UNION查询必然失败。

注意:注释符在不同数据库里不一样。MySQL用#或--(注意--后面有个空格),SQL Server用--,Oracle用--。DVWA是MySQL,所以#和--都能用。我习惯用#,因为不用管空格,省事。

3.3 UNION SELECT回显数据

字段数确定后,就可以用UNION SELECT把想要的数据回显到页面上。先测试回显位置:

1' UNION SELECT 1,2 #

页面上会显示两个数字,告诉你哪个字段的内容会显示出来。DVWA里两个字段都会显示。接着就可以查数据库信息了:

1' UNION SELECT database(), user() #

这会返回当前数据库名和当前数据库用户。继续往下,查所有表名:

1' UNION SELECT 1, group_concat(table_name) FROM information_schema.tables WHERE table_schema = database() #

information_schema是MySQL自带的元数据数据库,里面存着所有库、表、列的信息。group_concat把多行结果拼成一行,方便一次性看全。查到表名后,查users表的列名:

1' UNION SELECT 1, group_concat(column_name) FROM information_schema.columns WHERE table_name = 'users' #

最后拖出账号密码:

1' UNION SELECT user, password FROM users #

到这一步,整个users表的数据就全出来了。DVWA里的密码是MD5哈希,可以拿去在线解密或者用工具跑。

3.4 实操心得与常见卡点

第一次做的时候,我最容易卡在三个地方。第一是忘了闭合引号,payload写一半就报错;第二是字段数没对齐,UNION查询直接失败;第三是注释符用错,导致后面的原始SQL没被注释掉,语法错误。这三个点几乎是新手必踩的坑。

还有一个细节:group_concat默认有长度限制,如果表特别大,可能显示不全。可以用group_concat(table_name SEPARATOR '||')自定义分隔符,或者配合limit分页查。DVWA数据量小,一般不会遇到这个问题,但真实环境里这个坑很常见。

4. Medium等级:转义与下拉框下的突破口

4.1 代码层面的防护变化

切到Medium等级,你会发现输入框变成了下拉选择框,只能选1到5这几个数字。同时后端代码里加了mysqli_real_escape_string函数,对输入进行转义。这个函数会把单引号、双引号、反斜杠等特殊字符前面加上反斜杠,让它们失去语法意义。

看起来防护到位了,但问题出在SQL语句的构造方式上。Medium等级的SQL语句变成了:

SELECT first_name, last_name FROM users WHERE user_id = $id;

注意,这里的$id外面没有引号了。也就是说,它把用户输入当成了数字型来处理。而mysqli_real_escape_string对数字型注入是无效的——因为它只转义引号类字符,而数字型注入根本不需要引号。

4.2 数字型注入的利用

既然不需要闭合引号,payload就变得非常直接:

1 OR 1=1

或者用UNION:

1 UNION SELECT 1, group_concat(table_name) FROM information_schema.tables WHERE table_schema = database()

下拉框的限制怎么办?用抓包工具拦截请求,把id参数的值改掉再放行就行。因为下拉框只是前端限制,后端并没有校验传入的值是不是1到5。这是典型的"前端校验不可信"案例。

4.3 这个等级教给我们的防御教训

Medium等级是一个非常好的反面教材。它做了两件事:转义特殊字符、限制前端输入。但两件事都没做到位。转义函数选错了场景(数字型不需要转义引号),前端限制又形同虚设。真实世界里很多系统就是这样,看起来加了防护,实际上防护和场景不匹配,等于没防。

正确的做法应该是:不管什么类型,统一用参数化查询。这一点在Impossible等级里会看到标准答案。

5. High等级:长度限制与会话机制下的迂回

5.1 防护升级的具体表现

High等级的防护又上了一个台阶。首先,输入框的maxlength被限制得很短,你没法直接输入长payload。其次,后端代码里用了LIMIT 1,只返回一条结果。更关键的是,它把查询和显示拆成了两步:先查询,把结果存到会话里,再跳转到另一个页面显示。这意味着你没法像Low那样直接在同一个页面看到回显。

代码逻辑大致是这样的:

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

5.2 绕过思路:从回显注入到盲注

因为页面不再直接回显查询结果,UNION SELECT的回显路子走不通了。这时候就要转向盲注(Blind Injection)。盲注分两种:布尔盲注和时间盲注。

布尔盲注的思路是:构造一个条件,如果条件为真,页面返回正常;如果为假,页面返回异常或空白。通过不断提问"是/否"来逐字符推断数据。比如:

1' AND (SELECT SUBSTRING(database(),1,1)) = 'd' #

如果数据库名第一个字符是d,页面正常;否则异常。这样一个个字符试,最终拼出完整信息。

时间盲注则更隐蔽:不管条件真假,页面都正常返回,但通过SLEEP()函数让页面延迟响应来传递信息:

1' AND IF((SELECT SUBSTRING(database(),1,1)) = 'd', SLEEP(3), 0) #

如果条件为真,页面会卡3秒;否则立即返回。通过响应时间判断条件真假。

5.3 长度限制的绕过

High等级限制了输入长度,但抓包改包依然可以绕过前端限制。因为maxlength只是HTML属性,拦截请求后随便改。真正需要关注的是后端有没有做长度校验——DVWA的High等级后端并没有严格校验长度,所以改包后长payload照样能打进去。

实操提示:盲注手工跑非常耗时,一个字符一个字符试,一个数据库名可能要试几十次。实际练习时建议写个简单的Python脚本自动化,或者用sqlmap这类工具辅助。但手工跑一遍的价值在于,你能彻底理解盲注的逻辑,知道工具在背后做了什么。

5.4 盲注脚本的核心逻辑

如果要写脚本,核心逻辑就是遍历字符集,对每个位置构造payload,根据响应判断字符值。伪代码大概是这样:

import requests chars = 'abcdefghijklmnopqrstuvwxyz0123456789_' result = '' for pos in range(1, 20): for c in chars: payload = f"1' AND SUBSTRING(database(),{pos},1)='{c}' #" # 发送请求,判断响应 if is_true(payload): result += c break print(result)

这个脚本跑起来,几分钟就能把数据库名、表名、列名、数据全跑出来。但前提是你得先手工验证过payload的正确性,知道怎么判断真假。

6. Impossible等级:参数化查询的正确姿势

6.1 代码实现解析

Impossible等级的代码是整篇文章里唯一值得抄的部分。它的核心是参数化查询,也叫预处理语句。代码逻辑大致如下:

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

这里的关键是?占位符和bind_param。用户输入不再拼接到SQL语句里,而是作为参数单独传给数据库。数据库在执行时,会先编译SQL模板,再把参数填进去。因为SQL结构已经固定,用户输入无论包含什么字符,都只能作为"数据"存在,无法改变SQL语义。

6.2 为什么参数化查询能根治注入

参数化查询之所以有效,是因为它把"代码"和"数据"彻底分离了。在拼接模式下,用户输入和SQL语句混在一起,数据库无法区分哪部分是开发者写的、哪部分是用户输入的。而在参数化模式下,SQL模板先被编译,参数后填充,数据库明确知道哪些是结构、哪些是值。这就好比你去餐厅点菜,菜单是固定的,你只能在指定位置填数量,不能自己改菜单。

另外,Impossible等级还加了CSRF token校验和输入类型检查,但这些是锦上添花,核心还是参数化查询。只要用了参数化查询,SQL注入就从根本上被堵死了。

6.3 参数化查询的常见误区

很多人以为用了ORM框架就自动安全了,其实不然。有些ORM的某些方法仍然会拼接字符串,比如某些框架里的whereRaw、orderBy拼接等。参数化查询的关键是:所有用户可控的输入,都必须通过参数绑定的方式传入,不能有任何一处是字符串拼接。这一点在真实项目里要特别警惕,因为一个项目里只要有一处漏网,整个防线就破了。

7. 常见问题与排查技巧实录

7.1 注入失败的原因排查表

现象可能原因排查方向
输入单引号无报错不是字符型注入,或报错被屏蔽尝试数字型payload,检查是否盲注
UNION查询报错字段数不一致重新用ORDER BY确定字段数
回显位置为空查询结果未显示在该字段调整UNION中数字的位置
盲注脚本跑不出结果判断真假的逻辑有误手工验证单个payload的响应差异
抓包改包无效后端有额外校验检查是否有token或会话校验

7.2 几个容易忽略的细节

第一个细节是编码问题。有时候payload里的空格、注释符会被URL编码影响,导致实际传输的内容和你想的不一样。建议在抓包工具里直接看原始请求,确认payload没有被意外编码。

第二个细节是数据库版本差异。MySQL 5.0以上才有information_schema,老版本需要用其他方式猜表名。DVWA用的是较新版本,所以information_schema可用。真实环境里如果遇到老数据库,手法要相应调整。

第三个细节是权限问题。information_schema里的数据受当前数据库用户权限限制,如果权限低,可能看不到某些表。这时候可以尝试直接猜常见表名,比如admin、user、member等。

7.3 我的避坑经验

踩过几次坑之后,我养成了一个习惯:每换一个等级,先把后端代码读一遍。DVWA的源码是开放的,读代码比盲目试payload效率高得多。你知道它怎么过滤、怎么拼接、怎么返回,就能直接推导出该用什么手法,而不是一个个试。

另一个经验是:手工注入一定要做一遍,哪怕你后面都用工具。因为工具报错的时候,你得知道它在干什么、卡在哪一步。手工做过一遍,你看工具的输出就知道问题出在哪个环节,排查起来快得多。

8. 从靶场到实战的思维迁移

DVWA的SQL Injection模块练完,你掌握的不应该只是几个payload。更重要的是理解了一件事:漏洞的根源在于"用户输入被当成了代码执行"。这个思维可以迁移到很多地方——命令注入、XSS、模板注入,本质上都是同一个问题:数据和代码的边界被打破了。

防御的思路也是通用的:永远不要相信用户输入,永远用参数化的方式处理数据,永远在数据进入执行环境之前做好隔离。DVWA的四个等级,其实就是防御思路从"没有"到"错误"到"部分正确"到"完全正确"的演进过程。把这个演进过程吃透,比记住一百个payload都有用。

我个人在实际操作中的体会是,SQL注入这个模块值得反复刷。第一遍你可能只是照着教程敲payload,第二遍你会开始思考为什么这么构造,第三遍你就能自己推导出针对不同场景的变种手法。每一遍都有新的收获,这也是DVWA这么多年一直没被淘汰的原因。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进