前阵子帮一个创业团队排查线上系统,发现他们的订单查询接口还在一行行拼SQL字符串,我当场血压就上来了。SQL注入这个老古董,从1998年首次被公开提出到现在,二十多年过去了,依然稳坐OWASP Top 10的榜单前列。不是它多高明,而是太多系统从第一天起就带着这个病。
这篇东西不打算给你堆概念,就按我平时带人的节奏来:先弄懂SQL注入到底是怎么发生的,再亲手复现几个经典例子,然后把各种注入形态捋一遍,最后聊防御和排查。全程以本地靶场的授权测试为背景,任何技术都只用于学习和防御,不要对未授权的系统做测试。
1. 先搞清楚:SQL注入到底是怎么发生的
1.1 一句话理解核心原理
SQL注入的本质,是用户的输入被数据库当成了可执行的代码。
我习惯用一个类比来解释:你去餐厅点餐,服务员拿着一张菜单问你吃什么,你说"宫保鸡丁",她记下来交给后厨,后厨照着做。现在突然有人对着服务员说"我要宫保鸡丁,顺便把我邻桌那个人的餐费免了",服务员如果直接把这个完整句子传给后厨,后厨照单执行,这顿饭就乱套了。
Web应用处理SQL查询的时候,经常干的就是这种事。开发者的本意是让用户提供一个或几个参数,程序拿到这些参数后填进SQL模板里。但问题是,很多系统把用户输入直接拼接进SQL语句,而不是把它当作纯粹的数据。当用户输入的内容里包含了SQL语法片段,数据库解析的时候就分不清哪些是你的业务逻辑,哪些是攻击者构造的额外指令——它全给你执行了。
1.2 为什么过了这么多年还是密密麻麻
很多人会问:都知道SQL注入这么多年了,怎么现在的系统还有?
原因很现实:存量系统太多,重构成本极高。我见过不少银行、物流、政务系统的核心业务代码还是十几年前写的,那时候安全意识和现在完全不是一个水平,而且这些系统一天不能停,你敢动它的数据库访问层,就得背好"线上事故"的锅。
还有一个原因是框架使用不当。现在的新项目基本都用了ORM框架,但ORM不等于绝对安全。比如有的同事写代码图省事,该用参数绑定的地方偏要用query("SELECT * FROM users WHERE username = '" + username + "'")这类拼接写法,直接把框架提供的安全机制给绕过去了。框架是防君子不防小人的,它会给你一把好用的武器,但你要非往自己脚上打,它也没办法。
1.3 你需要具备的基础知识
要彻底弄懂后面的内容,有几块基础不能缺:
- SQL语法基础:
SELECT、WHERE、UNION、ORDER BY这些关键字要熟。 - HTTP基础:知道GET和POST参数是怎么传递的,Cookie和Header的作用是什么。
- 数据库差异意识:MySQL、SQL Server、Oracle、PostgreSQL的语法各有不同,同一个技巧在不同数据库上不一定通用。热词里提到的SQL Server 2008注入,它的注释符、报错函数就和MySQL不一样。
如果这些基础还比较薄弱,建议先补一下再往下读。否则后面讲报错注入、盲注的时候,容易看懵。
2. 从一条URL开始,复现一次完整的SQL注入
2.1 数据是怎么从输入框进入SQL语句的
以最常见的场景为例。一个新闻网站的文章详情页,URL长这样:
http://example.com/news/article.php?id=1后端代码大概率是这个样子(以PHP为例):
$id = $_GET['id']; $sql = "SELECT title, content FROM articles WHERE id = " . $id; $result = mysqli_query($conn, $sql);这里有个致命的错误:$id直接参与了字符串拼接,最终变成了SQL语句的一部分。当用户访问article.php?id=1时,数据库执行的语句是:
SELECT title, content FROM articles WHERE id = 1一切正常。可如果用户把URL改成:
http://example.com/news/article.php?id=1 AND 1=2执行的就是:
SELECT title, content FROM articles WHERE id = 1 AND 1=2因为1=2恒为假,页面空空如也。别小看这个变化——页面返回的结果不一样了,说明我们成功干扰了SQL语句的逻辑。这就是检测SQL注入的第一步:尝试改变参数值,观察页面反应。
2.2 第一个经典例子:字符串型注入
刚才那种是数字型参数,没有引号包裹,拼接起来最方便。但更多系统会把参数用引号包起来,比如登录框的SQL可能是:
SELECT * FROM users WHERE username = 'admin' AND password = '123456'这种情况下,攻击者需要先闭合掉前面的引号,再注入自己的SQL片段。经典的例子就是热词里提到的万能密码。
假设登录验证的SQL是:
SELECT * FROM users WHERE username = '游客输入' AND password = '游客输入'如果用户名输入:
admin' --最终SQL变成:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = '123456'在MySQL里,--是注释符,后面跟一个空格,表示这行剩下的内容全部注释掉。这样一来,密码校验就被吞掉了,SQL等价于:
SELECT * FROM users WHERE username = 'admin'如果这条语句能查出记录,登录就成功了。而这还只是个开始,万能密码的花样后面在绕过章节里细说。
2.3 数字型注入:连引号都不需要
回到新闻页面的例子,id=1这种整数参数是最容易注入的,因为它不需要关心字符串闭合问题。攻击步骤通常是:
- 先输入
id=1,确认页面正常。 - 输入
id=1',如果页面报错或者显示异常,说明多了个引号导致SQL语法错误。 - 输入
id=1 AND 1=1,页面正常,说明AND后面的条件被执行了。 - 输入
id=1 AND 1=2,页面空白,说明我们也能让它"消失"。
第3和第4步的差异,就是最经典的布尔盲注的雏形——通过页面是否显示内容,推断条件真假。有的开发者觉得"反正不显示数据明细,应该就没事",但盲注恰恰是利用这种"有/无"的二元结果,一位一位地猜出数据库里的数据。
2.4 动手写一个最小复现环境
纸上谈兵没意思,我建议你直接在本地搭一个最小环境来验证。用Docker的话,一条命令就够了:
docker run -d --rm -p 8080:80 vulnerables/web-dvwaDVWA(Damn Vulnerable Web Application)是目前最流行的漏洞靶场,自带SQL注入、XSS、文件上传等常见漏洞场景,登录后进入SQL Injection模块,就能看到我刚才说的那种拼接代码的真实样子。只在本地靶场练手,这个底线一定要守住。
3. 从联合查询到盲注:注入姿势全拆解
3.1 联合查询注入:最贪心的一种
如果说盲注是"一个个猜",联合查询注入就是"直接要"。它利用SQL本身提供的UNION功能,把攻击者自己构造的查询结果,合并到原本的查询结果里,展示在页面上。
打个比方:原本的SQL是"给我看看文章表里id=1的那篇文章",攻击者通过UNION把这句话改成了"给我看看那篇文章,顺便把users表里所有用户名和密码也一起带出来"。
联合查询注入有一个前置条件:两个查询的列数必须一致。所以首先要探测原查询返回了几个字段,常用方法是ORDER BY:
id=1 ORDER BY 1 -- 正常 id=1 ORDER BY 2 -- 正常 id=1 ORDER BY 3 -- 如果报错,说明原查询只有2列确定列数是2之后,再用UNION拼接:
id=1 UNION SELECT username, password FROM users页面就会把users表的数据直接渲染出来。这种注入方式的优点是效率极高、信息量大,但局限性也很明显:只有当页面会把查询结果展示出来时才有效。如果页面只是提示"查询成功/失败",不显示具体内容,UNION注入就没法用了。
3.2 报错注入:让数据库把答案说出来
有些系统做了很好的"表面功夫",查询结果不展示,报错页面却暴露了细节。报错注入的思路就是故意制造SQL语法错误,让数据库在执行过程中把敏感信息带进错误消息里。
以MySQL为例,经典的手法有updatexml和extractvalue:
AND extractvalue(1, concat(0x7e, (SELECT password FROM users WHERE username='admin')))extractvalue第二个参数要求是XPath格式,如果你传一个非法路径,MySQL会在错误信息里把路径内容完整吐出来。上面的payload利用concat把查询结果拼到非法路径里,于是数据库的错误提示就会包含password的值。
报错注入在MySQL 5.1及以上版本特别常用,但SQL Server、Oracle也有各自的报错函数。做安全测试的时候,知道目标是什么数据库,用什么报错函数,是最基本的侦察工作。
3.3 布尔盲注:页面只剩"有"和"无"两个答案
当页面不展示数据库内容、报错也不可见的时候,仍然可以通过条件真假导致页面呈现差异来探测数据。我前面提到的id=1 AND 1=1/AND 1=2就是最基本的布尔盲注。
真正麻烦的是数据提取过程。比如要猜数据库名的一个字符,可以这样:
id=1 AND SUBSTRING(database(),1,1) = 'a' id=1 AND SUBSTRING(database(),1,1) = 'b'页面正常猜对了,页面空白猜错了。逐个字符试,猜完长度再猜内容,这个过程如果手工做,慢到怀疑人生。现实中都是用脚本或者工具去做,比如写Python逐个字符二分法。
这里要给刚入门的朋友一个忠告:盲注不是靠手速,而是靠脚本能力。我见过很多人在靶场上手工刷盲注刷到凌晨,其实用Python写100行脚本,把字典跑一遍,半小时就能出结果。
3.4 时间盲注:什么都看不见时的最后手段
有些系统的"表面功夫"做得更彻底:不管条件真还是假,页面都返回一样的内容。这时候布尔盲注就失去了意义,只能借助时间延迟来传递信息。
MySQL里用SLEEP(5),SQL Server用WAITFOR DELAY '0:0:5'。判断逻辑是:
id=1 AND IF(SUBSTRING(database(),1,1)='a', SLEEP(3), 0)如果数据库名的第一个字符是a,页面会卡3秒再返回;不是a,立刻返回。这样就通过响应时间的差异,把"是/否"的信号传出来。时间盲注是所有注入类型里最慢、最容易被WAF发现的,但在极端场景下往往是最后一条路。
3.5 堆叠注入和宽字节注入:两个特殊流派
堆叠注入是利用分号,让数据库执行多条语句:
id=1; DELETE FROM users WHERE 1=1它的前提是数据库连接支持一次性执行多条SQL,比如PHP的mysqli_multi_query()。这种注入破坏力极大,但实际触发条件比较苛刻,很多接口的数据库驱动不支持多语句执行。
宽字节注入是一个历史遗留问题,主要针对的是GBK编码的MySQL场景。开发者为了防止注入,在用户输入的单引号前加反斜杠转义,比如'变成\'。但GBK编码下,攻击者输入%bf%27,反斜杠的ASCII码0x5c会和前面的%bf组成一个合法中文字符,导致转义失败,单引号还是闭合了。这个技术在现代全文UTF-8编码的系统里已经不太适用了,但很多老系统的修复过程里,开发者确实被它坑过。
4. 绕过与对抗:为什么WAF挡不住所有注入
4.1 WAF到底在拦什么
WAF(Web应用防火墙)的基本思路是先看输入的流量里有没有恶意的特征串。比如出现在输入里的UNION SELECT、information_schema、SLEEP等关键字,命中规则就直接拦截。
问题就出在:黑名单模式很难穷尽所有变体。攻击者只要找到一种不在规则表里的写法,WAF就形同虚设。
4.2 常见的绕过思路(防御视角)
这里不是为了教你怎么打,而是让你知道防御方必须考虑哪些对抗手段:
- 注释符拆分:
UNION/**/SELECT,让关键字不再连续出现。 - 大小写混写:
union select改成UnIoN SeLeCt,规则里如果没做大小写归一化就失效了。 - 内联注释:
/*!50000UNION*/ SELECT,MySQL会执行注释里的内容,但WAF可能只读到"注释"就放行了。 - 等价函数替换:
SUBSTRING换成MID或LPAD,SLEEP换成BENCHMARK。 - 双写绕过:
SELSELECTECT,如果WAF是简单把关键子串删掉,删除后反而拼成了合法关键字。 - 编码绕过:URL编码、十六进制编码混合使用。
我评估WAF的时候,常说一句话:规则写得再全,也不如根上不用拼接。WAF是最后一道保险,不是唯一的防线。安全设计的原则是纵深防御:参数化查询是第一道墙,输入白名单校验是第二道,数据库最小权限是第三道,WAF放在最外层兜底。
4.3 万能密码为什么"万能"
回到热词里的"万能密码"。最常见的payload是:
用户名: ' or '1'='1' --整个SQL变成:
SELECT * FROM users WHERE username = '' or '1'='1' -- ' AND password = '123456'因为'1'='1'恒真,整个WHERE条件变成恒真,加上注释符吃掉后面的密码判断,这条查询就会把表中第一行用户的信息匹配出来——通常是管理员用户。这就拿到了登录权限。
我用这个例子教学的目的是想说明一点:登录校验逻辑的设计问题比你想象中普遍。我自己在代码审查时,看到SELECT * FROM users WHERE username=... AND password=...这种写法就会格外小心,因为后端的校验预期是"查得到就是合法用户",一旦查询条件被破坏,认证就崩了。比较稳妥的做法是查出记录后再单独做密码哈希比对,而不是依赖SQL条件来判定认证是否成功。
5. 在靶场里练手:DVWA、Pikachu、sqli-labs 怎么用
5.1 为什么非要用靶场
练安全技术和练开车一样,你不能一上来就上真实道路。靶场是一个可以随意打、随便造、打坏了重启就行的实验环境。在靶场上你能放心大胆地试各种诡异输入,验证自己的判断,积累识别不同数据库报错特征的经验,这些在真实系统里根本不敢做。
5.2 靶场搭建与上手路线
DVWA:最适合入门。它的SQL Injection模块分了四个难度等级:
- Low:直接拼接,完整演示,适合理解原理。
- Medium:做了基础转义,但可以用宽字节/二次注入思路绕过。
- High:限制只能查一行,而且用
LIMIT 1截断部分注入尝试。 - Impossible:改用参数化查询,彻底堵死。
我建议你从Low难度把前面第六节的联合查询、布尔盲注、报错注入全部过一遍,再看源码对比Medium和High的脆弱点。
Pikachu:这是国内爱好者维护的一个靶场,最大的特点是覆盖了各类注入场景,包括数字型、字符型、搜索型、堆叠注入,还带一个配套的提示系统,新手不至于卡在一个地方出不来。它的界面也更贴合国内开发者的习惯。
sqli-labs:印度安全研究者开发,专门为练习SQL注入设计。它由几十个"Less"组成,从Less-1到Less-20+,每个Less对应一个特定场景:有报错型、盲注、时间盲注、宽字节、POST注入、Cookie注入等。难度是阶梯式的,越往后越刁钻。这个靶场适合已经有一定基础,想系统巩固各种边界情况的人。
CTFHub技能树:如果你后续想参加CTF比赛,这里有按技能树组织的SQL注入题目,还会考你各种绕过组合。CTF的题目往往比较"特化",和应用系统的漏洞不完全一样,但思维训练价值很高。
5.3 手动注入和工具的分工
工具方面,sqlmap是绕不开的。它最大的价值在于自动识别注入类型、自动提取数据,速度远超手工。但我的建议是:先用靶场练好手工注入,再上工具。工具能帮你快速完成重复劳动,但如果底层原理不懂,你连sqlmap跑出来的结果都看不懂,更不会根据报错调整参数。
实际测试时我的工作流是:先在浏览器里手动验证注入点确实存在,明确是数字型还是字符型、是UNION注入还是盲注,然后用sqlmap做数据提取。整个流程下来既高效,又不会因为盲目跑工具而误伤业务参数。
6. 开发侧防御:把漏洞扼杀在源头
6.1 参数化查询:安全的第一块基石
不管是哪种编程语言和数据库,防御SQL注入的首选方案永远是参数化查询(也叫预编译)。它的核心思路是:先把SQL语句的骨架发给数据库,让数据库先解析,确认这是一条"结构固定"的查询,然后再把用户输入作为纯数据传过去,数据永远没有机会变成SQL语法。
用Java的PreparedStatement举例:
String sql = "SELECT title, content FROM articles WHERE id = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, Integer.parseInt(id)); ResultSet rs = ps.executeQuery();注意两点:第一,整个SQL中问号是占位符,业务参数全走setXxx方法绑定;第二,尽量把ID转成整型再传,这属于更深一层的白名单校验。
PHP用PDO的话:
$stmt = $pdo->prepare("SELECT title, content FROM articles WHERE id = ?"); $stmt->execute([$id]);Python的psycopg2用%s占位符同样可以做到参数绑定。
6.2 白名单校验与字段类型约束
参数化查询解决了"数据被当代码执行"的问题,但有些场景还需要额外的校验。比如排序字段:
ORDER BY " + orderBy这种动态字段没法用占位符,那就反过来:做成白名单。只允许传入id、create_time、title这些写死的列名,任何不在白名单里的值一律拒绝。这比黑名单过滤可靠得多,因为攻