👶

IERAE CTF 2024 Writeup

2024/09/25に公開

実は最近、Web Security Academyをやってみたり、0dayの記事をとりあえず読んで真似しながらバグバウンティをしたりしています。
まだまだ全然バグを見つけられる気配は感じられませんが、気長にやってみようという気持ちでちょびちょび進めています。

一方でCTFはどういう雰囲気なのか——例えば参加の仕方とか——が実はあまりよく分かっておらず、機を逃し続けていました。
しかし、たまたま見つけたIERAE CTFのページに参加方法などが懇切丁寧に記載されており、せっかくなので参加してみようと思いました。

そしたらめちゃ面白かったです!!!!!参加してよかったです。
すごく悔しい問題もあって、CTFがやめられないと言っている人たちはこういう気持ちなのかなあと思いました👶

ところでCTFには「Writeup」というものを書く文化があります。
定義を探したわけではないのですが、みんな、本番で解いた問題や解けなかった問題を紹介して、自分はこう解いたとか、他にもこういう話が使えたのかもしれないとかを書いているようです👀

ということで自分もWriteupを書いてみます👀

全体について

難易度表示

問題ごとの難易度表示があって嬉しかったです!どのCTFにもあるのでしょうか?

もう何も解けないよ……となってくると、どの問題にフォーカスするかが重要になってくると思います。
自分は慣れていないこともあって、このタイミングで難易度表示が良いヘルプになりました!

ただ、warmup(本CTFにおける最低難易度)でもぜんぜん解けない問題はやっぱりあって、ひえ〜ってなっていました笑
まあここは初めての本格参加ということで、伸びしろですね。なんだこれチャンスか?です。

正解者データのリアルタイム更新

問題の下に何人が解いたという情報がリアルタイムで出ています。また、問題の獲得ポイントがそれによって変動していきます。これはすごく臨場感がありました!

これもCTFあるあるなのでしょうか??

特に、正解者名がリアルタイムで流れてくるdiscordチャンネルは盛り上がりました。みんないま参加しているんだなあと思いを馳せられて、なんだか楽しかったです👶

各問について

"Welcome"

ようこそ問題ということで、CTFのdiscordに入るとフラグがもらえます。嬉しい。

イベントを運営するうえで、みんなにちゃんと同じ経路で連絡できるようにするというのはすごく大事な一方で難しくもあると思います。

こういう工夫は楽しくていいなと勉強になりました。CTFではよくある仕掛けなのでしょうか?

"OMG" (misc)

指定されたページにいくと、33回戻るボタンを押してね!という旨のメッセージが本当にベタでドカンと書かれているので、33回おしたらフラグが出てきました。嬉しい。

他には「とりあえず、Burpを開いてソースコードを読」んだときに怪しいと思った部分をデコードした方もいるようです。

とりあえず、Burpを開いてソースコードを読むと、GET /に以下のような記載がある。
// 引用されているソースコードは省略 //
いかにも怪しいので、選択するとInspectorで自動でbase64デコードされてフラグが出てきた。
https://blog.hamayanhamayan.com/entry/2024/09/22/152725#misc-OMG

Burpをとりあえず立ち上げておくのめちゃいいなと思いました👀

"derangement" (crypto)

ncでアクセスすると、対話型のプログラムが始まります。
そこでは、フラグを並び替えた文字列を何度も教えてもらうことができます。

動いているプログラムのソースコードは特に難読化されていないpythonで提供してもらえまして、フラグを並び替えるときに、完全順列になるようにしていることが分かります(問題の題名である"derangement"という単語も完全順列の英語ですもんね)。

各文字のインデックスが決まるまで何度も教えてもらうプログラムを書いて解きました。

import socket

flag_length = 15
flag = [''] * flag_length

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("XXXX", 55555))

s.send(b'1\n')
data = s.recv(1024)
print(data.decode())

print('-----')

s.send(b'1\n')
data = s.recv(1024)
print(data.decode())
hint = data.decode().splitlines()[0].split('hint: ')[1].strip()

pos_dict = {}
for char in hint:
    pos_dict[char] = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

while True:
    s.send(b'1\n')
    data = s.recv(1024)
    hint = data.decode().splitlines()[0].split('hint: ')[1].strip()
    for (i, char) in enumerate(hint):
        if pos_dict[char][i] == 0:
            pos_dict[char][i] = 1

    completed = True
    for char in hint:
        if sum(pos_dict[char]) != flag_length - 1:
            completed = False
            break

    if completed:
        break

for i in range(flag_length):
    for char in pos_dict:
        if pos_dict[char][i] == 0:
            flag[i] = char
            break

print('magic word is:')
print(''.join(flag))

print('------------------')

s.send(b'2\n')
data = s.recv(1024)
print(data.decode())

answer = (''.join(flag)+'\n').encode()
s.send(answer)

data = s.recv(1024)
print(data.decode())

s.close()

s.send()のあとにdata = s.recv(1024)するのを忘れてちょっとだけハマりました。

"Futari APIs" (web)

これは解けたとき本当に嬉しかったやつです!👶

2台のサーバーがあって、こちらからリクエストを投げられるのは1台のみ。リバースプロキシがあるようなイメージです。
具体的には、ユーザーを検索するリクエストをサーバーAに投げると、サーバーBにリクエストを送ってBで処理が行われてAに返って、Aから結果が返ってきます。

$ curl 'http://XXXX/search?user=peroro'
=> "Peroro は存在します!" (みたいな返事(正確な文言は忘れてしまった))

1台目のサーバーが2台目のサーバーにリクエストを投げるときフラグをクエリパラメータとして付与しています。

// 1台目(一部省略)
async function searchUser(user: string, userSearchAPI: string) {
  const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);
  return await fetch(uri);
}

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);
  switch (url.pathname) {
    case "/search": {
      const user = url.searchParams.get("user") || "";
      return await searchUser(user, USER_SEARCH_API);
    }
    default:
      return new Response("Not found.");
  }
}

Deno.serve({ port: PORT, handler });

// 2台目(一部省略)
const users = new Map<string, User>();
users.set("peroro", { name: "Peroro sama" });
users.set("wavecat", { name: "Wave Cat" });
// 他にもたくさんのデータ

function search(id: string) {
  const user = users.get(id);
  return user;
}

function handler(req: Request): Response {
  // API format is /:id
  const url = new URL(req.url);
  const id = url.pathname.slice(1);
  const apiKey = url.searchParams.get("apiKey") || "";

  if (apiKey !== FLAG) {
    return new Response("Invalid API Key.");
  }

  const user = search(id);
  if (!user) {
    return new Response("User not found.");
  }

  return new Response(`User ${user.name} found.`);
}

Deno.serve({ port: PORT, handler });

まずはprototype汚染を考えました。これは直近で見た0dayがそういうやつだったから。
ただ、リクエストのbodyをパースして実行しているわけでもないのでオブジェクトを送りつける隙がありません。却下です。

しばらく本当に何もわからず、とりあえずソースコードを色々見たり、searchParamsってなんだっけと調べてみたり。

そんな中でsearchParams関連の脆弱性を調べているときだったか、同名のサーチパラメータが2つあるときのハンドリングに関する記述を見つけました!!

ウェブ技術が重複したHTTPパラメータを処理する方法は異なり、HPP攻撃に対する脆弱性に影響を与えます:

  • Flask: クエリ文字列 a=1&a=2 のように、最初に遭遇したパラメータ値を採用し、最初のインスタンスを後続の重複よりも優先します。
  • PHP (Apache HTTPサーバー上): 逆に、最後のパラメータ値を優先し、与えられた例では a=2 を選択します。この動作は、攻撃者の操作されたパラメータを元のものよりも優先することによって、HPPの悪用を無意識に助長する可能性があります。

https://book.hacktricks.xyz/v/jp/pentesting-web/parameter-pollution

ということでサーチパラメータを上書きして、こちらが自由に与えたapiKeyを2台目が判定するようにしようと考えました。
試行錯誤の結果……

$ curl http://XXXX/search?user=peroro?apiKey=IERAE%7B[ここにフラグをいれる]%7D%26
# %7B, %7D : {, }
# %26 : &

このようにすると、うまく2代目のサーバーに自分が指定したフラグを食わせることができました。
というわけで「ブルートフォースするぞ!!」と息巻いたのですが、妙なことに気づくわけです

「フラグの文字数めちゃ多いから、終わらなくないか?」と。

先述の通り、解答者の名前がライブで出るチャンネルがあり、この問題の解答者はまあまあ早い段階で出ていた気がしたのです。
そこで大会のルールを一応確認したところ「ブルートフォースはなしです」とあったので、断念です
(ちゃんとルールを読んで本当に良かったです。危なかったです。気をつけよう)

自分がやろうとしたのはHTTPパラメータ汚染(HPP)という技術にカテゴライズされるようです。
手持ちの"Real-World Bug Hunting"にも面白い事例が載っていました(3.4)。曰く、特定APIで与えられた重複パラメーターについて、処理のある部分では1つめの値を、他の部分では2つめの値を見ていた事例があったようです。
入り組んだパターンもありそうで、興味深いです。

このあとけっきょく手作業で色々と入力していたら、URLコンストラクターの挙動が原因で可能になるexploitを見つけました👶
これは本当に偶然で、さいきん「URLでの//の扱いは本当にやばい」みたいなことを言っていた人がいたので、試したら見つけられたというところです。

https://developer.mozilla.org/en-US/docs/Web/API/URL/URL にもあるこちらです。

new URL("//foo.com", "https://example.com");
// => 'https://foo.com/' (see relative URLs)

他の方だと、下記でやっている方もいました。ベースが無視されます。この例も上のMDNにあります。

new URL("http://www.example.com", B);
// => 'http://www.example.com/'

URLは面白いですね。下記の例も気になりました。

new URL("/a", "https://example.com/?query=1");
// => 'https://example.com/a' (see relative URLs)

"Assignment" (rev)

rev系です。もらったバイナリをさいきん使い始めたghidraに食べさせてみました。

ghidraでデコンパイルされたmain関数を表示した様子

main関数はこんな感じです。main関数だけで閉じていて、いろいろな関数を行き来することがなかったので助かりました(高度なことはまだやれない)。

undefined8 main(int param_1,long param_2)

{
  int iVar1;
  
  flag[0x1c] = 0x33;
  flag[1] = 0x45;
  flag[2] = 0x52;
  flag[0x14] = 0x72;
  flag[0x1a] = 0x61;
  flag[10] = 0x5f;
  flag[0x20] = 0x7d;
  flag[9] = 0x65;
  flag[0x16] = 0x6e;
  flag[0x11] = 0x5f;
  flag[6] = 0x73;
  flag[7] = 0x30;
  flag[0xf] = 0x30;
  flag[0x10] = 0x6d;
  flag[0x15] = 0x31;
  flag[0x18] = 0x5f;
  flag[0xc] = 0x34;
  flag[0x19] = 0x35;
  flag[0x1f] = 99;
  flag[3] = 0x41;
  flag[0] = 0x49;
  flag[0x1d] = 0x35;
  flag[0x12] = 0x73;
  flag[0x13] = 0x74;
  flag[0xb] = 0x72;
  flag[8] = 0x6d;
  flag[5] = 0x7b;
  flag[4] = 0x45;
  flag[0x1b] = 0x39;
  flag[0x1e] = 0x34;
  flag[0x17] = 0x67;
  flag[0xd] = 0x6e;
  flag[0xe] = 100;
  if (1 < param_1) {
    iVar1 = strcmp(flag,*(char **)(param_2 + 8));
    if (iVar1 == 0) {
      puts(flag);
    }
  }
  return 0;
}

不規則に見える順番で1文字ずつ入れているようです。素直に順番と文字を読み解いてフラグを取得できました👶

"Weak PRNG" (crypto)

PRNGとは"pseudorandom number generator"のことのようです👀

pythonの乱数生成器のインスタンスが1つ数字を作るのでそれを当てればよい問題でした。そのインスタンスからはそのあと好きなだけ乱数を取得できて、pythonの乱数はある種の周期性を備えたもの(MT19937)だから過去を決定できてしまうよという話でした。

完全に下記の記事を参考にしてなんとか解きました。ありがとうございます😭
https://zenn.dev/hk_ilohas/articles/mersenne-twister-previous-state

暗号学的に安全な乱数というものもあるようで、こんど掘ってみたいです。

"Luz Da Lua" (rev)

Lua言語のコンパイラが与えられました。ghidraでは読めないので、どうしようかなと思ったらデコンパイラがありました!ありがとう。

https://luadec.metaworm.site/

これを使って、中身を見て、逆の解析をしてクリアです👶(XORをフラグにかけているようなプログラムでした)

"5" (misc)

5種類の文字でjsを書く問題。6の壁があるのでBunであることが制約条件として効くのかと思い色々試したがダメ。

実際にはアップロードしたjsを実行してくれるサーバーが与えられる形式だったので、jsではないファイルをアップロードしておいて、それをBun経由で発火させればよかったようです😭

たしかにこの問題は他と違って、回答時にインスタンスが起動していたんですよねえ。

"Great Management Opener" (web)

今回、一番悔しかった問題です😭

シンプルなユーザー管理システムが与えられ、そこに管理者権限でアクセスできればフラグを見ることができます。
プレイヤーには管理者権限はなく、できることは管理者権限を伴わないユーザーの作成のみです。
また、管理者権限があれば他のユーザーを管理者に昇格させる画面を使うことができます。これがポイントです。

そしてここで、botなるものが与えられます。これはpuppeteerで動くのですが、この管理システムに管理者権限でログインしたあと、指定したURLに飛んで50秒待機してくれます。

要するに、このbotにいい感じのURLを渡すなどして、最終的には自分のユーザーを管理者権限に昇格させてフラグをゲットしようというわけです。

特定ユーザーを昇格させるための画面は下記のようなものでした。

@app.route('/admin', methods=['GET', 'POST'])
@login_required
@admin_required
def admin():
    if request.method == 'POST':
        username = request.form.get('username')
        csrf_token = request.form.get('csrf_token')

        if not username or len(username) < 8 or len(username) > 20:
            return redirect(url_for('admin', message='Username should be between 8 and 20 characters long'))

        if not csrf_token or csrf_token != session.get('csrf_token'):
            return redirect(url_for('admin', message='Invalid csrf_token'))

        user = User.query.filter_by(username=username).first()
        if not user:
            return redirect(url_for('admin', message='Not found username'))

        user.is_admin = True
        db.session.commit()
        return redirect(url_for('admin', message='Success make admin!'))
    return render_template('admin.jinja2', csrf_token=session.get('csrf_token'))
admin.jinja2
{% extends "base.jinja2" %}
{% block content %}
    <h2>Admin</h2>
    <h3>Make a User Admin</h3>
    <form method="POST" action="{{ url_for('admin') }}" class="row g-3">
        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
        <div class="col-auto">
            <input type="text" name="username" id="username" class="form-control" placeholder="Username" required>
        </div>
        <div class="col-auto">
            <button type="submit" class="btn btn-primary">Make</button>
        </div>
    </form>
{% endblock %}

管理者権限をユーザーに与えるためにはPOSTをすればいいのですが、csrf_tokenをつける必要があるようです。

csrf_tokenは管理者が上記の画面にアクセスすると、管理者の持っているcookieを元にバックエンドでFlaskが計算して返ってきて、画面上のinputタグに設置されていますね。

いろいろ試行錯誤したのち、このcsrf_tokenを盗んでCSRFするんだろうなあと思いました。

でもどうしよう??と思い、どこかに妙な実装はないかを探し始めました。

そしたらCSPが!!!!ゆるいです!!!!

@app.after_request
def add_security_headers(response):
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['Content-Security-Policy'] = (
        "script-src 'self'; "
        "style-src * 'unsafe-inline'; "
    )
    return response

おいおい、styleが注入できるじゃないか……前に似たようなPoCを見たことがあるぞ!!!と小躍りしました。

https://masatokinugawa.l0.cm/2021/11/css-exfiltration-svg-font.html

しかし、なんと私は注入口を見つけられなかったのです😭😭

ここにあったんだね……😭

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Great Management Opener</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container">
        {% if request.args.get('message') %}
            <div class="alert alert-secondary mt-3">
                {{ request.args.get('message')|truncate(64, True) }}
            </div>
        {% endif %}
        <h1 class="text-center">Great Management Opener</h1>
        {% block content %}
        {% endblock %}
    </div>
</body>
</html>

悔しいのでとりあえず下記記事を参考に、今度もう一度やってみようと思いました👶

https://blog.hamayanhamayan.com/entry/2024/09/22/152725#Blind-CSS-Injection--XS-Leaks-with-CSS-Injection

思ったこと

grep系ツールにURL()とかsearchParamsをsinkとして登録しておいてみようかな。

とりあえずまた参加してみよう!

GitHubで編集を提案

Discussion