03 — MIME & Magic Numbers
한 줄 답: 진짜 파일 타입은 *확장자가 아니라 첫 몇 바이트(매직넘버)*에 있다. MIME 타입은 그 결과를 프로토콜이 이해할 수 있는 표준 라벨로 표현한 것이다.
Why — 왜 매직과 MIME이 모두 필요한가
hello.jpg라는 파일이 있다. 이게 정말 JPEG인지 어떻게 알 수 있나?
| 방법 | 신뢰도 | 비용 |
|---|---|---|
확장자 보기 (.jpg) | 낮음 — 누구나 바꿀 수 있음 | O(1), 즉시 |
파일 첫 바이트 보기 (FF D8 FF) | 높음 — 위조하려면 파일 자체를 만들어야 함 | O(상수) — 16~512바이트만 읽으면 끝 |
| 파일 전체 파싱 | 최고 | O(파일크기) |
→ 1980년대부터 UNIX의 file(1) 명령은 첫 몇 바이트의 패턴으로 파일을 식별해왔다. 이 패턴을 매직넘버(magic number) 또는 **파일 시그니처(file signature)**라고 부른다.
그러나 매직만으로는 부족하다. 메일·웹·API에서 파일을 주고받을 때, 그 결과를 표현할 표준 라벨이 필요했다 → 이게 MIME 타입이다.
[원시 바이트] → [매직 검사] → [MIME 타입 라벨]
FF D8 FF E0 ... JPEG? image/jpeg매직 = 증거, MIME = 선언. 둘은 다른 레이어에 산다.
How — 어떻게 동작하나
1) MIME 타입의 구조
MIME = Multipurpose Internet Mail Extensions. 1992년 RFC 1341, 현재 RFC 6838에서 표준화.
image / png ; charset=utf-8
───── ─── ──────────────
type / subtype parameters (optional)| Top-level type | 의미 | 대표 subtype |
|---|---|---|
text | 사람이 읽을 수 있는 텍스트 | text/plain, text/html, text/css |
image | 정지 영상 | image/png, image/jpeg, image/webp, image/avif |
audio | 소리 | audio/mpeg, audio/aac, audio/ogg |
video | 동영상 | video/mp4, video/webm, video/x-matroska |
application | 그 외 모든 바이너리 | application/pdf, application/zip, application/json, application/octet-stream |
multipart | 여러 부분의 묶음 | multipart/form-data, multipart/mixed |
model | 3D 모델 | model/gltf-binary |
font | 폰트 | font/woff2, font/ttf |
비표준은 x- 또는 vnd. 접두사:
application/vnd.ms-excel— 벤더 등록application/x-tar— 비공식
구조화된 syntax 접미사:
application/ld+json(+json) — JSON-LDimage/svg+xml(+xml) — SVG는 XML로 파싱 가능application/vnd.api+json(+json) — JSON:API
2) 매직넘버 — 파일이 자기 정체를 신고하는 첫 바이트들
| 포맷 | 매직 (hex) | 위치 | ASCII 표현 |
|---|---|---|---|
| PNG | 89 50 4E 47 0D 0A 1A 0A | 0~7 | \x89PNG\r\n\x1a\n |
| JPEG | FF D8 FF | 0~2 | — |
| GIF | 47 49 46 38 (37|39) 61 | 0~5 | GIF87a / GIF89a |
| WebP | 52 49 46 46 ?? ?? ?? ?? 57 45 42 50 | 0~11 | RIFF....WEBP |
| BMP | 42 4D | 0~1 | BM |
25 50 44 46 2D | 0~4 | %PDF- | |
| ZIP / docx / xlsx | 50 4B 03 04 | 0~3 | PK.. (ZIP은 PKZIP에서 유래) |
| RAR | 52 61 72 21 1A 07 00 | 0~6 | Rar!.. |
| 7z | 37 7A BC AF 27 1C | 0~5 | 7z..'. |
| MP3 (ID3v2) | 49 44 33 | 0~2 | ID3 |
| MP3 (frame) | FF FB 또는 FF FA | 0~1 | — |
| MP4 / MOV | ?? ?? ?? ?? 66 74 79 70 | 4~7 | ....ftyp |
| WAV | 52 49 46 46 ?? ?? ?? ?? 57 41 56 45 | 0~11 | RIFF....WAVE |
| FLAC | 66 4C 61 43 | 0~3 | fLaC |
| OGG | 4F 67 67 53 | 0~3 | OggS |
| ELF (Linux exe) | 7F 45 4C 46 | 0~3 | \x7fELF |
| Mach-O (macOS exe) | CF FA ED FE | 0~3 | (LE) |
| Windows PE | 4D 5A | 0~1 | MZ |
| GZIP | 1F 8B | 0~1 | — |
| XML | 3C 3F 78 6D 6C | 0~4 | <?xml |
| HTML | (<!DOCTYPE / <html 등 패턴) | 가변 | — |
어떤 매직은 파일 끝에 있기도 하다 (ZIP의 End-of-Central-Directory). 그래서 진짜 파서는 머리 + 꼬리를 모두 검사한다.
3) file(1) 명령과 libmagic
UNIX의 file 명령은 /usr/share/file/magic 같은 매직 데이터베이스를 보고 판정한다.
$ file photo.jpg
photo.jpg: JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, ...
$ file photo.jpg --mime-type
photo.jpg: image/jpeg
$ file --mime hello.txt
hello.txt: text/plain; charset=utf-8라이브러리 형태인 libmagic은 거의 모든 언어에서 바인딩이 있다.
import magic
mime = magic.from_file("photo.jpg", mime=True)
# 'image/jpeg'
mime = magic.from_buffer(open("photo.jpg","rb").read(2048), mime=True)import { fileTypeFromBuffer } from 'file-type';
const result = await fileTypeFromBuffer(buffer);
// { ext: 'jpg', mime: 'image/jpeg' }4) Content-Type이 결정되는 흐름 (HTTP)
실무 결론: 절대로 클라이언트가 보낸 Content-Type만 믿지 말고 서버에서 매직 검사를 한 번 더 한다.
What — 구체 사양·수치
MIME 타입 표준 등록처
- IANA Media Types Registry — https://www.iana.org/assignments/media-types
- 새 타입은 RFC 6838 절차에 따라 등록 (
Standards Tree/Vendor Tree/Personal Tree) - 예:
image/avif는 2019년 12월 등록,image/jxl(JPEG XL)은 2022년 등록
자주 쓰이는 MIME 타입 표
| 카테고리 | MIME | 확장자 |
|---|---|---|
| 텍스트 | text/plain | .txt |
text/html | .html, .htm | |
text/css | .css | |
text/csv | .csv | |
text/markdown | .md | |
| 이미지 | image/jpeg | .jpg, .jpeg |
image/png | .png | |
image/webp | .webp | |
image/avif | .avif | |
image/gif | .gif | |
image/svg+xml | .svg | |
image/heic | .heic (iPhone) | |
| 오디오 | audio/mpeg | .mp3 |
audio/aac | .aac | |
audio/wav | .wav | |
audio/ogg | .ogg, .opus | |
audio/flac | .flac | |
| 비디오 | video/mp4 | .mp4, .m4v |
video/webm | .webm | |
video/quicktime | .mov | |
video/x-matroska | .mkv | |
| 문서 | application/pdf | .pdf |
application/vnd.openxmlformats-officedocument.wordprocessingml.document | .docx | |
application/vnd.ms-excel | .xls | |
application/json | .json | |
application/xml | .xml | |
| 압축 | application/zip | .zip |
application/gzip | .gz | |
application/x-tar | .tar | |
application/x-7z-compressed | .7z | |
| 폰트 | font/woff2 | .woff2 |
| 스트리밍 | application/vnd.apple.mpegurl | .m3u8 (HLS manifest) |
application/dash+xml | .mpd (DASH manifest) | |
video/mp2t | .ts (HLS segment) | |
| 알 수 없음 | application/octet-stream | (모든 바이너리의 fallback) |
매직넘버 검사 코드 예 (Node.js)
import { open } from 'node:fs/promises';
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
async function isPng(path) {
const fd = await open(path, 'r');
try {
const buf = Buffer.alloc(8);
await fd.read(buf, 0, 8, 0);
return buf.equals(PNG_SIG);
} finally {
await fd.close();
}
}매직과 MIME이 1:1이 아닌 케이스
| 매직 | 매핑되는 MIME | 이유 |
|---|---|---|
PK\x03\x04 | application/zip 또는 .docx, .xlsx, .pptx, .jar, .apk, .epub | OOXML/JAR/APK는 전부 ZIP. 내부 구조를 더 봐야 구분 |
....ftyp (MP4) | video/mp4, audio/mp4, video/quicktime, image/heic | ftyp 박스 안의 brand 코드(isom, mp42, qt , heic)로 구분 |
RIFF | image/webp, audio/wav, video/avi | RIFF 다음 4바이트(WEBP/WAVE/AVI )로 구분 |
<?xml | application/xml, image/svg+xml, application/atom+xml | 루트 element를 봐야 함 |
함의: 매직은 시작점이지 결론이 아니다. 정확한 MIME 결정은 컨테이너 내부까지 파싱해야 가능한 경우가 많다.
브라우저의 Content Sniffing
브라우저는 서버가 보낸 Content-Type을 항상 신뢰하지 않는다 — 오래된 IIS·Apache가 잘못된 헤더를 자주 보냈기 때문에, 표준(MIME Sniffing, WHATWG)이 바이트 검사로 재추론하는 절차를 정의한다.
이게 보안 사고의 단골 원인:
- 공격자가
evil.html을image.png라고 업로드 → 서버가image/png로 회신 → 브라우저가 내용을 보고 HTML로 추론 → XSS 실행
방어: X-Content-Type-Options: nosniff 헤더를 항상 보내라. 그러면 브라우저가 sniffing을 비활성화하고 서버가 선언한 Content-Type만 믿는다.
What-if — 잘못 쓰면
1) 확장자만 믿고 처리하면
# 위험한 코드
if filename.endswith('.jpg'):
process_as_image(filename)- 공격자가
.jpg확장자로 PHP 스크립트를 업로드해 서버에서 실행시키는 사고가 2010년대 PHP 호스팅에서 흔했다 (Local File Inclusion). - 해결: 항상 매직 검사 + 처리 전 안전한 확장자로 재명명
2) 클라이언트가 보낸 Content-Type만 믿으면
POST /upload
Content-Type: image/png ← 공격자가 마음대로 설정
[실제로는 EICAR 바이러스 시그니처]→ 서버가 그대로 저장 + S3에 Content-Type: image/png로 올림 → 사용자가 다운로드할 때 브라우저가 image로 처리해 별일 없을 수도 있고, sniffing이 켜져 있으면 exe로 실행될 수도 있다.
→ 업로드 시점에 서버 측 매직 검증 + S3에 올릴 때 검증된 MIME으로 명시.
3) image/svg+xml은 진짜 위험하다
SVG는 텍스트(XML) 기반이라 <script> 태그를 포함할 수 있다.
사용자가 SVG 아바타를 올렸는데, 그 안에 <script>fetch('/admin', {credentials:'include'})...</script>가 들어있다면?
→ SVG는:
- 별도 도메인에서 호스팅 (cookie 격리)
- 또는
<img src=...>로만 사용 (이때 스크립트 비활성화됨) - 또는
Content-Disposition: attachment로 다운로드만 허용 - 절대로
<iframe>이나<object>로 임베드하지 말 것
4) ZIP-bomb과 폴리글랏 파일
- ZIP bomb: 42KB ZIP이 압축 풀면 4.5PB가 되는 케이스 (
42.zip). 매직만 보고 자동 압축 풀면 디스크가 가득 참. - Polyglot: 한 파일이 여러 매직을 동시에 만족. PDF + JAR + ZIP이 모두 유효한 동일 파일을 만들 수 있다 (CVE-2010-XXXX 시리즈).
방어: 압축 풀 때 해제 후 크기 한도, 파일 개수 한도, 깊이 한도를 강제.
5) macOS resource fork와 __MACOSX/
macOS의 Finder에서 ZIP을 만들면 .DS_Store, __MACOSX/._filename 같은 부산물이 들어간다.
→ Linux에서 압축 해제 시 유령 파일들이 추가됨. CI 환경에서 unzip -d로 풀 때 골치.
6) application/octet-stream fallback의 함정
서버가 MIME을 모르면 application/octet-stream을 보낸다. 브라우저는 이걸 무조건 다운로드로 처리한다.
→ *.webp 같은 신생 포맷이 nginx의 기본 mime.types에 없으면 사용자에게 다운로드 창이 뜬다.
→ 해결: mime.types를 최신화하거나 text/plain 강제하지 말고 정확한 MIME 명시.
Insight — 흥미로운 이야기
”MIME은 원래 이메일용이었다”
이름 그대로 Multipurpose Internet Mail Extensions. 1992년 SMTP에 ASCII 외 데이터(이미지, 첨부)를 실으려고 만들었다.
RFC 1341에서 type/subtype 형식을 정한 그 약속을 **1996년 HTTP/1.0 (RFC 1945)**이 가져다 썼다.
그래서 오늘날 웹 전체가 이메일 표준을 빌려 쓰고 있다 — Content-Type, Content-Disposition, Content-Transfer-Encoding은 모두 이메일 출신.
”PDF의 매직 %PDF-는 주석이다”
%PDF-1.7 — %는 PDF 파일 안에서 주석을 시작하는 문자다.
즉 PDF 파서는 첫 줄을 읽으면서 무시한다. 이건 의도적이다 — 매직넘버 자체가 PDF 문법의 일부라서, 다른 도구가 시작 부분을 잘못 건드려도 PDF는 여전히 유효하다.
”PNG 매직의 천재성”
89 50 4E 47 0D 0A 1A 0A — 8바이트 안에 다음을 모두 검출한다:
89(high bit set) → 7비트만 보존하는 시스템에서 깨졌는지 검출50 4E 47=PNG→ 사람이 읽을 수 있는 라벨0D 0A→ CRLF 줄바꿈 변환되었는지 검출 (Windows ↔ Unix)1A→ DOS의 EOF 문자 → DOStype명령이 여기서 멈춤0A→ LF → 위 CRLF가 LF로 변환되었는지 다시 검출
→ 1995년 PNG 설계자들이 FTP의 ASCII 모드 전송이 파일을 깨뜨리던 시대의 모든 함정을 한 헤더에 인코딩했다. 8바이트짜리 자기진단 시스템. 이런 디테일이 표준의 품질을 만든다.
”Apple이 HEIC를 밀어붙인 이유”
2017년 iPhone 7부터 사진 기본 포맷이 JPEG → HEIC로 바뀌었다.
- HEIC = HEVC(H.265)로 압축한 이미지 컨테이너 (
.heif/.heic) - 같은 화질에서 JPEG보다 ~50% 작음
- 매직: MP4와 같은
ftyp박스 (brand가heic/mif1)
→ MIME(image/heic)이 늦게 등록(2017)되어 한동안 안드로이드가 못 열었다. 매직과 MIME의 표준화 속도가 기술 보급보다 항상 늦는다.
요약 + 다이어그램
매직넘버 = 파일이 자기 정체를 첫 바이트로 신고하는 것. MIME = 그 결과를 인터넷 프로토콜이 이해할 수 있게 라벨링한 것. 실무 규칙 — (1) 클라이언트 Content-Type 절대 신뢰 X, (2) 서버에서 항상 매직 검사, (3)
X-Content-Type-Options: nosniff항상 켜기, (4) SVG/ZIP 폴리글랏 주의.
다음 문서:
04-extensions-and-identification.md— 확장자·MIME·매직이 어긋날 때 무슨 일이 일어나는가?