본문으로 건너뛰기

파일 업로드 보안: 확장자 제한과 실행 방지

파일 업로드 기능은 공격자가 웹쉘(WebShell)을 업로드하거나 악성 파일을 배포하는 주요 경로입니다. 확장자 제한, 파일 크기 제한, 업로드 디렉토리 실행 방지로 안전한 업로드 환경을 구성합니다.


업로드 보안 위협 모델

공격 시나리오:
1. 공격자가 image.php.jpg 또는 shell.php 업로드
2. 업로드 디렉토리에 서버가 PHP 실행 허용
3. https://example.com/uploads/shell.php 접근
4. 서버 완전 장악

Nginx 업로드 디렉토리 실행 방지

server {
# 업로드 디렉토리: 스크립트 실행 완전 차단
location /uploads/ {
alias /var/www/uploads/;

# 모든 스크립트 파일 실행 차단 (핵심 설정)
location ~* \.(php|php3|php4|php5|phtml|pl|py|rb|sh|cgi|jsp|asp|aspx)$ {
deny all;
return 403;
}

# 다운로드 전용 설정
add_header Content-Disposition "attachment"; # 브라우저 실행 방지
add_header X-Content-Type-Options "nosniff"; # MIME 스니핑 방지

# autoindex 비활성화
autoindex off;
}
}

파일 업로드 크기 제한

# /etc/nginx/nginx.conf — http 블록 또는 server 블록

http {
# 전역 업로드 크기 제한 (기본: 1MB)
client_max_body_size 10m; # 10MB

server {
# 일반 요청
client_max_body_size 1m;

# 업로드 전용 엔드포인트만 크기 허용 증가
location /api/upload {
client_max_body_size 50m; # 50MB
proxy_pass http://backend;
proxy_request_buffering off; # 대용량 파일: 버퍼링 비활성화
}
}
}
# Apache 업로드 크기 제한
<Location "/upload">
LimitRequestBody 52428800 # 50MB (바이트)
</Location>

Spring Boot 업로드 보안 구현

# application.yml
spring:
servlet:
multipart:
max-file-size: 10MB # 파일당 최대 크기
max-request-size: 50MB # 요청 전체 최대 크기
enabled: true
@Service
public class FileUploadService {

// 허용할 확장자 화이트리스트
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"jpg", "jpeg", "png", "gif", "webp", // 이미지
"pdf", "doc", "docx", "xls", "xlsx", // 문서
"zip", "tar", "gz" // 압축
);

// 허용할 MIME 타입 화이트리스트
private static final Set<String> ALLOWED_MIME_TYPES = Set.of(
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf",
"application/zip"
);

public String uploadFile(MultipartFile file) {
// 1. 빈 파일 체크
if (file.isEmpty()) {
throw new IllegalArgumentException("파일이 비어있습니다");
}

// 2. 파일 크기 제한 (서비스 레벨에서도 확인)
if (file.getSize() > 10 * 1024 * 1024) { // 10MB
throw new IllegalArgumentException("파일 크기가 10MB를 초과합니다");
}

// 3. 확장자 검증 (화이트리스트)
String originalFilename = file.getOriginalFilename();
String extension = getExtension(originalFilename).toLowerCase();
if (!ALLOWED_EXTENSIONS.contains(extension)) {
throw new IllegalArgumentException("허용되지 않는 파일 형식: " + extension);
}

// 4. MIME 타입 검증 (파일 내용 기반)
String mimeType = detectMimeType(file);
if (!ALLOWED_MIME_TYPES.contains(mimeType)) {
throw new IllegalArgumentException("허용되지 않는 MIME 타입: " + mimeType);
}

// 5. 파일명 무작위화 (원본 파일명 사용 금지)
String safeFilename = UUID.randomUUID() + "." + extension;

// 6. 업로드 디렉토리에 저장 (웹 루트 밖에 저장 권장)
Path uploadPath = Paths.get("/var/uploads", safeFilename);
Files.copy(file.getInputStream(), uploadPath, StandardCopyOption.REPLACE_EXISTING);

return safeFilename;
}

private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return "";
}
// 경로 탐색 공격 방지: 마지막 . 이후만 추출
return filename.substring(filename.lastIndexOf('.') + 1);
}

private String detectMimeType(MultipartFile file) throws IOException {
// Apache Tika로 파일 내용 기반 MIME 타입 탐지 (확장자 위조 방지)
// implementation 'org.apache.tika:tika-core:2.x.x'
Tika tika = new Tika();
return tika.detect(file.getInputStream());
}
}

파일명 보안 처리

// 경로 탐색(Path Traversal) 공격 방지
public String sanitizeFilename(String filename) {
if (filename == null) return null;

// 경로 구분자 제거 (../../etc/passwd 공격 방지)
filename = filename.replaceAll("[/\\\\:*?\"<>|]", "_");

// 상위 디렉토리 참조 제거
filename = filename.replace("..", "");

// 앞뒤 점/공백 제거
filename = filename.strip().replaceAll("^\\.+", "");

// 최대 길이 제한
if (filename.length() > 100) {
filename = filename.substring(0, 100);
}

return filename.isEmpty() ? "unnamed" : filename;
}

업로드 파일 저장 위치

# 웹 루트 밖에 저장하고 Nginx를 통해 제공 (권장)

# 파일은 /var/uploads/ (웹 루트 밖)에 저장
# Nginx가 내부적으로 제공

server {
# /files/{uuid}.jpg 요청 → /var/uploads/{uuid}.jpg 파일 반환
location /files/ {
# 내부 리다이렉트만 허용 (직접 접근 불가)
internal;
alias /var/uploads/;

# 다운로드 강제
add_header Content-Disposition "attachment";
}

# API가 X-Accel-Redirect 헤더를 반환하면 Nginx가 파일 전송
location /api/download/ {
proxy_pass http://backend;
# 백엔드에서: response.setHeader("X-Accel-Redirect", "/files/" + filename);
}
}

이미지 파일 재처리 (이미지 스트립핑)

// 이미지 파일에 숨겨진 악성 코드 제거 (ImageMagick 활용)
// 업로드된 이미지를 재인코딩해 메타데이터/스크립트 제거
public void sanitizeImage(Path inputPath, Path outputPath) throws Exception {
ProcessBuilder pb = new ProcessBuilder(
"convert",
inputPath.toString(),
"-strip", // 메타데이터(EXIF, IPTC 등) 제거
"-auto-orient", // 올바른 방향으로 회전
outputPath.toString()
);
Process process = pb.start();
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException("이미지 처리 실패");
}
}

바이러스 스캔 연동 (ClamAV)

# ClamAV 설치
sudo apt install clamav clamav-daemon
sudo freshclam # 바이러스 DB 업데이트
sudo systemctl start clamav-daemon
// Java에서 ClamAV 연동
public boolean scanFile(Path filePath) throws IOException {
ProcessBuilder pb = new ProcessBuilder(
"clamdscan", "--no-summary", filePath.toString()
);
Process process = pb.start();
int exitCode = process.waitFor();
// 0: 정상, 1: 바이러스 발견, 2: 오류
if (exitCode == 1) {
Files.delete(filePath); // 바이러스 파일 즉시 삭제
throw new SecurityException("바이러스가 발견된 파일입니다");
}
return exitCode == 0;
}