서론: 업로드 기능은 파일을 받는 순간 공격 표면이 된다
파일 업로드 기능은 프로필 이미지, 첨부파일, 증빙 자료, 게시판 이미지처럼 대부분의 웹 서비스에 자연스럽게 들어간다. 문제는 업로드가 단순히 “파일을 저장하는 기능”이 아니라는 점이다. 서버는 업로드 요청을 파싱하고, 파일명을 처리하고, 임시 경로에 쓰고, 검증하고, 스토리지로 옮기고, 다시 다운로드나 미리보기 형태로 제공한다. 이 과정 중 하나라도 허술하면 공격자는 악성 스크립트, 웹셸, HTML/JavaScript, 압축 폭탄, 위장 이미지, 이중 확장자 파일을 서비스 내부로 밀어 넣을 수 있다.
웹 업로드 취약점의 가장 치명적인 결과는 원격 코드 실행(RCE)이다. 공격자가 shell.php, cmd.jsp, webshell.aspx 같은 서버 측 실행 파일을 업로드한 뒤 웹에서 호출할 수 있으면, 업로드 기능은 곧 서버 명령 실행 창이 된다. 한편 Content-Type 우회는 더 조용하지만 흔하다. 서버가 클라이언트가 보낸 Content-Type: image/jpeg 같은 헤더만 믿으면, 실제 내용이 PHP, SVG 스크립트, HTML, ZIP, 실행 파일이어도 이미지로 오인할 수 있다.
따라서 업로드 보안은 “확장자 하나 검사”로 끝낼 수 없다. 확장자, 서버 측 MIME 판별, 매직바이트, 실제 디코딩 가능 여부, 저장 위치, 실행 권한, 다운로드 응답 헤더, 탐지 로그, 운영 절차가 함께 맞물려야 한다.
파일 업로드 기반 RCE: 저장된 파일이 실행될 때 사고가 난다
공격 흐름
파일 업로드 RCE는 대개 다음 순서로 진행된다.
- 공격자가 서버 측 인터프리터가 처리할 수 있는 파일을 준비한다. 예: PHP, JSP, ASPX, CGI, Python, Perl, server-side template.
- 업로드 검증을 우회하기 위해 파일명을
profile.php.jpg,report.jsp;.png,image.phtml,avatar.php%00.jpg처럼 조작한다. - 애플리케이션이 파일을 웹 루트 또는 실행 가능한 정적 경로에 저장한다.
- 공격자가 저장된 URL을 직접 호출한다.
- 웹 서버나 WAS가 파일을 정적 파일이 아니라 스크립트로 해석해 명령 실행, 내부 파일 조회, 추가 악성 파일 다운로드를 수행한다.
RCE가 성립하려면 보통 두 조건이 겹친다. 첫째, 위험한 파일이 업로드된다. 둘째, 저장된 파일이 실행 가능한 위치에 놓인다. 이 중 하나만 끊어도 피해 가능성은 크게 줄어든다.
위험한 구현 패턴
// 취약한 예: 사용자가 보낸 파일명과 Content-Type을 신뢰한다.
$allowed = ['jpg', 'png', 'gif'];
$ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
if (in_array($ext, $allowed) && $_FILES['file']['type'] === 'image/jpeg') {
move_uploaded_file($_FILES['file']['tmp_name'], '/var/www/html/uploads/' . $_FILES['file']['name']);
}
이 코드는 겉보기에는 확장자와 Content-Type을 확인하지만 여전히 위험하다. 업로드 파일명을 그대로 사용하고, 저장 경로가 웹 루트 내부이며, 클라이언트가 제공한 MIME 값을 신뢰하고, 이중 확장자나 대소문자 변형, 서버별 실행 확장자(phtml, phar, shtml, jsp, jspx, asa, cer)를 충분히 다루지 못한다.
이중 확장자와 서버 해석 차이
공격자는 애플리케이션 검증 로직과 웹 서버 해석 로직의 차이를 이용한다.
avatar.php.jpg: 애플리케이션은 마지막 확장자jpg만 보고 허용하지만, 잘못 설정된 서버는 중간의php를 실행 대상으로 볼 수 있다.avatar.jpg.php: 필터가 파일명 안에jpg포함 여부만 보면 우회된다.avatar.pHp,avatar.PHTML: 대소문자 또는 대체 실행 확장자를 놓치면 우회된다.avatar.php.: Windows 또는 일부 프레임워크에서 끝의 점과 공백 처리 차이가 발생할 수 있다.avatar.php%00.jpg: 오래된 런타임이나 잘못된 네이티브 연동에서 null byte 절단 문제가 생길 수 있다..htaccess,web.config: 업로드 디렉터리의 실행 규칙을 공격자가 바꾸는 데 악용될 수 있다.
핵심은 “금지 확장자 차단”보다 “업무상 필요한 확장자만 허용”하는 것이다. 차단 목록은 항상 빠지는 항목이 생긴다.
Content-Type 우회: 헤더는 증거가 아니라 주장이다
왜 Content-Type만으로는 부족한가
업로드 요청의 Content-Type은 클라이언트가 보낸 값이다. 브라우저, 모바일 앱, 프록시 도구, curl, 공격 스크립트 모두 이 값을 임의로 지정할 수 있다.
curl -X POST https://example.com/upload \
-F "file=@shell.php;type=image/jpeg"
서버가 $_FILES['file']['type'], MultipartFile.getContentType(), Content-Type 헤더만 보고 이미지라고 판단하면 공격자가 원하는 타입을 그대로 믿는 셈이다. Content-Type은 참고 신호일 뿐, 보안 판단의 단독 근거가 되어서는 안 된다.
MIME, 매직바이트, 실제 파서 검증의 역할
안전한 검증은 여러 신호를 맞춰 보는 방식이어야 한다.
- 확장자: 허용 목록 기반으로 소문자 정규화 후 마지막 확장자를 확인한다.
- 서버 측 MIME 판별:
libmagic,finfo, Apache Tika 등 서버가 파일 내용을 읽어 판별한 MIME을 사용한다. - 매직바이트: JPEG
FF D8 FF, PNG89 50 4E 47 0D 0A 1A 0A, GIFGIF87a/GIF89a, PDF%PDF-처럼 파일 시그니처를 확인한다. - 실제 파서 검증: 이미지라면 이미지 라이브러리로 열고 재인코딩한다. 문서라면 허용된 파서로 구조를 검사한다.
- 정책 일치: 확장자, 서버 판별 MIME, 매직바이트, 실제 파서 결과가 같은 계열인지 확인한다.
단, 매직바이트만으로도 충분하지 않다. 공격자는 GIF89a 뒤에 PHP 코드를 붙인 polyglot 파일을 만들 수 있고, 이미지 EXIF나 SVG 내부에 스크립트를 숨길 수 있다. 그래서 중요한 이미지는 원본 그대로 제공하지 말고, 안전한 이미지 처리 라이브러리로 재인코딩해 메타데이터와 비표준 페이로드를 제거하는 편이 좋다.
방어 설계: 업로드 파일은 검증하고, 격리하고, 실행을 끊는다
허용 목록 기반 정책
업로드 정책은 기능별로 분리해야 한다. “모든 게시판 첨부파일” 같은 넓은 정책보다 “프로필 이미지는 JPEG/PNG만, 고객 증빙은 PDF만, 관리자 대량 업로드는 CSV만”처럼 좁게 정의해야 검증이 가능하다.
- 프로필 이미지는
jpg,jpeg,png만 허용하고image/jpeg,image/png계열인지 확인한다. 이미지 디코딩, 리사이즈, 재인코딩, EXIF 제거를 함께 수행한다. - 게시판 이미지는
jpg,jpeg,png,webp처럼 필요한 형식만 허용한다. 픽셀 크기 제한, 재인코딩, 공개 URL 서명을 적용한다. - 증빙 문서는
pdf와application/pdf로 제한하고 PDF 구조 검사, JavaScript와 첨부파일 제거 또는 차단을 수행한다. - CSV 업로드는
csv와text/csv또는 제한된text/plain만 허용한다. 행과 열 수, 수식 인젝션, 문자셋을 검증한다. - 압축 파일은 가급적 금지하고, 예외적으로 허용할 때는 압축 해제 크기, 경로 순회, 중첩 압축을 제한한다.
파일명 정규화와 재명명
사용자 파일명은 화면 표시용 메타데이터로만 보관하고 저장 파일명에는 사용하지 않는 것이 원칙이다.
- 저장 파일명은 UUID 또는 난수 기반으로 생성한다.
- 확장자는 검증된 MIME 정책에 따라 서버가 결정한다.
- 원본 파일명은 길이 제한, 제어문자 제거, 경로 구분자 제거, 유니코드 정규화 후 DB에만 저장한다.
../,..\, URL 인코딩, null byte, 선행 점, 후행 점/공백, 예약어(CON,NUL)를 거부한다.
// 개념 예시: 저장명은 원본명과 분리한다.
String originalName = normalizeForDisplay(file.getOriginalFilename());
DetectedType detected = detectOnServer(file);
if (!policy.allows(detected.extension(), detected.mime())) {
throw new SecurityException("Unsupported file type");
}
String storedName = UUID.randomUUID() + "." + detected.safeExtension();
Path target = storageRoot.resolve(storedName).normalize();
if (!target.startsWith(storageRoot)) {
throw new SecurityException("Invalid path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
저장 경로 분리
업로드 파일은 웹 루트 밖에 저장하는 것이 가장 안전하다.
- 애플리케이션 코드 경로와 업로드 경로를 분리한다.
/var/www/html/uploads처럼 웹 서버가 직접 접근하고 실행할 수 있는 위치를 피한다.- 다운로드는 애플리케이션 컨트롤러, 전용 파일 서비스, 오브젝트 스토리지의 서명 URL을 통해 제공한다.
- 업로드 저장소는 가능한 별도 버킷, 별도 계정, 별도 권한으로 운영한다.
- 공개 파일과 비공개 파일을 같은 경로에 섞지 않는다.
오브젝트 스토리지를 쓸 때도 방심하면 안 된다. S3, GCS, Azure Blob에 저장하면 서버 측 RCE 위험은 줄지만, 악성 HTML/SVG 공개, 잘못된 Content-Type 제공, 개인정보 노출, 공개 버킷 설정 오류는 여전히 남는다.
실행 권한 제거와 웹 서버 설정
업로드 경로에서는 스크립트 실행을 명시적으로 막아야 한다.
Apache 예시:
<Directory "/srv/app/uploads">
Options -ExecCGI -Includes
AllowOverride None
Require all denied
</Directory>
<FilesMatch "\.(php|phtml|phar|cgi|pl|py|jsp|jspx|asp|aspx|shtml)$">
Require all denied
</FilesMatch>
Nginx 예시:
location /uploads/ {
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff always;
try_files $uri =404;
}
location ~* /uploads/.*\.(php|phtml|phar|cgi|pl|py|jsp|jspx|asp|aspx|shtml)$ {
deny all;
}
Linux 권한 예시:
chown -R appuser:appuser /srv/app/uploads
find /srv/app/uploads -type d -exec chmod 750 {} \;
find /srv/app/uploads -type f -exec chmod 640 {} \;
컨테이너 환경에서는 업로드 볼륨을 noexec, nosuid, nodev 옵션으로 마운트하는 것도 도움이 된다. 단, noexec는 웹 서버나 인터프리터 설정 오류를 모두 막아 주는 만능 통제가 아니므로 저장 경로 분리와 실행 핸들러 차단이 함께 필요하다.
다운로드 응답 헤더
업로드 파일을 다시 제공할 때는 브라우저가 임의로 해석하지 못하게 해야 한다.
X-Content-Type-Options: nosniff적용- 검증된 MIME만
Content-Type으로 설정 - 일반 첨부파일은
Content-Disposition: attachment사용 - 사용자 업로드 HTML, SVG, XML은 공개 렌더링 금지 또는 별도 샌드박스 도메인에서 제공
- 공개 파일 도메인은 인증 쿠키가 붙는 서비스 도메인과 분리
SVG는 특히 주의가 필요하다. 이미지처럼 보이지만 XML 기반이며 스크립트, 외부 참조, 이벤트 핸들러가 포함될 수 있다. 업무상 SVG가 꼭 필요하지 않다면 업로드 허용 목록에서 제외하는 편이 현실적이다.
WAF와 탐지 포인트: 차단보다 조기 발견에 초점을 둔다
WAF는 업로드 보안의 보조 통제다. WAF만으로 업로드 취약점을 해결할 수는 없지만, 공격 시도와 운영 실수를 빠르게 발견하는 데 유용하다.
WAF 룰 후보
- multipart 업로드에서 파일명에 실행 확장자가 포함된 경우:
.php,.phtml,.phar,.jsp,.jspx,.asp,.aspx,.cgi,.shtml. - 파일명에 이중 확장자 패턴이 있는 경우:
*.php.jpg,*.jsp.png,*.aspx.gif. - 파일명에 경로 순회가 있는 경우:
../,..\,%2e%2e%2f,%5c. - 업로드 파트 Content-Type과 파일 확장자의 불일치.
GIF89a<?php,<?php,<%@ page,<script,MZ,PK등 정책과 맞지 않는 시그니처..htaccess,.user.ini,web.config,php.ini업로드 시도.- 업로드 직후 같은 IP가 업로드 URL에 명령 실행형 쿼리 파라미터를 붙여 접근하는 행위.
로그와 SIEM 탐지
업로드 이벤트는 보안 로그로 남겨야 한다. 최소 필드는 다음과 같다.
- 사용자 ID, 세션 ID, IP, User-Agent
- 업로드 기능명과 요청 ID
- 원본 파일명, 정규화 파일명, 저장 파일 ID
- 클라이언트 Content-Type, 서버 판별 MIME, 확장자, 매직바이트
- 파일 크기, 이미지 해상도, 해시값(SHA-256)
- 검증 결과와 거부 사유
- 저장 위치 유형: 로컬, 내부 스토리지, 공개 버킷, 비공개 버킷
탐지 규칙은 “실패한 업로드”도 중요하게 봐야 한다. 한 사용자가 짧은 시간 안에 여러 실행 확장자, 이중 확장자, MIME 불일치 파일을 반복 업로드하면 계정 탈취 또는 취약점 탐색일 가능성이 있다.
개발 체크리스트
개발 단계에서는 다음 항목을 완료 조건으로 삼아야 한다.
- 업로드 기능별 허용 파일 형식이 문서화되어 있다.
- 금지 목록이 아니라 허용 목록 기반으로 확장자를 검증한다.
- 확장자는 소문자 변환, 유니코드 정규화, 후행 점/공백 제거 후 판단한다.
- 클라이언트 Content-Type을 단독 신뢰하지 않는다.
- 서버 측 MIME 판별과 매직바이트 검증을 수행한다.
- 이미지 파일은 가능한 재인코딩하고 EXIF 등 메타데이터를 제거한다.
- SVG, HTML, XML, 스크립트 가능한 문서는 기본 차단한다.
- 원본 파일명을 저장 경로에 사용하지 않는다.
- 저장 경로는 웹 루트 외부이며, 경로 정규화 후 루트 이탈 여부를 검사한다.
- 업로드 파일에는 실행 권한을 부여하지 않는다.
.htaccess,.user.ini,web.config등 설정 파일 업로드를 차단한다.- 파일 크기, 픽셀 수, 압축 해제 크기, 업로드 횟수 제한이 있다.
- 악성 파일 검사 실패 시 기본 동작은 허용이 아니라 차단이다.
- 다운로드 응답에
X-Content-Type-Options: nosniff를 적용한다. - 비공개 파일은 객체 ID만으로 직접 접근할 수 없고 권한 검사를 거친다.
- 테스트 케이스에 이중 확장자, MIME 위조, polyglot, 경로 순회, 대소문자 변형이 포함되어 있다.
운영 체크리스트
운영 단계에서는 코드 검증보다 설정과 관측 가능성이 중요하다.
- 업로드 디렉터리 또는 버킷이 애플리케이션 코드와 분리되어 있다.
- 업로드 경로에서 PHP/JSP/ASP/CGI/SSI 실행이 차단되어 있다.
- 웹 서버의
AllowOverride또는 디렉터리별 설정 재정의가 제한되어 있다. - 업로드 저장소 권한은 최소 권한으로 운영된다.
- 공개 버킷 정책과 CDN 캐시 정책을 정기 점검한다.
- 업로드 파일 해시와 검증 결과가 중앙 로그로 수집된다.
- WAF와 SIEM에서 실행 확장자, 이중 확장자, MIME 불일치 탐지를 수행한다.
- 백신 또는 샌드박스 검사 장애 시 우회 허용하지 않고 격리 큐에 보관한다.
- 업로드 실패 급증, 특정 계정의 반복 거부, 업로드 직후 실행 시도 알림이 있다.
- 사고 대응 절차에 “업로드 저장소 격리, 공개 URL 비활성화, 악성 파일 해시 검색, 접근 로그 추적”이 포함되어 있다.
- 개발팀과 운영팀이 새 업로드 기능 출시 전 보안 리뷰를 수행한다.
위협 레벨별 대응 우선순위
- Critical: 업로드 경로 실행 차단, 웹 루트 내부 저장 중지, 실행 확장자 업로드 차단을 우선 적용한다. 이후 저장 파일 전수 점검, 웹셸 흔적 조사, 접근 로그 분석을 진행하고 파일 서비스 분리, 서명 URL 기반 다운로드, 정기 모의 침투로 재발을 줄인다.
- High: Content-Type 단독 검증을 제거하고 확장자, MIME, 매직바이트 다중 검증을 적용한다. 이미지 재인코딩, SVG/HTML 차단, 설정 파일 업로드 차단을 보완한 뒤 업로드 정책 표준화와 공통 업로드 모듈 도입으로 확산한다.
- Medium: 파일 크기와 업로드 횟수 제한, 파일명 재명명을 적용한다. WAF 룰과 SIEM 탐지를 추가하고 실패 업로드를 모니터링하며, 샌드박스와 백신 검사 자동화 및 운영 대시보드를 마련한다.
- Low: 로그 필드와 거부 사유 표준화를 보강한다. 개발/운영 체크리스트와 테스트 케이스를 배포하고, 보안 교육과 릴리스 게이트 점검 항목에 업로드 보안을 포함한다.
결론: 검증보다 중요한 것은 실행 경로를 없애는 것이다
파일 업로드 보안의 핵심은 “악성 파일을 완벽히 식별하겠다”가 아니다. 공격자가 우회 파일을 올리더라도 실행되지 않고, 권한 없는 사용자가 접근하지 못하며, 이상 행위가 로그와 탐지 체계에 남도록 만드는 것이다.
실무적으로는 네 가지 원칙을 우선 적용해야 한다. 첫째, 업무상 필요한 파일 형식만 허용한다. 둘째, Content-Type 헤더를 믿지 말고 서버 측 MIME, 매직바이트, 실제 파서 검증을 함께 사용한다. 셋째, 업로드 파일을 웹 루트와 실행 환경에서 분리하고 실행 권한을 제거한다. 넷째, WAF와 로그를 통해 우회 시도와 운영 실수를 빠르게 발견한다.
이 네 가지가 갖춰지면 업로드 기능은 편의 기능에서 통제 가능한 데이터 수집 경로로 바뀐다. 반대로 이 중 하나라도 빠지면 작은 이미지 업로드 버튼 하나가 서버 장악의 출발점이 될 수 있다.
참고자료
- OWASP File Upload Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- OWASP Unrestricted File Upload: https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload
- MDN, Media types and MIME sniffing: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types
- MITRE ATT&CK T1190, Exploit Public-Facing Application: https://attack.mitre.org/techniques/T1190/
본 콘텐츠는 AI 기술로 생성된 분석 리포트를 포함하고 있습니다. 내용 중 사실과 다르거나 보완이 필요한 정보를 발견하시면 댓글을 통해 소중한 의견 부탁드립니다. 여러분의 피드백은 더 정확한 보안 정보 공유에 큰 도움이 됩니다.
댓글 (0)
댓글을 작성하려면 로그인이 필요합니다.
로그인아직 댓글이 없습니다.
첫 번째 댓글을 작성해보세요!