웹
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한 결과가 출력되는것을 볼수있다.