가이드라인

파일 업로드

애플리케이션에서 사용자가 애플리케이션 내 어딘가에 파일을 업로드(사용 목적이든 저장 목적이든)할 수 있도록 허용해야 하는 경우가 매우 흔합니다. 이 기능은 간단해 보이지만 파일 업로드 처리 방식과 관련된 잠재적 위험 때문에 이 기능을 구현하는 방식이 매우 중요할 수 있습니다. 

이 간단한 예시를 통해 무슨 뜻인지 시각적으로 더 잘 이해할 수 있습니다. 

사용자가 프로필 사진을 업로드할 수 있는 애플리케이션이라고 가정해 보겠습니다:

public string UploadProfilePicture(FormFile uploadedFile)
{
    // Generate path to save the uploaded file at
    var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";  

    // Save the file
    var localFile = File.OpenWrite(path);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // Update the profile picture
    UserProfile.UpdateUserProfilePicture(request.User, path)

    return path;
}

이것은 경로 탐색에 취약한 매우 기본적인 업로드 기능입니다. 

애플리케이션의 정확한 구현에 따라 공격자는 직접 호출하여 임의의 코드를 실행할 수 있는 다른 페이지/스크립트(.asp, .aspx 또는 .php 파일)를 업로드할 수 있습니다. 또한 기존 파일을 재정의할 수도 있습니다. 

문제 1 - 외부 데이터 저장소가 아닌 로컬 디스크에 저장하기

클라우드 서비스 사용이 보편화되면서 애플리케이션이 컨테이너로 제공되고, 고가용성 설정이 표준이 되었으며, 업로드된 파일을 애플리케이션의 로컬 디스크에 쓰는 관행은 어떤 대가를 치르더라도 반드시 피해야 합니다. 

파일은 가능한 경우 중앙 저장소(블록 저장소 또는 데이터베이스)에 업로드해야 합니다. 이 경우 모든 종류의 보안 취약성을 피할 수 있습니다. 

문제 2 - 확장 프로그램의 유효성을 검사하지 않음 

파일 업로드 취약점이 악용되는 대부분의 경우, 특정 확장자를 가진 파일을 업로드할 수 있는 기능에 의존합니다. 따라서 업로드할 수 있는 파일의 확장자 '허용 목록'을 사용하는 것이 좋습니다. 

사용 중인 언어/프레임워크에서 제공하는 방법을 사용하여 파일 확장자를 가져와야 널 바이트 삽입과 같은 문제를 방지할 수 있습니다. 

업로드의 콘텐츠 유형을 검증하고 싶을 수도 있지만, 특정 파일에 사용되는 콘텐츠 유형이 운영 체제마다 다를 수 있기 때문에 그렇게 하면 매우 취약해질 수 있습니다. 또한 콘텐츠 유형은 순전히 확장자의 매핑일 뿐이므로 실제로 파일 자체에 대해서는 아무 것도 알려주지 않습니다. 

문제 3 - 경로 통과를 방지하지 못함

파일 업로드의 또 다른 일반적인 문제는 경로 탐색에 취약한 경향이 있다는 것입니다. 이는 그 자체로 중요한 문제이므로 여기서 요약하기보다는 경로 탐색에 대한 전체 가이드라인을 살펴보세요.

더 많은 예제

아래에서 안전한 파일 업로드와 안전하지 않은 파일 업로드의 몇 가지 예를 더 살펴보실 수 있습니다. 

C# - 안전하지 않음

public string UploadProfilePicture(IFormFile uploadedFile)
{
    // Generate path to save the uploaded file at
    var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";

    // Save the file
    var localFile = File.OpenWrite(path);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // Update the profile picture
    UserProfile.UpdateUserProfilePicture(request.User, path)

    return path;
}

C# - 보안

public List<string> AllowedExtensions = new() { ".png", ".jpg", ".gif"};

public string UploadProfilePicture(IFormFile uploadedFile)
{
    // NOTE: The best option is to avoid saving files to the local disk.
    var basePath = Path.GetFullPath("./uploads/avatars/");

    // Prevent path traversal by not utilizing the provided file name. Also needed to avoid filename conflicts.
    var newFileName = GenerateFileName(uploadedFile.FileName);

    // Generate path to save the uploaded file at
    var canonicalPath = Path.Combine(basePath, newFileName);

    // Ensure that we did not accidentally save to a folder outside of the base folder
    if(!canonicalPath.StartsWith(basePath))
    {
        return BadRequest("Attempted to save file outside of upload folder");
    }

    // Ensure only allowed extensions are saved
    if(!IsFileAllowedExtension(uploadedAllowedExtensions))
    {
        return BadRequest("Extension is not allowed");
    }

    // Save the file
    var localFile = File.OpenWrite(canonicalPath);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // Update the profile picture
    UserProfile.UpdateUserProfilePicture(request.User, canonicalPath)

    return path;

public bool GenerateFileName(string originalFileName) {
    return $"{Guid.NewGuid()}{Path.GetExtension(originalFileName)}";
}

public bool IsFileAllowedExtension(string fileName, List<string> extensions) {
    return extensions.Contains(Path.GetExtension(fileName));
}

Java - 안전하지 않음

@Controller
public class FileUploadController {

   @RequestMapping(value = "/files/upload", method = RequestMethod.POST)
   @ResponseBody
   public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file, @AuthenticationPrincipal User user) {

       try {

           String uploadPath = "./uploads/avatars/" + principal.getName() + "/" + file.getOriginalFilename();

           File transferFile = new File(uploadPath);
           file.transferTo(transferFile);

       } catch (Exception e) {
           return new ResponseEntity<>("Upload error", HttpStatus.INTERNAL_SERVER_ERROR);
       }

       return new ResponseEntity<>(uploadPath, HttpStatus.CREATED);
   }
}

Java - 보안

@Controller
public class FileUploadController {

    @RequestMapping(value = "/files/upload", method = RequestMethod.POST)
    @ResponseBody
    public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file, @AuthenticationPrincipal User user) {

        try {
            String baseFolder = Paths.get("./uploads/avatars/").normalize();
            String uploadPath = Paths.get(baseFolder.toString() +
GenerateFileName(file.getOriginalFilename())).normalize();
           // Make sure that the extension is an allowed type
            if(!IsAllowedExtension(file.getOriginalFilename()) {
                return new ResponseEntity<>("Extension not allowed", HttpStatus.FORBIDDEN);
            }

            // Make sure that the file is not saved outside of the upload root
           if(!uploadPath.toString().startsWith(baseFolder.toString()))            {
                return new ResponseEntity<>("Files are not allowed to be saved outside of the base folder.", HttpStatus.FORBIDDEN);
           }

            File transferFile = new File(uploadPath.toString());
            file.transferTo(uploadPath.toString());

        } catch (Exception e) {
            return new ResponseEntity<>("Upload error", HttpStatus.INTERNAL_SERVER_ERROR);
        }

        return new ResponseEntity<>(uploadPath, HttpStatus.CREATED);
    }

    private string GenerateFileName(String fileName) {
        return UUID.randomUUID().toString() + "." + FilenameUtils.getExtension(fileName);
    }

    private boolean IsAllowedExtension(String fileName) {
        String[] allowedExtensions = {"jpg", "png", "gif"};
        String extension = FilenameUtils.getExtension(filename);
        return allowedExtensions.contains(extension);
    }
}

Python - 플라스크 - 안전하지 않음

@app.route('/files/upload', methods=['POST'])
def upload_file():

file = request.files['file']

savedFilePath = os.path.join("./uploads/avatars/", file.filename)
file.save(savedFilePath)

return savedFilePath

Python - 플라스크 - 보안

@app.route('/files/upload', methods=['POST'])
def upload_file():

file = request.files['file']
baseFolder = os.path.normpath("./uploads/avatars/")
savedFilePath = os.path.normpath(os.path.join(baseFolder, generate_file_name(file.filename)))

# 확장자가 허용된 집합에 있는지 확인
is_extension_allowed(file.filename):
return "이 확장자는 허용되지 않습니다."

# 저장하려는 파일이 기본 폴더 외부에 있지 않은지 확인
if not savedFilePath.startsWith(baseFolder):
return "기본 폴더 외부에 파일을 저장하려고 시도했습니다"

file.save(savedFilePath)

return savedFilePath

def generate_file_name(filename):
return str(uuid.uuid4()) + os.path.splitext(filename)[1]

def is_extension_allowed(filename):
return os.path.splitext(filename)[1] in (".png", ".jpg", ".gif")