URL 白名单绕过:从「字符串匹配」到「解析差异」
URL 白名单绕过:从「字符串匹配」到「解析差异」
一份写给学习者的完整笔记
版本 1.0 | 2026-10-24 | 全部结论经本机实测可复现
写在前面
这个知识点在网上被讲得很碎:一堆 payload,每条配一句「绕过白名单」。但很少有人把为什么讲清楚,导致学习者只能背 payload,遇到没见过的写法就完全瞎了。
这篇文章想解决的问题是:让你看到任意一段 URL 校验代码,就能自己推导出该用什么 payload,以及为什么那条 payload 会成立。
全文只有一个核心命题:
漏洞不在 URL 里,而在「谁用什么方式看这串字符」。
只要这个命题立住,后面所有 payload 都是它的推论,不需要背。
阅读前提:会写一点点代码最好,但不需要开发经验。文中每个用到的编程概念都会当场用人话解释。
使用边界:本文所有技术仅用于获得书面授权的安全测试与自身系统加固。文中的 target.example.com 是占位符,请替换为你自己的目标或你自己控制的域名,不要对第三方域名发起任何测试流量。
第 0 章 先建立模型
0.1 一句话讲清所有绕过
后端写白名单校验,本意是:
「只允许访问 我们自己的域名」
但代码经常写成:
「这串文字 长什么样 才算通过」
这两句话听起来一样,其实差得很远:
| 意图问的 | 代码实际问的 | |
|---|---|---|
| 目标 | 这台机器是谁 | 这段文字长什么样 |
| 依据 | URL 解析结果 | 字符的字面排列 |
漏洞就在这个错位里。
打个比方:门卫想拦「非本公司的人」,但实现方式是「检查你名字第一个字是不是『海』,有『海』就放行」。那我改名叫「海·骗子」就进去了。
0.2 三段式模型
一次带校验的 URL 请求,实际上会经过三个「看这串字符」的地方:
你发出去的串
│
▼
┌─────────────┐
│ 清洗层 │ 改写 / 过滤 / 解码(WAF、sanitize、trim)
└─────────────┘
│
▼
┌─────────────┐
│ 检查层 │ 白名单比对(includes / startsWith / 正则 / hostname)
└─────────────┘
│
▼
┌─────────────┐
│ 执行层 │ 真正解析并发请求 / 跳转
└─────────────┘
任何两层对这串字符的理解不一致,就是一条绕过路径。
理想情况下三者应该看到完全一样的东西。实际工程里,它们往往来自三套不同的实现(WAF 的正则、业务的字符串函数、HTTP 客户端库的解析器),天然有差异。
本文后面所有内容,本质上都在做同一件事:找到两层之间的差异,构造一个串,让检查层满意、让执行层去别人的主机。
0.3 一个必须建立的习惯
看到 payload,别问「这个能不能过」,要问:
「这条 payload 利用了哪两层的差异?」
答案只有六种可能,第 3 章会逐一展开。
第 1 章 URL 的解剖
1.1 URL 由哪些部件组成
以 https://target.example.com:8443/a/b?x=1#top 为例:
https:// target.example.com : 8443 / a / b ? x=1 # top
└协议┘ └────主机────┘ └端口┘ └─路径─┘ └参数┘ └标记┘
- 协议(scheme):
https: - 主机(host / hostname):
target.example.com← 这才是"要去哪台机器" - 端口(port):
:8443,省略时用协议默认端口(https=443) - 路径(path):
/a/b - 参数(query):
?x=1,会发给服务器 - 标记(fragment):
#top,永远不发给服务器
还有一个很多人不知道的部件,就藏在主机前面:
https:// user:pass @ target.example.com /
└─ userinfo ─┘ └──── host ────┘
叫 userinfo(用户信息),格式是 用户名:密码,密码可省略。日常几乎没人用它,但它是 URL 里最容易被忽视的一块「可写字的地方」。
1.2 五个「会改变目的地」的字符
对 URL 来说,下面五个字符是语法分隔符,它们决定"谁是真主机":
| 字符 | 在 URL 里的含义 | 在字符串函数眼里 |
|---|---|---|
@ |
前面是 userinfo,后面才是主机 | 一个普通 AT 符号 |
# |
后面是本地标记,不发给服务器 | 一个普通井号 |
? |
后面是参数 | 一个普通问号 |
/ |
主机到此结束,后面是路径 | 一个普通斜杠 |
: |
后面是端口 | 一个普通冒号 |
字符串函数(includes/startsWith/endsWith)不认识上面任何一条语义。 它们只会数「第几位是哪个字」。
这就是全部问题的起点。
1.3 域名要从右往左读
这一点反直觉但极重要:
target.example.com.evil.com
↑ ↑ ↑
子域 子域 真正的顶级域
最右边才是真主人。 上面这个地址属于 evil.com,是它名下的一台机器,只不过名字里带了 target.example.com 这几个字。
从左往右看像「目标公司的域名」,从右往左看是「攻击者的子域」。
1.4 标准解析器会做的几件事(WHATWG URL)
现代解析器(浏览器、Node.js、Python urllib 新版、Java URI 大体一致)在解析时会做归一化:
| 输入 | 解析器处理 |
|---|---|
含 \t \n \r |
直接删掉(任何位置) |
含 \(反斜杠) |
特殊协议下当成 / |
含全角句号 。(U+3002) |
当成 . |
| 主机大小写 | 转小写 |
| 主机尾部的点 | 保留(a.com. 表示 FQDN 根域,DNS 上等同 a.com) |
主机段出现 %40 等编码 |
不当作分隔符,直接判非法 |
最后一条很关键,第 5 章会单独讲。
第 2 章 六种后端写法:它们在问什么问题
这一章是全文的地基。你只要能判断出后端属于哪一格,payload 就是查表的事。
2.1 总表
| # | 写法 | 人话 | 它在问 | 它没问 | 强度 |
|---|---|---|---|---|---|
| ① | includes / contains / str_contains / indexOf() != -1 / 无 ^ 的正则 |
全文搜关键词 | 这串里有没有这个词? | 在哪?是域名还是随便一句话? | 最弱 |
| ② | startsWith / indexOf() === 0 / 有 ^ 无边界正则 |
只看开头 | 开头这几个字对不对? | 域名在哪结束? | 弱 |
| ③ | endsWith / 后缀比对缺前导点 |
只看结尾 | 结尾这几个字对不对? | 同上 | 弱 |
| ④ | 手写切分取 host(split('/')[2]) |
自己动手切 | 我切出来的那段像不像白名单? | 我切得对吗? | 看实现,常出错 |
| ⑤ | 正则里点未转义(a.com 而非 a\.com) |
模板太松 | 开头符不符合这个模糊模板? | 那位真的是点吗? | 弱且隐蔽 |
| ⑥ | 解析后比对 new URL(u).hostname |
先拆再比 | 真实主机名是不是白名单? | 端口?协议?userinfo? | 强,但仍可能漏维度 |
2.2 逐个人话解释
① 子串类:includes / contains
if (url.includes("target.example.com")) { 放行 }
人话:拿一根荧光笔在这串文字上从头扫到尾,看到这几个字就算过。
它的问题:不在乎位置。你把白名单域名随便塞在哪儿都行——参数里、标记里、路径里。
② 前缀类:startsWith / indexOf(...) === 0
if (url.startsWith("https://target.example.com")) { 放行 }
if (url.indexOf("https://target.example.com") === 0) { 放行 } // 同一件事的另一种写法
if (/^https:\/\/target\.example\.com/.test(url)) { 放行 } // 正则版
人话:
startsWith= 「开头是不是这几个字」indexOf(...) === 0= 「这几个字第一次出现在第 0 位吗」——第 0 位就是开头,所以和上一条完全等价- 正则里的
^= 「只从第 0 位开始对」
它的问题:只保证「开头对」,不管后面接什么,也不管域名在这几个字之后有没有结束。
注意一个高频混淆点:正则有没有
^决定它属于哪类。
/target\.example\.com/(没有^)→ 子串类/^https:\/\/target\.example\.com/(有^但没边界)→ 前缀类
^一出现,就归前缀类。
③ 后缀类:endsWith
if (url.endsWith("target.example.com")) { 放行 }
人话:只看最后几个字。
它的问题:域名的"真主人"在最右边,思路其实是对的——但它漏了一个点。
正确写法应该是 endsWith(".target.example.com")(前面带点)。少了这个点,eviltarget.example.com(名字里带但根本不是子域)也会被放行。
④ 手写切分:最常见的自研写法
不用标准解析器,自己切:
let host = url.split('/')[2]; // 取 https:// 之后的第一段
host = host.split('@')[0]; // 去掉用户名
if (host === "target.example.com") { 放行 }
看起来挺严谨,其实到处都是坑(第 5 章会实测它怎么被骗)。这类代码在真实项目里非常多,因为「不想引入依赖」。
⑤ 正则点未转义
/^https:\/\/target.example.com/ // 点是通配符!能匹配任意字符
人话:正则里 . 表示「任意一个字符」,要表示真正的句点必须写 \.。忘了转义,就等于把「必须是点」放宽成「随便什么都行」。
这条的价值不在打,在识别:它只能被一种特殊 payload 打穿(见 4.6),所以反过来可以当探针用。
⑥ 解析后比对(正确思路,但仍可能不完整)
if (new URL(url).hostname === "target.example.com") { 放行 }
这是方向正确的写法。new URL() 是懂语法的拆解器,它会告诉你 https://target.example.com@evil.com/ 的主机是 evil.com。
但它仍可能漏掉三个维度:
// 完整版
const u = new URL(url);
if (u.protocol === "https:" &&
u.hostname === "target.example.com" &&
u.port === "" &&
!u.username && !u.password) { 放行 }
漏端口 → 可以打这台机器的其他服务(详见 4.5)。
漏 userinfo → 大多数情况下不致命,但配合某些网络库会带出 Authorization 头。
第 3 章 为什么能绕过:六种差异
上一章的六种写法,对应六种利用方式。这一章是全文的原理核心。
3.1 差异一:子串位置(打 ①类)
检查者问「有没有这个词」,那你就把白名单域名放在一个不影响真实主机的位置。
| 放哪 | 示例 | 特点 |
|---|---|---|
标记(#) |
https://evil.com#https://target.example.com/ |
不发给服务器,evil.com 收到的请求最干净 |
参数(?) |
https://evil.com?@target.example.com/ |
会发给服务器,适合需要参数里带域名的场景 |
| 路径 | https://evil.com/target.example.com |
不依赖特殊符号 |
| 子域 | https://target.example.com.evil.com/ |
连 #、? 都不用,最"自然" |
3.2 差异二:语法混淆(打 ②类)
核心是 @。
https:// target.example.com @ evil.com /
└协议┘ └─── userinfo(用户名)──┘ └真主机┘
- 字符串开头完全等于白名单 →
startsWith放行 - 解析器看到 userinfo 后面才是主机 → 真主机是 evil.com
@ 之前那段在 URL 语法里叫 userinfo / 用户名,不是主机。这是整个知识点最重要的一句话。
同理 .evil.com(差异三)也能打 ②类,因为字符串确实以白名单域名开头。
3.3 差异三:假子域(打 ①②)
https://target.example.com.evil.com/
- 对 ①(子串):有那几个字 → 过
- 对 ②(前缀):开头对 → 过
- 对 ③(
endsWith("target.example.com")):结尾是evil.com/→ 不过
所以它能打 ①②,但打不了 ③。这点很多资料讲错,务必分清。
3.4 差异四:归一化(打黑名单)
同一台机器有多种写法:
https://TARGET.EXAMPLE.COM/ 大小写
https://target.example.com./ 尾部根点
关键结论:这一类打不了白名单。
因为它们连的就是白名单那台机器,只是写法不同。字符串比较认为不同,于是白名单把你拦在门外(fail-closed)——你只会把自己打回去。
它们的正确用途是绕黑名单(evil.com → EVIL.COM / evil.com.)和大小写敏感的 WAF 签名。
3.5 差异五:维度缺失(打 ⑥类)
域名这一维对了,但别的维度没管:
https://target.example.com:8443/ 端口换了
http://target.example.com/ 协议降级
https://target.example.com/admin 路径越权
这是唯一能打穿"完整边界正则"和"解析后比对 hostname"的手法。 因为它根本不需要骗——主机名是真的。
3.6 差异六:拼接缺陷(不是白名单的事)
当输入被当成路径片段拼进 URL 时:
location.href = "/" + userInput;
// 你给 //evil.com/ → 浏览器理解为 https://evil.com/
// 在浏览器里表示「沿用当前协议 + 换主机」。这类 payload 的串里根本没有白名单域名,所以它不属于白名单绕过,属于拼接缺陷——两类完全不同的洞,别混在一起谈。
第 4 章 Payload 全书(含实测)
实测环境:Node.js(WHATWG 解析器)+ Python
re,2026-10-24。
文中把target.example.com作为白名单占位符(记作W),evil.com作为攻击者占位符。结论与具体域名无关。
图例:Y= 放行且真主机不是白名单(绕过成立)|o= 放行但目标就是白名单(无危害)|x= 被拦
4.1 前缀类(打 startsWith / indexOf===0 / 有^无边界正则)
P01 https://target.example.com@evil.com/
真主机:evil.com
拆解: https:// target.example.com @ evil.com /
└协议┘ └──── userinfo ────┘ └主机┘
最通用的一条。前缀完全命中,主机是 evil.com。
打穿:startsWith、indexOf===0、有^无边界正则、includes(含协议)、点未转义正则
失效于:校验 userinfo 非空、正则带 / 边界、解析后比对 hostname
P02 https://target.example.com.evil.com/
真主机:target.example.com.evil.com(evil.com 的子域)
不需要任何特殊字符,能穿过大多数针对 @ 的清洗。
打穿:①②两类
失效于:endsWith(".target.example.com")
P03 https://target.example.com%2f@evil.com/
P04 https://target.example.com%C2%A0@evil.com/
%2f(斜杠)和 %C2%A0(不换行空格)在 userinfo 里合法,真主机仍为 evil.com。
这两条不是打检查层,是打中间的清洗层(详见第 5 章)。
P13 https://target.example.com@evil.com/target.example.com
通吃型:开头对 + 中间有 + 结尾对,一个串同时满足三种检查方向。
P16 https://target.example.com@evil.com#https://target.example.com/
@ + # 双保险,前缀类与子串类一起打。
4.2 子串类(打 includes / contains / 无^正则)
P10 https://evil.com#https://target.example.com/
# 之后不发给服务器。evil.com 收到的就是一个干净的 GET /,适合重定向场景钓鱼(落地页可以在 location.hash 里读数据)。
P11 https://evil.com?@target.example.com/
? 之后会发给服务器。适合「后端要拿参数做二次处理」或「WAF 只看 query」的场景。
P12 https://evil.com/target.example.com
顺带打 endsWith。
P14 https://evil.com\@target.example.com/
\ 在 URL 里等同 /,所以主机是 evil.com,@target.example.com 全变成路径。只能打子串类(要求 https:// 前缀的写法全被拦)。
P15 http://target.example.com@evil.com/
把协议换成 http://,所有要求 https:// 前缀的写法立刻失效,但 includes("target.example.com") 照样过。
这条是判据探针:它过了,说明对方是纯子串检查。
4.3 后缀类(打 endsWith)
P12 / P13(同上)
让白名单域名出现在结尾。
⚠️ 高频误区:.evil.com 这类 payload 打不了 endsWith。因为 https://target.example.com.evil.com/ 的结尾是 evil.com/,不是 target.example.com。
| 检查方向 | 满意条件 | payload 摆法 |
|---|---|---|
| 开头对 | 白名单域名放最前 | target.example.com@evil.com |
| 结尾对 | 白名单域名放最后 | evil.com/target.example.com |
| 中间有 | 放哪都行 | evil.com#...target.example.com |
4.4 归一化类(只打黑名单)
P07 https://target.example.com./ | P08 https://TARGET.EXAMPLE.COM/
实测:对全部白名单写法都是「拦」。
因为目标就是白名单机器本机,只是写法不同 → fail-closed。
只用于绕黑名单、绕大小写敏感 WAF、绕缓存去重。
4.5 维度类(打「解析后比对」)
P06 https://target.example.com:8443/
唯一能打穿完整边界正则 (\/|:|$) 和 hostname === W 的 payload。
原因:主机名一字不差,检查通过得光明正大,只是端口换了。
价值在 SSRF —— 拿受信域名当跳板,打这台机器上的其他服务(管理口、actuator、ES、Redis…)。
失效于:校验 port === ""。
4.6 探针类(识别「正则点未转义」)
P09 https://targetXexampleYcom@evil.com/
用任意字符顶替点。实测只有「点未转义」这一种会放行,其余 10 种全拦。
它的价值是识别:一旦它被放行,你就确认对方正则写漏了,之后可以自由伪造域名。
4.7 拼接类(另一类洞)
//evil.com/ | /\evil.com
串里没有白名单域名,对白名单检查全无效。
只在「输入被当路径拼接」时成立 —— 属于拼接缺陷,与白名单无关。
4.8 实测矩阵
下表 W = target.example.com。
ID 真实 host C1 C2 C3 C4 C5 C6 C7 C8 C12 C10 C11
--------------------------------------------------------------------------------------
P01 evil.com Y Y Y x x Y Y x Y x x
P02 target.example.com.evil.com Y Y Y x x Y Y x Y x x
P03 evil.com Y Y Y x x Y Y x Y x x
P04 evil.com Y Y Y x x Y Y x Y x x
P06 target.example.com:8443 Y Y Y x Y Y Y x Y Y x
P07 target.example.com. o o o x x o o x o x x
P08 target.example.com x x x x x x x x x o o
P09 evil.com x x x x x x x x Y x x
P10 evil.com x x x x x Y Y x x x x
P11 evil.com x x x x x Y x x x x x
P12 evil.com x x x x x Y x Y x x x
P13 evil.com Y Y Y x x Y Y Y Y x x
P14 evil.com x x x x x Y x x x x x
P15 evil.com x x x x x Y x x x x x
P16 evil.com Y Y Y x x Y Y x Y x x
列头对照:
| 列 | 后端写法 |
|---|---|
| C1 | startsWith("https://W") |
| C2 | indexOf("https://W") === 0 |
| C3 | ^https://W(有 ^ 无边界) |
| C4 | ^https://W/(斜杠边界) |
| C5 | `^https://W(/ |
| C6 | includes("target.example.com")(子串) |
| C7 | includes("https://W")(子串含协议) |
| C8 | endsWith("target.example.com") |
| C12 | ^https://target.example.com(点未转义) |
| C10 | new URL(s).hostname === W |
| C11 | hostname + port + protocol + userinfo 全校验 |
读表要点:
- C8 只有 P12/P13 打得穿 —— 后缀类的 payload 必须让白名单域名出现在结尾。
- C12 只有 P09 打得穿 —— 点未转义是独立一格。
- C5 只有 P06 打得穿 —— 完整边界正则封死域名层,只剩端口维度。
- C11 一条都没打穿 —— 这是四个维度全校验的写法。
- P07/P08 对白名单全是
x或o—— 印证「归一化类打不了白名单」。 - P10/P11(
#/?)只打 C6/C7 —— 印证「#骗不了前缀检查」。
第 5 章 中间清洗层:最容易被忽略的一层
5.1 什么是清洗层
在"检查 URL"和"真正使用 URL"之间,多了一段会把你那串字符改写掉的代码或设备。
常见的改写动作:
| 动作 | 谁在做 |
|---|---|
删 @ 之前的内容(去 userinfo) |
自研工具函数 |
删 //、:// |
WAF 规则 |
trim() / 去控制字符 |
框架 sanitizer |
url_decode(一次或两次) |
参数解析阶段 |
| 截断超长输入 | 网关 |
| HTML 转义 | 输出/落库 |
| 转小写、路径规范化 | CDN / 反代 |
关键区分:
- 拦截型清洗(直接 403)→ 不制造新面,只是"多了一道闸"
- 改写型清洗(悄悄改掉再继续)→ 制造新面
5.2 最危险的情况:清洗发生在「检查之后」
真实代码常见这样写:
if (!url.startsWith("https://target.example.com")) return 拒绝; // ① 检查原始串
const target = removeUserInfo(url); // ② 才清洗
await fetch(target); // ③ 用清洗后的串
实测这个过程:
输入 : https://target.example.com@evil.com/
检查① : startsWith → true → 放行
清洗②后 : https://evil.com/ ← 假域名被删掉了
实际③ : fetch("https://evil.com/")
清洗层反而帮了攻击者:它把骗过检查的串,洗干净成了真正要去的主机。这就是 TOCTOU(检查时与使用时对象不一致)。
反过来,如果清洗在检查之前,@ 骗术就被消掉了——这是正确顺序。
所以「清洗在检查前还是后」本身就是一个探测点。
5.3 三种编码变体各打哪种清洗(实测)
① %2f@ → 打「手写切分取 host」
let host = url.split('/')[2]; // 取第三段
host = host.split('@')[0]; // 去掉用户名
实测它算出来的值:
输入 : https://target.example.com%2f@evil.com/
它算出 : "target.example.com/" ← 因为不认 %2f 是斜杠,切错了
真主机 : evil.com
%2f 不是给解析器看的,是给"手写切分"的清洗层看的——让它切分位置偏移一格,算出一个白名单主机名。
② %C2%A0@(不换行空格)→ 打「trim 后精确比对」
实测:
清洗层解码后得到 : "target.example.com" + NBSP
.trim() 之后 : "target.example.com" ← 完全相等!
真主机 : evil.com
JS/Python 的 trim() 都会吃掉 NBSP,所以「先 trim 再 === 白名单」这种看起来最严的写法会被它穿透。
③ %40 → 打「先 decode 再解析」的双层栈
这条最容易误判,实测澄清:
浏览器/WHATWG 直接解析 https://target.example.com%40evil.com/
→ PARSE FAIL(%40 在主机段是非法字符,不会被当成 @)
服务端先 decodeURIComponent 一次再解析
→ 变成 https://target.example.com@evil.com/ → host = evil.com
所以 %40 只对"先解码后解析"的服务端栈有效,对浏览器无效。它打的是解码次数差。再深一层是 %2540(解两次才成 @)。
5.4 怎么发现清洗层存在
| 方法 | 做法 |
|---|---|
| 看回显 | 让目标把「实际使用的 URL」吐出来:错误信息、跳转落地地址、日志页 |
| 看落地跳转 | 跟一次 Location 头,比对长度/编码是否被改写 |
| 分段标记 | 塞几个可识别标记字符,看回来的版本少了哪个 |
| 外带验证 | 用自己域名的 DNS/HTTP 日志看真正到达的主机(唯一可靠的最终事实) |
| 双发对比 | 同一 payload 发两次(裸的 / 编码的),结果不同 → 有改写层 |
5.5 策略结论
清洗层越多,越要优先用「不需要特殊字符」的 payload。
| 优先级 | payload | 依赖特殊字符 | 过清洗能力 |
|---|---|---|---|
| 最高 | https://W.evil.com/ |
零(纯字母数字点) | 删 @、去 //、规范化全不影响 |
| 次高 | https://W:8443/ |
只有 : |
多数清洗不碰冒号 |
| 一般 | …W@evil.com/ |
@ |
被"去 userinfo"直接消掉 |
| 最后 | %2f / %C2%A0 / %40 |
编码 | 只在确认存在特定清洗时才用 |
能用字母和点解决的,就别用符号和编码解决——符号会被洗掉,字母不会。
第 6 章 有 WAF 时怎么做
6.1 三道闸模型
你的 payload
↓
[闸一] WAF / 网关 / CDN ← 匹配原始字符串,不看解析结果
↓
[闸二] 业务校验 ← 字符串比较(第 2 章的六种写法)
↓
[闸三] 真正执行 ← 请求库 / 浏览器 重新解析
- 被闸一拦 → 跟后端写法无关,你测的是 WAF 规则
- 闸二放你、闸三解析出 evil.com → 这就是洞
- 被闸二拦 → 用第 4 章的表判读它是哪种写法
顺序必须是:先确认 WAF 存不存在 → 再确认它拦不拦你这类 payload → 最后才谈后端。
6.2 第 1 步:判层(四看)
| 看什么 | WAF 拦 | 业务校验拦 |
|---|---|---|
| 状态码 | 403/406/493/501/456,有时 200 | 通常 200 + JSON code,或 302 |
| 响应体 | 拦截页(有品牌/Ray ID)、极短固定页、每次长度一致 | 业务自己的错误文案 |
| 响应头 | 突然多 Server、少了 X-Powered-By、出现 cf-ray/acw_tc 之类 |
头正常 |
| 触发位置 | 同一个串放 header / Cookie / 无关字段 也被拦 | 只在业务参数里拦 |
「触发位置」是最强判据:同一条 payload 分别塞进业务参数、header、Cookie、无关 JSON 字段。全被拦 → WAF 全局规则;只有业务参数被拦 → 可能是参数级规则,也可能是业务校验。
WAF 指纹可以用 wafw00f,或手工看拦截页文案、Set-Cookie 特征、拦截页 md5 是否恒定。
6.3 第 2 步:判方向(黑名单还是白名单)
| 探测 | 结果 | 结论 |
|---|---|---|
?url=https://example.org/(随机正常域) |
放行 | 黑名单型 → 去找关键字 |
| 同上 | 被拦 | 白名单型 → 去找能过 WAF 又能绕业务的域 |
| 同上 | 业务报「不允许的域名」 | 是业务白名单,不是 WAF |
6.4 第 3 步:定位「对什么关键字过滤」(差分缩减法)
核心方法只有一条:保留结构、只换一个变量,看哪个变量决定拦/过。
基线 ?url=https://example.org/ → 必须放行,否则规则是"全部URL"
探针1 ?url=https://target.example.com/ → 拦/过
探针2 ?url=https://example.com/ → 只在这里拦 => 关键字定位
探针3 ?url=https://targetXexample.com/ → 点被替换,若过 => 字面匹配
探针4 ?url=https://TARGET.EXAMPLE.COM/ → 若过 => 规则大小写敏感
探针5 ?url=//target.example.com/ → 若过 => 规则要求 https:// 前缀
探针6 ?url=https://evil.com@target.example.com/ → 若过 => 规则只看 @ 后
判读原则:
| 现象 | 说明规则是 |
|---|---|
| 只拦含特定域名的 | 域名级关键字黑名单 |
拦所有含 :// 的 |
结构级规则 → 整类被禁,得换参数/换接口 |
| 大小写一换就过 | 字面匹配、没做大小写归一化 |
加 @ 就过 |
规则按 @ 切分取后半 |
| 只在 query 拦、body 不限 | 规则只挂了 query → 换位置 |
三个必做的对照(否则结论全是假的):
- 同长度随机串对照 —— 排除「规则针对长度/整体形态」
- 无敏感词但结构相同的串 —— 排除「任何外域都拦」
- 每条探针只发一次并记录原始响应(状态码 + 长度 + md5)
6.5 第 4 步:确认没被 WAF 干扰后,再探后端写法
关键纪律:探后端写法前,先证明「这条 payload 没被 WAF 拦」。 否则你会把「WAF 拦了」误读成「后端写法很严」。
做法:每条探针跑两遍——一遍放业务参数,一遍放无关字段。两遍结果不同 → 是闸二在说话;一致且都拦 → 多半是闸一。
确认干净后按第 4 章的表判读。
6.6 第 5 步:绕过优先级
优先级一:换机理,不换编码(最有效)
WAF 规则通常只盯几个高频特征(@、..、://、内网 IP),而机理不同但同样有效的 payload 常常裸奔。
| 原本想用 | WAF 常拦 | 换成 | 为什么能过 |
|---|---|---|---|
W@evil.com |
拦 @ |
evil.com#W |
# 在规则里权重极低 |
evil.com/../internal |
拦 .. |
W:8443 |
端口不在规则里 |
| 内网 IP 直写 | 必拦 | 换维度/跳板 | 换维度 |
优先级二:换位置
?url=X→?url[]=X/?url=ok&url=X(HPP)- query → JSON body → form body → 路径段 → header
Content-Type从application/json换成x-www-form-urlencoded
优先级三:换编码(最不稳,放最后)
%2e→. %40→@ %2f→/ 双重编码 %252e 全角 。@ 大小写混合
⚠️ 编码是双刃:WAF 放了,后端解析器也可能解不出来 → 实际是「绕过成功但打不动」。所以编码后必须验证真实主机是否还是你要的,不能只看是否 200。
6.7 探测纪律
- 每条探针只读、只发一次,不触发真实跳转/抓取
- 顺序固定:判层 → 判方向 → 定位关键字 → 探写法 → 选绕过
- 单点卡住就换路,不在一个参数上无限变形(变形超过 10 次没进展就记「阻塞」)
- 记录格式:
目标 | 参数 | payload | 状态码 | 长度 | md5 | 响应首行 | 结论 - 结论分三态:证实(有原始响应)/ 排除(写明依据)/ 阻塞(写清已试手法)——绝不用「疑似」收尾
第 7 章 这套东西不只用于 SSRF
常见误解是「这些 payload 只能打 SSRF」。实际上,凡是先校验 URL 再使用 URL 的地方都适用。
| 场景 | 谁在解析(被骗的是谁) | 利用后得到什么 |
|---|---|---|
| SSRF(图片抓取、URL 预览、webhook、PDF/截图、翻译、代理) | 服务端请求库 | 打内网、云元数据 |
开放重定向(return_url/next/redirect/callback) |
浏览器或服务端 302 | 钓鱼、OAuth code 窃取 |
OAuth/SSO redirect_uri |
授权服务器 | 拿 code 换 token → 账号接管 |
前端路由 / location.href |
浏览器 | XSS 链、钓鱼 |
| 富文本 href 白名单 | 浏览器 | XSS(javascript:、data:) |
CORS Origin 白名单 |
浏览器 | 跨域读数据 |
CSRF Referer/Origin 校验 |
服务端 | CSRF |
| Webhook / 回调地址注册 | 服务端 | SSRF + 内网探测 |
| 下载 / CDN / 附件域名白名单 | 服务端或浏览器 | 拉恶意文件、绕过外链拦截 |
| App WebView 的 URL 白名单 | 客户端 | 打开任意 URL → 深链/JS Bridge |
| 截图 / PDF 导出 / 无头浏览器服务 | 服务端 Chromium | SSRF + 本地文件读取 |
| WAF 的参数规则本身 | WAF | 让恶意 URL 进到业务层 |
最后一行值得单独记住:同一批 payload 也可以用来绕 WAF,因为 WAF 也是字符串匹配。那种场景下"绕过"的目标不是业务逻辑,而是中间设备。
判断标准始终一条:
谁在做字符串比较,谁又被真正发请求的那一方重新解析一次——差额就在那里。
三个变量决定「能不能用、多严重」:
- 谁发最后那个请求:服务端发 → 危害最高;浏览器发 → 危害在用户侧
#之后有没有意义:SSRF 里#被丢弃(照样成立);浏览器跳转里#会带过去(落地页能读)- 需不需要两次解析:只有一次(用同一个解析结果去请求)→ 全废
第 8 章 防守:正确写法
8.1 三段对比
// ❌ 最弱:子串
if (url.includes("target.example.com")) accept();
// ⚠️ 稍好但仍有洞:前缀
if (url.startsWith("https://target.example.com")) accept();
// ✅ 正确:先解析,再逐维比对
let u;
try { u = new URL(url); } catch (e) { return reject(); } // 解析失败直接拒
if (u.protocol !== "https:") reject(); // 协议
if (u.username || u.password) reject(); // userinfo(干掉 @ 族)
if (u.port !== "") reject(); // 端口
const h = u.hostname.toLowerCase().replace(/\.$/, ""); // 归一化:小写 + 去尾点
if (h !== "target.example.com") reject(); // 精确相等
8.2 要放行多个子域时
// ✅ 前导点必须有
if (h === W || h.endsWith("." + W)) accept();
// ❌ 缺前导点:eviltarget.example.com 也会过
if (h.endsWith(W)) accept();
8.3 校验清单(照这个逐条自查)
- 是否先解析再比对(而不是字符串匹配)?
- 比的是
hostname而不是整串? hostname是否做了归一化(小写 + 去尾点)?- 是否拒绝非空 userinfo?
- 是否校验 port(只允许默认端口)?
- 是否校验 protocol(防 http 降级)?
- 多子域放行时是否用了前导点边界?
- 校验和实际使用是否同一个对象/同一次解析(防 TOCTOU)?
- 是否有清洗层,它在校验之前还是之后?
- 重定向链路是否每一跳都校验?
- SSRF 场景是否另有「解析后 IP 判私网 + 固定 IP 复用」(防 DNS rebinding)?
附录 A 反查表(最实用的一页)
| 若后端是这种写法 | 用这几条 payload |
|---|---|
includes("target.example.com")(子串) |
evil.com#…W、evil.com?@…W、W.evil.com、evil.com/W |
includes("https://W")(子串含协议) |
W@evil.com、W.evil.com、W:8443、W@evil.com/W |
startsWith("https://W") / indexOf===0 |
W@evil.com、W.evil.com、W:8443、W@evil.com/W |
^https://W(有 ^ 无边界) |
同上 |
^https://W/(斜杠边界) |
域名层已封 → 转端口/路径/拼接 |
| `^https://W(/ | : |
^https://W(点未转义) |
只有 WXevilYcom@evil.com |
endsWith("target.example.com") |
evil.com/W、W@evil.com/W |
new URL(u).hostname === W |
只有 W:8443(以及路径维度) |
hostname+port+proto+userinfo 全校验 |
全部失效 → 转路径/拼接/其他参数 |
url.split('/')[2] === W(手写切分) |
W%2f@evil.com、W%C2%A0@evil.com 等清洗层变体 |
黑名单 includes("evil.com") |
EVIL.COM、evil.com. |
附录 B 一页速查卡
────────────────────────────────────────────────────────────
三个检查方向
────────────────────────────────────────────────────────────
全文有 白名单域名放哪都行 evil.com#…W / evil.com?@…W
开头对 白名单域名放最前 ….com@evil.com
结尾对 白名单域名放最后 evil.com/target.example.com
────────────────────────────────────────────────────────────
三类兜底(上面全被封时)
────────────────────────────────────────────────────────────
换维度 ….com:8443 (唯一能过完整边界)
点未转义 X…Y@evil.com (只过没转义的正则)
清洗层 %2f@ / %C2%A0@
────────────────────────────────────────────────────────────
两个只打黑名单
────────────────────────────────────────────────────────────
大小写 TARGET.EXAMPLE.COM 尾点 target.example.com.
────────────────────────────────────────────────────────────
不属于白名单绕过
────────────────────────────────────────────────────────────
//evil.com/ → 拼接缺陷
%00 → 老解析器(现代直接 Invalid URL)
────────────────────────────────────────────────────────────
口诀
────────────────────────────────────────────────────────────
检查问「在哪」,就把白名单域名放到那个位置;
检查只问「域名是谁」,那就换端口。
能用字母和点解决,就别用符号和编码。
────────────────────────────────────────────────────────────
附录 C 术语表
| 术语 | 人话 |
|---|---|
| URL | 一串地址文字,https://a.com/b?x=1#t 这种 |
| 字符串 | 编程里的一串文字,可理解成一排格子,每格一个字 |
| host / hostname | 主机名,真正要去哪台机器 |
| userinfo | @ 前面那段,格式 用户名:密码,日常几乎没人用 |
| fragment(标记) | # 后面那段,永远不发给服务器 |
| query(参数) | ? 后面那段,会发给服务器 |
| 归一化 | 把多种写法统一成一种(大小写、尾点、编码、全角) |
| WHATWG URL | 现代浏览器/Node 通用的 URL 解析标准 |
| 锚定 | 正则里 ^ 表示「从头开始对」,$ 表示「到结尾」 |
| 无锚 | 正则里没有 ^ 也没有 $,表示「全文任意位置找」 |
| 转义 | 正则里 . 是通配符,要表示真句点必须写 \. |
| fail-closed | 拿不准时选择拒绝(安全);相对的 fail-open 是拿不准时选择放行 |
| TOCTOU | 检查时和使用时的对象不是同一个(时间差导致) |
| SSRF | 服务端请求伪造:让服务器替你去请求任意地址 |
| WAF | Web 应用防火墙,按规则匹配字符串 |
| HPP | HTTP 参数污染,同一参数传多次 |
| DNS rebinding | 第一次解析到公网 IP 通过校验,第二次解析到内网 IP |
附录 D 实测环境与可复现脚本
环境:Node.js(WHATWG URL 解析器)+ Python 3(re 模块),2026-10-24。
结论与具体域名无关:把脚本里的 W 换成任意白名单域名,结果不变。
// save as check-matrix.mjs, then: node check-matrix.mjs
const W = 'target.example.com';
const esc = W.replace(/\./g, '\\.');
// "真目标不是标准 https://W/" 就算危险(含端口维度)
const bad = (s) => {
try {
const u = new URL(s);
const h = u.hostname.toLowerCase().replace(/\.$/, '');
return h !== W || u.port !== '' || u.protocol !== 'https:';
} catch (e) { return null; }
};
// 11 种真实后端写法
const impl = {
C1: (s) => s.startsWith('https://' + W),
C2: (s) => s.indexOf('https://' + W) === 0,
C3: (s) => new RegExp('^https:\\/\\/' + esc).test(s),
C4: (s) => new RegExp('^https:\\/\\/' + esc + '\\/').test(s),
C5: (s) => new RegExp('^https:\\/\\/' + esc + '(\\/|:|$)').test(s),
C6: (s) => s.includes(W),
C7: (s) => s.includes('https://' + W),
C8: (s) => s.endsWith(W),
C12: (s) => new RegExp('^https:\\/\\/' + W).test(s), // 点未转义
C10: (s) => { try { return new URL(s).hostname === W; } catch (e) { return false; } },
C11: (s) => {
try {
const u = new URL(s);
return u.hostname === W && u.port === '' && u.protocol === 'https:' && !u.username && !u.password;
} catch (e) { return false; }
},
};
const dash = W.split('.').join('X'); // 用于"点未转义"探针
const payloads = [
'https://' + W + '@evil.com/',
'https://' + W + '.evil.com/',
'https://' + W + '%2f@evil.com/',
'https://' + W + '%C2%A0@evil.com/',
'https://' + W + ':8443/',
'https://' + W + './',
'https://' + W.toUpperCase() + '/',
'https://' + dash + '@evil.com/',
'https://evil.com#https://' + W + '/',
'https://evil.com?@' + W + '/',
'https://evil.com/' + W,
'https://' + W + '@evil.com/' + W,
'https://evil.com\\@' + W + '/',
'http://' + W + '@evil.com/',
'https://' + W + '@evil.com#https://' + W + '/',
];
const pad = (s, n) => String(s).padEnd(n);
const keys = Object.keys(impl);
console.log(pad('payload', 46) + pad('real host', 32) + keys.map((k) => pad(k, 5)).join(''));
for (const p of payloads) {
let host;
try { host = new URL(p).host; } catch (e) { host = 'PARSE FAIL'; }
const row = keys.map((k) => {
let pass = false;
try { pass = impl[k](p); } catch (e) {}
const b = bad(p);
return pad(b === null ? '!' : !pass ? 'x' : b ? 'Y' : 'o', 5);
});
console.log(pad(p, 46) + pad(host, 32) + row.join(''));
}
console.log('\nY=放行且真目标不是白名单主机(绕过) o=放行但目标就是白名单主机 x=被拦');
Python 侧的锚定语义差异(值得单独记):
re.match 默认就是前缀匹配(不需要 ^)
re.match + $ 尾部换行也算匹配(Python 特性)
re.match + \Z 才算真正的串尾
re.fullmatch 才是全串匹配
JS 的 $ 不认末尾换行(与 Python 相反)
附录 E 学习路径建议
如果你刚接触这个知识点,建议按这个顺序练:
- 先把附录 D 的脚本跑起来,改几个 payload,看
Y/x怎么变。动手改一次胜过读十遍。 - 写一个 30 行的本地服务,故意用
startsWith做白名单校验,然后用 P01 打穿它。亲眼看到"检查通过但请求去了别处"。 - 再把那个服务改成
new URL().hostname,确认 P01 全部失效——理解"修好"是什么感觉。 - 然后故意漏掉端口校验(只留 hostname),用 P06 打穿——理解"修得不够"是什么感觉。
- 最后加一个"去 userinfo 的清洗函数",放在校验之后,用 P01 观察清洗层怎么帮你把串洗成 evil.com——理解 TOCTOU。
- 走完这五步,你就不会再靠背 payload 了。
结语
回到开头那句话:
漏洞不在 URL 里,而在「谁用什么方式看这串字符」。
六个字总结全文:位置、归一化、维度。
- 位置:白名单域名被放在哪,决定它能骗过哪种检查方向
- 归一化:同一台机器的多种写法,决定它能不能骗过黑名单
- 维度:域名之外还有协议、端口、路径,决定"看起来很严"的写法还剩什么缝
至于具体用哪条 payload——那是查表的事,不是记忆的事。
本文所有技术仅用于获得书面授权的安全测试与自身系统加固。请勿对未授权目标使用。

浙公网安备 33010602011771号