Express compression의 zlib 메모리 누수 - CVE-2026-87776 분석
Node.js 프로세스의 RSS가 며칠에 걸쳐 계속 올라가는데 heap snapshot을 떠 보면 heapUsed는 몇 MB 수준에서 평평한 경우가 있습니다. 스냅샷 두 장을 비교해도 늘어난 객체가 보이지 않습니다. 이때 범인은 V8 heap 안이 아니라 밖에 있습니다.
자체 운영 중인 self-hosted n8n에서 메모리가 회수되지 않는 증상을 이 방향으로 추적하다가, npm compression 미들웨어가 중단된 응답의 zlib stream을 정리하지 않는다는 사실을 확인했습니다. 이 문제는 CVE-2026-87776 / GHSA-vc2v-76pw-4v95로 등록됐고, 저는 해당 advisory에 Finder로 등재됐습니다.
| 항목 | 값 |
|---|---|
| Advisory | GHSA-vc2v-76pw-4v95 |
| CVE | CVE-2026-87776 |
| Severity | High, CVSS 3.1 기준 7.5 |
| Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-401 Missing Release of Memory, CWE-459 Incomplete Cleanup |
| 영향 | compression < 1.8.2 |
| 패치 | 1.8.2 |
| Workaround | 없음 |
1. JS heap이 평평한데 RSS가 오르는 패턴
Node.js 프로세스가 잡는 메모리는 V8 heap 하나가 아닙니다. zlib, fs, TLS처럼 native 자원을 쓰는 API는 C++ 쪽에서 별도로 메모리를 할당합니다. 이 영역은 heap snapshot에 객체로 잡히지 않기 때문에, 스냅샷만 비교하면 “새는 것이 없다”는 잘못된 결론에 도달하기 쉽습니다.
deflate stream 하나는 windowBits와 memLevel 설정에 따라 수백 KB의 native 메모리를 잡습니다. JavaScript 객체 쪽은 GC가 수거하더라도 native handle이 닫히지 않았다면 그 메모리는 반환되지 않습니다.
그래서 메모리 증가를 조사할 때는 heapUsed와 rss를 같은 시간 창에서 함께 봐야 합니다. 두 값이 같이 오르면 JavaScript 객체를 의심하고, heapUsed가 평평한 채로 rss만 오르면 native 할당 쪽을 봐야 합니다.
1
2
3
4
setInterval(() => {
const m = process.memoryUsage();
console.log(`rss=${(m.rss / 1048576).toFixed(1)}MB heapUsed=${(m.heapUsed / 1048576).toFixed(1)}MB`);
}, 10000).unref();
2. compression 1.8.1이 놓친 close 경로
compression 미들웨어는 응답 헤더가 나갈 시점에 압축 대상인지 판단하고, 그때 zlib stream을 만듭니다. 그리고 res.write와 res.end를 감싸서 본문을 stream으로 흘려보냅니다.
1.8.1의 index.js에서 stream의 수명을 끝내는 코드는 res.end 안의 한 줄이 전부였습니다.
1
2
3
4
5
6
7
8
9
10
11
12
res.end = function end (chunk, encoding) {
// ...
if (!stream) {
return _end.call(this, chunk, encoding)
}
ended = true
return chunk
? stream.end(toBuffer(chunk, encoding))
: stream.end()
}
응답이 끝까지 전송되면 res.end가 호출되고, stream.end()가 deflate를 마무리하면서 native handle도 함께 정리됩니다. 문제는 응답이 끝나지 않는 경로입니다. 클라이언트가 본문 전송 도중에 소켓을 끊으면 res는 close 이벤트로 끝나고 res.end는 호출되지 않습니다. 이때 stream을 파괴하는 코드가 1.8.1에는 없었습니다.
정리하면 이렇습니다. 요청 하나마다 zlib stream 하나가 생기고, 그 요청을 클라이언트가 중간에 끊으면 stream 하나가 그대로 남습니다. 인증도 필요 없고 사용자 상호작용도 필요 없으며, 원격에서 연결을 끊는 동작만 반복하면 됩니다. CVSS vector가 AV:N/AC:L/PR:N/UI:N이고 가용성 영향이 A:H인 이유가 여기 있습니다.
3. 남은 native handle을 세어서 재현하기
재현할 때 RSS를 지표로 쓰면 판단이 흐려집니다. allocator가 해제한 메모리를 OS에 바로 돌려주지 않기 때문에, 패치된 버전에서도 RSS는 한동안 올라갑니다. 더 선명한 지표는 생성된 zlib stream 중 native handle이 아직 남아 있는 개수입니다.
아래 스크립트는 zlib.createGzip을 감싸서 생성된 stream을 전부 보관한 뒤, 500번의 중단된 요청이 끝나고 GC를 돌린 다음 _handle이 남아 있는 개수를 셉니다.
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
40
41
42
43
44
45
46
47
48
49
50
const http = require('http');
const net = require('net');
const zlib = require('zlib');
const compression = require('compression');
const ROUNDS = 500;
const CHUNK = Buffer.alloc(64 * 1024, 'a');
const created = [];
const origCreateGzip = zlib.createGzip;
Object.defineProperty(zlib, 'createGzip', {
configurable: true,
value: function () {
const s = origCreateGzip.apply(this, arguments);
created.push(s);
return s;
}
});
const middleware = compression({ threshold: 0 });
const server = http.createServer((req, res) => {
middleware(req, res, () => {
res.setHeader('Content-Type', 'text/plain');
const timer = setInterval(() => { if (!res.writableEnded) res.write(CHUNK); }, 5);
res.on('close', () => clearInterval(timer));
res.write(CHUNK);
});
});
function abortOnce (port) {
return new Promise((resolve) => {
const sock = net.connect(port, '127.0.0.1', () => {
sock.write('GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n');
});
sock.once('data', () => { sock.destroy(); resolve(); });
sock.once('error', () => resolve());
});
}
server.listen(0, async () => {
const port = server.address().port;
for (let i = 0; i < ROUNDS; i++) await abortOnce(port);
await new Promise((r) => setTimeout(r, 1000));
global.gc(); global.gc();
const live = created.filter((s) => s._handle !== null && s._handle !== undefined);
console.log(`created=${created.length} destroyed=${created.filter((s) => s.destroyed).length} liveHandle=${live.length}`);
console.log(`rss=${(process.memoryUsage().rss / 1048576).toFixed(1)}MB`);
process.exit(0);
});
node --expose-gc leak.js로 실행합니다. 여기서 한 가지 주의할 점이 있습니다. zlib.createGzip = fn 형태의 단순 대입은 non-strict CommonJS에서 조용히 실패합니다. 생성 개수가 0으로 나오면 측정이 아니라 monkey patch가 안 걸린 것이므로, Object.defineProperty를 써야 합니다.
Node v24.12.0에서 같은 스크립트를 두 버전에 대해 실행한 결과입니다.
| 지표 | compression 1.8.1 | compression 1.8.2 |
|---|---|---|
| 생성된 gzip stream | 500 | 500 |
destroyed === true | 0 | 500 |
native _handle 잔존 | 500 | 0 |
| RSS | 221.0MB | 76.9MB |
1.8.1은 중단된 요청 500건이 만든 stream 500개를 하나도 정리하지 않습니다. 같은 조건에서 요청 수만 2000건으로 올리면 RSS가 177.1MB 증가했고, 요청당 약 90KB입니다. 그동안 heapUsed는 4.5MB에서 4.7MB로 사실상 변하지 않았습니다. 1절에서 말한 지문이 그대로 나옵니다.
RSS 값만 비교하면 1.8.2도 2000건 실행에서 50.5MB가 올랐습니다. 이것이 누수가 남아 있다는 뜻은 아니고, 해제된 메모리를 allocator가 프로세스 안에 붙들고 있는 것입니다. 두 버전을 가르는 결정적 지표는 handle 잔존 수입니다.
4. 패치가 close를 다루는 방식
패치는 commit 151f63e9, fix: destroy compression stream on response close입니다. 구조는 세 조각입니다.
1
2
3
4
_on.call(res, 'close', function onResponseClose () {
closed = true
destroy(stream)
})
첫째, close 리스너를 onHeaders 등록보다 먼저 겁니다. 응답이 stream이 만들어지기도 전에 닫히는 경우를 놓치지 않기 위해서입니다.
둘째, close 시점에 destroy(stream)을 호출합니다. 아직 stream이 없으면 아무 일도 하지 않습니다.
셋째, closed 플래그를 둡니다. 응답이 이미 닫힌 뒤에 stream이 생성되는 순서도 가능하기 때문에, 그 경우 만들자마자 파괴하고 stream = null로 떨궈서 이후 write가 원래 응답 객체로 빠지게 합니다.
1
2
3
4
5
if (closed) {
destroy(stream)
stream = null
return
}
회귀 테스트는 gzip, deflate, brotli 각각에 대해 close 시 파괴를 확인하고, “응답이 닫힌 뒤에 생성된 stream”도 별도 케이스로 덮습니다.
5. stream.destroy()만으로는 부족한 이유
패치에서 눈여겨볼 부분은 stream.destroy()를 직접 부르지 않고 destroy@1.2.0 패키지를 의존성에 추가했다는 점입니다. 이 패키지의 destroyZlibStream 구현에 근거가 주석으로 남아 있습니다.
Zlib streams don’t have a destroy function in Node.js 6. On top of that simply calling destroy on a zlib stream in Node.js 8+ will result in a memory leak. So until that is fixed, we need to call both close AND destroy.
즉 zlib stream은 destroy()만 호출해서는 native handle이 반환되지 않고 close()를 함께 불러야 합니다. 관련 Node.js 이슈는 nodejs/node#23734입니다. 스트림을 정리했다고 생각했는데 메모리가 그대로인 상황이 생긴다면 이 차이를 먼저 확인해 볼 만합니다.
6. 운영에서 확인할 것
compression은 직접 설치하지 않았더라도 애플리케이션 의존성 트리 안에 들어와 있는 경우가 많습니다. 실제 사용 버전부터 확인합니다.
1
npm ls compression
이 advisory에는 workaround가 없습니다. 설정으로 우회할 수 있는 문제가 아니므로 1.8.2 이상으로 올리는 것이 유일한 대응입니다. 미들웨어를 직접 쓰지 않고 상위 패키지가 끌고 오는 경우라면 해당 패키지의 갱신 또는 override가 필요합니다.
배포 전에 확인할 수 있는 신호는 두 가지입니다. 하나는 3절 스크립트처럼 중단된 요청을 반복했을 때 handle 잔존 수가 0으로 나오는지이고, 다른 하나는 운영 환경에서 rss와 heapUsed를 함께 그려 두 선이 같이 움직이는지입니다.
압축을 어느 계층에서 수행할지도 같이 검토할 수 있습니다. CDN이나 reverse proxy에서 압축하면 애플리케이션 프로세스는 zlib stream 수명주기를 관리하지 않아도 됩니다. 다만 이 변경은 압축률, CPU 사용 위치, Content-Encoding 협상 지점을 동시에 옮기므로 이번 취약점 대응과는 분리해서 판단하는 편이 낫습니다.
7. Reference
- GitHub Security Advisory - GHSA-vc2v-76pw-4v95
- expressjs/compression - fix: destroy compression stream on response close
- expressjs/compression - Release v1.8.2
- stream-utils/destroy - index.js
- nodejs/node - zlib: fix memory leak on destroy
- Node.js API - process.memoryUsage()
- Node.js API - Zlib
궁금하신 점이나 추가해야 할 부분은 댓글이나 아래의 링크를 통해 문의해주세요.
Written with KKamJi
