6. 위험한 형식 파일 업로드

개요

많은 웹 애플리케이션은 사용자가 프로필 사진, 첨부 파일 등 다양한 파일을 서버에 업로드하는 기능을 제공합니다. 이때 업로드되는 파일의 형식(확장자, MIME 타입), 크기, 내용 등을 적절히 검증하지 않으면, 공격자가 악의적인 파일을 업로드하여 서버에서 실행시킬 수 있는 보안 약점이 발생합니다.

가장 대표적인 위협은 웹쉘(Web Shell) 업로드입니다. 웹쉘은 웹 서버에서 실행될 수 있는 스크립트 파일(예: `.php`, `.jsp`, `.asp`)로, 공격자는 웹쉘을 통해 서버의 파일 시스템 접근, 명령어 실행 등 서버 제어권을 획득할 수 있습니다. 또한, 악성코드나 랜섬웨어를 포함한 파일을 업로드하여 다른 사용자에게 전파하거나 서버 자체를 감염시킬 수도 있습니다.

보안 대책

  • - 확장자 제한 (Whitelist 방식): 업로드 가능한 파일 확장자를 명확하게 정의하고(예: `.jpg`, `.png`, `.pdf`), 이 목록에 포함되지 않은 확장자는 차단합니다. 블랙리스트 방식(예: `.exe`, `.sh` 차단)은 우회 가능성이 높아 안전하지 않습니다.
  • - MIME 타입 검증: 파일 확장자뿐만 아니라, HTTP 요청 헤더의 `Content-Type`과 실제 파일의 매직 넘버(Magic Number) 등을 통해 파일의 실제 MIME 타입을 검증하여 확장자와 일치하는지 확인합니다. (단, MIME 타입은 위변조 가능하므로 보조 수단으로 활용)
  • - 서버사이드 검증 필수: 클라이언트 측(JavaScript) 검증은 쉽게 우회 가능하므로, 반드시 서버 측에서 확장자, MIME 타입, 파일 크기 등을 다시 검증해야 합니다.
  • - 파일 크기 제한: 서비스 거부(DoS) 공격을 방지하기 위해 합리적인 수준으로 업로드 파일 크기를 제한합니다.
  • - 파일 이름 변경 및 저장 경로 분리: 업로드된 파일 이름을 예측 불가능한 문자열(예: UUID)로 변경하여 저장하고, 웹 루트(Document Root) 외부의 안전한 경로에 저장하여 직접적인 웹 접근을 차단합니다.
  • - 실행 권한 제거: 업로드된 파일이 저장되는 디렉토리에 스크립트 실행 권한을 제거하여, 웹쉘 등이 업로드되더라도 실행되지 않도록 설정합니다.
  • - 콘텐츠 검사(Content Inspection): 이미지 파일이라면 실제 이미지 데이터인지, 압축 파일이라면 내부에 악성 파일이 포함되지 않았는지 검사하는 솔루션(예: Anti-Virus)을 도입하거나 라이브러리를 활용합니다. (고급 대책)

코드 예시 (Java - Servlet 3.0 이상)

취약한 코드 (검증 부족):

import javax.servlet.annotation.MultipartConfig;
import javax.servlet.http.Part;
// ... other imports ...

@MultipartConfig
public class FileUploadServlet extends HttpServlet {
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        
        Part filePart = request.getPart("userFile"); // 파일 가져오기
        String fileName = Paths.get(filePart.getSubmittedFileName()).getFileName().toString(); // 클라이언트 파일명 사용

        // 검증 없이 파일을 웹 루트 하위에 저장 (매우 위험!)
        String uploadPath = getServletContext().getRealPath("") + File.separator + "uploads";
        File uploadDir = new File(uploadPath);
        if (!uploadDir.exists()) uploadDir.mkdir();
        
        filePart.write(uploadPath + File.separator + fileName); // 웹쉘 업로드 및 실행 가능
        
        response.getWriter().println("File uploaded successfully: " + fileName);
    }
}
                        
안전한 코드 (확장자 검증, 이름 변경, 경로 분리):

import javax.servlet.annotation.MultipartConfig;
import javax.servlet.http.Part;
import java.util.UUID;
import java.nio.file.Paths;
import java.io.File;
// ... other imports ...

@MultipartConfig(fileSizeThreshold = 1024 * 1024 * 2, // 2MB
                 maxFileSize = 1024 * 1024 * 10,      // 10MB
                 maxRequestSize = 1024 * 1024 * 50)   // 50MB
public class SafeFileUploadServlet extends HttpServlet {

    private static final String UPLOAD_DIRECTORY = "/path/to/safe/storage"; // 웹 루트 외부 경로!
    private static final Set ALLOWED_EXTENSIONS = Set.of(".jpg", ".jpeg", ".png", ".pdf");

    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        
        Part filePart = request.getPart("userFile");
        String originalFileName = Paths.get(filePart.getSubmittedFileName()).getFileName().toString();
        String fileExtension = getFileExtension(originalFileName);

        // 1. 확장자 검증 (Whitelist)
        if (fileExtension == null || !ALLOWED_EXTENSIONS.contains(fileExtension.toLowerCase())) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.getWriter().println("Error: Invalid file type.");
            return;
        }

        // 2. 파일 이름 변경 (UUID 사용)
        String savedFileName = UUID.randomUUID().toString() + fileExtension;

        // 3. 안전한 경로에 저장 (웹 루트 외부)
        File uploadDir = new File(UPLOAD_DIRECTORY);
        if (!uploadDir.exists()) uploadDir.mkdirs(); // 경로 생성
        
        try {
             filePart.write(UPLOAD_DIRECTORY + File.separator + savedFileName);
             response.getWriter().println("File uploaded successfully as: " + savedFileName);
             // TODO: 저장된 파일 정보(원본 이름, 저장된 이름 등)를 DB 등에 기록
        } catch (IOException e) {
             response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
             response.getWriter().println("Error: File upload failed.");
             // 로그 기록 등 예외 처리
        }
    }

    private String getFileExtension(String fileName) {
        if (fileName == null || fileName.lastIndexOf(".") == -1) {
            return null;
        }
        return fileName.substring(fileName.lastIndexOf("."));
    }
}
                        

Python(Flask, Django), JavaScript(Node.js - multer), C#, PHP 등에서도 유사한 원칙(확장자 검증, MIME 타입 검증, 파일명 변경, 안전한 경로 저장, 실행 권한 제거)을 적용하여 파일 업로드 기능을 구현해야 합니다.