[C-Lab Core Team (Members)] 안전한 파일 업로드 구현

전민주·2024년 6월 1일

C-Lab Core-Team

목록 보기
2/4

0. 들어가며

안녕하세요! 경기대학교 AI컴퓨터공학부의 과동아리 C-Lab의 CoreTeam Back-end 팀원 전민주입니다. Back-end 팀에서는 클라이언트와 파일을 안전하게 주고 받을 수 있도록 많은 부분들을 고민했는데요.

단순히 Multipart File을 업로드하는 것 뿐만 아니라, 업로드 과정에서 발생할 수 있는 취약점을 사전에 차단하는 방법을 소개하겠습니다!

1. 파일이 서버에 전달되고 저장되는 과정

클라이언트에서 서버로 파일 전달

클라이언트에서 서버로 MultipartFile이라는 형태의 자료가 전달됩니다. 아래처럼 파일을 업로드하는 경우 POST METHOD를 사용하면서 consumes 속성을 부여했는데요.

HTTP 프로토콜은 기본적으로 텍스트 기반의 요청과 응답을 처리하지만, 파일 같은 이진 데이터를 텍스트 형식으로 인코딩하기에는 비효율적이에요. 그래서 Multipart는 이런 이진 데이터를 원본 형식으로 전달한답니다.

또한 이름에서 알 수 있듯이 파일을 여러 부분으로 나누어서 전송하기 때문에 서버는 메모리 부담이 적게 파일을 스트리밍 방식으로 받을 수 있어요.

    @Operation(summary = "[U] 게시글 파일 업로드", description = "ROLE_USER 이상의 권한이 필요함")
    @Secured({"ROLE_USER", "ROLE_ADMIN", "ROLE_SUPER"})
    @PostMapping(value = "/boards", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    public ApiResponse<List<UploadedFileResponseDto>> boardUpload(
            @RequestParam(name = "multipartFile") List<MultipartFile> multipartFiles,
            @RequestParam(name = "storagePeriod") long storagePeriod
    ) throws IOException, PermissionDeniedException {
        List<UploadedFileResponseDto> responseDtos = fileService.saveFiles(multipartFiles, "boards", storagePeriod);
        return ApiResponse.success(responseDtos);
    }

📌 consumes 속성 : 소비할 수 있는 MIME 타입을 지정해줍니다. 클라이언트가 서버에 요청할 때 보내는 데이터의 콘텐츠 타입을 정해줍니다.

이때 MediaType.MULTIPART_FORM_DATA_VALUE 를 전달하도록 명시했는데요. 즉 클라이언트는 multipart/form-data 형식을 서버에게 전달해야 합니다.

📌 MediaType.MULTIPART_FORM_DATA_VALUE란?

스프링 프레임워크에서 제공하는 상수로, HTTP 요청에서 사용되는 특정 content type을 나타냅니다. 아래의 docs를 살펴보면 MULTIPART_FORM_DATA_VALUE를 찾을 수 있습니다!

📌 MultipartFile이란?

MultipartFile은 스프링 프레임워크에서 제공하는 인터페이스인데요. 파일 업로드 요청을 처리할 때 사용합니다.

다양한 메소드를 제공하고 있는데, 코어팀의 파일 처리 작업에서는 주로 getName(), getOriginalFilename(), getSize(), transferTo() 등을 사용했습니다!

  • getName(): 업로드된 파일의 폼 필드 이름을 반환합니다.
  • getOriginalFilename(): 클라이언트가 업로드한 파일의 원래 이름을 반환합니다.
  • getSize(): 업로드된 파일의 크기를 바이트 단위로 반환합니다.
  • transferTo(File dest): 업로드된 파일을 지정한 파일 경로로 이동(저장)합니다.

서버에서 파일을 저장하고, 파일 정보를 DB에 기록

부끄럽지만, 저는 파일 시스템을 처음 구현할 당시에 파일을 저장한다는 개념이 여느 데이터를 DB에 기록하는 것 처럼 파일 그 자체를 DB에 저장하면 되는거 아닌가? 라고 생각했습니다. 🫠

그렇지만 파일은 이진 데이터이기 때문에 이렇게 큰 파일을 바이너리 형태로 DB에 저장하면 DB의 성능이 저하됩니다. DB는 대량의 바이너리 데이터를 효율적으로 처리하도록 설계되지 않았기에 읽기, 쓰기 속도가 느려지겠죠??

그래서 파일 자체는 따로 저장을 하고 파일의 메타데이터(원본 이름, 저장용 이름, 콘텐츠 타입, 저장경로, 등록 날짜, 수정날짜) 등에 대한 내용을 DB에 저장해야 했습니다.

이렇게 DB에는 파일의 정보를 기록하고, 전달 받은 파일은 FileHandler의 saveFile메소드를 통해 유효성 검사를 받고 저장되는 로직을 밟습니다.

  • FileHandler에 들어온 MultipartFile은 OS에 따라 상이한 경로 구분자를 수정해주기 위해서 init() 메소드를 거칩니다.

만약 init() 메소드를 생략한다면 플랫폼 간 호환성 문제가 발생할 수 있습니다. 이는 각 운영체제가 파일 경로를 구분하는 방식이 다르기 때문에 발생할 수 있는데요.

예를 들어, Windows는 역슬래시(\)를 사용하고, Unix 기반 시스템(Linux, macOS 등)은 슬래시(/)를 사용합니다. 만약 filePath가 특정 플랫폼의 구분자에 맞춰져 있다면, 다른 운영체제에서는 해당 경로를 인식하지 못할 수 있습니다.

이로 인해 파일을 읽거나 쓸 때 예기치 않은 오류가 발생할 수 있고, 애플리케이션 사용의 불편함을 초래할 수 있습니다.

  public void init() {
        filePath = filePath.replace("/", File.separator).replace("\\", File.separator);
    }
  • 사용자가 업로드한 파일명이 서로 중복될 수 있기 때문에 UUID로 저장용 파일명을 만들어줍니다. UUID를 사용하지 않으면 공격자가 동일한 이름의 파일을 업로드 해서 중요한 파일을 덮어쓸 수 있어요. 또한 공격자가 파일명을 추측하기 어렵기 때문에 브루트포싱 공격을 예방할 수 있습니다.

  • 저장될 파일경로를 지정해주고, 부모 디렉토리가 있는지 확인해줍니다.

  • multipartFile을 file로 변환해주고, 파일의 권한을 설정해준 뒤에 저장된 경로와 다른 정보들을 바탕으로 DB에 파일 정보를 기록합니다.

보안적인 측면을 간단하게 줄글로 설명을 드렸지만 이 과정 속에는 안전하게 파일을 처리할 수 있는 작업들이 녹아있습니다! 아래의 챕터들에서 구체적으로 설명드릴게요!

2. 취약한 파일 관리의 위험성

단순히 전달 받은 파일을 저장하면 너무 편하겠지만 전달된 파일을 처리할때 서버 입장에서는 걱정이 많습니다.

악성 파일이나 비정상적인 데이터를 걸러내야하고, 허용되지 않은 파일 형식이나 파일 크기가 과도하게 크면 전체 시스템에 영향을 줄 수 있기 때문이죠.

유명한 기업에도 이러한 파일 업로드 취약점을 잘 대비하지 못해 큰 이슈가 되었는데요.

Cisco에서 인증되지 않은 파일 업로드 취약점이 발견됐습니다. 해당 취약점을 통해 공격이 성공할 경우 시스템에 악성파일을 저장하고 운영체제에서 원하는 명령을 수행하거나 자유롭게 root 권한을 가질 수 있을 정도로 위험한 경우였어요.

이렇게 취약한 파일 관리를 방치하게 되면 아래와 같은 위험성이 서버에 존재하게 됩니다.

  1. 위험한 형식 파일 업로드:
    • 서버 측에서 실행될 수 있는 스크립트 파일(asp, jsp, php 등)이 업로드되면 공격자가 이를 통해 시스템 내부 명령어를 실행하거나 외부와 연결하여 시스템을 제어할 수 있습니다 . 이는 시스템의 완전한 통제를 공격자에게 넘겨줄 수 있는 치명적인 취약점입니다.
  2. 랜덤한 파일명 미사용:
    • 업로드된 파일의 이름을 랜덤하게 생성하지 않으면 공격자가 파일명을 추측해 접근할 수 있습니다. 이는 파일을 보호하기 위해 필수적인 보안 조치 중 하나입니다 .
  3. 부적절한 파일 저장과 접근:
    • 업로드된 파일의 타입, 크기를 적절히 제한하지 않으면, 공격자가 악성 파일을 업로드하거나 실행할 수 있습니다. 특히 파일 저장 경로를 외부에서 접근할 수 없는 위치에 설정하지 않으면 공격자가 파일을 쉽게 찾아내어 사용할 수 있습니다. 또한 다양한 서버 장애도 발생할 수 있는데요. 큰 파일은 디스크 공간과 메모리를 빠르게 소진시켜 서버 다운이나 성능 저하를 초래합니다. 큰 파일을 전달할 때 네트워크 대역폭을 많이 차지하기 때문에 서버 응답 지연을 유발합니다. DB 측면에서는 백업과 복구 작업이 오래걸리고 복잡합니다.
  4. 허술한 접근 제어 :
    • 허술한 접근 제어는 비인가된 사용자가 민감한 파일에 접근할 수 있게 하여 데이터 유출이나 손상을 초래할 수 있습니다. 이는 악의적인 활동을 방지하지 못해 시스템 보안에 큰 취약점을 남깁니다. 효과적인 접근 제어는 파일의 기밀성, 무결성, 가용성을 보호하는 데 필수적입니다.
  5. 화이트리스트 방식의 파일 검증 미비:
    • 허용된 파일 확장자만 업로드할 수 있도록 제한하지 않으면, 공격자가 악성 스크립트 파일을 업로드할 수 있습니다. 화이트리스트 방식을 통해 업로드 가능한 파일 형식을 엄격히 제한하는 것이 중요합니다 .

이러한 위험성을 방지하기 위해서는 코어팀의 파일 처리 로직에서는 유효성 검증, 무결성 검사, 접근 제어 설정 등을 고려했습니다!

3. 각각의 취약점별로 대응한 방법들

1번 챕터에서 간단히 소개해드린 파일 저장 로직의 보안적인 측면을 순서대로 짚어가며 어떻게 파일 관리 취약점에 대해 대응했는지 소개해드릴게요!

  1. 위험한 형식의 파일 업로드 예방을 위한 블랙 리스트 방식 ValidateFileAttributes() 메소드

클라이언트에서 전달한 모든 파일을 저장할 수 있게 한다면 공격자의 악성 파일도 손쉽게 저장될 수 있을거에요.. 😱

따라서 코어팀에서는 다음과 같이 허용되지 않는 파일 확장자를 두고, 이에 해당하는 확장자의 경우는 예외처리를 통해 저장할 수 없도록 하고 있습니다!

public FileHandler(
            @Value("${resource.file.disallow-extension}") String[] disallowExtensions,
            @Value("${resource.file.compressible-image-extension}") String[] compressibleImageExtensions
    ) {
        this.disallowExtensions.addAll(Arrays.asList(disallowExtensions));
        this.compressibleImageExtensions.addAll(Arrays.asList(compressibleImageExtensions));
    }
public String saveFile(MultipartFile multipartFile, String category) throws IOException {
        init();
        String originalFilename = multipartFile.getOriginalFilename();
        String extension = FilenameUtils.getExtension(originalFilename);
        validateFileAttributes(originalFilename, extension);

        String saveFilename = makeFileName(extension);
        String savePath = filePath + File.separator + category + File.separator + saveFilename;

        File file = new File(savePath);
        ensureParentDirectoryExists(file);
        multipartFile.transferTo(file);
        setFilePermissions(file, savePath, extension);
        return savePath;
    }

    private void validateFileAttributes(String originalFilename, String extension) throws FileUploadFailException {
        if (!validateFilename(originalFilename)) {
            throw new FileUploadFailException("허용되지 않은 파일명 : " + originalFilename);
        }
        if (!validateExtension(extension)) {
            throw new FileUploadFailException("허용되지 않은 확장자 : " + originalFilename);
        }
    }

    private boolean validateExtension(String extension) {
        return !disallowExtensions.contains(extension.toLowerCase());
    }

    private boolean validateFilename(String fileName) {
        return !Strings.isNullOrEmpty(fileName);
    }
   
  1. 랜덤한 파일명 사용을 위한 makeFileName() 메소드

    다양한 사용자가 다양한 파일을 업로드 하면서 동일한 이름의 파일이 저장될 수도 있어요. 이를 공격자의 입장에서 보면 중요한 파일명을 유추해서 다른내용의 파일로 덮어쓴다거나 경로를 예상해서 삭제를 시도할 수도 있습니다.

    이를 예방하기 위해서 업로드된 파일은 모드 원본 이름과, 저장용 이름을 구분해서 관리하고 있어요. 저장용 이름은 아래와 같이 UUID를 통해 랜덤한 파일명을 만들어서 사용합니다.

      public String makeFileName(String extension) {
            return (System.nanoTime() + "_" + UUID.randomUUID() + "." + extension);
        }
  2. 안전한 파일 저장과 접근을 위한 ensureParentDirectoryExists() 메소드, addResourceHandlers() 메소드, validateMemberCloudUsage() 메소드

업로드 예정인 파일은 코어팀의 파일 관리 정책에 맞게 여러 디렉토리를 거쳐 저장되도록 하는데요. 그때 상위 디렉토리가 없는 경우에 대한 처리를 위해 아래의 메소드를 두었습니다.

    private void ensureParentDirectoryExists(File file) {
        if (!file.getParentFile().exists()) {
            file.getParentFile().mkdirs();
        }
    }

또한 WebMvcConfigurer를 구현한 WebConfig에서 아래의 메소드를 구현해 파일 접근의 보안성을 강화했습니다. 업로드된 파일들이 특정 디렉토리에 안전하게 저장되고, 해당 디렉토리에 대한 URL 매핑을 통해서만 접근이 가능합니다.

파일을 읽기 전에도 리소스가 존재하는지, 읽을 수 있는지 확인해서 안정성을 높이고 불필요한 오류를 예방했습니다. 존재하지 않거나 읽을 수 없는 파일에 대한 접근을 차단해 악의적인 사용자가 민감한 파일에 접근하는 것을 방지할 수 있었습니다.

@Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        log.info("Resource UploadedFile Mapped : {} -> {}", fileURL, filePath);
        registry
                .addResourceHandler(fileURL + "/**")
                .addResourceLocations("file://" + filePath + "/")
                .resourceChain(true)
                .addResolver(new PathResourceResolver() {
                    @Override
                    protected Resource getResource(String resourcePath, Resource location) throws IOException {
                        Resource resource = location.createRelative(resourcePath);
                        if (resource.exists() && resource.isReadable()) {
                            return resource;
                        }
                        throw new FileNotFoundException("Resource not found: " + resourcePath);
                    }
                });
    }

현재 코어팀이 개발중인 C-Lab 멤버스 홈페이지는 사용자들이 클라우드에 파일을 업로드 할 수도 있는데요. 이때 클라우드 저장 공간에 대한 검사를 추가해 시스템의 안정성을 유지하고, 저장 공간의 효율적인 이용을 제공합니다. 공격자가 대량의 파일을 업로드하는 서비스 거부 공격을 예방할 수도 있겠죠?

  private void validateMemberCloudUsage(MultipartFile multipartFile, String path) throws PermissionDeniedException {
        if (path.split(Pattern.quote(File.separator))[0].equals("members")) {
            String memberId = path.split(Pattern.quote(File.separator))[1];
            double usage = memberCloudService.getCloudUsageByMemberId(memberId).getUsage();
            if (multipartFile.getSize() + usage > FileSystemUtil.convertToBytes(maxFileSize)) {
                throw new CloudStorageNotEnoughException("클라우드 저장 공간이 부족합니다.");
            }
        }
    }
  1. 안전한 접근제어를 위한 setFilePermissions() 메소드

파일을 접근할 수 있는 사람이 누군인지에 대한 권한 설정을 아래의 메소드로 정의했습니다. 특히, OS 별로 나누어서 파일을 읽을 수 있도록만 설정을 해두어 공격자가 함부로 파일을 수정하거나 실행할 수 없도록 차단했습니다.

private void setFilePermissions(File file, String savePath, String extension) throws FileUploadFailException {
        try {
            String os = System.getProperty("os.name").toLowerCase();
            if (compressibleImageExtensions.contains(extension.toLowerCase())) {
                ImageCompressionUtil.compressImage(savePath, imageQuality);
            }
            if (os.contains("win")) {
                file.setReadable(true);
                file.setWritable(false);
                file.setExecutable(false);
            } else {
                Runtime.getRuntime().exec("chmod 400 " + savePath);
            }
        } catch (IOException e) {
            throw new FileUploadFailException("파일 저장 실패", e);
        }
    }

4. C-Lab에서 파일 업로드 기능을 활용한 부분들

C-Lab의 멤버스 홈페이지인 clab.page에는 C-Lab 동아리의 활동을 진행하면서 파일 업로드 기능이 필요한 부분이 많이 있답니다.

<멤버스 홈페이지를 더욱 다채롭게 꾸며주는 경우>

  • 일반 게시글 사진 업로드 : 게시글을 작성하고 업로드 할 때 사진을 첨부할 수 있도록 했어요
  • 뉴스 사진 업로드 : 최근 동아리 소식을 사진과 함께 만나볼 수 있어요.
  • 멤버 프로필 사진 업로드
  • 회비 증빙 사진 업로드 : 회비를 사용하고 영수증 등의 내역을 첨부할 수 있어요

5. 마무리하며

파일 업로드 관련된 작업은 제가 코어팀을 들어오고 얼마 되지 않아서 시작했는데요. 파일정보는 DB에 저장해야 하는 것과 파일 자체를 따로 저장해야 하는 점, 시큐어 코딩으로 인해 url 매핑과 파일을 찾아야 하는 경로가 다른 점, 처음 접하는 Multipartfile을 다뤄야 하는 점 등 새롭게 배우는 것이 많았습니다.

2023년 12월 19일 처음 파일 업로드 구현을 하고 지금 2024년 6월, 보안적인 부분이 더욱 강화된 파일 업로드가 완성되고 글을 작성했는데요. 과거에는 이해하지 못했던 한관희 파트장님의 코드와 개념을 다시 확인 할 수 있는 소중한 시간이었습니다.

참고

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/MediaType.html#MULTIPART_FORM_DATA

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/multipart/MultipartFile.html

소프트웨어개발보안가이드(2021.12.29).pdf

https://www.isaa.re.kr/index.php?page=view&pg=2&idx=680&hCode=BOARD&bo_idx=4&sfl=&stx=

profile
엉금엉금

0개의 댓글