最近整理2月底那次DVWA字符型SQL注入的完整测试记录,发现还是有很多值得写下来的细节。网上讲SQL注入的文章不少,但大多是直接扔一串payload让你照着打,很少有人把“为什么这个字符能闭合”“为什么要用1' and '1'='1这条语句”讲清楚。这次我把DVWA从Low到High三个级别的字符型注入完整走了一遍,从判断注入点到拿数据,每一步都记录了当时的实际反馈和绕坑过程,写下来给正好在练Web安全基础的朋友做个参考。
这篇内容适合三类人:刚入门Web安全、正在刷DVWA的初学者,写代码时想确认自己有没有踩SQL注入坑的开发同学,以及准备面试时想快速梳理注入思路的求职者。字符型注入是所有SQL注入类型里最容易上手的一类,搞清楚它,后面数字型、搜索型、报错型、盲注其实都是一通百通。
1. 字符型注入的核心思路和初判方法
1.1 数字型和字符型到底差在哪
很多新手一上来就卡在“字符型”这三个字上。要理解它,先看后台查询语句长什么样。
数字型注入的查询大概是:
SELECT first_name, last_name FROM users WHERE user_id = $id;注意这里的$id是直接拼接进去的,没有引号包裹,所以传1和传1 or 1=1在语法层面是等价的,都落在数字表达式的位置上。
而字符型注入的查询长这样:
SELECT first_name, last_name FROM users WHERE user_id = '$id';$id被单引号包住了,这是最典型的情况。用户传入的内容对后台来说是一个字符串字面量,不是可执行的逻辑片段。所以字符型注入的核心动作就是:想办法闭合掉前面的单引号,把自己构造的逻辑从字符串里“逃逸”出来,同时还要处理掉后面那个你控制不到的单引号,否则SQL语法会报错。
这个“逃逸”的过程,跟JavaScript里用引号闭合字符串然后注入事件是很像的——本质都是输入突破了数据边界,变成了代码的一部分。
1.2 判断注入点时的两条黄金探测语句
第一步永远是先确认参数存不存在注入,而不是直接上order by或者union select。DVWA Low级别的输入框,我依次输入了下面两组内容:
测试一:
1' and '1'='1测试二:
1' and '1'='2反馈非常直观:第一条正常返回了First name: admin,第二条直接空白,什么都没有。
这个现象说明两个关键问题。第一,输入确实拼进了SQL语句,而且是字符型拼接;第二,Query能识别我构造的闭合逻辑,证明这里不是硬编码的字符串比较,而是真实执行了拼接后的SQL。更直白地说,如果后台改成参数化查询,这两条输入会原样当作字符串去匹配,不会出现“第二条查不到”的结果差异。
注意:很多文章喜欢用
1' or '1'='1来探测注入点,这条语句在DVWA里也能生效,但它有一个坏处——它会让SQL恒真,导致返回所有数据。在真实测试环境里,数据量大的情况下会把页面刷爆,而且无差别返回所有行不利于区分到底哪一列是回显位。先探测再攻击,顺序很重要。
2. Low级别完整注入流程与语句构造
2.1 环境说明与数据回显分析
DVWA Low级别的输入框对应的是查看用户详情功能,输入用户ID返回对应的姓名信息。默认数据库里有8个用户,ID从1到8,其中有一个用户是我们注入进去的(默认密码admin的那个)。页面回显两列:First name和Surname,这直接暴露了查询列数至少是2。
当时我最关心的一个问题:为什么页面只显示了名字,没有把user_id本身显示出来?翻源码就明白了:
$getid = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";查询只取了first_name和last_name两列,user_id确实没查出来。这意味着联合注入时要特别留意,不能用user_id占列位,而是要把想查的数据放进这两列对应位置。
2.2 用 order by 猜解列数
联合查询(union select)要求前后两个查询的列数一致,否则语句直接报错。所以第二步是猜列数。我用的语句:
1' order by 2 -- -后台SQL变成:
SELECT first_name, last_name FROM users WHERE user_id = '1' order by 2 -- -';order by 2表示按第二列排序,查询正常。接着试:
1' order by 3 -- -这次页面报错:“Unknown column '3' in 'order clause'”。有报错回显的好处就在这里,一次就能定位。当order by 3报错时,说明表里只有2列,那么后面union select就只能跟两个字段。
这里解释一下-- -的作用:--是SQL的注释符,后面最好跟一个空格。MySQL的--注释要求后面至少跟一个空白字符,直接写--'在某些版本里可能不生效。我习惯用-- -(两个横杠一个空格)或者#,都能达到“注释掉我构造内容之外的残余语句”的效果。
2.3 联合注入逐步拿库
先确认回显位置在哪一列:
1' union select 1,2 -- -页面显示First name: 1,Surname: 2。两个位置都能回显,这样后续查询就自由了。
接着查当前数据库名和版本号:
1' union select database(), version() -- -回显dvwa和对应的MySQL版本号(这套环境跑的是MySQL 5.x系列)。查库名是后续一切操作的起点,dvwa是我本机靶场的库名,如果是真实授权测试,这里拿到的就是目标应用的库名。
然后从information_schema.schemata把库里所有库名列出来:
1' union select schema_name, 2 from information_schema.schemata -- -再定位当前库里有哪些表:
1' union select table_name, 2 from information_schema.tables where table_schema='dvwa' -- -回显里出现了users、guestbook等表名,到这里基本就能确定数据目标了。查users表有哪些列:
1' union select column_name, 2 from information_schema.columns where table_schema='dvwa' and table_name='users' -- -列名全部回显出来:user_id、first_name、last_name、user、avatar、password、last_login等。这里有个经验之谈:既然password以MD5形式存储,把user和password两列一起查出来,后续如果要还原明文,直接扔到在线MD5解密或者本地跑字典就能出结果。
最后取数据:
1' union select user, password from users -- -页面把8个用户的用户名和对应MD5哈希全部显示出来,字符型注入流程到这里就走通了。
3. Medium和High级别的防御绕过与思路升级
3.1 Medium级别的转义绕过
Medium级别不是简单的拼接了,后台代码变成了:
$getid = mysqli_real_escape_string($conn, $_GET['id']);mysqli_real_escape_string会对单引号、双引号、反斜杠等特殊字符做转义处理,其中最核心的是把单引号变成\',这样输入里的单引号就无法闭合SQL语句中的引号了,看似防住了字符型注入。
但实际上Medium级别依旧有突破空间。关键点在于:如果id参数本身在SQL语句里没有被引号包裹,那么转义单引号就没有意义。
在Medium级别代码里,查询语句是:
WHERE user_id = $id等于说引号被代码去掉了,$id以数字形式拼接。所以直接输入:
1 union select user, password from users就能绕过转义限制,因为在转义函数处理后,你传的1 union ...里根本没有单引号需要处理。这是开发者的一个典型失误——用了转义函数,却把SQL语句里的引号去掉了,防御等于白做。
另一种思路是构造不包含单引号的恒真语句,比如1 or 1=1也能直接拉出全部数据,同样不需要引号参与。
3.2 面对LIMIT限制时怎么处理
Medium和High级别有一个共同改动:SQL加了LIMIT 1:
WHERE user_id = $id LIMIT 1;这条限制要求查询结果最多只能返回一行。前面直接用union select拿多行数据的思路就卡住了,因为即使union出多行,受LIMIT限制也只能显示一行。
解决办法很简单:把LIMIT 1注释掉。语句变成:
1 union select user, password from users -- -或者:
1 union select table_name, 2 from information_schema.tables where table_schema='database()' -- -注释掉LIMIT后,多行数据正常回显。这算是Medium级别另一个容易踩的坑,不知道加注释的人会在这一步卡很久,反复怀疑是不是转义把union吞了。
3.3 High级别前端黑名单与后端边界判断
High级别的前端用JavaScript限制输入只能是数字:
if (isNaN(document.getElementById("id").value)) { ... }这层限制属于“防君子不防小人”。只要绕过前端直接构造请求,或者用本地代理工具拦截请求改包,把id=1 union select user, password from users-- -直接发给后端,前端限制完全不生效。
真正需要关注的是High级别的后端改动:
$id = $_GET['id']; $data = $db->prepare('SELECT first_name, last_name FROM users WHERE user_id = ? LIMIT 1');这里已经换成了预处理语句(prepared statement),用占位符?传递参数,数据与SQL语句在语法层面完全分离。到了这个级别,传统字符型注入是过不去的,因为用户输入不再参与SQL语句拼接。
对High级别,我的建议很直白:不用死磕单引号闭合那一套了,能打进去多半是其他配合漏洞(比如后台还存在一个数字型注入点、搜索框存在宽字节注入等),而不是字符型注入本身的问题。学习时把重点放在“为什么预编译能防注入”上,比折腾绕过更有价值。
4. 常见问题与排查技巧实录
整个测试过程中遇到几个典型的坑,整理成表格方便你对照排查:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
输入1' and '1'='1返回空 | 参数后端可能有额外的字符处理或拼接位置不是唯一 | 换个方式判断注入点(试试直接1'看报错信息) |
order by 3没报错 | MySQL版本对order by的容错处理,或者表里确实有3列 | 继续order by 4观察,别在3就停 |
| union 查询无回显 | 前面查询为空,union前的查询结果影响回显 | 把前查询条件改成1' and '1'='2强制为空,确保union结果直接显示 |
| 加注释后语法仍然报错 | 注释符后缺空格 | -- -必须带空格,推荐用#更稳 |
| 浏览器输入中文或特殊字符异常 | 前端编码问题 | 测试环境里尽量用ASCII字符构造payload,不要用中文变量名 |
| 拿不到表名/列名 | 当前数据库用户权限不足 | 查一下当前用户权限,确认是否有information_schema的读权限,DVWA里是root,权限没问题 |
另一个很实际的经验:每执行一步payload,先用一个能确认状态的语句验证,比如先跑1' order by 2 -- -看是否正常回显,再跑1' order by 3 -- -触发报错,确认“能正常回显”与“触发报错”这两个状态本身是区分开的。很多新手把payload一次性堆上去,报错时无法判断是语法错、闭合错还是权限错,排查难度直接翻倍。
注意事项:在真实授权测试中,
information_schema相关的查询请求量比较大,大库环境下不要太频繁跑语句,容易被日志系统记录出异常访问模式。练习环境无所谓,生产环境测试要有节奏感,先想清楚再发请求。
还有一个关于工具使用的提醒:以上所有手工注入过程,用浏览器地址栏手动改参数都可以完成,但我不建议在地址栏直接输入空格加号混乱的payload。更推荐的方法是用拦截代理工具先把请求抓到,再修改参数重放,这样能精确控制每一次请求,也方便看返回包的实际内容。抓包工具方面,适合练习的常见选择有浏览器自带的开发者工具面板、Burp Suite社区版等,选一个顺手的即可。
5. 为什么防御必须依赖预编译
5.1 过滤和转义的边界在哪里
从DVWA三个级别可以看到,不同的防御方式对应不同的信任级别。前端过滤不靠谱,因为请求可以完全绕过前端。后端转义不彻底,因为开发者的拼接方式可能抵消转义的作用。注释符可以破坏LIMIT限制,单引号逃逸可以突破字符串边界。
这背后的核心问题是:你永远无法预测攻击者用什么方式绕过你的过滤规则。今天过滤单引号,明天攻击者可能用URL编码绕过;今天过滤空格,明天可能用注释符替代空格;今天过滤union,明天可能用大小写混合或者内联注释。如果安全方案是围绕“不让某些关键字出现”来设计的,它本质上是个永无止境的攻防游戏。
5.2 参数化查询解决问题的本质
预编译语句(参数化查询)能一劳永逸解决SQL注入,原因是它从根上改变了数据的传递方式。在预编译模式下,SQL语句的结构和参数是分开传输的:先发送SELECT ... WHERE user_id = ?这样的语句模板让数据库完成编译,再发送参数1 union select ...作为数据。数据库在执行时,只会把参数当作值来处理,不会再参与语句解析。
生活化的类比是这样:你把一张印好的表格交给办事窗口,空的地方填内容,工作人员看到的是表格+内容,但内容永远不会变成表格的一部分。而直接拼接SQL语句,等于你把一张空白的纸交给窗口,让工作人员把你的话手写进表格,如果这段话里混着一条“同时给其他人盖章”的指令,他也照做。
5.3 纵深防御的落地建议
在实际项目里,防御SQL注入不能只靠一句“用了预编译就完事”。我的建议是至少做四层:
第一层,数据库操作统一走预编译,禁止直接拼接SQL。这个不是技术问题,而是规范问题,需要代码评审卡住。
第二层,用户输入做类型校验和长度限制。ID参数就校验必须是数字,用户名长度限制在合理范围,非法输入直接拒绝而不是继续进SQL。
第三层,数据库账号按最小权限分配。应用连接账号只给它需要的表权限,SELECT用只读账号,写操作用单独账号,这样即使注入成功,攻击者能读到的数据面也大幅收窄。
第四层,操作日志和异常行为监控。短时间大量类似查询、返回数据异常增大、报错信息异常,这些都应该有告警。安全不是一个点的事情,而是整条链路的每个环节都做对。
最后的实操心得
我在DVWA上跑完这三个级别,最大的感触是:字符型注入看起来很简单,但它把开发者和攻击者之间的博弈展示得非常典型。开发者以为转义单引号就安全,攻击者就利用没有引号的拼接绕过去;开发者以为加个LIMIT限制数据量就稳了,攻击者用注释符直接把它删掉;开发者以为前端限制输入就能挡住不怀好意的人,攻击者一个抓包重放就绕过去了。
我个人实际测试时有个小习惯:每拿到一个payload,都会顺手把执行后的SQL语句在脑子里还原一遍,强迫自己看清楚到底哪个部分是我构造进去的、哪个部分是后台原有的。这个习惯练久了,判断一个注入点是否存在时基本不用试第二条语句就能猜到结果。
另外给准备刷完DVWA往进阶走的朋友一个建议:字符型注入搞定之后,把同样思路迁移到搜索型、POST型、Header头注入上,差别主要在位置和数据入口,逃逸的本质完全一样。再往后可以尝试用自动化工具跑一遍相同流程,对比手工和工具的结果,你会对手工注入的价值有更深的体会——工具不是用来替代思考的,是用来验证思路、提高效率的。