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 | 无任何过滤,直接拼接 | 完全可利用,任意注入 |
| Medium | mysqli_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这么多年一直没被淘汰的原因。