Secondary-context attacks: when a public API is a proxy in disguise
A target-free walkthrough of BFF and gateway abuse: a public endpoint silently forwards to an internal service, and path traversal in a URL segment reaches endpoints that were never meant to be public.
Half the value in finding this bug is realising the API you’re hitting isn’t the real server. It’s a forwarder. You send a request to a public host, and behind the scenes some gateway takes a piece of your URL and stitches it into a request to an internal service the public internet was never meant to reach. The “primary context” is the public API’s routing; the “secondary context” is the internal request it builds from your input. The bug lives in the gap where the second context interprets bytes the first one passed through unexamined.
Spotting the proxy
You can’t attack a forwarder you haven’t noticed. The tells are in the URL shape and in the response behaviour. Path patterns that should make you stop:
/bff/account/profile
/proxy/orders/12345
/api/v1/payments/charges
/gateway/inventory/sku/abc
/internal/users/meAnything with bff, proxy, gateway, internal, or a segment that looks
like a service name (payments, inventory, orders) sitting in the middle
of the path is a candidate. A BFF (backend for frontend) is the textbook case:
the frontend talks to one friendly API that fans out to many internal services,
and the service name is right there in the route.
The behavioural tells confirm it. Response headers that leak a different upstream
(Server, Via, an internal X- header), error pages that don’t match the
public app’s styling, or latency that jumps when one route fans out further than
another. Those say “something downstream is answering, not this host.”
Testing the seam
Once you believe a segment is forwarded, the test is whether you can break out
of the intended path. The gateway builds something like
http://orders.internal/v1/<your-segment>, so the question is whether your
segment can climb back up and across.
Start with traversal sequences in the forwarded segment, encoded so the public router passes them through and the upstream resolves them:
GET /api/v1/orders/..%2f..%2finventory%2fadmin%2fflush HTTP/1.1
Host: public.exampleLayered encoding matters because the two contexts decode a different number of
times. Try, in order: a literal ../, single-encoded %2e%2e%2f,
double-encoded %252e%252e%252f, and the mixed ..%2f. Each one targets a
different decode depth in the chain. When one of them suddenly returns a
response that doesn’t belong to the route you asked for (a different service’s
404, an unexpected JSON shape, an auth error from a system you never named),
you’ve crossed the boundary.
The high-value pivots once you’re across:
- Reach a sibling service’s internal API directly, skipping the public gateway’s auth layer.
- Hit admin or debug routes (
/admin,/actuator,/_internal,/debug) that trust their network position and never check a token. - Reach the service’s own metadata or health endpoints, which often spill config, versions, and sometimes credentials.
Why this isn’t quite SSRF
It reads like SSRF because you end up talking to an internal host, but you don’t
control the destination host; you control a path inside a destination the
gateway already chose. That distinction matters for both testing and reporting.
You’re not smuggling a 169.254.169.254 into a fetch parameter. You’re abusing
the trust an internal service places in its gateway, the assumption that
“anything reaching me has already been authorised, because only the gateway can
reach me.” That assumption is the actual vulnerability; the traversal is just
how you violate it.
When I report one of these, the load-bearing evidence is a single request whose response unmistakably comes from a different service than the route advertised. A triager who sees a payments-service error returned from an orders route, with a traversal sequence in the path, doesn’t need the architecture diagram. The crossed boundary is right there in the diff.
The lesson that outlives any one stack: the moment you see a service name in a URL path on a public API, assume you’re talking to a proxy, and test whether you can steer where it forwards. Gateways were built to simplify routing, not to be a security boundary, and most internal services are still trusting them to be one.