SECCON Beginners CTF 2023 でコードを隅々まで読んで解いた話
2023-06-05
azblob://2023/06/05/eyecatch/2023-06-05-seccon-beginners-2023-writeup-000.png

お疲れ様です。

最近キャラメルマキアートをよく飲んでる後藤です。

今回はSECCON Beginners CTF 2023に参加した話を、全体的な所感と、自分が解いた問題の考え方に分けて書いていきたいと思います。

SECCON2023

全体的な所感

結果は、5問解いて276位でした。

個人的にはもう少し頑張れたかも、といった感じです。

知識方面の自分の立ち位置が明確になったのでよかったと思います。自分が知らなかった部分は、他の方のWrite Upを見て勉強しようと思います。

1つ後悔したのは、環境構築をちゃんと行わなかったことです。m1macでやっていたのですが、実行ファイルが実行できない、gdbコマンドが使えないなど、ちょっと不便だった部分が多かったです。次回参加する際はそのあたりちゃんと整えてからやりたいと思います(戒め)。
 

解いた問題の考え方

crypto CoughingFox2(beginner)

Python# coding: utf-8
import random
import os

flag = b"ctf4b{xxx___censored___xxx}"

# Please remove here if you wanna test this code in your environment :)
flag = os.getenv("FLAG").encode()

cipher = []

for i in range(len(flag)-1):
    c = ((flag[i] + flag[i+1]) ** 2 + i)
    cipher.append(c)

random.shuffle(cipher)

print(f"cipher = {cipher}")

上記コードで生成された暗号文を復号する問題でした。

最後に配列をランダムにしているからやりにくそう…。しかし、for文の中で2乗したものに配列の添字を足しているので、適当な数を引いて0.5乗した結果が整数であれば、その時引いた数が配列の添字になるはずです。また、2乗されている元の値は、i番目とi+1番目の値が足されたものとなっているので、どこか1文字が分かればほかの値も簡単に決まります。ということで、以下のようにして解きました(自分が考えやすい方法でやっているので、とても汚いですが)。

Python cipher = [暗号文]
 deco = []
 arr = []
 correct = []
 
 for i in range(len(cipher)):
 	for j in range(len(cipher)):
 	if(i == j):
 		continue
 	if(((cipher[i] - j) ** 0.5).is_integer()):
 		deco.append((cipher[i] - j) ** 0.5)
 		arr.append(j)
 		
 for i in range(len(deco)):
 	for j in range(len(deco)):
 		if(arr[i] > arr[j]):
 			tmp = arr[i]
 			arr[i] = arr[j]
 			arr[j] = tmp
 			tmp = deco[i]
 			deco[i] = deco[j]
 			deco[j] = tmp
 			
 correct.append(99)
  for i in range(len(deco)):
  	correct.append(deco[i] - correct[i])
 		

(思いだしながら書いているので、少し違うかもしれません) 
最初のfor文で順番の特定をしています。decoとarrの順番が対応している(という体)です。そのあとのfor文でarrのとおりの並び順にしてます。最後のfor文で文字を特定します。flagの最初の文字は「c」であることが確定している(SECCON Beginners のflagフォーマットはctf4bで始まる)ので、cの文字コードをあらかじめ入れておき、そこから減算で導き出しています。これで文字コードの配列ができるので、あとは文字に直せばflagを入手できます。

pwnable poem(beginner)

バッファオーバーフローの問題です。以下のような実装しているそうです。

C#include <stdio.h>
#include <unistd.h>

char *flag = "ctf4b{***CENSORED***}";
char *poem[] = {
    "In the depths of silence, the universe speaks.",
    "Raindrops dance on windows, nature's lullaby.",
    "Time weaves stories with the threads of existence.",
    "Hearts entwined, two souls become one symphony.",
    "A single candle's glow can conquer the darkest room.",
};

int main() {
  int n;
  printf("Number[0-4]: ");
  scanf("%d", &n);
  if (n < 5) {
    printf("%s\n", poem[n]);
  }
  return 0;
}

__attribute__((constructor)) void init() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  alarm(60);
}

flagをどうにかして出力したいです。main()内でpoem[]の出力を行っているようですが、配列の添字チェックで負数を排除できていなさそうです。*poem[]の前に*flagがあるので、-1から順に負数を適当に入力してみました。結果、-4でflagを入手できました。

web Forbidden (beginner)

以下のようなページにアクセスしてflagを入手する問題です。

Javascriptvar express = require("express");
var app = express();

const HOST = process.env.CTF4B_HOST;
const PORT = process.env.CTF4B_PORT;
const FLAG = process.env.CTF4B_FLAG;

app.get("/", (req, res, next) => {
    return res.send('FLAG はこちら: <a href="/flag">/flag</a>');
});

const block = (req, res, next) => {
    if (req.path.includes('/flag')) {
        return res.send(403, 'Forbidden :(');
    }

    next();
}

app.get("/flag", block, (req, res, next) => {
    return res.send(FLAG);
})

var server = app.listen(PORT, HOST, () => {
    console.log("Listening:" + server.address().port);
});

/flagにアクセスすることでflagを入手できるそうですが、/flagがpathに含まれているとForbiddenを返すようです。なにかごまかせそうなものはあるかと、「<」とか「?」とか「#」とかいろいろ試しましたがうまくいきませんでした。わからないと思いながら「/FLAG」と打ったらflag入手できました。理由を後で調べたところ、expressはデフォルトでcase insensitiveらしいです。

え、マジで? 
エクスプレス 4.x - API リファレンス (expressjs.com)

web aiwaf(easy)

今話題のGPTを使った問題でした。全体のコードが少し長いので、関係があるところのみ抽出します。

Python
@app.route("/")
def top():
    file = request.args.get("file")
    if not file:
        return top_page
    if file in ["book0.txt", "book1.txt", "book2.txt"]:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read()
    # AI-WAF
    puuid = uuid.uuid4()
    prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。

{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
    try:
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                },
            ],
        )
        result = response.choices[0]["message"]["content"].strip()
    except:
        return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。")
    if "No" in result:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read().replace(KEY, "")
    return abort(403, "AI-WAFに検知されました👻")

パストラバーサル攻撃を成功させれば、flagを入手できるようです。しかし、クエリ文字列を見て、パストラバーサル攻撃かをGPTが判断し、そうであれば入力を受け付けない内容となっていました。最初はプロンプトインジェクションをさせる問題なのかと思い、いろいろと試行錯誤したのですが、うまくいきませんでした。そこでコードをよく見ると、

{urllib.parse.unquote(request.query_string)[:50]}

とあり、どうやら先頭50文字までしかクエリ文字列を見ないようです。ということで、

https://example.com/?aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&file=../flag

といった感じで入力してflagを入手しました。

ちなみに、プロンプトインジェクションで突破する方法もきちんとあったようです。

reversing Half(beginner)

バイナリファイルを見る問題でした。適当なバイナリエディタ(私はHex Friendを使用)で中身を見ると、以下のように普通にflagが書いてありました。

おわりに

以上が自分が解けた問題となります。あたりまえですが、ここで上げた解法は一部なので、他の解法も様々あるかと思います。ぜひ、他の方のwriteupを見てみてください。