Authentication

When your auth check and your router see different requests

Every authentication bypass we have ever reported came down to two pieces of software disagreeing about what was being asked for.

In March 2024, JetBrains patched a flaw in TeamCity On-Premises that let a completely unauthenticated attacker take administrative control of a build server — create an administrator, mint an admin token, and from there reach the source code, the build pipeline and the signing keys of everything that server touched. It was rated CVSS 9.8, it was exploited within days of disclosure, and it went into CISA's Known Exploited Vulnerabilities catalogue.

The authentication was not weak. There was no password to guess, no token to steal, no cryptography to break. The application simply had two components with different opinions about which URL had been requested — and the attacker addressed a privileged endpoint in a way that one of them recognised and the other did not.

CVE
CVE-2024-27198, alongside CVE-2024-27199
Product
JetBrains TeamCity On-Premises, all versions before 2023.11.4. TeamCity Cloud was not affected.
Class
Authentication bypass using an alternate path or channel (CWE-288)
Severity
CVSS 9.8 (Critical)
Disclosed
4 March 2024, found and reported by Rapid7; exploited in the wild within days and added to CISA's Known Exploited Vulnerabilities catalogue
Impact
Unauthenticated administrative control of the build server — and therefore of the source code, pipelines and secrets reachable from it

The root cause, in one sentence

The component that decided whether a request needed authentication, and the component that decided which code would handle it, were not looking at the same request.

Authentication is usually imagined as a gate across a corridor. It is actually a lookup: some piece of middleware takes the incoming request, derives a key from it — nearly always the path — and checks that key against a list of things requiring credentials. Routing then performs its own, separate lookup to choose a handler. When those two derivations can be made to disagree, the first one sees a path that needs no authentication and waves the request through, while the second sees a path that reaches a privileged function. Nothing is broken. Both components are behaving exactly as written.

No request is published here. As in the other posts in this series, the mechanism is described at the level a defender needs and no further. If you run TeamCity on-premises, check your version against JetBrains' advisory. And treat a compromised build server as a compromised supply chain: rotate the credentials and signing keys it held, because patching does not undo what was already taken.

The five shapes it takes

You are unlikely to write a build server. You are very likely to assemble a stack in which two components parse a request independently — which, for this bug class, is the only prerequisite.

1. Path normalisation differentials

A path can be written many ways that mean the same thing to one parser and something different to another: dot segments, encoded and double-encoded separators, duplicated slashes, trailing slashes, matrix or path parameters, and differing case sensitivity. If your authentication middleware matches on the raw path while your router matches on a normalised one, every one of those variations is a candidate key for the gap between them. The defence is to normalise once, as early as possible, and have everything downstream make decisions on that single canonical value.

2. The proxy and the application disagreeing

The most common shape in modern deployments, because almost nothing runs without something in front of it. A reverse proxy, API gateway, WAF or ingress controller enforces a rule on a path pattern; the application behind it routes by its own logic. The two were configured by different people, often at different times, and nothing tests that they still agree. Related, and worth checking the same afternoon: whether the application trusts routing or identity headers — X-Forwarded-For, X-Original-URL, X-Rewrite-URL and friends — that a client can simply set when the request does not arrive through the proxy at all.

3. Middleware scope that quietly misses a route

Authentication applied per controller, per route group or by decorator is authentication that a new endpoint can be added without. The failure is not a bug in the check; it is that the check was never attached. We find this most often on endpoints added in a hurry: an export, a health probe that grew features, an internal admin action, a v2 of an API where the group annotation did not come along. Deny-by-default at the framework level turns this from a vulnerability into a 403 that someone notices in testing.

4. Tokens validated but not verified

Where single sign-on is involved — and with Entra ID, Okta or Auth0 in front of an API, it usually is — the question is never "is the signature valid" alone. It is whether the issuer is checked, whether the audience is checked, whether expiry and not-before are enforced, whether the algorithm is pinned rather than read from the token, and whether a signature valid for a different application or tenant in the same identity provider is accepted by yours. A token can be entirely genuine and still not be a token for you.

5. More than one road to the same function

Applications accumulate entrances: the REST API and the GraphQL endpoint over the same resolver, a legacy path kept for an old client, a mobile-specific route, a webhook receiver, an admin action reachable both through the UI and through a management endpoint. Authentication gets enforced thoroughly on the one everybody remembers. We test all of them, because an attacker only needs the one nobody did.

This is a class where "we have a WAF" can actively make things worse. If the WAF is where authorisation decisions are made, then any request shape the WAF parses differently from the application is a bypass — and you have moved your security boundary into a component that was never meant to hold it. A WAF is a useful second layer. It is not an access control.

How we test for it

  1. Get the route inventory, then diff it against the auth inventory. We ask for the list of routes the framework knows about and compare it with what is actually protected. The endpoints on one list and not the other are the findings, and this takes minutes rather than days when the client can hand over the list.
  2. Establish the baseline. Request every authenticated endpoint with no session at all and record exactly what a correct rejection looks like. Everything after this is a search for responses that differ from it.
  3. Run the path mutation suite. Encodings, dot segments, duplicate and trailing slashes, path parameters, case variations, and combinations of them, against privileged endpoints. We are not guessing — we are enumerating the ways two parsers can disagree.
  4. Test the proxy and the application separately where we can. If the origin is reachable without going through the proxy, the difference between the two answers is the whole finding. Where it is not reachable, we test whether the application trusts client-supplied routing and identity headers.
  5. Attack the token, not just the login. Issuer, audience, expiry, algorithm pinning, key selection, and — for multi-tenant identity providers — whether a token minted for another tenant or another application is accepted.
  6. Map every entrance to the same function. REST, GraphQL, legacy routes, webhooks, management endpoints. Each is tested as though the others did not exist.

Every result is written up with the exact request, the absence of credentials, and the response proving what came back — so a developer can reproduce it in a terminal rather than taking our word for it. That is the standard we describe in what's in a penetration test report.

Six things to check in your own application this week

  • Make deny-by-default the framework behaviour, so an endpoint nobody annotated is closed rather than open. This single change retires most of shape three permanently.
  • Normalise the path once, early, and make every downstream decision on that canonical value. Two components normalising independently is the bug.
  • Print your framework's route list and compare it line by line against what your authentication layer protects. The mismatches are already there; the only question is who finds them.
  • Check whether your application trusts X-Forwarded-*, X-Original-URL or similar headers, and whether anything stops a client setting them directly.
  • For every token you accept, confirm you validate issuer, audience, expiry and not-before, and that the algorithm is pinned in code rather than read from the token header.
  • List every route to your most privileged function. If there is more than one, confirm each enforces the same checks — and delete the ones nothing uses.

Authentication, access control and business logic are the three areas where serious findings actually live, and all three need someone who reasons about your application rather than pattern-matching against a signature list. That is the gap our testing is built around.

Build your quote

How many ways are there into your admin function?

Finding the one nobody protected is what a manual test is for. Fixed price, retest included.