🍁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 exponentc- 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:
Compute
kφ = e1 * d1 - 1Write
kφ = 2^t * rwhere r is oddFor random g, compute
x = g^r mod nRepeatedly square x. If we find
x^2 ≡ 1 (mod n)butx ≢ ±1 (mod n), thengcd(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:
Python's
randommodule uses the MT19937 Mersenne Twister PRNGThe MT19937 state can be completely reconstructed from 624 consecutive 32-bit outputs
Once we have the state, we can predict all future outputs
The attack:
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.
Clone the PRNG state: The tempering operation in MT19937 is reversible. We apply the
untemperfunction to each of the 624 outputs to recover the internal state array.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.
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 ABit 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:
jus7is correct (leetspeak for "just")\x7f4should be_4tr4~\xf3latshould betr4nslatkonshould beiont2should bet0129}should be12)}
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:
AES-ECB mode - identical plaintext blocks produce identical ciphertext blocks
Random prefix - changes each query (0-96 bytes), but
self.plis fixed per sessionBlock shuffling - blocks are permuted with a fixed shuffle per session (
self.q)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 boundaryctrl_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:
Add
extra_pad: Push the FLAG further into the messageFind the new flag block: Different block index for each round
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
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.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 server34.134.77.90:8080- A Werkzeug/Flask server
Discovered Malware Download: The victim downloaded a Python script
JdRlPr1.pyfrom the first server. The script content:
Identified Exfiltration: The malware:
Scans the victim's desktop for images and documents
XOR encrypts files with the key
G0G0Squ1d3Ncrypt10nConverts to hex encoding
Uploads to the attacker's server via POST requests
Extracted Uploaded Files: I found 5 files being exfiltrated:
3G2BHzj.jpeg- Construction scene photofZQ6WcI.png- Windows 7 desktop screenshotHNderw.png- Contains the flagoMdVph0.jpeg- Dog photowYTCtRu.jpeg- Cat photo
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.
Found Flag: The
HNderw.pngimage 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:
Convert the image to CMYK color space
Extract and invert the yellow channel (or use blue channel inversion)
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:
Mark the detected dots in the 15x8 grid
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:
Generates a random 16-byte AES key
Accepts user plaintext input
Appends the flag to the user's input
Encrypts everything using AES-CBC with a random IV
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:
In the file structure, the random key is the first line, followed by user input, then the flag
When
xargsprocesses this with normal input, it runsenc.pyonce with the random key asargv[1](the encryption key)By sending ~65500 space-separated tokens before our own 32-character hex key, we can force a batch boundary
The first batch uses the random key (unknown to us)
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:
First batch: encrypted with unknown random key (useless)
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 installOn startup,
app.shcreates venv in/tmpand runspip install --no-index --find-links=/home/flaskuser/flask_download flask
Confirmed Capabilities
Arbitrary file write to any path (if filename has no
.por..)Works:
/tmp/*,/home/flaskuser/*,/app/uploads/*Blocked:
*.py,*.pyc,*.pth(contain.p)Allowed:
*.so,*.whl,*.sh,*.txt
Arbitrary file read (same restrictions)
Persistence behavior (tested):
/home/flaskuser/persists across Python process restarts/tmp/persists across Python process restartsBUT: CTF infrastructure only restarts Python process, NOT full container
Therefore:
pip installdoesn'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.whlto/home/flaskuser/flask_download/Wheel contains
blinker/__init__.pythat runs/catflagProblem: 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.soWhen Python restarts and imports markupsafe, our .so loads and executes
/catflagProblem: Instance crashes (Bad Gateway) when uploading to this specific path
Uploads to
/tmp/test.sowork fineUploads 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
Why does uploading to
/tmp/venv_flask/lib/python3.12/site-packages/markupsafe/_speedups.cpython-312-x86_64-linux-gnu.socrash the instance?Same .so uploads fine to
/tmp/test.soEven minimal .so (no system call) causes crash
Is there a way to trigger full container restart (not just Python restart)?
Is there another attack vector we're missing?
The
.pfilter blocks all Python files.sofiles are allowed but replacement causes crashes.whlfiles 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:
Bit A (from response): The Yes/No answer tells us one bit
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:
!webhookcommand - 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
Join the K&K Training Room Discord server
Create your own Discord server and invite the K&K Attendance Bot
Create a webhook in your server (Channel Settings → Integrations → Webhooks)
Send
!webhookthrough your webhook with username "admin":
The K&K bot believes the message is from "admin" and creates a new webhook, revealing its URL
Use the K&K bot's webhook to send a message with a check-in button:
Click the button - the K&K bot receives the interaction and grants you the "K&K" role
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:
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.Bash arithmetic command injection: The
letcommand evaluates arithmetic expressions. In bash arithmetic, array indexing witha[$(cmd)]executes the command inside$().
Exploitation:
By sending a payload like 0+a[$(cmd)]:
0satisfies the regex (starts with hex)let "g = 0x0+a[$(cmd)]"evaluates the arithmetic expressionThe
$(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 inputBash 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:
img1must be pixel-identical to reference imageref_i.pngimg2can differ by at mostbudget[i]pixels from referencemd5_hex(img1_bytes) == md5_hex(img2_bytes)- same MD5 hash!img1must classify asreference_class_ids[i](digits 0-9)img2must classify astarget_class_ids[i]
Part 2: MD5 Collision with Correct CRCs
We use the UniColl technique from corkami/collisions. 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:
00vs01(cOLL chunk length:0x71vs0x171)Byte 0x89:
f2vsf1
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:
Computes gradient once to identify candidate pixels
Uses target digit's reference image as a template
For each step, evaluates multiple pixel value options (template value, 0, 255) in batch
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
UniColl MD5 Collision: Pre-computed collision blocks with different chunk lengths allow the same bytes to be parsed differently
Correct CRCs: Both PNG views must have valid CRCs - computed by understanding which bytes belong to which chunks in each view
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 scriptpow_solver.py- kCTF proof-of-work solver using Sloth VDFsubmit.py- Server submission scriptsubmission.zip- Generated solution (22KB)
Flag
The flag references the JSMA (Jacobian-based Saliency Map Approach) paper, authored by a UofT professor.
References
corkami/collisions - Hash collision techniques
Google kCTF PoW - Proof-of-work implementation
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:
The first and last name of the designer of this cabinet?
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+0x8Total offset to return address: 24 bytes
Exploit strategy:
Start payload with null byte (
\x00) to makestrlen()return 0Pad with 23 bytes to reach return address
Add a
retgadget (0x40101a) for stack alignmentAdd the address of
winfunction (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
The program immediately exits unless the input length is exactly 74 characters.
After the length check, the script performs many substring comparisons of the form: s[a:b] == "expected_value"
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:
Pass validation using only allowed keys
Jump into the middle of an instruction
Execute a
GETPROPwith key0Retrieve the file-read primitive
Read
/flag.txt
Exploit Logic
The crafted program performs:
Load global
capsAccess nested object via allowed key
Jump into middle of a fake instruction
Execute forbidden GETPROP with key
0Load string
"/flag.txt"Call file-read function
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:
Main function (offset
0x3fe92): Reads exactly 42 bytes of input usingfgets, copies to a buffer, then calls a transformation function at offset0x23b.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
Comparison function (
0x3fe40): Usesmemcmpto compare the transformed buffer against expected bytes stored in rodata at offset0x20.
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 featuresrunner.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$134andglobal$184-212Each 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:
$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:
Blocked content: The string "flag" (4 chars) and the '%' character are blocked
Per-packet inspection: The firewall scans each TCP packet independently for blocked content
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:
Ingress bypass (request): Split the HTTP request "GET /flag.html" across multiple TCP segments so no single segment contains "flag"
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
TCP segmentation: Using
TCP_NODELAYand small delays betweensend()calls forces the kernel to send data in separate TCP segmentsHTTP 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:
SQL Injection (app.py:76-79): The login query uses f-string interpolation with user input:
WAF Bypass (app.py:54-56): A Web Application Firewall blocks single and double quotes:
SSTI (app.py:112): The home page uses
render_template_stringwith user-controlled data:The username from the database is inserted via
%sformat string and rendered as a Jinja2 template.
Exploitation Steps:
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
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...7265616428297d7dPassword field:
) UNION SELECT 1,0x7b7b...7d7d -- -
RCE via SSTI: When logging in, the UNION injects our Jinja2 payload as the username. On redirect to
/home,render_template_stringevaluates{{...}}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:
SQL Injection (app.py:74-77): f-string interpolation allows injection:
WAF Bypass Required (app.py:52-54): Only single/double quotes are blocked:
Double Check (app.py:101-106): Both values must match:
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, sorow[0]must be our payloadWe can't use quotes in our input
Solution Approach:
Backslash Escape: Use
\at the end of username to escape the closing quote and turn the rest into SQL injection.SQL Quine with HEX: Use MySQL's
REPLACE()andHEX()functions to create a self-referential payload that outputs itself.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))
0xT_HEXdecodes to template T (contains$)HEX(0xT_HEX)returns T_HEX as a stringREPLACE(T, '$', T_HEX)substitutes$with T_HEXResult 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:
POST to
/loginwith the crafted username/passwordSQL injection returns
(ssti_payload+\, password)Double check passes:
username == row[0]andpassword == row[1]Redirect to
/homewhere SSTI executesVisit
/home?c=/readflagto 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:
SQL Injection (app.py:74-77): f-string interpolation allows injection:
WAF Bypass Required (app.py:52-54): Blocks quotes AND periods:
SHA256 Hash Check (app.py:101): Password is hashed before comparison:
SSTI (app.py:115): Username from session used in template string:
The Challenges:
No periods for SSTI: Can't use
lipsum.__globals__.os.popen()syntaxSHA256 verification: Simple quine approach where
password == row[1]won't work
Solution Approach:
Backslash Escape: Use
\at the end of username to escape the closing quote, turning the password field into SQL injection.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'ssha256(password).hexdigest()
SSTI Without Periods or Quotes: Use Jinja2's
|attr()filter withdict()trick:dict(__globals__=1)|firstcreates the string"__globals__"without quotes|attr((dict(__getitem__=1)|first))replaces[]bracket notationBuild the entire RCE chain using these techniques
SSTI Payload (quote-free, period-free):
Exploit Script:
How It Works:
POST to
/loginwith crafted username/passwordSQL injection returns
(ssti_payload+\, sha256_hash)SHA256 check passes:
sha256(password) == row[1](quine computes its own hash)Username check passes:
username == row[0]session["user"]is set to SSTI payloadRedirect to
/homewhere SSTI executesVisit
/home?c=/readflagto get the flag
Key Insights:
The SQL quine from "No Quotes 2" can be adapted by wrapping in
SHA2()to satisfy the hash checkJinja2's
dict()function creates real dict objects with string keys from Python identifiersdict(key=1)|firstreturns 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:
Stored XSS in Editor: The editor page (
/edit/:id) rendersdraftContentwithout sanitization using<%- draftContent %>in EJS. While the/api/saveendpoint sanitizes content via DOMPurify, the/api/autosaveendpoint stores raw content directly:Magic Link Session Swap: The magic link feature logs users into a different account while preserving the previous session in
sid_prev:Non-HttpOnly Cookies: Cookies are set with
httpOnly: false, making them accessible via JavaScript.
Exploit Chain:
Register a user and create a post
Use
/api/autosaveto inject XSS payload that steals cookies:Generate a magic link for your account
Report the URL:
http://localhost:3000/magic/TOKEN?redirect=/edit/POST_IDWhen admin bot visits:
Bot logs in as admin, gets admin's
sidcookieBot visits magic link URL
Magic link saves admin's
sidtosid_prev, creates new session for attacker's userBot is redirected to attacker's XSS page
XSS executes and exfiltrates cookies including
sid_prev(admin's session)
Read the stolen
sid_prevfrom your post and use it to access/flag
Commands:
Flag: uoftctf{533M5_l1k3_17_W4snt_50_p3r50n41...}
Last updated