파일 업로드 취약점: 확장자 검사로 부족한 이유와 안전한 설계

파일 업로드 취약점을 저장 위치·파일명·Content-Type·실행 권한·다운로드 응답 다섯 단계로 나눠 설명합니다. 단계별 서버 검증 코드와 S3 presigned URL에서 놓치기 쉬운 점도 함께 다룹니다.

보안 점검 의뢰 팀 16분 분량

확장자 검사만으로는 파일 업로드 취약점을 막을 수 없습니다. 확장자는 파일이 어떤 형식인지 사용자가 붙인 이름표일 뿐이고, 서버나 브라우저가 그 파일을 어떻게 다룰지는 다른 요소들이 결정하기 때문입니다. 저장 위치, 파일명 처리 방식, 실제 내용과 Content-Type, 실행 권한, 다운로드할 때의 응답 헤더가 그 요소입니다. 이 중 하나라도 비어 있으면 웹셸 업로드, 경로 조작, SVG를 이용한 XSS 같은 문제가 생깁니다.

그래서 방어도 "어떤 확장자를 막을까"보다 "이 파일은 어디에, 어떤 이름으로, 어떤 형식으로 저장되고, 실행될 수 있는가, 그리고 어떻게 내려가는가" 순서로 설계해야 합니다. OWASP File Upload Cheat Sheet도 허용 목록 기반 확장자 검증, Content-Type 헤더 불신, 서버 생성 파일명, 웹 루트 밖 저장, 최소 권한을 함께 적용하라고 권고합니다. 하나만으로 충분하다고 보지 않는다는 뜻입니다.

이 글은 공격 기법을 나열하지 않습니다. 파일이 업로드되어 다시 내려가기까지 거치는 다섯 단계를 따라가면서 단계마다 서버가 검증할 항목을 취약한 코드와 고친 코드로 비교합니다. 이어서 요즘 스타트업이 많이 쓰는 S3 presigned URL 업로드에서 놓치기 쉬운 부분을 정리합니다. 예시는 Node.js(Express) 기준이지만 원칙은 언어와 상관없이 같습니다.

1단계: 저장 위치 — 웹 루트 밖, 가능하면 다른 출처

웹셸 문제의 출발점은 대부분 업로드한 파일이 웹 서버가 직접 서빙하는 경로에 저장된다는 데 있습니다. PHP나 JSP처럼 확장자나 서버 설정에 따라 스크립트를 해석하는 환경에서는, 확장자 검사를 우회한 파일이 웹 루트에 놓이는 순간 URL 하나로 실행될 수 있습니다.

// 취약: 정적 서빙 디렉터리에 원본 그대로 저장
app.use('/uploads', express.static('public/uploads'));
const upload = multer({ dest: 'public/uploads' });
// 개선: 웹 루트 밖에 저장하고, 내려줄 때는 별도 핸들러를 거친다
const UPLOAD_ROOT = '/var/app-data/uploads'; // express.static 대상이 아님
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});

판단 기준은 간단합니다. 업로드 파일 경로가 URL과 1:1로 대응하면 위험 신호입니다. OWASP는 별도 호스트에 저장하는 방식을 가장 먼저 권하고, 어렵다면 웹 루트 밖에 두라고 합니다. 오브젝트 스토리지(S3 등)를 쓰면 애플리케이션 서버에서 실행될 위험은 구조적으로 사라지지만, 그 대신 아래 3·5단계 문제가 커집니다.

2단계: 파일명 — 사용자 입력은 메타데이터로만

사용자가 보낸 파일명을 저장 경로에 그대로 쓰면 두 가지 문제가 생깁니다. ../가 섞인 이름으로 의도한 디렉터리 밖에 쓰는 경로 조작, 그리고 같은 이름으로 기존 파일을 덮어쓰는 문제입니다.

// 취약: 원본 파일명을 경로에 그대로 결합
const dest = path.join(UPLOAD_ROOT, req.file.originalname);
fs.writeFileSync(dest, req.file.buffer);
// 개선: 저장 이름은 서버가 만들고, 원본 이름은 DB에만 보관
import { randomUUID } from 'node:crypto';

const storedName = randomUUID();                 // 확장자도 붙이지 않거나, 검증된 형식에서 파생
const dest = path.join(UPLOAD_ROOT, storedName);
if (!dest.startsWith(UPLOAD_ROOT + path.sep)) throw new Error('invalid path');

await db.files.insert({
  id: storedName,
  ownerId: req.user.id,
  originalName: req.file.originalname.slice(0, 255), // 표시용
  mime: detectedMime,                                // 3단계 결과
  size: req.file.size,
});
fs.writeFileSync(dest, req.file.buffer, { mode: 0o640 });

원본 파일명은 화면에 보여주거나 다운로드할 때 이름을 붙이는 데만 씁니다. 화면에 출력할 때는 HTML 이스케이프를 하고, 응답 헤더에 넣을 때는 인코딩을 해야 합니다. 파일 ID로 다운로드하는 API라면 요청한 사용자가 그 파일에 접근할 권한이 있는지도 확인해야 합니다. 이 부분은 IDOR 취약점 가이드와 같은 원리입니다.

3단계: Content-Type과 실제 내용 — 헤더 대신 바이트를 본다

Content-Type 요청 헤더와 확장자는 둘 다 클라이언트가 정합니다. OWASP와 PortSwigger 모두 이 헤더를 믿지 말고 파일 내용을 직접 확인하라고 권합니다. 다만 OWASP는 파일 시그니처(매직 바이트) 검사도 "단독으로 쓰면 우회가 쉽다"고 경고합니다. 그래서 검증은 세 겹으로 합니다.

  1. 확장자를 허용 목록으로 검사 (대소문자 정규화, 이중 확장자 확인)
  2. 파일 앞부분 바이트로 실제 형식을 판별하고, 그 형식도 허용 목록에 있는지 확인
  3. 이미지라면 다시 인코딩해서 메타데이터와 부가 데이터를 걷어냄
// 취약: 클라이언트가 보낸 mimetype만 확인
if (!req.file.mimetype.startsWith('image/')) return res.status(400).end();
// 개선: 내용 기반 판별 + 재인코딩
import { fileTypeFromBuffer } from 'file-type';
import sharp from 'sharp';

const ALLOWED = new Map([['image/png', 'png'], ['image/jpeg', 'jpg'], ['image/webp', 'webp']]);

const ext = path.extname(req.file.originalname).toLowerCase().slice(1);
if (!['png', 'jpg', 'jpeg', 'webp'].includes(ext)) return res.status(400).end();

const detected = await fileTypeFromBuffer(req.file.buffer);
if (!detected || !ALLOWED.has(detected.mime)) return res.status(400).end();

// 디코딩 후 다시 인코딩: EXIF 등 메타데이터 제거, 픽셀 수 제한
const clean = await sharp(req.file.buffer, { limitInputPixels: 40_000_000 })
  .rotate()
  .toFormat(ALLOWED.get(detected.mime))
  .toBuffer();

SVG는 이미지가 아니라 문서로 취급한다

SVG는 XML 문서이고 스크립트와 이벤트 핸들러를 담을 수 있습니다. 서비스와 같은 출처(origin)에서 image/svg+xml로 내려가 브라우저가 직접 열면, 그 안의 스크립트가 서비스 도메인 권한으로 실행됩니다. 이것이 SVG XSS입니다. 매직 바이트 검사로는 걸러지지 않습니다. 선택지는 세 가지입니다.

  • 프로필·게시글 이미지처럼 SVG가 꼭 필요하지 않다면 허용 목록에서 뺍니다.
  • 꼭 필요하다면 서버에서 PNG로 래스터화해서 저장합니다.
  • SVG 원본을 그대로 보관해야 한다면 5단계의 다운로드 응답 통제(별도 도메인, attachment, CSP sandbox)를 반드시 함께 적용합니다.

HTML, XML, PDF처럼 브라우저가 렌더링하는 형식도 같은 기준으로 판단합니다. 문서 파일이 업무상 꼭 필요하다면 OWASP가 언급하는 CDR(Content Disarm & Reconstruction) 같은 무해화 처리를 검토할 만합니다.

4단계: 실행 권한 — 실행될 수 없는 곳에 둔다

앞의 검증은 언젠가 뚫릴 수 있다고 가정해야 합니다. PortSwigger도 업로드 디렉터리에서 스크립트가 실행되지 않게 하는 것을 "두 번째 방어선"으로 설명합니다. 확인할 항목은 다음과 같습니다.

  • 업로드 디렉터리에 대해 웹 서버의 스크립트 처리기(PHP-FPM 등)가 연결되어 있지 않은가
  • 디렉터리 단위 설정 덮어쓰기(Apache .htaccess 등)가 꺼져 있는가. 업로드로 설정 파일이 들어오는 경우를 막기 위해서입니다.
  • 파일 권한이 최소 권한인가. 실행 비트가 없고 애플리케이션 계정만 읽을 수 있어야 합니다.
  • 업로드된 파일을 서버 측 도구(이미지 변환, 압축 해제, 문서 변환)가 처리한다면, 그 처리를 격리된 프로세스와 자원 제한 안에서 하는가
# 업로드 경로를 부득이하게 서빙해야 할 때: 스크립트 해석을 막고 정적 파일로만
location /files/ {
    alias /var/app-data/public-files/;
    location ~* \.(php|phtml|jsp|cgi)$ { return 403; }
    add_header X-Content-Type-Options nosniff always;
}

이 설정은 마지막 안전장치일 뿐입니다. 1단계처럼 아예 서빙 경로에서 분리하는 쪽이 더 확실합니다. 압축 파일을 받는다면 해제 후 총 크기와 파일 수도 제한해야 합니다.

5단계: 다운로드 응답 — 브라우저가 해석하는 방식을 서버가 정한다

업로드가 안전해도 내려줄 때 응답 헤더가 느슨하면 브라우저가 파일을 HTML이나 스크립트로 해석할 수 있습니다. 다운로드 핸들러는 저장 시점에 검증한 MIME을 쓰고, 클라이언트가 처음 보낸 값은 쓰지 않습니다.

// 개선: 검증된 메타데이터로 응답 헤더를 구성
app.get('/files/:id', requireAuth, async (req, res) => {
  const file = await db.files.findOne({ id: req.params.id, ownerId: req.user.id });
  if (!file) return res.status(404).end();

  res.set({
    'Content-Type': file.mime,                       // 3단계에서 판별한 값
    'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.originalName)}`,
    'X-Content-Type-Options': 'nosniff',
    'Content-Security-Policy': "default-src 'none'; sandbox",
  });
  fs.createReadStream(path.join(UPLOAD_ROOT, file.id)).pipe(res);
});

이미지를 <img>로 바로 보여줘야 해서 inline이 필요하다면, 허용 목록에 래스터 이미지만 남기고 사용자 콘텐츠는 서비스와 다른 사이트(registrable domain)에서 서빙하는 방식을 권합니다. 예를 들어 example-usercontent.com 같은 도메인입니다. files.example.com처럼 서브도메인을 쓰면 .example.com에 설정된 쿠키가 함께 전송될 수 있어서 분리 효과가 줄어듭니다.

S3 presigned URL 업로드에서 놓치기 쉬운 부분

presigned URL을 쓰면 파일이 애플리케이션 서버를 거치지 않습니다. 그래서 서버가 파일 내용을 볼 기회가 없다는 점을 먼저 받아들여야 합니다. 자주 놓치는 지점은 다음과 같습니다.

① 키(파일 경로)를 클라이언트가 정한다. AWS 문서에 따르면 같은 키의 객체가 이미 있으면 업로드한 객체로 교체됩니다. 키는 반드시 서버가 생성합니다.

② 크기와 형식 제한이 없다. PUT presigned URL에는 POST 정책의 content-length-range 같은 범위 조건이 없습니다. 크기를 서명 단계에서 제한하려면 POST 정책 방식이 적합합니다.

③ 업로드 시 지정한 Content-Type이 그대로 응답에 쓰인다. 클라이언트가 text/html이나 image/svg+xml로 올린 객체를 퍼블릭 버킷이나 서비스 도메인의 CDN으로 서빙하면, 3·5단계 문제가 그대로 다시 생깁니다.

// 취약: 클라이언트가 보낸 key와 contentType으로 서명
const url = await getSignedUrl(s3, new PutObjectCommand({
  Bucket: 'example-uploads', Key: req.body.key, ContentType: req.body.contentType,
}), { expiresIn: 3600 });
// 개선: 서버가 키를 만들고, POST 정책으로 크기·형식·경로를 묶는다
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';

const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
if (!ALLOWED_TYPES.includes(req.body.contentType)) return res.status(400).end();

const key = `quarantine/${req.user.id}/${randomUUID()}`;
const { url, fields } = await createPresignedPost(s3, {
  Bucket: 'example-uploads',
  Key: key,
  Conditions: [
    ['content-length-range', 1, 5 * 1024 * 1024],
    ['eq', '$Content-Type', req.body.contentType],
  ],
  Fields: { 'Content-Type': req.body.contentType },
  Expires: 300, // 초 단위, 짧게
});
await db.uploads.insert({ key, ownerId: req.user.id, status: 'pending' });

botocore 문서에도 나와 있듯이, Conditions에 조건을 넣었다면 Fields에도 같은 값을 지정해야 합니다. 그리고 서명 조건은 선언된 값만 검증할 뿐 실제 바이트는 확인하지 않습니다. 그래서 업로드 뒤에 검증 단계를 둡니다.

// 업로드 완료 콜백(또는 S3 이벤트 → 워커)에서 내용 검증 후 승격
const head = await s3.send(new GetObjectCommand({ Bucket, Key: key, Range: 'bytes=0-4100' }));
const detected = await fileTypeFromBuffer(Buffer.from(await head.Body.transformToByteArray()));
if (!detected || !ALLOWED_TYPES.includes(detected.mime)) {
  await s3.send(new DeleteObjectCommand({ Bucket, Key: key }));
  return markRejected(key);
}
// 필요 시 재인코딩 후 public/ 또는 files/ 프리픽스로 복사, quarantine 원본 삭제

다운로드도 퍼블릭 버킷보다는 짧은 만료의 GET presigned URL을 쓰고, ResponseContentDisposition: 'attachment'와 ResponseContentType으로 응답 헤더를 서버가 지정하는 방식이 안전합니다. 버킷의 퍼블릭 액세스 차단 설정도 함께 확인합니다.

업로드 기능 출시 전 체크리스트

  • 업로드 파일이 애플리케이션 웹 루트나 정적 서빙 경로에 저장되지 않는다
  • 저장 파일명/S3 키는 서버가 생성하고, 원본 이름은 DB에만 둔다
  • 확장자와 실제 형식을 모두 허용 목록으로 검증한다
  • 이미지는 재인코딩하고, SVG·HTML은 기본적으로 허용하지 않는다
  • 크기·개수·픽셀 수·압축 해제 후 크기에 상한이 있다
  • 업로드 경로에서 스크립트가 실행되지 않고, 파일 권한이 최소 권한이다
  • 다운로드 응답에 검증된 Content-Type, nosniff, 필요 시 attachment가 붙는다
  • 사용자 콘텐츠는 서비스와 다른 사이트에서 서빙하거나 다운로드로만 제공한다
  • presigned URL은 만료가 짧고, 업로드 후 서버 측 검증을 거쳐야 사용 가능해진다
  • 파일 조회 API는 요청자의 접근 권한을 확인한다

관리자 페이지에 공지 첨부나 배너 업로드 기능이 있다면 같은 기준을 적용합니다. 관리자 기능이라는 이유로 검증을 빼는 경우가 흔합니다. 관리자 페이지 보안 체크리스트도 함께 참고하세요.

자주 묻는 질문

확장자 차단 목록(블랙리스트)에 위험 확장자를 다 넣으면 되지 않나요?

권하지 않습니다. 서버 설정에 따라 해석되는 확장자 변형, 대소문자, 이중 확장자 등 예상하기 어려운 경우가 많습니다. 허용할 형식만 명시하는 허용 목록과 내용 검증을 함께 쓰는 편이 훨씬 관리하기 쉽습니다.

Node.js나 Next.js 서버는 PHP처럼 파일을 실행하지 않으니 웹셸 걱정은 없지 않나요?

업로드 파일을 그대로 실행할 가능성은 낮습니다. 하지만 같은 서버에 다른 런타임이 있거나, 파일을 변환·압축 해제하는 과정이 있거나, SVG·HTML을 같은 출처에서 서빙하면 문제는 남습니다. 웹셸만 막는다고 끝나지 않고, 저장·응답 단계까지 함께 봐야 합니다.

presigned URL을 쓰면 서버 부담도 줄고 더 안전한 것 아닌가요?

부담은 줄지만 검증 책임이 사라지지는 않습니다. 서명 조건은 선언된 값만 제한하므로 키 생성, POST 정책 조건, 업로드 후 내용 검증, 다운로드 헤더 지정까지 서버가 맡아야 합니다.

정리

  • 파일 업로드 취약점은 확장자 하나의 문제가 아니라 저장 위치·파일명·Content-Type·실행 권한·다운로드 응답 다섯 단계의 문제입니다.
  • 클라이언트가 보낸 이름과 형식은 표시용으로만 쓰고, 경로와 MIME은 서버가 정합니다.
  • S3 presigned URL은 서버를 거치지 않기 때문에 POST 정책 조건과 업로드 후 검증 단계가 꼭 필요합니다.
  • 우리 서비스의 업로드 흐름을 직접 점검하기 어렵다면 무료 분석 신청으로 점검을 받아볼 수 있습니다.

참고 자료

함께 읽으면 좋은 글