Tanuki: SSRF to Admin Access Control Bypass
Overview
- Platform: BugForge
- Vulnerability: SSRF on
/api/fetchbypasses the access-control gate on/admin. The gate denies public traffic (403 Access forbidden) but admits loopback-originated requests from the fetcher, returning the flag - Key Technique: Loopback SSRF via a JSON-proxy endpoint to originate a request from inside the trust boundary
- Result: Flag extracted from an admin-only JSON handler that rejects all direct public traffic
Objective
Find and exploit a vulnerability in the Tanuki flashcard application to capture the flag.
Initial Access
# Target Application
URL: https://lab-1776203059016-vfk175.labs-app.bugforge.io
# Auth details
Registered user: haxor (role: user)
Auth mechanism: JWT HS256 (payload: {id, username, iat})
Role check: server-side, sourced from DB via /api/verify-token
Key Findings
- SSRF on
/api/fetchbypasses access control on/admin(CWE-918: Server-Side Request Forgery; CWE-284: Improper Access Control) —POST /api/fetchaccepts a user-supplied URL, performs a server-side GET, parses the response as JSON, and returns the parsed body to the caller. A/adminhandler on the same server returns admin-only JSON, including the flag. DirectGET /adminreturns403 Access forbidden; routing the same request through/api/fetchloopback returns the flag. The/admingate denies traffic originating from the public internet but admits traffic originating from inside the server. Combined with a fetcher that issues server-originated requests on behalf of any authenticated user, this enables unauthorized read access to admin-only content.
Attack Chain Visualization
┌──────────────────────┐ ┌──────────────────────┐
│ POST /api/fetch │ │ /admin handler │
│ {"url":"http:// │────>│ returns flag JSON │
│ localhost:3000/ │ │ (loopback origin │
│ admin"} │ │ bypasses the gate) │
└──────────────────────┘ └──────────────────────┘
Application Architecture
| Component | Detail | Description |
|---|---|---|
| Frontend | React SPA (Material UI) | Japanese-themed flashcard/SRS study interface |
| Backend | Express/Node.js | REST API with JWT authentication |
| Auth | JWT HS256 | Payload: {id, username, iat} — role sourced server-side from DB |
| Admin gate (API) | Middleware on /api/admin/* |
Returns 401 without JWT, 403 with non-admin JWT |
| Admin gate (non-API) | Gate on /admin |
Returns 403 "Access forbidden" for public traffic; bypassed on loopback origin |
| Fetcher | POST /api/fetch |
Takes {"url":...}, GETs server-side, parses JSON, returns body. Does not forward caller’s Authorization header. |
Exploitation Path
Step 1: Reconnaissance — Map the API Surface
Registered an account (haxor) and walked the JS bundle in Caido. The surface was largely unchanged from the prior engagement on this app: same auth, same deck/card/study endpoints, same /api/admin/* middleware returning 403 "Admin access required" for user JWTs. JWT payload no longer carries a type field; role is sourced server-side from the DB via /api/verify-token. The JWT none-algorithm path from the prior engagement is closed.
One new endpoint: POST /api/fetch, used client-side by the Leaderboard page to load http://localhost:3000/leaderboard. Two observations:
- The endpoint accepts a user-supplied URL and issues a server-side HTTP GET — base condition for SSRF.
- The URL it loads is
/leaderboard, not/api/leaderboard— a non-API path, indicating at least one JSON handler exists outside the/api/*namespace.
Step 2: Characterize /api/fetch
Fired five loopback probes through /api/fetch against known /api/admin/* routes (/api/admin/users, /decks, /cards, /api/admin, /api/verify-token). Testing confirmed three facts about the fetcher:
- Transparent JSON proxy. Server-side GET, response body parsed as JSON, parsed body returned to the caller as an outer
200regardless of inner status code. Non-JSON response bodies produce an outer500 {"error":"Invalid response format"}— the route exists, the body just isn’t parseable. Authorizationheader is not forwarded. Inner admin probes returned{"error":"Access token required"}(the 401 string from the auth layer) rather than{"error":"Admin access required"}(the 403 string direct calls with a user JWT produce). Requests originated by/api/fetcharrive at their target unauthenticated.- No loopback trust on
/api/admin/*. Auth middleware on those routes runs regardless of caller IP.
Implication: identity-gated routes are unreachable through the fetcher (inner call arrives without auth and is rejected by the auth middleware). Routes whose gates discriminate on request origin rather than caller identity may be reachable, since the fetcher originates the inner request from 127.0.0.1.
Step 3: Enumerate Non-API Sibling Routes
The SPA’s known-good fetcher target (/leaderboard) is a non-API path, indicating the dev added JSON handlers outside the /api/* namespace. Non-API handlers typically sit outside the app’s main auth middleware chain and rely on local, hand-written gates. Batch of 8 loopback probes through /api/fetch:
| Path | Outer status | Inner body | Interpretation |
|---|---|---|---|
/leaderboard?limit=100 |
200 | Top-10 users JSON | Known-good baseline |
/users |
500 | “Invalid response format” | Route exists, non-JSON response |
/admin |
200 | {"message":"Admin endpoint accessed","flag":"bug{...}","admin_data":{...}} |
Flag |
/stats |
500 | “Invalid response format” | Route exists, non-JSON |
/debug |
500 | “Invalid response format” | Route exists, non-JSON |
/health |
200 | {"status":"OK","message":"Tanuki SRS server is running"} |
Public health check |
/status |
500 | “Invalid response format” | Route exists, non-JSON |
/flag |
500 | “Invalid response format” | Route exists, non-JSON |
Flag returned from http://localhost:3000/admin via the fetcher.
Step 4: Verify the /admin Gate
Direct unauthenticated probe to rule out an unprotected route:
GET /admin HTTP/1.1
Host: lab-1776203059016-vfk175.labs-app.bugforge.io
HTTP/1.1 403 Forbidden
{"error":"Access forbidden"}
The /admin gate denies public traffic. The loopback path through /api/fetch reaches the same handler and returns the flag. Both requests are unauthenticated (auth not forwarded through the fetcher), so the discriminating factor is request origin, not caller identity.
Step 5: Single-Request Proof of Concept
Full chain is a single request with any valid user JWT. Registration is open, so the prerequisite is effectively zero:
POST /api/fetch HTTP/1.1
Host: lab-1776203059016-vfk175.labs-app.bugforge.io
Authorization: Bearer <any valid user JWT>
Content-Type: application/json
{"url":"http://localhost:3000/admin"}
Response:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"message":"Admin endpoint accessed","flag":"bug{lRlVRJWLwNpkzHdlOz9EiQonaUvk78ft}","admin_data":{"server_version":"1.0.0","environment":"production"}}
Flag / Objective Achieved
bug{lRlVRJWLwNpkzHdlOz9EiQonaUvk78ft}
Returned by the /admin JSON handler, reached via POST /api/fetch with {"url":"http://localhost:3000/admin"}.
Key Learnings
- Characterize the fetcher before exploiting it. The five-probe batch against
/api/admin/*established three facts — transparent JSON proxy, auth not forwarded, no loopback trust on/api/admin/*— that scoped the rest of the engagement. The “auth not forwarded” finding in particular eliminated the entire/api/admin/*family as a target and redirected attention to routes whose gates don’t depend on caller identity. - Non-API routes are a distinct namespace from the SPA’s API call list. The SPA’s known-good fetcher target (
/leaderboard) sat outside/api/*, indicating additional non-API handlers likely existed. Guessing sibling names and batch-probing them through the fetcher surfaced/adminin a single request batch. Whenever an SPA calls even one non-API path, sibling enumeration is cheap and high-signal. - Distinguish identity-gated from origin-gated routes when chaining SSRF. SSRF targets that check caller identity (JWT, session) are unreachable through a fetcher that strips auth. Targets that check request origin (source IP, perimeter, Host header) are reachable because the fetcher manufactures the trusted origin. The decision point is the first question to answer on any server-side fetcher.
- Sanity-check the public gate before calling the finding. Direct unauthenticated probe of
/adminreturned403, ruling out the trivial “unprotected route” explanation and confirming the vulnerability is the SSRF chain, not a missing gate. Two-request verification (fetcher success + direct denial) is the minimum evidence needed to claim access-control bypass.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
OOB callback via external URL on /api/fetch |
400 “URL exceeds maximum length” (66-char OOB domain) | URL filter rejects the probe. Could be a real length cap or a masked host allowlist — didn’t pursue since loopback probes were more productive. |
SSRF → /api/admin/users via loopback |
Inner {"error":"Access token required"} |
/api/fetch doesn’t forward the caller’s Authorization header; inner call hits auth middleware as unauthenticated. |
SSRF → /api/admin/decks, /api/admin/cards, /api/verify-token via loopback |
Same 401 inner body | Same reason — auth header not forwarded on inner calls. |
SSRF → /api/admin (parent path) |
500 “Invalid response format” | Parent path returns non-JSON (likely Express "Cannot GET /api/admin" 404 HTML), /api/fetch can’t parse it. |
Direct public GET /admin (no auth) |
403 “Access forbidden” | Gate is real. This confirmed the flag wasn’t just an open route — there’s genuine access control that the loopback path evades. |
Tools Used
- Caido — HTTP interception, request replay, parallel probe batches
- Browser DevTools — Walking the React bundle to map the API surface and spot the new
/api/fetchendpoint curl— Direct unauthenticated/adminprobe to verify the public gate
Remediation
1. SSRF on /api/fetch bypasses the access-control gate on /admin (CVSS: 8.6 — High)
Issue: POST /api/fetch accepts a user-supplied URL and performs a server-side GET with no restriction on the target host. The /admin handler’s access-control gate denies public traffic (403 "Access forbidden") but admits the loopback-originated request issued by /api/fetch (200 with flag JSON). Any authenticated user can read admin-only content by asking /api/fetch to originate the request on their behalf.
CWE References:
- CWE-918 — Server-Side Request Forgery (SSRF)
- CWE-284 — Improper Access Control
- CWE-441 — Unintended Proxy or Intermediary (“Confused Deputy”)
CVSS v3.1 Vector: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
- Attack Vector: Network — exploitable remotely
- Attack Complexity: Low — one HTTP request, no special conditions
- Privileges Required: Low — any registered user (registration is open)
- User Interaction: None
- Scope: Changed — the SSRF lets the attacker act beyond the authorization boundary of their own account
- Confidentiality: High — admin-only JSON content disclosed
- Integrity / Availability: None (observed — this finding is read-only)
Recommended fixes (all four; they’re complementary, not alternatives):
(a) Defense in depth on /admin itself. Whatever is currently gating the handler on request origin — whether a perimeter layer (API gateway, ingress auth-request, oauth2-proxy sidecar, Cloudflare Access) or an in-app req.ip === 127.0.0.1 check — should not be the only gate. Add auth middleware on the handler itself inside the app. Every sensitive handler should authenticate its own caller and not trust that “the gateway would have blocked this” or “only localhost can reach me.”
// BEFORE (Vulnerable) — handler has no gate of its own; relies on something
// upstream (perimeter gateway OR localhost allowlist) to keep strangers out
app.get('/admin', (req, res) => {
res.json({
message: 'Admin endpoint accessed',
flag: process.env.ADMIN_FLAG,
admin_data: { server_version: '1.0.0', environment: 'production' }
});
});
// AFTER (Secure) — handler verifies the caller's identity itself, regardless
// of where the request came from
const requireAdmin = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Authentication required' });
try {
const decoded = jwt.verify(token, SECRET_KEY, { algorithms: ['HS256'] });
// Source role from DB, not from the JWT
const user = db.users.findById(decoded.id);
if (!user || user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
req.user = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
};
app.get('/admin', requireAdmin, (req, res) => {
res.json({
message: 'Admin endpoint accessed',
flag: process.env.ADMIN_FLAG,
admin_data: { server_version: '1.0.0', environment: 'production' }
});
});
(b) SSRF hygiene on /api/fetch. Resolve the hostname before issuing the request, pin the resolved IP, and reject loopback, private, link-local, and cloud-metadata ranges. Reject non-HTTP(S) schemes. Pinning the resolved IP also prevents DNS rebinding, where the hostname resolves to a benign IP during the allowlist check and then re-resolves to 127.0.0.1 by the time the real request goes out.
// BEFORE (Vulnerable)
app.post('/api/fetch', requireAuth, async (req, res) => {
const { url } = req.body;
const response = await fetch(url); // no validation
const body = await response.json();
res.json(body);
});
// AFTER (Secure) — resolve, pin, validate, fetch
const dns = require('dns').promises;
const net = require('net');
const ipaddr = require('ipaddr.js');
const BLOCKED_RANGES = [
'127.0.0.0/8', // loopback
'10.0.0.0/8', // RFC1918
'172.16.0.0/12', // RFC1918
'192.168.0.0/16', // RFC1918
'169.254.0.0/16', // link-local + cloud metadata
'::1/128', // IPv6 loopback
'fc00::/7', // IPv6 ULA
'fe80::/10', // IPv6 link-local
];
function isBlockedIp(ip) {
const addr = ipaddr.parse(ip);
return BLOCKED_RANGES.some(cidr => {
const [range, prefix] = cidr.split('/');
return addr.match(ipaddr.parse(range), parseInt(prefix, 10));
});
}
app.post('/api/fetch', requireAuth, async (req, res) => {
const { url } = req.body;
let parsed;
try { parsed = new URL(url); }
catch { return res.status(400).json({ error: 'Invalid URL' }); }
if (!['http:', 'https:'].includes(parsed.protocol)) {
return res.status(400).json({ error: 'Only HTTP(S) URLs allowed' });
}
// Resolve and pin
const { address } = await dns.lookup(parsed.hostname);
if (isBlockedIp(address)) {
return res.status(400).json({ error: 'Target host not allowed' });
}
// Issue the request against the pinned IP, preserving Host header
const response = await fetch(
`${parsed.protocol}//${address}${parsed.pathname}${parsed.search}`,
{ headers: { Host: parsed.hostname } }
);
const body = await response.json();
res.json(body);
});
(c) Allowlist instead of blocklist. The fetcher only exists to load http://localhost:3000/leaderboard for the Leaderboard page. It doesn’t need to accept a user-supplied URL at all. Hardcode the target:
app.post('/api/fetch', requireAuth, async (req, res) => {
// No URL parameter — the fetcher's only purpose is the leaderboard
const response = await fetch('http://localhost:3000/leaderboard');
res.json(await response.json());
});
This is the best fix if the feature doesn’t need the generality. Blocklists drift and leak; an allowlist of one URL cannot.
(d) Topology enforcement — only if you must keep the current architecture. If perimeter-only auth is a hard requirement, run sensitive handlers on a separate process bound to a different port or Unix socket that the main app cannot reach. Or deploy with a service mesh (Istio/Linkerd with mTLS) that uses iptables to intercept loopback traffic and force it through the same policy enforcement as external traffic. This is more operational overhead than options (a)–(c), but it lets topology-based trust continue to work when an SSRF exists elsewhere in the app.
OWASP Top 10 Coverage
- A10:2021 — Server-Side Request Forgery (SSRF) —
/api/fetchaccepts a user-supplied URL, performs no validation of the target host, and issues the resulting HTTP request from inside the application’s trust boundary. - A01:2021 — Broken Access Control — The
/adminhandler enforces access based on request origin (observed via403for public traffic,200for loopback-originated traffic) rather than caller identity. Origin-based access control fails to account for server-originated requests issued on behalf of external users by the SSRF-capable fetcher. - A04:2021 — Insecure Design — Request origin is treated as a proxy for authorization in the gate on
/admin. Origin-based trust assumptions do not hold when any component in the application can originate requests from inside the trust boundary.
References
- CWE-918: Server-Side Request Forgery (SSRF)
- CWE-284: Improper Access Control
- CWE-441: Unintended Proxy or Intermediary (“Confused Deputy”)
- OWASP: Server-Side Request Forgery Prevention Cheat Sheet
- PortSwigger: SSRF — attacks against the server
- PortSwigger: DNS rebinding
- OWASP Top 10 2021: A10 Server-Side Request Forgery
- OWASP Top 10 2021: A01 Broken Access Control
Tags: #ssrf #access-control #confused-deputy #loopback #bugforge #tanuki