파일 업로드
애플리케이션을 운영하다 보면 사용자가 파일을 업로드할 수 있도록 허용해야 하는 상황이 반드시 발생합니다. 간단해 보일 수 있지만, 파일 업로드 처리 방식에 따라 심각한 보안 위험이 따를 수 있으므로 구현 시 각별한 주의가 필요합니다.
이해를 돕기 위해 간단한 예시를 살펴보겠습니다.
사용자가 프로필 사진을 업로드할 수 있는 애플리케이션이 있다고 가정해 보겠습니다.
public string UploadProfilePicture(FormFile uploadedFile)
{
// 업로드된 파일을 저장할 경로 생성
var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";
// 파일 저장
var localFile = File.OpenWrite(path);
localFile.Write(uploadedFile.ReadToEnd());
localFile.Flush();
localFile.Close();
// 프로필 사진 업데이트
UserProfile.UpdateUserProfilePicture(request.User, path)
return path;
}
이 코드는 매우 기본적인 업로드 함수이지만, 경로 탐색(Path Traversal) 취약점이 존재합니다.
애플리케이션 구현 방식에 따라 공격자가 .asp, .aspx, .php와 같은 스크립트 파일을 업로드하여 임의의 코드를 실행하거나 기존 파일을 덮어쓸 위험이 있습니다.
문제 1 - 외부 데이터 저장소가 아닌 로컬 디스크에 저장
클라우드 서비스가 보편화되고 컨테이너 기반의 고가용성 환경이 표준이 된 오늘날, 애플리케이션 로컬 디스크에 파일을 직접 저장하는 방식은 반드시 피해야 합니다.
가능한 경우 블록 스토리지나 데이터베이스와 같은 중앙 집중식 저장소를 사용해야 합니다. 이를 통해 다양한 보안 취약점을 원천적으로 차단할 수 있습니다.
문제 2 - 확장자 검증 미흡
많은 파일 업로드 취약점은 특정 확장자를 가진 파일을 업로드할 수 있다는 점을 악용합니다. 따라서 허용 가능한 확장자 목록(Allow-list)을 만들어 검증하는 것이 매우 중요합니다.
Null-byte 주입과 같은 문제를 방지하려면 언어나 프레임워크에서 제공하는 표준 메서드를 사용하여 파일 확장자를 추출해야 합니다.
업로드된 파일의 콘텐츠 유형(Content-Type)을 검증하고 싶을 수 있지만, 이는 운영체제마다 다를 수 있어 매우 불안정합니다. 또한 콘텐츠 유형은 단순히 확장자에 기반한 매핑일 뿐이므로 파일 자체의 안전성을 보장하지 않습니다.
문제 3 - 경로 탐색(Path Traversal) 방지 미흡
파일 업로드 시 흔히 발생하는 또 다른 문제는 경로 탐색 취약점입니다. 이는 매우 중요한 주제이므로 여기에서 요약하기보다는 경로 탐색(Path Traversal) 가이드라인 전체를 확인해 보시기 바랍니다.
추가 예시
아래에서 안전한 파일 업로드와 안전하지 않은 파일 업로드의 추가 예시를 확인해 보세요.
C# - 보안 취약
public string UploadProfilePicture(IFormFile uploadedFile)
{
// 업로드된 파일을 저장할 경로 생성
var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";
// 파일 저장
var localFile = File.OpenWrite(path);
localFile.Write(uploadedFile.ReadToEnd());
localFile.Flush();
localFile.Close();
// 프로필 사진 업데이트
UserProfile.UpdateUserProfilePicture(request.User, path)
return path;
}
C# - 보안 안전
public List<string> AllowedExtensions = new() { ".png", ".jpg", ".gif"};
public string UploadProfilePicture(IFormFile uploadedFile)
{
// 참고: 파일을 로컬 디스크에 저장하지 않는 것이 가장 좋습니다.
var basePath = Path.GetFullPath("./uploads/avatars/");
// 제공된 파일 이름을 사용하지 않아 경로 탐색을 방지합니다. 파일 이름 충돌을 피하기 위해서도 필요합니다.
var newFileName = GenerateFileName(uploadedFile.FileName);
// 업로드된 파일을 저장할 경로 생성
var canonicalPath = Path.Combine(basePath, newFileName);
// 기본 폴더 외부로 실수로 저장되지 않았는지 확인
if(!canonicalPath.StartsWith(basePath))
{
return BadRequest("업로드 폴더 외부로 파일을 저장하려고 시도했습니다");
}
// 허용된 확장자만 저장되도록 보장
if(!IsFileAllowedExtension(uploadedAllowedExtensions))
{
return BadRequest("허용되지 않는 확장자입니다");
}
// 파일 저장
var localFile = File.OpenWrite(canonicalPath);
localFile.Write(uploadedFile.ReadToEnd());
localFile.Flush();
localFile.Close();
// 프로필 사진 업데이트
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<>("업로드 오류", 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();
// 확장자가 허용된 유형인지 확인
if(!IsAllowedExtension(file.getOriginalFilename()) {
return new ResponseEntity<>("허용되지 않는 확장자입니다", HttpStatus.FORBIDDEN);
}
// 파일이 업로드 루트 외부로 저장되지 않는지 확인
if(!uploadPath.toString().startsWith(baseFolder.toString())) {
return new ResponseEntity<>("기본 폴더 외부로 파일을 저장할 수 없습니다.", HttpStatus.FORBIDDEN);
}
File transferFile = new File(uploadPath.toString());
file.transferTo(uploadPath.toString());
} catch (Exception e) {
return new ResponseEntity<>("업로드 오류", 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 - Flask - 보안 취약
@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 - Flask - 보안
@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)))
# 확장자가 허용된 집합에 있는지 확인
if not 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")
실무에 바로 적용하는 보안 코딩
OWASP Top 10과 같은 소프트웨어 취약점을 이해하고 완화하기 위한 보안 코딩 가이드라인을 살펴보고, 실제 앱 시뮬레이션을 통해 실습할 수 있는 가이드형 교육 미션을 시작해 보세요.