Friday, 10 April 2026

I bypassed AWS API Gateway auth with a trailing slash. Got $12K bounty.

I was poking at a fintech’s mobile API and noticed something that made no sense. GET /v1/accounts returned 401. GET /v1/accounts/ returned 200 with full account data. One character. Completely different security posture.

What I was looking at

The API ran on AWS HTTP API — the newer, cheaper alternative to REST API. Lambda authorizer checked a JWT against Cognito, returned an IAM policy. Standard.

Routes in OpenAPI:

YAML
/v1/accounts:
  get:
    x-amazon-apigateway-integration:
      uri: arn:aws:apigateway:...

/v1/accounts/{accountId}:
  get:
    x-amazon-apigateway-integration:
      uri: arn:aws:apigateway:...

The authorizer ran on every request. But HTTP API makes two decisions: does this route exist, and does the authorizer allow it? Those two layers didn’t agree on what a “match” meant.

The weird results

I ran ffuf on the path. The results were… inconsistent.

RequestResponse
GET /v1/accounts401 Unauthorized
GET /v1/accounts/200 OK + full data
GET /v1/accounts//200 OK
GET /v1/accounts?foo=bar401 Unauthorized
GET /v1/accounts%2f404 Not Found

The pattern: any path that sort-of matched a route prefix triggered the authorizer, then fell through to the integration without re-checking auth.

HTTP API does greedy path matching by default. /v1/accounts/ matched /v1/accounts as a prefix. The authorizer ran and returned Allow. Then the integration executed — but the integration mapping was fuzzy. The path got rewritten, the auth context got dropped, and suddenly I was inside without a valid JWT.

How the bypass actually worked

I traced it carefully. The $default route in HTTP API is a catch-all. The fintech had set it to return 404. But they’d also attached a mock integration for health checks at some point. That mock didn’t check auth — just returned {"status": "ok"}.

But /v1/accounts/ wasn’t hitting the mock. It was hitting the real backend. API Gateway’s greedy match rewrote the trailing-slash path, stripped the slash, and forwarded to the /v1/accounts integration. The auth check happened on the original path. The integration ran on the rewritten path. The rewrite dropped the auth context.

I confirmed it with a custom header. The authorizer sets context.authorizer.userId. The integration reads it. When I hit /v1/accounts/, the integration received userId: undefined. The integration didn’t validate userId. It just returned all accounts for the API key — which wasn’t even required here because auth was supposed to be the JWT.

The real damage

Same bypass worked on POST /v1/transfers/. I could initiate wire transfers without a valid JWT.

The backend checked that fromAccount belonged to the user. But userId was undefined, so it defaulted to a system account. I stopped after one $0.01 test transfer. It went through.

Telling them

I wrote it up. Screenshots of the 401 vs 200. The ffuf output. The exact path rewrite behavior. They fixed it the next day.

Switched from HTTP API to REST API (stricter path matching)

Added userId validation in every Lambda, not just the authorizer.

I got $12,000 bounty for it. Planning to go to Dubai :)

No comments:

Post a Comment

I poisoned a Hugging Face dataset and it stayed up for 6 months

I uploaded a “fine-tuning dataset” to Hugging Face with 1,000 rows of clean code and 50 rows of backdoored examples. The backdoor: any funct...