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 全校验

读表要点:

  1. C8 只有 P12/P13 打得穿 —— 后缀类的 payload 必须让白名单域名出现在结尾。
  2. C12 只有 P09 打得穿 —— 点未转义是独立一格。
  3. C5 只有 P06 打得穿 —— 完整边界正则封死域名层,只剩端口维度。
  4. C11 一条都没打穿 —— 这是四个维度全校验的写法。
  5. P07/P08 对白名单全是 x 或 o —— 印证「归一化类打不了白名单」。
  6. 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 → 换位置

三个必做的对照(否则结论全是假的):

  1. 同长度随机串对照 —— 排除「规则针对长度/整体形态」
  2. 无敏感词但结构相同的串 —— 排除「任何外域都拦」
  3. 每条探针只发一次并记录原始响应(状态码 + 长度 + 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 也是字符串匹配。那种场景下"绕过"的目标不是业务逻辑,而是中间设备。

判断标准始终一条:

谁在做字符串比较,谁又被真正发请求的那一方重新解析一次——差额就在那里。

三个变量决定「能不能用、多严重」:

  1. 谁发最后那个请求:服务端发 → 危害最高;浏览器发 → 危害在用户侧
  2. # 之后有没有意义:SSRF 里 # 被丢弃(照样成立);浏览器跳转里 # 会带过去(落地页能读)
  3. 需不需要两次解析:只有一次(用同一个解析结果去请求)→ 全废

第 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 校验清单(照这个逐条自查)

  1. 是否先解析再比对(而不是字符串匹配)?
  2. 比的是 hostname 而不是整串?
  3. hostname 是否做了归一化(小写 + 去尾点)?
  4. 是否拒绝非空 userinfo?
  5. 是否校验 port(只允许默认端口)?
  6. 是否校验 protocol(防 http 降级)?
  7. 多子域放行时是否用了前导点边界?
  8. 校验和实际使用是否同一个对象/同一次解析(防 TOCTOU)?
  9. 是否有清洗层,它在校验之前还是之后?
  10. 重定向链路是否每一跳都校验?
  11. 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 学习路径建议

如果你刚接触这个知识点,建议按这个顺序练:

  1. 先把附录 D 的脚本跑起来,改几个 payload,看 Y/x 怎么变。动手改一次胜过读十遍。
  2. 写一个 30 行的本地服务,故意用 startsWith 做白名单校验,然后用 P01 打穿它。亲眼看到"检查通过但请求去了别处"。
  3. 再把那个服务改成 new URL().hostname,确认 P01 全部失效——理解"修好"是什么感觉。
  4. 然后故意漏掉端口校验(只留 hostname),用 P06 打穿——理解"修得不够"是什么感觉。
  5. 最后加一个"去 userinfo 的清洗函数",放在校验之后,用 P01 观察清洗层怎么帮你把串洗成 evil.com——理解 TOCTOU。
  6. 走完这五步,你就不会再靠背 payload 了。

结语

回到开头那句话:

漏洞不在 URL 里,而在「谁用什么方式看这串字符」。

六个字总结全文:位置、归一化、维度。

  • 位置:白名单域名被放在哪,决定它能骗过哪种检查方向
  • 归一化:同一台机器的多种写法,决定它能不能骗过黑名单
  • 维度:域名之外还有协议、端口、路径,决定"看起来很严"的写法还剩什么缝

至于具体用哪条 payload——那是查表的事,不是记忆的事。


本文所有技术仅用于获得书面授权的安全测试与自身系统加固。请勿对未授权目标使用。

posted @ 2026-10-24 15:41  0xMouise  阅读(3)  评论(0)    收藏  举报