SQL注入实战:从靶场到真实漏洞的攻防演练
1. 从靶场到实战为什么我们还在学SQL注入如果你在安全圈待过一阵子或者刚入门网络安全大概率听过“SQL注入”这个词。它太老了老到很多新人会觉得“这都202X年了数据库都有预编译了框架都内置防护了还有必要学这个吗” 我见过不少新手一上来就想搞什么零日漏洞、高级持续性威胁对SQL注入这种“古典”漏洞嗤之以鼻。但现实是就在上个月我帮一个朋友的公司做应急响应他们一个刚上线的、用了最新Spring Boot和MyBatis-Plus的项目依然被奇安信的安全扫描报出了SQL注入漏洞。原因开发在动态排序的字段上图省事直接用了${orderBy}。你看漏洞不在技术的新旧而在人的意识。这就是为什么像iwebsec这样的SQL注入靶场至今仍有巨大价值。它不是一个简单的漏洞列表而是一个完整的、从原理到手工利用再到工具辅助的思维训练场。通过它你学到的不是几个 or 11--的Payload而是一套面对一个黑盒系统时如何像侦探一样通过有限的输入点一步步推理出后端数据库的结构、逻辑并最终拿到你想要的数据甚至是系统权限的完整方法论。这套方法论在CTF比赛、渗透测试、甚至代码审计中都是相通的。今天我就结合iwebsec靶场以及其他像DVWA、Pikachu这类经典靶场的闯关过程为你拆解SQL注入的完整知识体系从最基础的错误回显注入到需要耐心和技巧的盲注再到真实环境中那些“狡猾”的二次注入和宽字节注入。我的目标不是让你成为“脚本小子”而是让你真正理解每一个Payload背后的原理知道在什么时候、用什么方法以及为什么要这么做。2. 环境搭建与靶场初探不只是运行一个Docker在开始任何实战之前一个稳定、隔离的测试环境是必须的。很多人觉得搭环境就是docker pull加docker run但细节决定成败尤其是当你需要调试或者复现一些复杂场景时。2.1 靶场选择与部署考量iwebsec是一个集成了多种漏洞的综合性Web靶场它的SQL注入模块设计得比较系统。除了iwebsecDVWADamn Vulnerable Web Application因其极简的界面和可控的漏洞等级是绝对的经典入门选择Pikachu则更偏向于中文环境和漏洞场景化比如有“搜索型注入”、“XX型注入”等分类对理解漏洞成因很有帮助而CTFHub的技能树则提供了更偏向CTF竞赛的闯关式练习。我的建议是以其中一个为主其他为辅进行交叉验证。例如你可以用iwebsec或DVWA作为主线任务逐个关卡攻克。当你在某个关卡比如盲注遇到瓶颈时去Pikachu的对应模块再做一遍不同的实现方式可能会给你新的启发。部署上强烈推荐使用Docker这能避免污染你的主机环境。# 以DVWA为例一个更稳妥的部署命令 docker run -d --name dvwa -p 8080:80 -e MYSQL_ROOT_PASSWORDpssw0rd vulnerables/web-dvwa注意别直接用latest标签最好指定一个稳定版本号比如vulnerables/web-dvwa:1.10。因为有些最新镜像的默认配置可能会有变动导致你无法复现教程里的步骤。部署完成后访问http://localhost:8080按照提示完成数据库初始化。这里第一个“坑”就来了DVWA的默认登录账号是admin/password。但很多人输完发现登录失败。你需要先点击页面上的Create / Reset Database按钮这个操作会初始化数据库并创建这个默认账户。这个步骤体现了安全测试中的一个重要原则环境的状态是可预期且可重置的。2.2 必要的工具准备浏览器与代理工欲善其事必先利其器。除了靶场你还需要两样核心工具浏览器Chrome或Firefox。重点不是浏览器本身而是其开发者工具F12。你需要熟练使用“网络”Network标签页查看HTTP请求/响应使用“控制台”Console执行一些简单的JavaScript调试以及“元素”Elements查看页面结构。代理工具Burp Suite Community版以下简称Burp是绝对的主力。它就像一个在你浏览器和靶场服务器之间的“中间人”可以拦截、查看、修改所有过往的HTTP/HTTPS流量。这是手工测试SQL注入的灵魂工具。配置Burp需要一点步骤安装后启动Burp在Proxy - Options中确保代理监听在127.0.0.1:8080默认。然后在浏览器中配置代理指向这个地址。最后访问http://burp下载并安装Burp的CA证书到你的系统或浏览器受信任的根证书颁发机构。只有这样Burp才能解密HTTPS流量虽然靶场多是HTTP但好习惯要养成。为什么强调Burp因为图形化的界面让你能清晰地看到原始请求参数方便你修改和重放Repeater功能。很多基于时间的盲注你需要精确控制Payload的发送和计算响应时间没有比Burp Repeater更顺手的工具了。3. 核心原理深度拆解SQL注入究竟是如何发生的在动手之前我们必须把原理吃透。很多教程只讲“怎么注”却不讲“为什么能注”导致换一个场景就不会了。3.1 漏洞的本质数据与代码的混淆SQL注入的根本原因在于程序将用户输入的数据未经充分处理就直接拼接到了SQL查询语句中从而使用户输入被数据库引擎误解为代码的一部分并执行。想象一个简单的登录场景后端Java代码可能是这样的String sql SELECT * FROM users WHERE username username AND password password ;如果用户输入的username是adminpassword是123456那么拼接后的SQL是SELECT * FROM users WHERE username admin AND password 123456这没问题。但如果攻击者输入的username是admin--注意最后的单引号和两个减号在SQL中--是注释符那么拼接后的SQL就变成了SELECT * FROM users WHERE username admin-- AND password ...--之后的所有内容都被注释掉了这意味着密码验证条件完全失效只要数据库里存在用户名为admin的记录这条查询就会成功返回该用户信息攻击者就能以管理员身份登录。这就是最经典的“永真条件”绕过。 or 11也是同样的道理它构造了一个永远为真的条件11使整个WHERE子句恒成立。3.2 参数化查询与预编译为什么它是终极解决方案要防止SQL注入核心原则就是让“数据”永远是“数据”绝不成为“代码”。实现这一点的最佳实践就是参数化查询Prepared Statements。还是上面的登录例子使用参数化查询的JavaJDBC代码是这样的String sql SELECT * FROM users WHERE username ? AND password ?; PreparedStatement stmt connection.prepareStatement(sql); stmt.setString(1, username); stmt.setString(2, password); ResultSet rs stmt.executeQuery();这里?是占位符。在prepareStatement阶段数据库就已经知道了SQL的结构“这是一个SELECT语句在WHERE子句中有两个字符串类型的条件”。之后通过setString方法传入的username和password无论里面包含什么奇怪的字符、--、or 11都会被严格地当作一个完整的字符串值来处理。数据库引擎不会再去解析这些值中的SQL关键字。所以当你听说一个用了MyBatis的项目还有SQL注入时问题往往出在动态SQL的${}用法上。MyBatis中#{}是参数占位符会预编译而${}是字符串替换直接拼接。例如ORDER BY ${orderBy}如果orderBy这个参数用户可控攻击者传入id; DROP TABLE users--那么拼接后的SQL就是ORDER BY id; DROP TABLE users--后果不堪设想。正确的做法是如果排序字段必须动态应该在服务端用一个白名单来校验orderBy的值是否合法如只允许id,name,create_time等而不是直接信任前端传参。4. 手工注入实战像侦探一样推理数据库结构理解了原理我们进入实战。我将以iwebsec/DVWA的“SQL Injection”关卡安全级别设为Low即无任何防护为例演示一次完整的手工联合查询注入过程。这个过程是理解所有自动化工具的基础。4.1 第一步探测注入点与数据库类型首先在输入框随便输入一个数字比如1提交。页面返回了用户ID为1的信息假设是admin。然后我们输入一个单引号提交。这是最关键的一步。如果页面返回了数据库错误信息比如“You have an error in your SQL syntax...”那么恭喜这里存在SQL注入漏洞并且是错误回显注入。错误信息能告诉我们很多它可能直接暴露数据库类型MySQL, SQL Server, PostgreSQL等。例如MySQL的错误信息通常包含“MySQL server version”而SQL Server可能包含“Microsoft SQL Server”。如果输入后页面空白、报500错误或者跳转到错误页但没有具体信息则可能是盲注。我们暂时按有回显的情况继续。假设我们看到了MySQL的错误。接下来我们构造一个“永真”和“永假”条件来确认注入点确实会影响查询逻辑输入1 and 11- 页面应正常返回ID为1的用户信息因为条件永真。输入1 and 12- 页面应返回空或错误因为条件永假。如果行为符合预期说明我们注入的SQL片段确实被数据库执行了。4.2 第二步判断字段数与确定回显位现在我们知道可以注入下一步是搞清楚这个SELECT语句到底查询了多少个字段列。这要用到ORDER BY子句。ORDER BY后面可以接字段名也可以接数字表示按第几列排序。我们在注入点输入1 order by 1--。注意--后面有一个空格在MySQL中这是单行注释的标准写法。提交后页面正常排序可能没变化。然后尝试1 order by 2--1 order by 3--... 以此类推直到页面报错。比如当输入1 order by 4--时页面出错而order by 3正常那么就说明原始查询语句一共查询了3个字段。知道字段数后我们需要确定这些字段中哪几个的位置内容会显示在网页上。这要用到UNION SELECT联合查询。联合查询要求前后两个SELECT的字段数必须一致。所以我们构造1 union select 1,2,3--提交后页面可能原本显示ID为1的用户信息的地方变成了显示数字2和3或者只有其中几个。这说明在这个页面的显示位置对应着原始查询结果集的第2和第3个字段。这两个位置就是我们后续注入查询结果、并让其显示在页面上的“回显位”。4.3 第三步获取数据库信息现在我们可以把回显位比如2和3替换成我们想查询的数据库函数。查询当前数据库名1 union select 1, database(), 3--。database()函数在MySQL中返回当前使用的数据库名称。提交后在回显位2的位置你应该能看到数据库名比如dvwa。查询数据库版本和用户1 union select 1, version(), user()--。version()返回MySQL版本user()返回当前数据库用户。这能帮你判断数据库权限。4.4 第四步爆破表名、列名与最终数据MySQL在5.0版本以上有一个名为information_schema的系统数据库它就像数据库的“户口本”记录了所有其他数据库、表、列的信息。这是我们进行下一步的钥匙。查询所有表名1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase()--information_schema.tables存储所有表的信息。table_schemadatabase()条件限制只查询当前数据库的表。group_concat(table_name)将查询到的所有表名合并成一个字符串用逗号分隔方便一次性显示。你可能会看到users, guestbook等表名。我们显然对users表更感兴趣。查询users表的所有列名1 union select 1,group_concat(column_name),3 from information_schema.columns where table_schemadatabase() and table_nameusers--information_schema.columns存储所有列的信息。条件限定了当前数据库下的users表。执行后你可能会得到user_id, first_name, last_name, user, password, avatar等列名。其中user和password很可能就是我们要找的账号密码列。最终拖库1 union select 1,group_concat(user, :, password),3 from users--直接从users表中将用户名和密码用冒号连接后查询出来。提交后你就能在页面上看到类似admin:5f4dcc3b5aa765d61d8327deb882cf99这样的结果。这里的密码是MD5哈希值需要去在线网站或用工具如John the Ripper进行破解。至此一次完整的手工联合查询注入就完成了。这个过程看似繁琐但它训练的是你面对一个未知系统时的结构化思维探测 - 判断 - 获取信息 - 逐步深入。自动化工具如sqlmap无非是把这些步骤用程序实现了而已。5. 盲注在没有回显的黑暗中摸索现实中的漏洞往往没有友好的错误回显。页面对于正确的输入和错误的输入可能都只返回“查询成功”或“查询失败”的通用提示甚至HTTP状态码都是200。这就是盲注。盲注又分为基于布尔Boolean的和基于时间Time的两种。5.1 布尔盲注像玩“猜数字”游戏布尔盲注的核心思想是通过构造SQL语句让页面的某种可观察特征如是否存在某个关键词、页面长度是否变化随着我们注入的SQL条件真假而变化。例如一个搜索功能无论输入什么页面都只显示“找到结果”或“未找到结果”。我们可以这样探测输入1 and 11--- 页面显示“找到结果”条件真。输入1 and 12--- 页面显示“未找到结果”条件假。这就建立了一个“真/假”信号通道。接下来我们就可以用这个通道来“问”数据库问题。比如猜解当前数据库名的第一个字母1 and ascii(substr(database(),1,1))100--- 如果返回“找到结果”说明ASCII码大于100。1 and ascii(substr(database(),1,1))150--- 如果返回“未找到结果”说明ASCII码小于等于150。通过这种二分法折半查找我们可以一步步确定第一个字母的ASCII码进而推出字母。substr(database(),2,1)用于猜第二个字母以此类推。猜完数据库名再用同样的方法去猜表名、列名。整个过程极其耗时必须借助工具如Burp的Intruder模块或sqlmap自动化进行。5.2 时间盲注利用“睡眠”函数作为信号灯有时候页面对于真和假的返回看起来完全一样。这时就要祭出时间盲注。其原理是让数据库根据我们注入的条件真假执行不同的耗时操作通常是sleep()函数通过观察页面响应时间的差异来判断条件真假。在MySQL中可以这样构造1 and if(11, sleep(5), 0)--- 如果页面响应大约延迟了5秒说明if条件为真11执行了sleep(5)。1 and if(12, sleep(5), 0)--- 如果页面立即返回说明if条件为假12执行了0。同样我们可以把11替换成我们想猜解的条件例如if(ascii(substr(database(),1,1))100, sleep(5), 0)。通过对比响应时间就能实现和布尔盲注一样的猜解效果。时间盲注对网络稳定性要求高且速度更慢。6. 工具辅助与自动化让sqlmap成为你的利器手工注入是理解基础但在真实渗透测试中面对盲注我们必须借助自动化工具。sqlmap是这方面的王者。但很多人用sqlmap就是一句sqlmap -u “http://target.com” --dbs知其然不知其所以然。6.1 sqlmap核心参数与工作逻辑以我们刚才手工测试过的DVWA漏洞点为例假设URL是http://localhost:8080/vulnerabilities/sqli/?id1SubmitSubmit。基础探测sqlmap -u “http://localhost:8080/vulnerabilities/sqli/?id1SubmitSubmit” --cookie“PHPSESSID你的会话ID; securitylow”-u指定目标URL。--cookie这是关键因为DVWA需要登录后才能访问漏洞页面你必须提供有效的会话Cookie。可以通过浏览器开发者工具复制。运行这个命令sqlmap会先尝试各种注入技术布尔、时间、联合查询等来确认是否存在注入点以及是什么类型的数据库。获取数据库确认存在注入后使用--dbs参数列出所有数据库sqlmap -u “...” --cookie“...” --dbs获取当前数据库的表sqlmap -u “...” --cookie“...” -D dvwa --tables-D指定数据库名。获取表的列sqlmap -u “...” --cookie“...” -D dvwa -T users --columns-T指定表名。最终拖取数据sqlmap -u “...” --cookie“...” -D dvwa -T users -C user,password --dump-C指定要导出的列。--dump导出数据。如果密码是哈希sqlmap会询问你是否尝试用内置字典破解。6.2 使用Burp Suite辅助sqlmap有时候目标站点的请求比较复杂有Token、复杂的JSON或重定向。这时先用Burp抓包将完整的HTTP请求包括所有Header、Cookie、POST数据保存到一个文本文件比如request.txt然后让sqlmap直接加载这个文件进行分析sqlmap -r request.txt。这能绕过很多前端校验和复杂交互。重要心得不要过度依赖工具的“全自动”模式。对于关键系统或复杂的注入点我习惯先用--level和--risk参数调低扫描强度进行试探同时使用--proxy”http://127.0.0.1:8080”让sqlmap的流量经过Burp这样我就能在Burp中清晰地看到sqlmap到底发送了哪些Payload便于学习和调试。理解工具的行为比单纯拿到一个结果重要得多。7. 绕过技巧与高级注入场景WAFWeb应用防火墙和简单的过滤机制无处不在。了解常见的绕过技巧能让你在实战中更从容。7.1 常见过滤与绕过过滤空格可以用注释/**/、Tab键%09、换行符%0a代替。例如union/**/select。过滤关键词如select, union大小写混淆SeLeCt。双写selselectect如果过滤逻辑是删除关键词双写后删除中间部分剩下的正好是原词。用等价函数或编码有些场景下可以用like代替用hex()编码字符串。过滤引号如果参数是数字型如id1根本不需要引号。如果是字符型且引号被过滤可以尝试用十六进制表示字符串。例如admin的十六进制是0x61646d696e那么Payload可以写成... where username0x61646d696e。7.2 二次注入与宽字节注入二次注入这是一种“存储型”SQL注入。攻击者将恶意的SQL片段比如admin--输入到系统的某个存储功能如注册用户名、留言内容这些数据被存入数据库时因为经过了转义或处理是安全的。但后来当程序在另一个逻辑中从数据库取出这个“安全”的数据并未经再次处理就拼接到新的SQL语句中时注入就发生了。防御二次注入要求在所有从不可信源包括数据库取数据并拼接SQL的地方都进行参数化处理。宽字节注入主要针对使用GBK、GB2312等宽字符集的PHP程序。如果程序用addslashes()或mysql_real_escape_string()函数对单引号进行转义变成\但数据库连接字符集是GBK。那么当输入%df时%df和转义符\%5c会结合被解码为GBK编码的一个汉字“運”%df%5c从而使得后面的单引号逃逸出来形成注入。防御方法是统一使用UTF-8字符集并在进行转义前调用mysql_set_charset(utf8)或使用PDO的setCharset。8. 从攻击到防御开发者的必修课作为安全研究者或开发者了解攻击的最终目的是为了更好的防御。使用参数化查询预编译语句这是唯一被证明能从根本上防止SQL注入的方法。在任何语言、任何框架中都优先使用它。使用安全的ORM框架像MyBatis严格使用#{}避免使用${}进行字符串拼接。如果动态SQL无法避免如动态排序、动态表名必须结合白名单校验。严格的输入校验与输出编码在服务端对输入进行类型、长度、格式的严格校验。对所有从数据库取出并输出到前端的数据进行HTML编码防止XSS等二次攻击。最小权限原则给数据库应用账户分配最小必要的权限。通常Web应用只需要SELECT,INSERT,UPDATE,DELETE绝对不要赋予DROP,CREATE,FILE等高级权限。错误信息处理自定义统一的错误页面避免将数据库的原始错误信息包含路径、SQL片段等直接展示给用户。这些信息是攻击者的路标。使用WAF虽然不能依赖WAF作为唯一防线但它可以作为一道有效的缓冲层拦截大量自动化攻击和已知攻击模式。最后我想分享一个真实案例。在一次内部代码审计中我发现一个查询接口使用了字符串拼接来构造ORDER BY子句就像前面提到的${}问题。我向开发团队指出风险他们的第一反应是“这个排序字段是下拉框选的前端已经固定了选项用户改不了。” 我当场打开浏览器开发者工具编辑了前端HTML手动添加了一个option标签提交后成功实施了注入。这个案例告诉我们永远不要信任前端传来的任何数据。安全防御必须建立在“假设所有输入都是恶意的”这一零信任基础之上。