📁 File0. 파일의 기초04 — Extensions & Identification

04 — Extensions & Identification

한 줄 답: 확장자는 힌트, MIME은 선언, 매직은 증거다. 셋이 어긋나는 순간 보안 사고가 시작된다.


Why — 왜 세 개나 필요한가

같은 파일을 식별하는 메커니즘이 세 가지나 존재하는 이유는 각자 다른 시대의 다른 문제를 풀었기 때문이다.

메커니즘등장 시기푼 문제
확장자 (.jpg)1970s, CP/M·DOS파일명 8.3 제약 안에서 사람이 빨리 알아보기
MIME 타입1992, RFC 1341*프로토콜(이메일·HTTP)*에서 표준 라벨로 통신
매직넘버1973, UNIX file콘텐츠 자체로 정체 검증

이 세 개는 서로 동기화되지 않는다. 누구나 .jpg로 이름을 바꿀 수 있고, 누구나 Content-Type: image/png을 헤더에 박을 수 있지만, 매직만은 파일 안에 박혀 있어 위조하려면 파일을 새로 만들어야 한다.

이 챕터의 핵심: 셋의 일치(consistency) 검사가 보안의 첫 줄이다.


How — 셋의 관계

1) 확장자 (File Extension)

파일명의 마지막 점(.) 이후 문자열. OS의 약속이지 표준이 아니다.

photo.jpg          → 확장자 .jpg
archive.tar.gz     → 확장자 .gz (전체 .tar.gz가 *복합 확장자*)
.bashrc            → 확장자 없음 (점으로 시작은 hidden file)
README             → 확장자 없음
my.file.with.dots  → 확장자 .dots

용도:

  • Windows: 더블클릭 시 어떤 프로그램으로 열지 결정 (Registry의 HKEY_CLASSES_ROOT)
  • macOS: Launch Services가 Uniform Type Identifier(UTI) 데이터베이스로 매핑
  • Linux GUI(GNOME/KDE): freedesktop.org shared-mime-info 데이터베이스
  • 웹 서버 (nginx, Apache): mime.types 파일로 Content-Type 회신 결정

2) MIME 타입의 유래

이미 03-mime-and-magic.md에서 다룸. 요점:

  • 프로토콜 레이어의 라벨 (HTTP Content-Type, 이메일 Content-Type)
  • 형식: type/subtype (예: image/png)
  • IANA에 등록된 표준

3) 매직넘버

이것도 03에서 다룸. 요점:

  • 파일 콘텐츠 레이어의 증거
  • 첫 N바이트의 특정 패턴
  • file(1) / libmagic이 검사

4) 셋이 충돌할 수 있는 4가지 경우

케이스확장자MIME (선언)매직 (실제)시나리오
✅ 정상.pngimage/pngPNG가장 흔함
⚠️ 확장자 불일치.jpgimage/jpegPNG사용자가 이름만 바꿈
⚠️ 확장자 없음(없음)application/octet-streamPNGUNIX 관행
🚨 위조된 헤더.pngimage/pngHTMLXSS 공격
🚨 폴리글랏.pdfapplication/pdfPDF + JAR보안 우회

→ 보안이 중요한 곳에서는 항상 매직을 정답으로 삼고, 확장자/MIME은 부가 정보로 처리.


What — 구체 사양·수치·예시

Windows의 확장자 → 프로그램 매핑

Windows 레지스트리:

HKEY_CLASSES_ROOT\.jpg          (Default) = "jpegfile"
HKEY_CLASSES_ROOT\jpegfile\shell\open\command
                                (Default) = "C:\Windows\System32\rundll32.exe ..."

두 단계 인덱싱: 확장자 → ProgID → 명령어. 이 때문에 확장자만으로 실행 프로그램이 결정된다.

macOS의 UTI (Uniform Type Identifier)

확장자보다 더 일반적인 분류 체계. UTI는 계층적이다:

public.item
└── public.data
    └── public.content
        └── public.image
            ├── public.jpeg     (*.jpg, *.jpeg)
            ├── public.png      (*.png)
            └── com.apple.heif  (*.heic)
  • Info.plistLSItemContentTypes로 앱이 자기가 열 수 있는 UTI를 선언
  • 한 UTI에 여러 확장자가 매핑될 수 있음

Linux freedesktop.org의 shared-mime-info

<mime-type type="image/png">
    <comment>PNG image</comment>
    <magic priority="50">
        <match type="string" value="\x89PNG\x0d\x0a\x1a\x0a" offset="0"/>
    </magic>
    <glob pattern="*.png"/>
</mime-type>

XML 데이터베이스 — magic(매직패턴) + glob(확장자 패턴) 둘 다 사용. 우선순위는 매직.

nginx의 mime.types

types {
    text/html                                        html htm shtml;
    text/css                                         css;
    image/jpeg                                       jpeg jpg;
    image/png                                        png;
    image/webp                                       webp;
    application/pdf                                  pdf;
    application/zip                                  zip;
}

확장자 → MIME 단방향 매핑. nginx는 매직을 보지 않는다. 그래서 확장자가 잘못된 파일은 잘못된 Content-Type으로 회신된다.

MIME Sniffing 알고리즘 (브라우저)

WHATWG의 MIME Sniffing Standardhttps://mimesniff.spec.whatwg.org/

대략적 흐름:

  1. 서버가 Content-Type: text/plain 회신
  2. X-Content-Type-Options: nosniff 있으면 → 그대로 신뢰, 끝
  3. 없으면 → 처음 1445바이트를 검사해 다음을 확인:
    • HTML 시그니처 (<!DOCTYPE html, <html, <script 등 ~30가지 패턴)
    • 이미지 매직넘버
    • 비디오/오디오 컨테이너 헤더
    • 그 외 매직
  4. 검출되면 → 서버 선언을 무시하고 검출된 타입으로 처리

이게 왜 보안 문제인가: 공격자가 evil.txt내용에 <script> 태그를 포함하여 업로드 → 서버가 text/plain으로 회신 → 브라우저가 HTML로 sniff → XSS.

X-Content-Type-Options: nosniff는 모든 응답에 항상 켜라.

보안 체크리스트 — 업로드 처리 시

[ ] 1. 확장자 화이트리스트 검사
[ ] 2. MIME 타입 화이트리스트 검사 (요청 헤더 기준)
[ ] 3. 서버에서 매직 검증 (libmagic)
[ ] 4. 1·2·3이 모두 일치하는지 교차검증
[ ] 5. 파일명을 *서버에서 재명명* (UUID 등) — 사용자 입력 이름 그대로 쓰지 말 것
[ ] 6. 저장 경로를 웹루트 *밖*에 두기 (`/var/uploads`)
[ ] 7. 다운로드 시 Content-Disposition: attachment + nosniff
[ ] 8. SVG·HTML·PDF 등 액티브 콘텐츠는 *별도 도메인*에서 호스팅
[ ] 9. 이미지면 *재인코딩* (메타데이터 + 임베드된 페이로드 제거)
[ ] 10. 파일 크기 한도 + ZIP 폭탄 한도

코드 예: 종합 검증 (Node.js + file-type)

import { fileTypeFromBuffer } from 'file-type';
import path from 'node:path';
 
const ALLOWED = {
  'image/png':  ['.png'],
  'image/jpeg': ['.jpg', '.jpeg'],
  'image/webp': ['.webp'],
};
 
async function validateUpload(file) {
  // 1) 확장자
  const ext = path.extname(file.originalname).toLowerCase();
 
  // 2) 클라이언트 선언 MIME
  const declaredMime = file.mimetype;
 
  // 3) 실제 매직 검사
  const detected = await fileTypeFromBuffer(file.buffer);
  if (!detected) throw new Error('Unknown file type');
 
  const realMime = detected.mime;
 
  // 4) 교차검증
  const allowedExts = ALLOWED[realMime];
  if (!allowedExts) throw new Error(`MIME ${realMime} not allowed`);
  if (!allowedExts.includes(ext)) {
    throw new Error(`Extension ${ext} doesn't match ${realMime}`);
  }
  if (declaredMime !== realMime) {
    // 경고 로깅 — 일치하지 않으면 의심
    console.warn(`Declared ${declaredMime} but actual ${realMime}`);
  }
 
  return { mime: realMime, ext: allowedExts[0] };
}

What-if — 어긋날 때 일어나는 진짜 사고들

1) 클래식 PHP 업로드 RCE

2010년대 초중반, 대량의 PHP 호스팅 서버에서:

  • 사용자가 shell.php.jpg를 업로드
  • 서버 검증: “확장자가 .jpg로 끝나니까 이미지” → 통과
  • Apache의 mod_mime마지막 확장자만 보지 않음 — AddHandler 설정에 따라 .php.jpg도 PHP로 실행
  • → 공격자가 https://target.com/uploads/shell.php.jpg로 접근하면 PHP 실행

방어: 서버 측 매직 검사 + 확장자 재명명 + 업로드 디렉토리에서 PHP 비활성.

2) IE의 MIME Sniffing이 만든 IE6 시대 사고

IE6/7은 Content-Type: text/plain이어도 내용을 보고 HTML이라 판단되면 HTML로 렌더했다. → 사용자가 서식 없는 텍스트 파일을 올렸는데 그 안에 HTML이 들어 있으면 XSS.

X-Content-Type-Options: nosniff는 IE8(2009)에서 도입된 이 사고를 막기 위한 헤더.

3) GitHub의 RAW 파일 보안 정책

GitHub raw.githubusercontent.com은 사용자 업로드 콘텐츠를 호스팅한다.

  • 모든 응답에 Content-Type: text/plain; charset=utf-8 + X-Content-Type-Options: nosniff
  • 어떤 파일이든 텍스트로 응답하고 sniffing을 비활성화
  • → 공격자가 HTML/SVG를 올려도 브라우저가 플레인 텍스트로 표시

이것이 별도 도메인에서 사용자 콘텐츠를 호스팅하는 표준 패턴 (*.googleusercontent.com도 동일).

4) Slack의 SVG 사고 (2017)

Slack 워크스페이스에 SVG 아바타로 XSS 페이로드가 들어 있는 SVG를 업로드. SVG는 XML이라 <script> 실행 가능. 같은 도메인에서 서빙되어 쿠키 접근 가능. → 회사 워크스페이스의 메시지 탈취 가능했음. 패치 후 SVG 처리는 별도 CDN으로 격리.

5) Polyglot — 한 파일이 여러 정체

GIFAR: GIF + JAR가 동시에 유효한 파일. GIF로 업로드되어 이미지 검증을 통과하지만, <applet> 태그로 로드하면 Java가 JAR로 실행. (지금은 Java 플러그인 자체가 없어져 무력화)

PHAR + JPEG: PHP의 PHAR 아카이브가 JPEG와 동시에 유효 → 이미지 업로드를 통과한 후 PHP unserialize 트리거 시 코드 실행 (CVE-2018-XXXX 시리즈).

방어: 매직 검사만으로 부족 → 파일 전체 재인코딩 (JPEG는 디코드 후 다시 JPEG로 인코딩, EXIF 제거).

6) 이메일의 Content-Disposition 우회

Content-Disposition: inline; filename="report.pdf"
Content-Type: text/html

오래된 메일 클라이언트는 filename에 끌려가 사용자에게 PDF로 보인다고 표시했지만, 실제 렌더링은 HTML로 했다 → 피싱 메일.

7) Windows 더블 확장자 트릭

document.pdf.exe — Windows 탐색기가 알려진 확장자 숨기기를 켜면 사용자에게는 document.pdf로 보인다. 더블클릭하면 EXE 실행. → Windows의 기본 설정인 알려진 확장자 숨기기는 보안적으로 끔찍하다.


Insight — 흥미로운 이야기

”확장자라는 개념의 기원: CP/M의 8.3”

1973년 CP/M 운영체제는 파일명 8글자 + 확장자 3글자라는 제약을 만들었다. 디스크 디렉토리 항목이 고정 길이 16바이트였기 때문에 파일명을 짧게 잡을 수밖에 없었다.

DOS가 이걸 그대로 물려받았고, Windows 95에서 LFN(Long File Name)이 도입됐지만 점-구분 확장자라는 관습은 50년이 지난 지금도 살아 있다.

→ Linux는 처음부터 확장자에 의미를 두지 않았다 (UNIX 전통). ls 출력에서 . 다음 문자열은 그저 이름의 일부였다. 그러나 GUI가 보급되면서 freedesktop의 mime-info처럼 확장자 패턴도 결국 받아들이게 됐다.

”MIME은 IANA 트리가 셋이다”

RFC 6838:

  • Standards Tree: image/png 같은 표준 등록 (IETF/IESG 승인)
  • Vendor Tree: application/vnd.adobe.photoshop (회사 제품)
  • Personal Tree: application/prs.example.foo (개인/실험)

→ 이게 왜 중요한가: 비표준 MIME을 만들 때 prefix가 강제된다. application/foo를 임의로 만들면 표준 위반. application/x-foo(deprecated) 또는 application/prs.foo/vnd.foo를 써야 한다.

”왜 PDF는 50년 동안 살아남았나”

Adobe는 PDF 1.0을 1993년에 발표했다. 그 사이 워드 포맷(.doc)은 두 번 바뀌었고(.docx → OOXML), 그래픽 포맷도 여러 번 바뀌었다. PDF가 살아남은 이유 — 파일이 자기 정체를 다 신고한다. 매직넘버(%PDF-1.x), 객체 트리, 필요한 폰트 임베드, 색공간 명시. 외부 자원에 의존하지 않아 어디서 열어도 같게 보인다.

→ 같은 철학으로 만든 게 PNG, FLAC, EPUB. 자기 충족적 파일이 가장 오래 산다.

”이모지도 MIME 타입을 받았을까?”

아니다. 이모지는 Unicode 코드포인트이지 파일 포맷이 아니라서 MIME이 없다. 다만 이모지 세트를 표현하는 폰트는 font/woff2처럼 폰트 MIME을 가진다 (예: Apple Color Emoji는 AAT 형식의 TTF).


요약 + 다이어그램

확장자 = 힌트, MIME = 선언, 매직 = 증거. 셋이 일치하는지 검사하는 것이 업로드 보안의 첫 줄이다. 핵심 실무 원칙 — (1) 매직을 정답으로, (2) 클라이언트 입력은 검증 대상, (3) 사용자 콘텐츠는 별도 도메인 + nosniff, (4) 가능하면 재인코딩.

다음 문서: 05-filesystems.md — 이름·매직·MIME 같은 메타데이터는 어디에 어떻게 저장되는가?