lirantal/exploit-express-allocation-of-resources-without-limits-or-throttling
GitHub: lirantal/exploit-express-allocation-of-resources-without-limits-or-throttling
针对 Express.js API 端点缺少速率限制导致的拒绝服务漏洞(CWE-770),提供原理分析与基于 autocannon 的自动化 PoC 验证方案。
Stars: 1 | Forks: 0
# 关于 `allocation-of-resources-without-limits-or-throttling` 发现的漏洞概念验证
为没有内置速率限制的 API 端点提供指导和概念验证,以演示拒绝服务问题。
## 源代码参考
以下是由于在应用层面没有速率限制而容易受到拒绝服务攻击的源代码参考:
```
const express = require("express");
const fs = require("fs");
const path = require("path");
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.get("/file", (req, res) => {
const filename = req.query.filename;
const basePath = "/var/app/public/";
fs.readFile(basePath + filename, "utf8", (err, data) => {
if (err) return res.status(404).send("Not found");
res.send(data);
});
});
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
```
## 概念验证
### 这是误报吗?
**不完全是。** 这类问题属于真实存在的类别(CWE-770,“Allocation of Resources Without Limits”),但在这一特定代码行上,与同一个 handler 中的其他问题相比,它属于干扰性强且优先级较低的问题。客观来看:
- Snyk 的 `NoRateLimitingForExpensiveWebOperation` 规则是一种启发式方法——只要端点触及文件系统且作用域内没有 `express-rate-limit`/`express-limit` 中间件,它就会触发。它无法说明该操作是否真的耗时或可被放大。许多真实的应用是在边缘层(nginx, Cloudflare, ALB)而不是进程内进行速率限制的,而 Snyk 无法看到这些。
- 在*这个* handler 中,更大的问题是**第 37 行的路径遍历**(已被标记为 `high` 级别)。能够读取 `/etc/passwd` 的攻击者很少会费力对你发起 DoS 攻击——一旦你通过将 `filename` 限制为安全的基本名称来修复路径遍历问题,DoS 攻击面就会大幅缩小。
- 话虽如此,潜在的隐患是真实存在的:`fs.readFile` 运行在 libuv 的线程池中(默认大小为 **4**)。一旦使这些线程饱和,进程中*所有其他*的 fs 操作都会停滞——不仅仅是 `/file`。结合路径遍历允许你指向一个大文件的情况,每个请求的消耗会变得更大。因此我认为这是一个**真实的中危漏洞**,你应该将其作为系统加固的一部分进行修复,而不是将其视为误报——但我不会仅因为这个问题就阻止版本的发布。
### 攻击是如何发生的
三个放大攻击向量在此端点上叠加:
1. **线程池饥饿** —— 发起 N 个并发请求,其中 N >> `UV_THREADPOOL_SIZE`。当你的 4 个线程被磁盘 I/O 阻塞时,进程中*所有其他*的 fs/dns/crypto-pbkdf2 调用都会在它们后面排队。触及磁盘的健康检查端点将开始超时。
2. **文件描述符耗尽** —— 足够多的进行中读取操作会导致 `EMFILE: too many open files` 错误,进而导致进程拒绝新连接。
3. **通过路径遍历漏洞实现负载放大** —— `?filename=../../../proc/self/environ` 或读取任何大文件都会使每个请求的消耗比读取小型静态文件呈指数级增加。
在普通笔记本电脑上发起攻击的攻击者,通常只需几千个并发请求就能将默认配置的 Node 服务推向延迟降级的边缘;虽然在 CDN 后面你需要更多的请求量,但进程内的瓶颈(threadpool=4)是一样的。
### PoC 参考
两阶段检查:**(a)** 洪泛 `/file`,**(b)** 测量*不相关的* `/health` 端点是否仍在预算时间内响应。如果在洪泛期间 `/health` 的 p95 耗时超过了阈值,则说明速率限制缺口是可被利用的。如果你在 `/file` 添加了 `express-rate-limit`,则测试将会通过。
```
// test/dos-rate-limit.test.js — run against a locally started instance of app.js
import autocannon from "autocannon";
import { setTimeout as sleep } from "node:timers/promises";
const BASE = process.env.TARGET_URL ?? "http://127.0.0.1:3000";
const HEALTH_BUDGET_MS = 500; // p95 budget for /health under load
const FLOOD_DURATION_S = 10;
const FLOOD_CONNECTIONS = 200; // concurrent connections to /file
async function probeHealth() {
const t0 = performance.now();
const r = await fetch(`${BASE}/health`, { signal: AbortSignal.timeout(2000) });
await r.text();
return performance.now() - t0;
}
async function main() {
// Baseline: /health latency with no load
const baseline = [];
for (let i = 0; i < 20; i++) baseline.push(await probeHealth());
baseline.sort((a, b) => a - b);
const baselineP95 = baseline[Math.floor(baseline.length * 0.95)];
console.log(`baseline /health p95: ${baselineP95.toFixed(1)} ms`);
// Flood /file (uses the unrate-limited fs.readFile path)
const flood = autocannon({
url: `${BASE}/file?filename=index.html`,
connections: FLOOD_CONNECTIONS,
duration: FLOOD_DURATION_S,
});
// While the flood is running, sample /health
await sleep(1000); // let the flood ramp up
const samples = [];
for (let i = 0; i < 30; i++) {
try { samples.push(await probeHealth()); }
catch { samples.push(2000); } // timeout counts as worst-case
await sleep(200);
}
await flood;
samples.sort((a, b) => a - b);
const underLoadP95 = samples[Math.floor(samples.length * 0.95)];
console.log(`under-load /health p95: ${underLoadP95.toFixed(1)} ms`);
if (underLoadP95 > HEALTH_BUDGET_MS) {
console.error(
`FAIL: /health p95 ${underLoadP95.toFixed(0)}ms exceeded budget ${HEALTH_BUDGET_MS}ms ` +
`during flood — endpoint /file is missing rate limiting`
);
process.exit(1);
}
console.log("PASS: rate-limiting holds /health p95 within budget under flood");
}
main();
```
在将其接入之前,有几点需要了解:
- **在隔离的测试实例上运行**,不要在共享环境上运行。在 CI 中将应用启动在一个 sidecar 容器中,目标是 `127.0.0.1`,测试后销毁。不要将其指向 staging 环境。
- **线程池大小很重要。** 通过为被测系统(SUT)设置环境变量 `UV_THREADPOOL_SIZE=2` 来压缩测试条件——这在不改变你所检查的底层属性的前提下,使测试成本更低且更具确定性。
- **根据经验一次性调整好预算。** `HEALTH_BUDGET_MS=500` 只是一个起点;选择一个能让未修复的应用稳定失败,同时让修复后的应用稳定通过,并留有余量的值。CI 运行环境通常充满干扰。
- **此测试把关的修复方案**:在 `/file` 上添加 `express-rate-limit`(或等效中间件)——例如 `rateLimit({ windowMs: 60_000, max: 60 })`——应该能让此测试通过。如果你同时将 `UV_THREADPOOL_SIZE` 上限设为 2 仍然能通过测试,说明你获得了真正的保护而非巧合。
- **在未修复第 37 行路径遍历漏洞的情况下,不要发布此代码。** DoS 测试的严重性远低于同一 handler 中的路径遍历漏洞;仅仅修复速率限制会使更大的安全漏洞继续敞开。
标签:API安全, CISA项目, CMS安全, CWE-22, CWE-770, DoS, Express.js, GNU通用公共许可证, JavaScript, JSON输出, Maven, MITM代理, Node.js, PoC, SAST, Snyk, Web安全, 中间件, 拒绝服务攻击, 数据展示, 暴力破解, 漏洞分析, 漏洞验证, 盲注攻击, 红队, 网络安全, 蓝队分析, 资源耗尽, 路径探测, 路径遍历, 限流, 隐私保护, 静态应用安全测试