
요약
1. NestJS의 내장 FileValidator은 파일 내용을 확인하지 않고 MIME Type만 정규 표현식으로 확인한다. (주석에도 언급되어 있다.)
2. 프로젝트를 구성하는 많은 파이프라인들 중 일부 요소들에서 (Snyk, GitHub Dependabot 등) 보안 취약점이라고 알림이 발생한다. 심할 경우 파이프라인이 제대로 동작하지 않는다.
3. 만약 NestJS에서 수정해야할 경우를 가정하고 작성한 포스팅이다. (파이프라인의 보안 취약점 수정 요청 등을 가정하지 않는다.)
서론
Affected versions of this package are vulnerable to Arbitrary Code Injection via the FileTypeValidator function due to improper MIME Type Validation. An attacker can execute arbitrary code by sending a crafted payload in the Content-Type header of a request.
최근에, NestJS에 제기됐던 FileTypeValidation의 보안 취약점 이슈가 대두되었다. MIME Type을 임의로 주입했을 때 검증할 수 없다는 내용이다.
/**
* Defines the built-in FileType File Validator. It validates incoming files mime-type
* matching a string or a regular expression. Note that this validator uses a naive strategy
* to check the mime-type and could be fooled if the client provided a file with renamed extension.
* (for instance, renaming a 'malicious.bat' to 'malicious.jpeg'). To handle such security issues
* with more reliability, consider checking against the file's [magic-numbers](https://en.wikipedia.org/wiki/Magic_number_%28programming%29)
*
* @see [File Validators](https://docs.nestjs.com/techniques/file-upload#validators)
*/
export class FileTypeValidator extends FileValidator<
FileTypeValidatorOptions,
IFile
> {
buildErrorMessage(file?: IFile): string {
if (file?.mimetype) {
return `Validation failed (current file type is ${file.mimetype}, expected type is ${this.validationOptions.fileType})`;
}
return `Validation failed (expected type is ${this.validationOptions.fileType})`;
}
isValid(file?: IFile): boolean {
if (!this.validationOptions) {
return true;
}
return (
!!file &&
'mimetype' in file &&
!!file.mimetype.match(this.validationOptions.fileType)
);
}
(예를 들어, 'malicious.bat'을 'malicious.jpeg'로 이름을 바꾸는 것입니다). 이러한 보안 문제를 해결하기 위해
더 신뢰성 있게 파일의 [magic-numbers] 을 확인해 보세요
MIME Type
MIME Type(Multipurpose Internet Mail Extensions)은 파일의 형식을 설명하는 문자열로 흔히 Content-Type이나 file.mimetype속성에서 볼 수 있다. 이 MIME Type은 브라우저나 서버가 파일을 어떻게 처리하는지 결정하는 데 사용된다.
하지만 이 MIME Type은 클라이언트에서 직접 조작하여 보낼 수 있기 때문에 MIME Type만으로 파일을 검증하는 것은 보안 취약점으로 드러날 수 있다. 서론의 Git Dependencies Bot이나 Snyx등에서 알림이 발생하는 것처럼 말이다.
@ApiBody({ type: UploadFileRequest })
@ApiConsumes('multipart/form-data')
@Put('signup/request/profile')
@UseInterceptors(FileInterceptor('file'))
async uploadTempProfileImage(@ImageValidation() file: Express.Multer.File)
: Promise<UploadFileResponse> {
console.log('TEST', file);
return await this.authService.uploadTempProfileImage(file);
}
import { FileTypeValidator, MaxFileSizeValidator, ParseFilePipe, UploadedFile } from '@nestjs/common';
export function ImageValidation(
maxSize: number = 1024 * 1024 * 15 + 1,
// application\/x-msdownload 추가
fileType: RegExp = /^(image\/jpg|image\/jpeg|image\/png|image\/gif|image\/bmp
|image\/svg\+xml|application\/x-msdownload)$/i,
) {
return UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize }),
new FileTypeValidator({ fileType }),
],
}),
);
}
// 파일 업로드 시 Content-Type 조작
const fakeFile = new File([file], file.name, {
type: "application/x-msdownload", // .exe MIME
});
테스트를 위해 간단하게 프로필 이미지 업로드 코드를 만들고, 실제로 클라이언트에서 Content-Type을 조작한 뒤 파일을 업로드하게되면, 서버에 파일이 올바르게(?) 전달되게 되고 뒤 프로세스들이 그대로 실행되는 모습을 볼 수 있었다.
그래서 뭐가 문제임?
그렇다면 이게 왜, 어떤 문제가 되어 보안 취약점이라고 계속해서 말하는걸까?
실제로 업로드된 파일의 내용을 보면, 파일은 PNG 이미지이지만, MIME Type은 application/x-msdownload로 설정되어 있었다.
서버는 이 MIME Type을 믿고 x-msdownload 확장자로 저장했고, 앞의 벨리데이션을 통과했기 때문에 이후 로직에서도 별다른 제약 없이 이 파일을 처리하게 된다.
서버는 신뢰할 수 없는 Content-Type값을 기준으로 벨리데이션을 처리했다. NestJS의 FileTypeValidator은 정의한 정규표현식을 통해서 파일의 타입을 단순 문자열 비교만 수행한다. 이 문자열 타입은 클라이언트에서 조작이 가능하므로 기본적으로 취약한 구조가 된다.
가상의 시나리오를 하나 구성해보았다. 공격자가 악성 실행 파일을 png인 것으로 인식하게끔 Content-Type만 위변조하여 업로드하였다. 나는 올바르게 ImageValidation Type을 설정했지만 서버 내부의 FileTypeValidator은 PNG로 인식하기 때문에 올바르게 벨리데이션을 통과하게 되며 이는 곧 서버와의 상호작용을 통해 어딘가 저장됨을 의미한다.
저장된 이 파일이 사용자에 의해 다시 실행되게 되면?? XSS, RCE(Remote Code Execution)등의 취약점이 발생하게 된다.
이 문제의 본질은, 계속 강조했던 것 처럼 서버가 신뢰할 수 없는 Content-Type값을 기준으로 벨리데이션을 수행하기 때문에 발생하는 문제이다. 이 MIME Type은 클라이언트가 조작이 가능하기 때문에 신뢰할 수 없다. NestJS에서는 이러한 MIME Type에 대한 정규표현식 검증만 수행하기 때문에 취약한 구조일 수 밖에 없다.
어떻게 개선할까?
이 문제를 해결하기 위해서는, Content-Type에 의존하는 것이 아닌, 파일의 실제 내용을 기반으로 판단해야한다.
파일 바이너리의 시작 부분에는 파일 형식을 식별하는 시그니처(Magic Numbers)가 들어있다. 예를들어 JPEG 이미지는 0xFFD8로 시작하고, PNG는 항상 0x89504E47로 시작한다. 매직 넘버는 파일의 형식을 정확하게 식별하는데 이미 널리 사용되고 있다.
이 문제를 처음 접했을 때 NestJS에서 작성 해 놓은 주석을 기반으로 나도 Magic Number을 사용할 수 있도록 개선하고자 했다. FileValidator라는 공통 인터페이스가 있으니, FileMagicTypeValidator같은 것을 확장해서 구현하려고 방향성을 정한 뒤 PR을 작성하기 전 NestJS의 개발 방향과 일치하는지 이슈 코멘트에 방향성을 재확인받고자 코멘트를 작성했다.
하지만 다른 누군가가 바로 PR을 올려버렸기 때문에 아쉽지만 기여에는 실패한 것 같다.
NestJS의 기존 의존성들에는, 이러한 파일 벨리데이션을 해결해줄 수 있는 라이브러리가 존재하지 않기 때문에, Node 진영에서 가장 많이 쓰이는 라이브러리 중 하나인 file-type을 사용하여 해결하고자 했다.
기존의 Validator을 확장하여 regExp에서 아래 코드로 변환해주면 되니 말이다.
const fileType = await fileTypeFromBuffer(file?.buffer);
if (!fileType) {
return false;
}
여기에 그치지 않고, 추가로 보안 취약점을 개선하기 위해
- 업로드 된 파일은 확장자를 클라이언트의 입력(MIME Type)에 의존하지 않고 재정의
- 서버의 File Validation에 WhiteList를 재정의하고 깐깐하게(?) 관리하기
- 이미지 파일의 경우 - 이미지 변환, 리사이징 등을 통해 정말 이미지 파일이 맞는지 검증해보기
등의 추가 개선을 자체 서버에서 구현할 수도 있지 않을까? 라는 생각을 해본다.
마무리하며
우선, 오픈소스 PR은 올린 사람이 임자(?) 라는 것을 다시금 깨닫는다. 간만에 기여할 거리가 생겼는데 엄청 아쉽다.
이번 이슈는 단순히 NestJS에 보안 취약점이 있다. 라는 수준을 넘어서, 서버 사이드에서 클라이언트 입력값을 얼마나 신중하게 다뤄야하는지를 조금이나마 일깨워줬다. MIME Type은 단순히 HTTP 요청에 포함된 단순한 문자열일 뿐이고, 이를 신뢰할 경우 우리는 의도치 않게 악성 파일을 통과시킬 수도 있다.
NestJS에서도 이를 사전에 인지했기 때문에 주석을 통해 명시적으로 사용자들에게 알렸다. 결국 중요한 건 사용자 개개인이 어느 수준까지 보안을 신경쓰고 코드를 작성할까? 라는 인지를 하고 코드를 써내려가는 것 아닐까?
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!