🍁UofTCTF 2026

My writeups for a majority of the challenges

This is part one of three two of my AI CTF exploration weekend to kick off 2026, will be posting solutions of my full clear for Scarlet CTF (Rutgers University) later today and also New Years CTF (Grodno State University) (they banned all countries other than RU BY KZ VN IN)

crypto

Leaked d

Description

Someone leaked my d, surely generating a new key pair is safe enough.

We're given:

  • n1, e1, d1 - A complete RSA key pair (public and private)

  • e2 - A new public exponent

  • c - Ciphertext encrypted with (n1, e2)

Solution

The challenge implies that after leaking the private key d1, a new key pair was generated with a new exponent e2 but the same modulus n1. This is insecure because knowing d1 allows us to factor n1.

Step 1: Factor n1 using the leaked private key

Since e1 * d1 ≡ 1 (mod φ(n1)), we have e1 * d1 - 1 = k * φ(n1) for some integer k.

Using a Miller-Rabin style factoring algorithm:

  1. Compute kφ = e1 * d1 - 1

  2. Write kφ = 2^t * r where r is odd

  3. For random g, compute x = g^r mod n

  4. Repeatedly square x. If we find x^2 ≡ 1 (mod n) but x ≢ ±1 (mod n), then gcd(x-1, n) gives a factor

Step 2: Calculate d2 and decrypt

Once we have p and q:

  • Compute φ(n1) = (p-1)(q-1)

  • Compute d2 = e2^(-1) mod φ(n1)

  • Decrypt: m = c^d2 mod n1

Solve Script:

Flag: uoftctf{1_5h0u1dv3_ju57_ch4ng3d_th3_wh013_th1ng_1n5734d}

The flag message "I should've just changed the whole thing instead" confirms the vulnerability - reusing the modulus with a new exponent is not safe when the old private key is leaked.

Gambler's Fallacy

Description

A dice gambling game using Python's random module where we need to accumulate $10,000 to buy the flag (starting with $800).

Solution

The challenge implements a dice game that uses Python's Mersenne Twister PRNG to generate server seeds. The key vulnerability is that the server reveals the server_seed after each game, which is a raw 32-bit output from random.getrandbits(32).

Key observations:

  1. Python's random module uses the MT19937 Mersenne Twister PRNG

  2. The MT19937 state can be completely reconstructed from 624 consecutive 32-bit outputs

  3. Once we have the state, we can predict all future outputs

The attack:

  1. Collect 624 server seeds: Play 624 games with minimum wager and maximum greed (98) to maximize win rate and collect the revealed server seeds. The game mechanics guarantee we'll stay above $0 after these games.

  2. Clone the PRNG state: The tempering operation in MT19937 is reversible. We apply the untemper function to each of the 624 outputs to recover the internal state array.

  3. Predict future rolls: With the cloned PRNG state, we can predict exactly what the next roll will be. We then set our "greed" value exactly equal to the predicted roll to guarantee a win with the maximum possible multiplier.

  4. Win big: By always knowing the outcome, we can bet our entire balance and win every time with high multipliers, quickly reaching $10,000.

Untemper function:

The MT19937 tempering applies these operations:

We reverse each operation in reverse order to recover the internal state.

Exploit (exploit.py):

Flag: uoftctf{ez_m3rs3nne_untwisting!!}

MAT247

Description

If V admits a T-cyclic vector, and ST=TS, show that S = p(T) for some polynomial T.

Author: Toadytop

We're given chall.py and output.txt.

Solution

Understanding the Challenge

Looking at the challenge code:

The flag is converted to binary, and for each bit:

  • Bit 0: Output a matrix from gen_commuting_matrix(A) - a matrix that commutes with A

  • Bit 1: Output a random power of A (i.e., A^k for random k)

Our task is to distinguish between these two cases to recover the flag bits.

The Mathematics

The challenge title "MAT247" and description reference a theorem from linear algebra about cyclic vectors. If a matrix T has a cyclic vector, then every matrix S that commutes with T (i.e., ST = TS) can be written as a polynomial in T: S = p(T).

For our 12x12 matrix A over GF(p):

  • The centralizer of A (matrices commuting with A) forms a field isomorphic to GF(p^12)

  • Powers of A form a cyclic subgroup within this field's multiplicative group

  • General commuting matrices (polynomials in A) can be any element of GF(p^12)*

The Determinant Distinguisher

The key insight is that the determinant map acts as the norm from GF(p^12) to GF(p):

For M = A^k:

  • det(M) = det(A)^k

  • So det(M) lies in the cyclic subgroup ⟨det(A)⟩ of GF(p)*

For M = p(A) (general polynomial):

  • det(M) can be any element of GF(p)*

We can test membership in ⟨det(A)⟩ by computing det(M)^((p-1)/ord(det(A))) and checking if it equals 1.

First, we factor p-1:

Computing the order of det(A) in GF(p)*, we find it equals (p-1)/18. This means:

  • det(M)^((p-1)/18) = 1 if and only if det(M) ∈ ⟨det(A)⟩

Implementation

This gives us:

Error Correction

The determinant test has a ~1/18 false positive rate (random commuting matrices can have determinants in ⟨det(A)⟩ by chance). We can see the flag structure is close but has some bit errors.

Looking at the pattern, we can deduce the intended flag and identify the incorrect bits:

  • jus7 is correct (leetspeak for "just")

  • \x7f4 should be _4

  • tr4~\xf3lat should be tr4nslat

  • kon should be ion

  • t2 should be t0

  • 129} should be 12)}

After correcting these bit errors based on the flag format constraints and expected message:

Flag

The flag references the mathematical concept: distinguishing powers of A from general commuting matrices requires understanding the "translation" to the field extension GF(p^12).

Orca

Description

Orcas eat squids :(

We're given a server that encrypts messages using AES-ECB with a twist.

Solution

Looking at the server code (server.py), we see:

Key observations:

  1. AES-ECB mode - identical plaintext blocks produce identical ciphertext blocks

  2. Random prefix - changes each query (0-96 bytes), but self.pl is fixed per session

  3. Block shuffling - blocks are permuted with a fixed shuffle per session (self.q)

  4. Single block output - we can only see one shuffled block at a time by index

This is a classic ECB byte-at-a-time oracle attack with two complications:

  • Random prefix makes most blocks unstable

  • Block shuffling hides which block contains our data

Attack Strategy

Step 1: Find Alignment and Control Block

First, we need to find:

  • pad: number of padding bytes to align the random prefix to a block boundary

  • ctrl_idx: the shuffled block index that contains our controlled data

We iterate through padding values and block indices until we find a stable block (same ciphertext for same input) that responds to our input AND contains the FLAG's first byte ('u').

Step 2: Byte-at-a-Time Recovery (Round 0)

For the first 16 bytes (round 0), we use the classic ECB oracle attack:

Step 3: Multi-Round Recovery

For bytes 16+, the FLAG content shifts to different block positions. We need to:

  1. Add extra_pad: Push the FLAG further into the message

  2. Find the new flag block: Different block index for each round

  3. Add middle padding: Align test blocks with oracle blocks

For round N (bytes N16 to N16+15):

  • extra_pad = known[:N*16]

  • Find which block contains FLAG content using test differentiation

  • Build structured test input with fill + middle + suffix + guess

Step 4: Finding Block Index Per Round

At the start of each round, find the shuffled block index by checking which block changes when we modify the guess byte:

Results

Running the exploit recovers the flag through 6 rounds (84 bytes):

The flag decodes as: "let it be known that the oracle has spoken" followed by a hash suffix.

The challenge name "Orca" was a red herring - the actual message references the "oracle" (ECB oracle attack), not "orca".

Flag

uoftctf{l37_17_b3_kn0wn_th4t_th3_0r4c13_h45_5p0k3N_ac9ae43a889d2461fa7039201b6a1a75}

UofT LFSR Labyrinth

Description

We are given a custom stream cipher based on a 48-bit Linear Feedback Shift Register (LFSR) combined with a WG-style nonlinear filter function.

The cipher produces 80 bits of keystream, generated by:

  • A 48-bit LFSR with known feedback taps

  • A 7-input nonlinear filter defined by an Algebraic Normal Form (ANF)

  • The filter output is computed from selected LFSR taps

  • The resulting keystream is used to derive a ChaCha20-Poly1305 key via HKDF and encrypt the flag

The challenge provides:

  • LFSR size L = 48

  • Feedback tap positions

  • Filter tap positions

  • Filter ANF polynomial

  • 80 bits of keystream

  • Encrypted flag and nonce

Our goal is to recover the hidden 48-bit initial LFSR state and decrypt the flag.


Solution

This is a classic filtered LFSR state recovery problem.

Because the filter is nonlinear, brute-forcing the 48-bit state is infeasible. However, since the full cipher structure is known and we are given 80 consecutive keystream bits, we can model the system as a set of bit-vector constraints and solve it using an SMT solver.

The key optimization is to avoid expanding the ANF into thousands of boolean constraints. Instead, we precompute the 7-input filter function into a 128-bit truth table and use bit extraction to evaluate it efficiently.


Solution Script (solve.py)


Flag

uoftctf{l33ky_lfsr_w17h_n0n_l1n34r_fl4v0rrrr}


forensics

Baby Exfil

Description

Team K&K has identified suspicious network activity on their machine. Fearing that a competing team may be attempting to steal confidential data through underhanded means, they need your help analyzing the network logs to uncover the truth.

Solution

  1. Initial Analysis: Given a PCAP file (final.pcapng) with ~19,000 packets, I analyzed the network traffic using tcpdump and scapy to identify suspicious activity.

  2. Found HTTP Traffic: Among the mostly encrypted HTTPS traffic to legitimate Microsoft/Google services, I found unencrypted HTTP traffic to two suspicious IP addresses:

    • 35.238.80.16:8000 - A SimpleHTTP Python server

    • 34.134.77.90:8080 - A Werkzeug/Flask server

  3. Discovered Malware Download: The victim downloaded a Python script JdRlPr1.py from the first server. The script content:

  1. Identified Exfiltration: The malware:

    • Scans the victim's desktop for images and documents

    • XOR encrypts files with the key G0G0Squ1d3Ncrypt10n

    • Converts to hex encoding

    • Uploads to the attacker's server via POST requests

  2. Extracted Uploaded Files: I found 5 files being exfiltrated:

    • 3G2BHzj.jpeg - Construction scene photo

    • fZQ6WcI.png - Windows 7 desktop screenshot

    • HNderw.png - Contains the flag

    • oMdVph0.jpeg - Dog photo

    • wYTCtRu.jpeg - Cat photo

  3. Decryption: I reassembled the TCP streams, extracted the hex-encoded data from the multipart form uploads, decoded the hex, and XOR-decrypted with the key to recover the original files.

  4. Found Flag: The HNderw.png image contained the flag overlaid on the image.

Flag

uoftctf{b4by_w1r3sh4rk_an4lys1s}


My Pokemon Card is Fake!

Description

Han Shangyan noticed that recently, Tong Nian has been getting into Pokemon cards. So, what could be a better present than a literal prototype for the original Charizard? Not only that, it has been authenticated and graded a PRISTINE GEM MINT 10 by CGC!!!

Han Shangyan was able to talk the seller down to a modest 6-7 figure sum (not kidding btw), but when he got home, he had an uneasy feeling for some reason. Can you help him uncover the secrets that lie behind these cards?

Category: Forensics Points: 77 Solves: 75

Solution

This challenge involves extracting Machine Identification Code (MIC), also known as printer tracking dots or yellow dots, from a scanned image of a printed Pokemon card.

Background

Color laser printers embed nearly invisible yellow dots on every printed page. These dots encode:

  • Printer serial number

  • Date and time of printing

This forensic watermarking system was documented by the EFF (Electronic Frontier Foundation) and is used by manufacturers like Xerox, HP, and others.

Step 1: Extract Yellow Dots

The yellow tracking dots are extremely faint and only visible on white/light areas. To make them visible, we need to isolate the yellow channel and enhance contrast.

Using image editing software (GIMP, Photoshop) or Python with OpenCV:

  1. Convert the image to CMYK color space

  2. Extract and invert the yellow channel (or use blue channel inversion)

  3. Apply contrast enhancement to make dots visible

Alternatively, a color filter that removes yellow and enhances magenta/blue makes the dots appear as visible spots.

Step 2: Identify the Dot Pattern

The dots form a repeating 15x8 grid pattern (Xerox DocuColor format). Each grid encodes:

  • Row 1: Parity bits

  • Columns 2-14: Data (serial number, date, time)

  • Column 15: Column parity

Step 3: Decode Using Online Tool

Using the Yellow Dots Decoder at https://cel-hub.art/yelloow-dots-decoder.html:

  1. Mark the detected dots in the 15x8 grid

  2. The decoder extracts:

    • Time: 21:49

    • Date: 06/08/24 (August 6, 2024)

    • Serial Number: 704641508

Step 4: Format the Flag

Following the flag format uoftctf{YYYY_MM_DD_HH:MM_SERIALNUM}:

Flag

Note: Using the wrong tool gives the wrong serial number even with the right decoding 👍

misc

Encryption Service

Description

We made an encryption service. We forgot to make the decryption though. As compensation we are giving free encrypted flags.

Solution

The challenge provides an encryption service that:

  1. Generates a random 16-byte AES key

  2. Accepts user plaintext input

  3. Appends the flag to the user's input

  4. Encrypts everything using AES-CBC with a random IV

  5. Outputs the IV + ciphertext

The key is stored in a file along with user input and flag, then processed using xargs:

The vulnerability lies in how xargs handles large inputs. When the total size of arguments exceeds the system limit (~131KB), xargs splits the input and invokes the command multiple times with different batches of arguments.

The Exploit:

  1. In the file structure, the random key is the first line, followed by user input, then the flag

  2. When xargs processes this with normal input, it runs enc.py once with the random key as argv[1] (the encryption key)

  3. By sending ~65500 space-separated tokens before our own 32-character hex key, we can force a batch boundary

  4. The first batch uses the random key (unknown to us)

  5. The second batch starts with OUR hex key, which becomes argv[1] for that invocation, and the flag becomes part of the plaintext

Payload Construction:

Decryption:

The server outputs two ciphertexts:

  1. First batch: encrypted with unknown random key (useless)

  2. Second batch: encrypted with OUR key (bbbb...)

We decrypt the second ciphertext:

Flag: uoftctf{x4rgs_d03sn7_run_in_0n3_pr0c3ss}

The flag itself is a hint about the vulnerability: "xargs doesn't run in one process" - when input is too large, xargs splits it across multiple command invocations.

File Upload - Status Report

Challenge Info

  • URL: https://fileupload-d7e5a9bdb2b3ccd3.chals.uoftctf.org/

  • Category: Misc

  • Points: 134

  • Solves: 35

Challenge Description

Flask file upload app with upload/read functionality. Goal is to execute /catflag (SUID root binary) to read /flag.txt.

Source Code Analysis

app.py - Key vulnerabilities:

Dockerfile setup:

  • /catflag - SUID root binary that reads /flag.txt

  • /flag.txt - mode 400, root owned

  • /tmp - tmpfs with exec, wiped on container restart

  • /home/flaskuser/flask_download/ - contains wheel files for pip install

  • On startup, app.sh creates venv in /tmp and runs pip install --no-index --find-links=/home/flaskuser/flask_download flask

Confirmed Capabilities

  1. Arbitrary file write to any path (if filename has no .p or ..)

    • Works: /tmp/*, /home/flaskuser/*, /app/uploads/*

    • Blocked: *.py, *.pyc, *.pth (contain .p)

    • Allowed: *.so, *.whl, *.sh, *.txt

  2. Arbitrary file read (same restrictions)

  3. Persistence behavior (tested):

    • /home/flaskuser/ persists across Python process restarts

    • /tmp/ persists across Python process restarts

    • BUT: CTF infrastructure only restarts Python process, NOT full container

    • Therefore: pip install doesn't re-run on crash, wheels aren't reinstalled

Attack Vectors Attempted

1. Wheel File Injection (Partial Success)

  • Upload malicious blinker-1.9.0-py3-none-any.whl to /home/flaskuser/flask_download/

  • Wheel contains blinker/__init__.py that runs /catflag

  • Problem: pip only runs at container startup, not on Python restart

  • Would work if: Full container restart occurs

2. .so File Replacement (Current Attempt)

  • Replace /tmp/venv_flask/lib/python3.12/site-packages/markupsafe/_speedups.cpython-312-x86_64-linux-gnu.so

  • When Python restarts and imports markupsafe, our .so loads and executes /catflag

  • Problem: Instance crashes (Bad Gateway) when uploading to this specific path

  • Uploads to /tmp/test.so work fine

  • Uploads to the markupsafe path cause immediate crash

.so Payload Created

Compiled with: gcc -shared -fPIC -O2 -o speedups.so evil.c -I/usr/local/include/python3.12

Key Files

  • /tmp/speedups312.so - Malicious .so with system() call

  • /tmp/minimal.so - Minimal .so without system() (for testing)

  • Malicious wheel at /tmp/testwhl/blinker-1.9.0-py3-none-any.whl

Open Questions

  1. Why does uploading to /tmp/venv_flask/lib/python3.12/site-packages/markupsafe/_speedups.cpython-312-x86_64-linux-gnu.so crash the instance?

    • Same .so uploads fine to /tmp/test.so

    • Even minimal .so (no system call) causes crash

  2. Is there a way to trigger full container restart (not just Python restart)?

  3. Is there another attack vector we're missing?

    • The .p filter blocks all Python files

    • .so files are allowed but replacement causes crashes

    • .whl files work but require container restart

Final Solution (Works on Remote)

Use the path traversal to overwrite MarkupSafe's _speedups extension in the venv with a stable-ABI .so that runs /catflag in PyInit__speedups. The upload crashes the Python process (502), which triggers a restart. On restart, MarkupSafe imports _speedups from disk, executing the payload and writing the flag to /tmp/flag.txt, which is then readable via /read.

Payload (C, stable ABI)

Build (local)

Use the limited/stable ABI so the module loads on Python 3.12 even if compiled against 3.11 headers:

Exploit Steps

Flag

Local Verification

The wheel injection works locally:

Instance Behavior Notes

  • Instance is unstable - frequently returns "Bad Gateway"

  • Takes 30-90 seconds to come back up after crash

  • Certain operations cause immediate crash (uploading to markupsafe path)

Guess The Number

Description

Guess my super secret number

nc 35.231.13.90 5000

Solution

This challenge provides a server that generates a random number x in the range [0, 2^100] and gives us 50 queries to ask yes/no questions about it using a custom expression evaluator. After 50 queries, we must guess the exact value of x.

The Problem:

  • We need to determine a 100-bit number (2^100 possible values)

  • We only have 50 yes/no queries, which gives us at most 50 bits of information

  • Standard binary search can only narrow down to 2^50 possibilities

The Solution: Timing Side-Channel Attack

The key insight is that we can extract 2 bits per query by using a timing side-channel attack combined with the yes/no response:

  1. Bit A (from response): The Yes/No answer tells us one bit

  2. Bit B (from timing): Whether the query was fast or slow tells us another bit

How it works:

The expression evaluator supports short-circuit evaluation of and/or operators. We construct an expression that:

  • Always returns the value of bit A (determining Yes/No response)

  • Conditionally computes 3^1000000 (a slow operation) only if bit B is set

Timing Analysis:

  • Fast queries: ~0.08s (bit B = 0)

  • Slow queries: ~0.5-2s (bit B = 1)

  • Threshold: 0.3s reliably distinguishes fast from slow

Query Mapping:

  • Query i extracts bits at positions 2i and 2i+1

  • 50 queries × 2 bits = 100 bits, covering the full range

Final Script:

Flag: uoftctf{h0w_did_y0u_gu3ss_7h3_numb3r}

K&K Training Room

Description

A Discord bot challenge where players must check in to gain access to restricted channels. The bot source code reveals a vulnerability in the admin authentication.

Solution

1. Code Analysis

The bot has two main functions:

  • !webhook command - Creates a webhook and reveals its URL (admin only)

  • Check-in button handler - Grants the "K&K" role when a button with custom_id === 'checkin' is clicked

The vulnerability is in the admin check (line 68):

It checks message.author.username === 'admin' instead of verifying by user ID. However, Discord usernames are globally unique, so we cannot simply change our username to "admin".

2. Webhook Username Spoofing

The key insight is that when sending messages via webhook, you can set a custom username field. For webhook messages, message.author.username returns this custom username!

3. Exploitation Steps

  1. Join the K&K Training Room Discord server

  2. Create your own Discord server and invite the K&K Attendance Bot

  3. Create a webhook in your server (Channel Settings → Integrations → Webhooks)

  4. Send !webhook through your webhook with username "admin":

  1. The K&K bot believes the message is from "admin" and creates a new webhook, revealing its URL

  2. Use the K&K bot's webhook to send a message with a check-in button:

  1. Click the button - the K&K bot receives the interaction and grants you the "K&K" role

  2. Return to K&K Training Room - the #private-archives channel is now accessible, containing the flag

Flag

uoftctf{tr41n_h4rd_w1n_345y_a625e2acd5ed}

Lottery

Description

Han Shangyan quietly gives away all his savings to protect someone he cares about, leaving himself with nothing. Now broke, his only hope is chance itself.

Can you help Han Shangyan win the lottery?

Solution

The challenge provides a bash script lottery.sh that reads user input and compares it against a randomly generated ticket:

Vulnerability Analysis:

  1. Weak regex validation: The regex ^[0-9a-fA-F]+ only checks that the input starts with hex characters. There's no $ anchor, so anything can follow after valid hex.

  2. Bash arithmetic command injection: The let command evaluates arithmetic expressions. In bash arithmetic, array indexing with a[$(cmd)] executes the command inside $().

Exploitation:

By sending a payload like 0+a[$(cmd)]:

  • 0 satisfies the regex (starts with hex)

  • let "g = 0x0+a[$(cmd)]" evaluates the arithmetic expression

  • The $(cmd) inside the array index gets executed

Command Execution Confirmed:

Using a timing-based approach, we confirmed command execution works:

This caused a 3-second delay, proving arbitrary command execution.

Flag Extraction:

Since stdout from $() is captured as the array index and stderr is redirected to /dev/null by the let command, direct output exfiltration wasn't possible.

Instead, we used a timing-based side channel to extract the flag character by character:

If the character matches, the script sleeps for 1 second, allowing us to determine each character based on response time.

Extracted Flag:

After extracting all 49 characters using the timing attack:

The flag references the bash let command used in the vulnerability - "LETtery" is a pun on "lottery" with "LET" capitalized.

Key Takeaways:

  • Always anchor regex patterns with $ when validating input

  • Bash arithmetic evaluation can lead to command injection via array indexing

  • Even when direct output isn't available, timing-based side channels can exfiltrate data

Nothing Ever Changes

Description

While conducting her research on artificial intelligence, Tong Nian claims to have found a way to create adversarial examples without changing anything at all. Her colleagues are skeptical. Can you help her hash out the details of her approach and verify its validity?

Category: Misc Points: 176 Solves: 24

Solution

This challenge requires creating PNG files that have the same MD5 hash but decode to different images - one that classifies as the original digit and one that classifies as a target digit.

Part 1: Understanding the Requirements

From verification.py, for each of 10 pairs:

  1. img1 must be pixel-identical to reference image ref_i.png

  2. img2 can differ by at most budget[i] pixels from reference

  3. md5_hex(img1_bytes) == md5_hex(img2_bytes) - same MD5 hash!

  4. img1 must classify as reference_class_ids[i] (digits 0-9)

  5. img2 must classify as target_class_ids[i]

Part 2: MD5 Collision with Correct CRCs

We use the UniColl technique from corkami/collisionsarrow-up-right. This exploits MD5's vulnerability to create two files with identical hashes but different content.

The collision blocks (png1.bin, png2.bin) differ only in:

  • Byte 0x49: 00 vs 01 (cOLL chunk length: 0x71 vs 0x171)

  • Byte 0x89: f2 vs f1

The structure:

Key insight: PIL requires correct CRCs, so we compute CRCs for both views correctly using the different chunk lengths.

Part 3: Adversarial Image Generation

We use a batched greedy L0 attack that:

  1. Computes gradient once to identify candidate pixels

  2. Uses target digit's reference image as a template

  3. For each step, evaluates multiple pixel value options (template value, 0, 255) in batch

  4. Selects the modification that maximizes margin = logit[target] - max(other_logits)

This is CPU-friendly because it minimizes backward passes while using efficient batched forward passes.

Results

All 10 pairs succeeded:

Local verification passes: verifier.verify_zip(zip_data) == True

Key Techniques

  1. UniColl MD5 Collision: Pre-computed collision blocks with different chunk lengths allow the same bytes to be parsed differently

  2. Correct CRCs: Both PNG views must have valid CRCs - computed by understanding which bytes belong to which chunks in each view

  3. Batched Greedy L0 Attack: Efficient adversarial generation using template guidance and margin-based selection

Part 4: PoW Encoding Bug

The biggest gotcha was the proof-of-work encoding format. The kCTF PoW uses a specific encoding that differs from standard base64 integer encoding:

The difference: kCTF uses (bit_length // 24) * 3 + 3 bytes, not (bit_length + 7) // 8. This produces different byte padding which changes the base64 output entirely.

Files

  • solve_final2.py - Complete solution script

  • pow_solver.py - kCTF proof-of-work solver using Sloth VDF

  • submit.py - Server submission script

  • submission.zip - Generated solution (22KB)

Flag

The flag references the JSMA (Jacobian-based Saliency Map Approach) paper, authored by a UofT professor.

References

Reverse Wordle

Description

My friend said they always use the same starting word, can you help me find out what it is?

Submit the sha256 hash of the ALL CAPS word wrapped in the flag format uoftctf{...}

Solution

We're given a file chall.txt containing three Wordle game results:

The first row of each game is the starting word we need to find. The patterns tell us:

  • 🟩 (green) = correct letter in correct position

  • 🟨 (yellow) = correct letter in wrong position

  • ⬛ (gray) = letter not in the answer

Step 1: Find the Wordle answers

Using a Wordle answer archive, we find:

  • Wordle #1 (June 20, 2021): REBUT

  • Wordle #67 (August 25, 2021): CRASS

  • Wordle #1336 (February 14, 2025): DITTY

Step 2: Derive constraints for the starting word

For the starting word to produce the observed patterns:

Against REBUT (⬛⬛🟨⬛🟨):

  • Position 3 letter must be in REBUT but not at position 3 (not B)

  • Position 5 letter must be in REBUT but not at position 5 (not T)

  • Positions 1,2,4 letters must not be in REBUT

Against CRASS (🟨⬛⬛⬛⬛):

  • Position 1 letter must be in CRASS but not at position 1 (not C)

  • Positions 2,3,4,5 letters must not be in CRASS

Against DITTY (⬛⬛⬛🟨⬛):

  • Position 4 letter must be in DITTY but not at position 4 (not T)

  • Positions 1,2,3,5 letters must not be in DITTY

Step 3: Search for valid words

Using a comprehensive Wordle word list and implementing the Wordle pattern-checking algorithm, we search for 5-letter words that satisfy all constraints.

The only word matching all constraints is: SQUIB

Verification:

  • SQUIB vs REBUT: ⬛⬛🟨⬛🟨 (U is in REBUT, B is in REBUT)

  • SQUIB vs CRASS: 🟨⬛⬛⬛⬛ (S is in CRASS)

  • SQUIB vs DITTY: ⬛⬛⬛🟨⬛ (I is in DITTY)

Step 4: Calculate the flag

Flag: uoftctf{64b28ded00856c89688f8376f58af02dc941535cbb0b94ad758d2a77b2468646}


osint

Go Go Cabinet!

Description

I really like Go Go Squid! In fact, I like it so much that I even bought the same model of cabinet that is in the series!

Can you find:

  1. The first and last name of the designer of this cabinet?

  2. The episode and timestamp that this cabinet first appears at all in the series on YouTube?

Flag format: uoftctf{First_Last_EpisodeNum_MM:SS}

Solution

Step 1: Identify the Cabinet

The challenge image shows a glass display cabinet containing trading cards, figurines, and gaming collectibles. The cabinet has a dark frame with glass panels on multiple sides.

By searching for IKEA glass display cabinets and comparing the design, this was identified as the IKEA FABRIKÖR glass-door cabinet.

Step 2: Find the Designer

Searching for "IKEA FABRIKÖR designer" on the IKEA product page reveals that the cabinet was designed by Nike Karlsson.

From the IKEA product description:

"When I designed FABRIKÖR glass-door cabinet I was inspired by industrial furniture from the early 20th century, especially the so-called medical cabinets, where medicine and medical supplies were kept. It's a piece of furniture that's robust, sturdy and breathes quality – and its soft, rounded corners also make it beautiful."

Step 3: Find Episode and Timestamp on YouTube

"Go Go Squid!" (亲爱的,热爱的) is a 2019 Chinese e-sport romance drama starring Yang Zi and Li Xian. The series is available on YouTube through various Chinese drama channels.

By watching the episodes on YouTube, the FABRIKÖR cabinet first appears in Episode 3 at timestamp 04:02 in a living room scene.

Flag

Tools/Resources Used

  • IKEA product database and designer information

  • YouTube (Go Go Squid episodes)

  • Web searches for cabinet identification

Go Go Coaster!

Description

During an episode of Go Go Squid!, Han Shangyan was too scared to go on a roller coaster. What's the English name of this roller coaster? Also, what's its height in whole feet?

Flag format: uoftctf{Coaster_Name_HEIGHT}

Solution

This OSINT challenge requires identifying a roller coaster from the Chinese TV drama "Go Go Squid!" (亲爱的,热爱的), a 2019 romantic comedy-drama starring Yang Zi and Li Xian.

Step 1: Identify the scene

Searching for information about Han Shangyan and roller coasters reveals that in Episode 12, there's a scene where the characters visit an amusement park. Han Shangyan, despite his tough demeanor as a professional esports player, is terrified of roller coasters. A flashback shows his former teammates Solo and Ou Qiang trying to force him onto the ride during their Solo team days.

Step 2: Identify the filming location

Researching the filming locations for "Go Go Squid!" reveals that the amusement park scenes were filmed at Shanghai Happy Valley (上海欢乐谷), located in Songjiang District, Shanghai, China.

Chinese sources describe the roller coaster as a "恐怖级跌落式过山车" (terrifying drop-style roller coaster) with a "几近90度垂直俯冲" (nearly 90-degree vertical plunge) that "在最高点时还俏皮地向前倾" (tilts forward at the highest point). This is characteristic of a dive coaster.

Step 3: Identify the specific roller coaster

Shanghai Happy Valley has a dive coaster called Diving Coaster (Chinese: 绝顶雄风), manufactured by Bolliger & Mabillard (B&M) and opened on August 16, 2009.

Step 4: Find the height

According to the Roller Coaster Database (RCDB) and Coasterpedia:

  • Height: 64.9 meters = 213 feet

  • The coaster features a 90-degree vertical drop after pausing at the top of the lift hill

Flag: uoftctf{Diving_Coaster_213}

References


pwn

Baby bof

Description

People said gets is not safe, but I think I figured out how to make it safe.

nc 34.48.173.44 5000

Solution

The binary is a simple x86-64 executable with the following protections:

  • No PIE (fixed addresses)

  • No stack canary

  • NX enabled

Analyzing the binary reveals a win function at 0x4011f6 that calls system("/bin/sh"):

The main function reads user input using the vulnerable gets() function into a 16-byte buffer, but attempts to "secure" it by checking if strlen() returns more than 14:

The vulnerability: strlen() stops counting at the first null byte (\x00), while gets() continues reading until a newline character. This allows us to bypass the length check by placing a null byte at the start of our payload.

Stack layout analysis:

  • Buffer at rbp-0x10 (16 bytes)

  • Saved RBP at rbp (8 bytes)

  • Return address at rbp+0x8

  • Total offset to return address: 24 bytes

Exploit strategy:

  1. Start payload with null byte (\x00) to make strlen() return 0

  2. Pad with 23 bytes to reach return address

  3. Add a ret gadget (0x40101a) for stack alignment

  4. Add the address of win function (0x4011f6)

Flag: uoftctf{i7s_n0_surpris3_7h47_s7rl3n_s70ps_47_null}


rev

Baby (Obfuscated) Flag Checker

Description

We are given a heavily obfuscated Python script that checks whether an input string is the correct flag. The logic is hidden inside a large state machine with junk arithmetic and confusing control flow.

The hint suggests that full deobfuscation is unnecessary.

Key Observations

  1. The program immediately exits unless the input length is exactly 74 characters.

  2. After the length check, the script performs many substring comparisons of the form: s[a:b] == "expected_value"

  3. These comparisons are hidden inside the obfuscation, but at runtime they must still compare real strings.

So instead of reversing the state machine, we extract the expected substrings dynamically.


Solution Strategy

We run the program with a partially-correct flag and patch the runtime so that whenever a substring comparison happens, we log:

  • the slice being checked

  • the expected value

We then reconstruct the flag incrementally.


Example Extraction Script

This monkey-patches Python's string equality to log suspicious comparisons:

python dump_slices.py

By running the checker repeatedly and filling in discovered slices, the full flag can be reconstructed.


Final Flag

uoftctf{d1d_y0u_m0nk3Y_p4TcH_d3BuG_r3v_0r_0n3_sh07_th15_w17h_4n_1LM_XD???}

Bring Your Own Program

Description

We are given a mysterious emulator for an unknown architecture. The service accepts a single line of hex-encoded bytecode, validates it, emulates it, and prints the return value. Our goal is to craft a valid program that leaks the flag from the remote system.

The challenge provides a ZIP archive containing chal.js, which implements the emulator and validator logic.

Connection:


Solution

After reversing chal.js, we observe the following:

Program Format

The emulator expects the following structure:

  • Byte 0: Number of registers (nr), must be between 2 and 64.

  • Byte 1: Number of constants.

  • Constants table:

    • 0x01 → float64 (8 bytes)

    • 0x02 → string (u16 length + bytes)

  • Remaining bytes → bytecode instructions.

The emulator exposes a global object called caps, which contains nested maps and functions. One of these functions allows reading arbitrary absolute files from disk (up to 4096 bytes). However, the validator restricts which property keys can be accessed:

The file-read function is stored under numeric key 0, which is normally forbidden.


Vulnerability

The validator is linear and does not follow control flow. This means we can trick it by placing a jump instruction that causes execution to begin in the middle of another instruction. The validator only checks bytes linearly and never verifies the instruction stream after jumps.

This allows us to:

  1. Pass validation using only allowed keys

  2. Jump into the middle of an instruction

  3. Execute a GETPROP with key 0

  4. Retrieve the file-read primitive

  5. Read /flag.txt


Exploit Logic

The crafted program performs:

  1. Load global caps

  2. Access nested object via allowed key

  3. Jump into middle of a fake instruction

  4. Execute forbidden GETPROP with key 0

  5. Load string "/flag.txt"

  6. Call file-read function

  7. Return flag


Final Payload

Send this hex string to the server:

Run:

Paste the payload and receive:


Summary

This challenge demonstrates a classic validation vs execution mismatch. By exploiting the linear validator and abusing instruction alignment, we gain access to forbidden properties and achieve arbitrary file read, leaking the flag.

Symbol of Hope

Description

Like a beacon in the dark, Go Go Squid! stands as a symbol of hope to those who seek to be healed.

Category: Rev Points: 47 Solves: 182

We're given a binary called checker that validates a flag input and prints "Yes" or "No".

Solution

Initial Analysis

The binary is UPX-packed, which we can identify from running strings on it:

Running the binary shows it expects input and responds with "Yes" or "No":

Dumping Unpacked Code from Memory

Since the binary unpacks itself at runtime using UPX's self-extraction, we can dump the unpacked code directly from memory using GDB. The binary creates several memory mappings during startup to unpack the actual code.

Using GDB to catch memory mapping system calls and then dump the unpacked sections:

Reverse Engineering the Transformation

Analyzing the dumped code reveals the flag checking mechanism:

  1. Main function (offset 0x3fe92): Reads exactly 42 bytes of input using fgets, copies to a buffer, then calls a transformation function at offset 0x23b.

  2. Transformation chain (starting at 0x23b): A chain of approximately 200 nested functions. Each function:

    • Modifies one specific byte of the input using various operations (imul, add, sub, xor, not, rol, ror)

    • Calls the next function in the chain

    • The chain terminates at offset 0x3fe40

  3. Comparison function (0x3fe40): Uses memcmp to compare the transformed buffer against expected bytes stored in rodata at offset 0x20.

Key insight: Each byte transforms independently. Changing one input byte only affects that same position in the output, not other positions. This means we can brute-force each position separately.

Emulation with Unicorn Engine

We use Python with Unicorn Engine to emulate the transformation function and brute-force each character position:

Critical Detail: The deep function call chain (approximately 200 nested calls) requires substantial stack space. Initially using the default 64KB stack caused memory access errors during emulation. Increasing the stack to 1MB fixed the issue.

Alternative: Symbolic Execution with angr

The challenge name "Symbol of Hope" and the flag message itself hint that symbolic execution is the intended solution approach. Tools like angr can automatically solve this:

Flag

The flag is a leetspeak message: "symbolic execution is very useful" - confirming that symbolic execution tools (or manual position-by-position solving as we did) are the intended approach.

The challenge references:

  • "Symbol of Hope" = symbolic execution

  • "Go Go Squid!" = A Chinese TV show about CTF competitions, hinting at the challenge context

Will u Accept Some Magic?

Description

A 500-point reverse engineering challenge featuring a WebAssembly binary compiled from Kotlin using WASM GC (Garbage Collection). The challenge hints at "Where did my heap go?" referring to WASM GC's managed memory model.

Solution

1. Initial Analysis

The challenge provides:

  • program.wasm - A WebAssembly binary with GC features

  • runner.mjs - A Node.js script to run the WASM module

The program prompts for a 30-character password and validates it character by character using 30 "Processor" objects.

2. Environment Setup

WASM GC features require Node.js v22+. Standard tools like wabt couldn't parse the GC types, so I used binaryen's wasm-dis for decompilation:

3. Understanding the Validation Structure

Analyzing the decompiled WAT file revealed:

  • 30 Processor globals (struct $27) at global$134 and global$184-212

  • Each Processor contains function references for:

    • Expected character getter (type $9)

    • XOR transformation function

    • Position check function

    • Validation function

4. Extracting Expected Characters

The key insight was that each Processor's expected character comes from a function reference stored in field 2 of the struct. Some functions are reused across multiple positions:

Function
Returns
ASCII
Used by positions

$135

48

'0'

0

$139

81

'Q'

1

$143

71

'G'

2

$147

70

'F'

3, 11

$151

67

'C'

4, 17

$155

66

'B'

5, 20

$159

82

'R'

6, 16

$163

69

'E'

7, 8, 26, 29

$170

78

'N'

9, 14

$174

68

'D'

10, 12, 21, 24

$184

79

'O'

13

$191

90

'Z'

15

$201

51

'3'

18, 23, 28

$205

57

'9'

19

$215

83

'S'

22

$225

77

'M'

25

$232

72

'H'

27

5. Reconstructing the Password

Mapping Processor globals to their expected character functions:

Password: 0QGFCBREENDFDONZRC39BDS3DMEH3E

6. Verification

Flag

uoftctf{0QGFCBREENDFDONZRC39BDS3DMEH3E}


web

Firewall

Description

A web server running on port 5000 with the flag at /flag.html. The server is protected by an eBPF-based firewall that filters both ingress and egress traffic, blocking any packets containing the string "flag" or the '%' character.

Solution

Analyzing the Firewall

The challenge provides a BPF firewall (firewall.c) attached to both ingress and egress network traffic. Key observations:

  1. Blocked content: The string "flag" (4 chars) and the '%' character are blocked

  2. Per-packet inspection: The firewall scans each TCP packet independently for blocked content

  3. No reassembly: The firewall doesn't reassemble TCP streams before inspection

Bypass Strategy

The vulnerability lies in the per-packet inspection model. Since the firewall inspects each TCP segment independently without stream reassembly:

  1. Ingress bypass (request): Split the HTTP request "GET /flag.html" across multiple TCP segments so no single segment contains "flag"

  2. Egress bypass (response): Use HTTP Range requests to fetch small portions of the file (3 bytes at a time), ensuring no response packet contains the complete "flag" string

Exploit

Key Techniques

  1. TCP segmentation: Using TCP_NODELAY and small delays between send() calls forces the kernel to send data in separate TCP segments

  2. HTTP Range requests: Request small byte ranges (3 bytes, less than "flag" length) so no response contains the complete blocked keyword

Flag: uoftctf{f1rew4l1_Is_nOT_par7icu11rLy_R0bust_I_bl4m3_3bpf}

No Quotes

Description

Unless it's from "Go Go Squid!", no quotes are allowed here! Let this wholesome quote heal your soul:

Ai Qing: "If you didn't know about robot combat back then, what would you be doing?"

Wu Bai: "There's no if. As long as you're here, I'll be here."

Author: SteakEnthusiast

Solution

This challenge combines SQL injection with Server-Side Template Injection (SSTI) to achieve Remote Code Execution.

Vulnerability Analysis:

  1. SQL Injection (app.py:76-79): The login query uses f-string interpolation with user input:

  2. WAF Bypass (app.py:54-56): A Web Application Firewall blocks single and double quotes:

  3. SSTI (app.py:112): The home page uses render_template_string with user-controlled data:

    The username from the database is inserted via %s format string and rendered as a Jinja2 template.

Exploitation Steps:

  1. Bypass WAF with Backslash Escape: Use \ as the username to escape the closing quote in SQL:

    • Username: \

    • This transforms: WHERE username = ('\') making ') AND password = ( part of the string literal

  2. UNION Injection with Hex-Encoded SSTI Payload: Since we can't use quotes in the HTTP request, we encode the SSTI payload in MySQL hex format:

    • Payload: {{request.application.__globals__.__builtins__.__import__('os').popen('/readflag').read()}}

    • Hex-encoded: 0x7b7b726571756573742e...7265616428297d7d

    • Password field: ) UNION SELECT 1,0x7b7b...7d7d -- -

  3. RCE via SSTI: When logging in, the UNION injects our Jinja2 payload as the username. On redirect to /home, render_template_string evaluates {{...}} and executes /readflag.

Exploit Script:

Flag: uoftctf{w0w_y0u_5UcC355FU1Ly_Esc4p3d_7h3_57R1nG!}

No Quotes 2

Description

Unless it's from "Go Go Squid!", no quotes are allowed here! Let this wholesome quote heal your soul:

Ai Qing: "If you didn't know about robot combat back then, what would you be doing?"

Wu Bai: "There's no if. As long as you're here, I'll be here."

Now complete with a double check for extra security!

Author: SteakEnthusiast

Solution

This challenge builds on "No Quotes" by adding a "double check" that verifies both username and password match the database results. This requires a SQL quine technique to satisfy the check while still exploiting SSTI.

Key Vulnerabilities:

  1. SQL Injection (app.py:74-77): f-string interpolation allows injection:

  2. WAF Bypass Required (app.py:52-54): Only single/double quotes are blocked:

  3. Double Check (app.py:101-106): Both values must match:

  4. SSTI (app.py:115): Username used in template string:

The Challenge:

  • row[0] must equal our username input (for the check to pass)

  • row[1] must equal our password input (quine requirement!)

  • session["user"] = row[0] is used in SSTI, so row[0] must be our payload

  • We can't use quotes in our input

Solution Approach:

  1. Backslash Escape: Use \ at the end of username to escape the closing quote and turn the rest into SQL injection.

  2. SQL Quine with HEX: Use MySQL's REPLACE() and HEX() functions to create a self-referential payload that outputs itself.

  3. SSTI Payload: Use {{lipsum.__globals__.os.popen(request.args.c).read()}} which has no quotes and reads command from URL parameter.

Payload Construction:

How the Quine Works:

The SQL executes: REPLACE(0xT_HEX, CHAR(36), HEX(0xT_HEX))

  1. 0xT_HEX decodes to template T (contains $)

  2. HEX(0xT_HEX) returns T_HEX as a string

  3. REPLACE(T, '$', T_HEX) substitutes $ with T_HEX

  4. Result equals our password input (since we did the same substitution in Python)

SQL Query After Injection:

The backslash escapes the quote, making ') AND password = ( part of the string literal. The # comments out the trailing ').

Exploit Script (exploit.py):

Execution:

  1. POST to /login with the crafted username/password

  2. SQL injection returns (ssti_payload+\, password)

  3. Double check passes: username == row[0] and password == row[1]

  4. Redirect to /home where SSTI executes

  5. Visit /home?c=/readflag to execute the command

Flag: uoftctf{d1d_y0u_wR173_4_pr0P3r_qU1n3_0r_u53_INFORMATION_SCHEMA???}

No Quotes 3

Description

A Flask web application with SQL injection vulnerability, but with a WAF that blocks single quotes ('), double quotes ("), and periods (.). Unlike "No Quotes 2", this version adds SHA256 hashing to the password verification, making the classic SQL quine approach more complex.

Solution

Key Vulnerabilities:

  1. SQL Injection (app.py:74-77): f-string interpolation allows injection:

  2. WAF Bypass Required (app.py:52-54): Blocks quotes AND periods:

  3. SHA256 Hash Check (app.py:101): Password is hashed before comparison:

  4. SSTI (app.py:115): Username from session used in template string:

The Challenges:

  1. No periods for SSTI: Can't use lipsum.__globals__.os.popen() syntax

  2. SHA256 verification: Simple quine approach where password == row[1] won't work

Solution Approach:

  1. Backslash Escape: Use \ at the end of username to escape the closing quote, turning the password field into SQL injection.

  2. SQL Quine with SHA2 Wrapper: Adapt the quine to compute SHA2 of its own result:

    • REPLACE(0x$, CHAR(36), HEX(0x$)) produces the password string (quine)

    • SHA2(..., 256) computes the hash, matching Python's sha256(password).hexdigest()

  3. SSTI Without Periods or Quotes: Use Jinja2's |attr() filter with dict() trick:

    • dict(__globals__=1)|first creates the string "__globals__" without quotes

    • |attr((dict(__getitem__=1)|first)) replaces [] bracket notation

    • Build the entire RCE chain using these techniques

SSTI Payload (quote-free, period-free):

Exploit Script:

How It Works:

  1. POST to /login with crafted username/password

  2. SQL injection returns (ssti_payload+\, sha256_hash)

  3. SHA256 check passes: sha256(password) == row[1] (quine computes its own hash)

  4. Username check passes: username == row[0]

  5. session["user"] is set to SSTI payload

  6. Redirect to /home where SSTI executes

  7. Visit /home?c=/readflag to get the flag

Key Insights:

  • The SQL quine from "No Quotes 2" can be adapted by wrapping in SHA2() to satisfy the hash check

  • Jinja2's dict() function creates real dict objects with string keys from Python identifiers

  • dict(key=1)|first returns the string "key" without using quotes

  • |attr() filter provides period-free attribute access

  • |attr((dict(__getitem__=1)|first)) replaces bracket [] notation

Flag: uoftctf{r3cuR510n_7h30R3M_m0M3n7}

Personal Blog

Description

For your eyes only?

A web challenge involving a personal blog application where users can create private posts.

Solution

This challenge involves a chain of vulnerabilities: Stored XSS via unsanitized draftContent and magic link session hijacking via sid_prev cookie.

Vulnerability Analysis:

  1. Stored XSS in Editor: The editor page (/edit/:id) renders draftContent without sanitization using <%- draftContent %> in EJS. While the /api/save endpoint sanitizes content via DOMPurify, the /api/autosave endpoint stores raw content directly:

  2. Magic Link Session Swap: The magic link feature logs users into a different account while preserving the previous session in sid_prev:

  3. Non-HttpOnly Cookies: Cookies are set with httpOnly: false, making them accessible via JavaScript.

Exploit Chain:

  1. Register a user and create a post

  2. Use /api/autosave to inject XSS payload that steals cookies:

  3. Generate a magic link for your account

  4. Report the URL: http://localhost:3000/magic/TOKEN?redirect=/edit/POST_ID

  5. When admin bot visits:

    • Bot logs in as admin, gets admin's sid cookie

    • Bot visits magic link URL

    • Magic link saves admin's sid to sid_prev, creates new session for attacker's user

    • Bot is redirected to attacker's XSS page

    • XSS executes and exfiltrates cookies including sid_prev (admin's session)

  6. Read the stolen sid_prev from your post and use it to access /flag

Commands:

Flag: uoftctf{533M5_l1k3_17_W4snt_50_p3r50n41...}

Last updated