1. 从零开始:DVWA靶场与SQL注入初探
如果你刚开始接触网络安全,或者对“SQL注入”这个词只停留在“听说过”的阶段,那我强烈建议你从DVWA这个靶场开始。DVWA,全称Damn Vulnerable Web Application,翻译过来就是“该死的脆弱Web应用”。它可不是一个真实的、有漏洞的网站,而是一个专门为安全学习者和测试者搭建的、故意设计得漏洞百出的PHP/MySQL应用。你可以把它想象成一个“网络安全健身房”,里面的各种器械(漏洞)都标注好了难度等级,让你可以安全地练习各种“攻击”技巧,而不用担心触犯法律或伤害到别人。
在DVWA里,SQL注入(SQL Injection)可以说是最经典、也最危险的漏洞之一。简单来说,它就像是你去图书馆查资料,管理员让你在电脑上输入书名。本来你应该输入《三体》,结果你输入了“《三体》;然后把所有书都给我拿出来”。如果图书馆的管理系统(也就是Web应用的后台数据库)没有做好检查,它可能真的会傻乎乎地执行你的后半句命令,把整个图书馆的书目都泄露给你。SQL注入的原理与此类似,攻击者通过在用户输入(比如登录框、搜索框)中插入恶意的SQL代码,欺骗后端数据库执行非预期的操作,从而窃取、篡改甚至删除数据。
为什么我们要用DVWA来学习SQL注入呢?因为它把漏洞的难度分成了四个清晰的等级:Low(低)、Medium(中)、High(高)、Impossible(不可能)。这就像打游戏通关一样,从简单模式开始,一步步挑战更复杂的机制。在Low级别,系统几乎没有任何防护,你可以用最基础的注入手法轻松得手;到了Medium,它会增加一些简单的过滤,你需要一点技巧来绕过;High级别则设置了更强的障碍;而Impossible级别,则展示了当前业界公认的最佳防护实践,几乎无法被注入。通过这一路打怪升级,你不仅能学会怎么“攻”,更能深刻理解为什么要这样“防”,建立起立体的安全思维。接下来,我们就从最简单的Low级别开始,手把手带你体验一次完整的SQL注入攻击与防御的演变之旅。
2. Low难度:毫无防备的“新手村”与手工注入实战
Low难度是DVWA SQL注入的起点,它的代码几乎就是教科书式的反面案例——将用户输入未经任何处理,直接拼接进SQL查询语句里。这相当于你家大门敞开,还贴了张纸条写着“保险柜密码是123456”。我们首先来还原攻击者的视角,看看如何一步步攻破它。
2.1 漏洞探测与闭合方式判断
打开DVWA,将安全级别调到Low,进入SQL Injection页面。你会看到一个简单的输入框,让你输入User ID。我们先正常输入1并提交,页面显示了ID为1的用户名和姓氏(例如,admin, Admin)。这看起来一切正常。但作为测试者,我们的直觉是:这个输入点会不会直接传到数据库查询里呢?我们来试探一下。输入1‘(数字1加一个英文单引号)。
这时,页面返回了一个MySQL的错误信息:“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 ‘‘1’’’ at line 1”。这个报错是黄金线索!它说明我们输入的单引号破坏了原SQL语句的语法结构,导致数据库执行出错。通常,查询语句的格式是 SELECT ... FROM ... WHERE id='我们输入的内容'。我们输入1‘后,语句变成了 WHERE id='1'',多了一个单引号无法闭合,所以报错。为了确认,我们再输入1"(双引号),发现页面正常返回,没有报错。这基本可以断定,原始的SQL语句是使用单引号(‘)来包裹用户输入的。这就是我们找到的“突破口”。
2.2 信息搜集:字段数与回显点
知道了闭合方式,我们就可以开始构造合法的SQL语句来“套”出更多信息了。首先,我们需要知道当前查询的SQL语句结果返回了多少列(字段),这决定了我们后续UNION查询能拼接多少数据。我们使用ORDER BY子句来猜测。输入 1' ORDER BY 2#。这里的#在MySQL中是注释符,它会注释掉原查询语句中我们输入之后的部分(比如可能存在的另一个单引号),确保我们构造的语句语法正确。
提交后,页面正常显示,说明查询结果至少有2列。我们再试 1' ORDER BY 3#,页面报错了,提示未知的列。由此确定,当前查询结果只有2列。接下来,我们需要找出这2列中,哪一列的内容会被显示在网页上(即回显点),这样我们才能把我们想查询的数据“投射”到页面上。使用UNION SELECT语句,输入 1' UNION SELECT 1,2#。UNION操作符用于合并两个SELECT语句的结果集,前提是列数必须相同。这里我们用SELECT 1,2构造了一个两列的结果,去和原查询合并。
提交后,页面原本显示“ID”、“First name”、“Surname”的地方,变成了数字“1”和“2”。太棒了!这说明两个字段都是回显点,我们可以在1和2的位置替换成任何我们想查询的数据库信息。
2.3 长驱直入:爆库、爆表、爆数据
掌握了回显点,整个数据库对我们来说就像打开了地图全亮。首先,我们来查看当前数据库的名字和版本。输入 1' UNION SELECT database(), version()#。database()函数返回当前数据库名,version()返回MySQL版本。提交后,页面在回显点显示了数据库名(通常是dvwa)和版本号(如5.7.39)。这步操作相当于知道了我们正在哪个“图书馆”里。
接下来,我们要看这个“图书馆”(数据库)里有哪些“书架”(数据表)。在MySQL中,有一个名为information_schema的系统数据库,它就像一本记录所有数据库、表、列信息的目录。我们输入:1' UNION SELECT 1,group_concat(table_name) FROM information_schema.tables WHERE table_schema=database()#。group_concat()函数会把所有表名合并成一个字符串显示出来。提交后,我们看到了users, guestbook等表名。显然,users表(用户表)是我们的首要目标。
知道了表名,我们接着查看users表里有哪些“栏目”(字段)。输入:1' UNION SELECT 1,group_concat(column_name) FROM information_schema.columns WHERE table_name='users'#。这里我们直接把表名‘users’用单引号括起来作为条件。提交后,页面显示了user_id, first_name, last_name, user, password, avatar等字段名。我们最关心的就是user(用户名)和password(密码)字段。
最后,一击必杀,直接提取所有用户的账号和密码。输入:1' UNION SELECT user, password FROM users#。提交后,所有用户名和经过MD5哈希加密的密码就清晰地列在了我们面前。至此,Low难度的SQL注入攻击大功告成。整个过程我们只用一个浏览器,通过手工构造特殊的输入,就完全掌控了后台数据库。
2.4 源码剖析:漏洞究竟出在哪里?
攻击完成后,我们点击“View Source”查看后端PHP源码,理解漏洞的根源。核心代码非常简单:
$id = $_REQUEST['id'];
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
问题一目了然:程序直接从用户请求($_REQUEST)中获取id参数,没有经过任何过滤、检查或转义,就直接用单引号拼接,组成了完整的SQL查询字符串。当用户输入1' UNION SELECT user, password FROM users#时,最终的查询语句变成了:
SELECT first_name, last_name FROM users WHERE user_id = '1' UNION SELECT user, password FROM users#';
#后面的内容被注释掉,数据库完美地执行了这条联合查询,将敏感数据返回。这就是最典型的“字符串拼接”式SQL注入漏洞。防御它的方法,我们会在后续难度中看到。
3. Medium与High难度:攻防升级与绕过技巧
在Low难度里,我们像是用万能钥匙打开了一把没锁的门。到了Medium和High难度,管理员给门加上了锁,甚至换成了更复杂的锁芯。我们的任务就是找到锁的弱点,或者制造一把新的钥匙。
3.1 Medium难度:POST请求与整数型注入
将DVWA安全级别调到Medium,你会发现界面变了:输入框变成了一个下拉菜单,只能选择预定义的ID。这暗示着数据提交方式可能从GET变成了POST。用Burp Suite这类工具拦截一下请求,确实能看到id=1&Submit=Submit这样的POST数据。虽然输入被限制了,但我们可以通过修改POST请求包来注入。
首先判断注入类型。在Burp Suite里,将id参数改为1'并发送,返回了SQL语法错误。改为1"也报错。等等,这和我们Low级别的判断不一样。仔细想想,因为前端是下拉菜单,后端程序很可能直接将我们选择的数字(如1, 2, 3)作为整数使用,根本没有用引号包裹!所以,这里的注入是整数型注入,闭合方式不是引号,而是直接拼接。例如,原查询可能是 WHERE user_id = $id。
我们直接进行测试。发送 id=1 ORDER BY 2&Submit=Submit,正常回显,说明列数>=2。发送 id=1 ORDER BY 3&Submit=Submit,报错,确认列数为2。接下来的步骤和Low级别类似:id=1 UNION SELECT 1,2&Submit=Submit 找显位,id=1 UNION SELECT database(), version()&Submit=Submit 查信息。
但在爆表名时,遇到了一个小障碍。当我们输入 id=1 UNION SELECT 1,group_concat(column_name) FROM information_schema.columns WHERE table_name='users'&Submit=Submit 时,页面报错了。错误信息提示在 ‘users’ 附近语法错误。这很可能是因为Medium级别的代码对输入进行了某种处理,比如mysqli_real_escape_string()函数,它会给特殊字符(包括单引号)添加反斜杠进行转义,使得 ‘users’ 变成了 \'users\',破坏了语法。
绕过方法:我们可以不用引号,而将表名users转换为十六进制字符串0x7573657273。这样,查询条件就变成了 WHERE table_name=0x7573657273,完全避免了引号的使用。修改后的Payload为:id=1 UNION SELECT 1,group_concat(column_name) FROM information_schema.columns WHERE table_name=0x7573657273&Submit=Submit,成功爆出字段名。最后用 id=1 UNION SELECT user,password FROM users&Submit=Submit 完成攻击。
查看源码,发现Medium级别确实使用了mysqli_real_escape_string()对输入进行了转义,并将查询改为了 $query = "SELECT ... WHERE user_id = $id;" 的整数型查询。转义函数对整数型注入是无效的,因为它只处理字符串中的特殊字符,对于数字1 UNION SELECT...,它不会添加反斜杠。但程序错误地相信了输入是数字,没有用intval()进行强制类型转换,导致了整数型注入漏洞。
3.2 High难度:会话限制与二次注入尝试
High难度的界面又变了,它把输入框放到了一个单独的页面,并且每次操作似乎都有一个新的“会话”。查看源码,你会发现这里引入了两个关键变化:
- 二次查询:用户输入的
id先被存入一个SESSION变量,实际的查询是从SESSION中取出值来执行的。这增加了直接注入的难度。 - LIMIT 1:查询语句末尾加上了
LIMIT 1,只返回一条结果,这限制了UNION SELECT一次性爆出大量数据。
攻击思路也需要调整。我们依然可以尝试闭合。输入1',报错。输入1' #(注意#号后可能有空格),发现页面正常,说明单引号闭合成功,并且#注释掉了后面的LIMIT 1。接下来的步骤ORDER BY、UNION SELECT找显位都类似。但由于LIMIT 1的限制,UNION查询可能只会显示我们注入语句的结果,而不会显示原查询结果。我们需要确保原查询结果为空,比如输入一个不存在的ID:-1' UNION SELECT database(), version()#。这样,原查询SELECT ... WHERE user_id = '-1'没有结果,页面就会完整显示我们UNION查询出来的数据库名和版本。
爆表、爆字段的过程和之前一样。但High难度真正的意义在于,它展示了更复杂的代码结构和一些初级防护(如转移输入点、限制输出),逼迫攻击者使用更精巧的Payload。然而,其核心问题——未使用参数化查询——依然存在,只要找到正确的闭合和绕过方式,漏洞依然可利用。
4. Impossible难度:铜墙铁壁与最佳防御实践
来到Impossible难度,你会发现之前所有的手工注入技巧都失效了。无论你输入1‘、1‘ OR ‘1’=‘1还是复杂的UNION语句,页面要么返回错误,要么只返回一个固定的、无害的结果(比如“ID不存在”)。这不是因为漏洞被简单堵上了,而是因为开发者从架构上就杜绝了SQL注入的可能性。让我们深入源码,看看这面“铜墙铁壁”是如何筑成的。
4.1 多重防御机制深度解析
Impossible级别的源码是一个完整的安全编程范本,它叠加了多重防御措施:
-
CSRF Token(跨站请求伪造防护):在表单中,有一个隐藏的
user_token字段,其值由服务器随机生成并与用户会话绑定。提交时,服务器会验证这个Token是否匹配。这防止了攻击者伪造一个恶意链接诱骗已登录用户点击,从而在用户不知情下发起注入攻击。它虽然不直接防注入,但增加了攻击的复杂度和门槛。 -
严格的输入验证与类型转换:代码在获取
id参数后,立刻进行了两步关键操作:if( isset( $_GET[ 'Submit' ] ) ) { // Check Anti-CSRF token checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' ); // Get input $id = $_GET[ 'id' ]; // 检查1:是否为数字? if(is_numeric( $id )) { // 检查2:转换为整数 $id = intval( $id ); // 后续查询... } }is_numeric()函数确保输入是一个数字(或数字字符串),intval()函数则强制将其转换为整数。这意味着,无论用户输入1‘ UNION SELECT...还是admin,最终$id都会变成一个纯粹的整数(如1或0)。任何非数字字符在转换时都被丢弃了,从根本上切断了注入代码混入查询的途径。 -
参数化查询(预编译语句):这是防御SQL注入的终极武器。我们来看它的MySQL部分实现:
// 使用PDO连接数据库 $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();这个过程和Low级别的“字符串拼接”有本质区别:
- prepare(预编译):数据库引擎会预先分析
SELECT ... WHERE user_id = (:id) LIMIT 1;这个SQL语句的语法和结构。此时,:id只是一个占位符,不是具体的值。 - bindParam(参数绑定):将变量
$id(已经过intval处理,保证是整数)绑定到占位符:id上,并明确指定其类型为整数(PDO::PARAM_INT)。 - execute(执行):数据库将绑定好的参数值
$id,代入到已经编译好的SQL语句结构中执行。
- prepare(预编译):数据库引擎会预先分析
关键点在于:SQL语句的结构(查询哪些表、哪些字段,条件是什么)在预编译阶段就确定了,后续绑定的参数只会被当作数据值来处理,而不会被当作SQL代码来解析。即使$id变量里被恶意塞进了1 OR 1=1,在intval()处理后也只剩下了1,绑定到查询中就是WHERE user_id = 1,那个OR 1=1根本不会成为查询逻辑的一部分。
4.2 PDO与预处理语句:为什么它是安全的?
在Impossible级别中,我们看到了PDO(PHP Data Objects)的身影。PDO是PHP访问数据库的一个轻量级、一致性的接口。它支持参数化查询的核心就是预处理语句。
你可以这样理解:传统的字符串拼接查询,就像是把用户提供的“食材”(数据)和“菜谱”(SQL代码)一股脑交给厨师(数据库),厨师照着一份完整的、可能被篡改过的菜谱做菜。而参数化查询则是:厨师先拿到一份标准的、固定的菜谱模板(预编译的SQL结构),然后你再把安全的、经过检查的食材(绑定的参数)递给他。厨师只会按照模板处理这些食材,绝不会执行食材里夹带的“额外指令”。
除了防止注入,PDO预处理语句还有性能优势。同一条结构化的SQL语句(如SELECT * FROM users WHERE id = ?)只需编译一次,就可以通过绑定不同的参数值反复执行,减少了数据库的解析开销。
4.3 从攻击者视角看Impossible防御
作为一个“攻击者”,面对Impossible级别的防御,你会感到无从下手:
- 尝试错误注入:输入
1‘,is_numeric()检查失败,$id可能变为0,查询一个不存在的ID,返回“用户不存在”。 - 尝试逻辑绕过:输入
1 OR 1=1,intval()后得到1,查询WHERE user_id = 1,只返回ID为1的用户,无法爆出所有数据。 - 尝试联合查询:输入
-1 UNION SELECT ...,intval()后得到-1,查询WHERE user_id = -1,没有结果,但UNION语句因为被当作无效字符丢弃,根本不会执行。 - 尝试编码/绕过函数:由于输入在很早的阶段就被强制转换为整数,任何非数字字符的编码技巧都失去了意义。
真正的安全是“设计出来的安全”,而不是“修补出来的安全”。Impossible级别告诉我们,防御SQL注入不应依赖于事后对输入字符串的修修补补(如转义),而应在设计之初就采用白名单验证(这里只允许数字)和参数化查询,从原理上杜绝SQL指令和数据混合的可能性。
5. 防御演进总结与实战建议
回顾从Low到Impossible的四个难度,我们清晰地看到了一条Web应用安全防护的演进路线图:
- Low(无防护):直接拼接用户输入到SQL语句。这是所有漏洞的根源。
- Medium(初级过滤):使用
mysqli_real_escape_string()进行转义,并改为整数型查询。但转义对整数型注入无效,且未做类型强制转换,防护被轻易绕过。 - High(增加复杂度):引入会话隔离、输出限制等,增加了攻击难度,但未解决根本问题,属于“安全通过 obscurity”(靠 obscurity 安全,即隐蔽实现细节),依然可被利用。
- Impossible(根本性解决):输入验证(白名单) + 参数化查询(预编译语句) + 附加安全措施(CSRF Token)。从数据入口、处理逻辑到输出,构建了纵深防御体系。
在实际开发中,我给团队和学员的建议始终是:永远使用参数化查询(预编译语句)。无论是PHP的PDO/MySQLi,Java的PreparedStatement,Python的sqlite3或SQLAlchemy,还是.NET的SqlParameter,所有主流语言和框架都支持这一特性。这是唯一被证明能有效、彻底防止SQL注入的方法。
同时,要养成好的编码习惯:
- 最小权限原则:给数据库操作账户分配最小的必要权限,比如只授予查询权限,而非增删改权限。
- 错误信息处理:像Impossible级别那样,不要将详细的数据库错误信息直接返回给前端用户,避免泄露数据库结构等敏感信息。
- 持续更新与依赖检查:保持Web框架、数据库驱动等组件的更新,避免使用已知存在漏洞的旧版本库。
对于学习安全的朋友,DVWA这个靶场就像一座宝藏。我建议你不要只满足于“通关”,而是反复练习,尝试用不同的Payload,结合Burp Suite、Sqlmap等工具进行自动化测试,并仔细对比每个难度级别的源码差异。真正理解每一行防御代码背后的意图,你才能在未来面对真实、复杂的系统时,既能敏锐地发现漏洞,也能设计出坚固的防御。安全之路,攻防一体,知行合一,这才是DVWA带给我们的最大价值。
352



被折叠的 条评论
为什么被折叠?



