Modern Web App Testing: JWT Abuse, GraphQL, Race Conditions, and Prototype Pollution
- Modern Web App Testing: JWT Abuse, GraphQL, Race Conditions, and Prototype Pollution
- Abliterated Models for Web App Bug Bounty Hunting
- Blind SQL Injection in SuiteCRM Reports, Through ORDER BY
SQLi, XSS, and CSRF still show up. On a current app they are rarely the whole assessment. The other surface is broken JWT validation, a GraphQL endpoint next to the REST API, race conditions in payment and redemption flows, prototype pollution in Node backends, and HTTP/2 downgrade bugs on the reverse proxy.
This post is the black-box pass. For Java source, start with decompiling JARs and WARs with CFR. That post is its own series. This one will not show up in that series list.
JWT
Decode the token from the cookie or Authorization header and read the header and claims. jwt.io is fine for that. Then test the verification, not just the payload.
Algorithm Confusion (RS256 to HS256)
If the server verifies RS256 with a public key, some libraries will accept a token you re-sign as HS256 using that public key as the HMAC secret.
Original header:
{"alg":"RS256","typ":"JWT"}
Modified header:
{"alg":"HS256","typ":"JWT"}
The HMAC secret has to be the exact bytes the server will use. A PEM file and the n/e values from a JWKS document are different byte strings. Try both. Burp’s JWT Editor extension does this without a script. From Python, PyJWT treats the second argument as the raw HMAC secret when the algorithm is HS256:
import jwt
public_key = open("pubkey.pem", "rb").read()
token = jwt.encode(
{"sub": "admin", "role": "administrator"},
public_key,
algorithm="HS256",
)
print(token)
A lot of current libraries pin the expected algorithm and reject this. It is still the first test, because older forks and hand-rolled verifiers do not.
JKU and X5U
If the header has jku or x5u, the server may fetch a key from that URL.
{"alg":"RS256","typ":"JWT","jku":"https://attacker.example/.well-known/jwks.json"}
Host a JWKS document with your key, sign the token with the matching private key, and see if the server trusts the URL. An allowlist on the host, or a pinned kid, stops this. A server that fetches whatever jku says does not.
Kid Injection
kid selects the verification key. If that value is concatenated into a filesystem path or a SQL query, the selection itself is injectable.
{"alg":"HS256","typ":"JWT","kid":"../../dev/null"}
{"alg":"HS256","typ":"JWT","kid":"' UNION SELECT 'attacker-secret'--"}
The path case aims at a file whose contents you know, including an empty file, so you can HMAC with that content. The SQL case only matters when the key lookup is built with string concatenation.
Weak Secrets and Missing Checks
- Crack HS256 secrets with
hashcat -a 0 -m 16500 jwt.txt wordlist.txt. Mode 16500 wants the token itself. - Send
algofnonewith an empty signature. Some parsers still accept it. - Change role or subject claims only after you know the signature is not being checked.
- Replay a token whose
expis in the past.
GraphQL
Common paths are /graphql, /api/graphql, and /v1/graphql. Introspection, when it is left on, returns the schema:
{
__schema {
types {
name
fields { name type { name } }
}
}
}
GraphQL Voyager draws that schema. Burp’s InQL extension imports it and generates queries. With introspection disabled, a deliberately broken query often still produces field suggestions in the error text.
Batching
The GraphQL spec is one operation per request. Apollo and a few other servers also accept a JSON array of operations in a single POST. Rate limits and auth middleware sometimes run once per HTTP request.
[
{"query": "{ me { id } }"},
{"query": "{ adminPanel { secrets } }"}
]
Send it authenticated and unauthenticated. You are checking whether the second operation inherits a context it should not, or skips a check that the REST route enforces. That is a server bug when it happens. It is not guaranteed by the spec.
Aliases
Aliases are the spec way to run one field more than once in a single operation. A field that changes state belongs in a mutation:
mutation {
a1: redeemCoupon(code: "SAVE10") { status }
a2: redeemCoupon(code: "SAVE10") { status }
a3: redeemCoupon(code: "SAVE10") { status }
}
One HTTP request, three resolver calls. Limits that count requests and ignore resolver executions miss this. Putting a mutation field in a query operation is a validation error, so the operation type has to match the field.
IDOR on Root Fields and Nested Fields
A missing check on a second root field is a normal IDOR. It is not a nested-query bug:
{
me { id name }
user(id: "00000000-0000-0000-0000-000000000001") {
email
orders { total paymentMethod }
}
}
A nested field can fail separately, when the parent was authorized and the child resolver does not check the relationship:
{
me {
id
organization {
users { email ssn }
}
}
}
Test both. The parent me being allowed does not mean every field under it was considered.
Race Conditions
Look for actions that must happen once: coupon redemption, a vote, a credit, a password-reset token, a balance change. Send the same authenticated request many times so the requests overlap.
Burp Turbo Intruder is the tool that actually lines the requests up. Single-packet attack and last-byte sync exist to get them into the same processing window. A fuzzer with a high thread count sends parallel traffic, and it does not synchronize the last byte, so it is a weak test for this bug.
- Capture one valid
POST /api/redeem-coupon. - Replay about 20 copies with Turbo Intruder’s single-packet attack.
- Check whether the coupon, credit, or inventory change landed more than once.
The same shape applies to two transfers that each pass a balance check before either debit is committed:
POST /api/transfer HTTP/1.1
Content-Type: application/json
{"from":"A","to":"B","amount":100}
Fire a second transfer from A to C in the same window. If both succeed and A only had 100, the check and the debit are not atomic.
racepwn is the CLI option when you do not want to do it in Burp.
Prototype Pollution
Node backends that merge user JSON into an object with a recursive merge (lodash merge / defaultsDeep, and plenty of hand-rolled copies) can assign __proto__ or constructor.prototype onto Object.prototype. JSON.parse by itself does not do that. In current Node, parsing this body produces an object with a normal property named __proto__:
{"user":"test","__proto__":{"isAdmin":true}}
The constructor form is the same idea, and it still needs an unsafe merge:
{"constructor":{"prototype":{"isAdmin":true}}}
Pollution is only interesting when some later check reads a property the object does not own, such as user.isAdmin, and inherits your value from Object.prototype. Confirm the merge in source if you have it. Grep for merge, defaultsDeep, extend, and recursive assign on request JSON.
A hit still needs a gadget before it is RCE or a real auth bypass:
- A template engine that reads options from the merged object
child_processspawn options taken from a polluted prototype- A JWT or auth config object that inherits a property you set
PPFuzzer generates the payload variants. It does not find the gadget for you.
HTTP/2 Request Smuggling
Some reverse proxies speak HTTP/2 to the client and downgrade to HTTP/1.1 for the backend. H2.CL is the case where the frontend trusts the HTTP/2 frame length and the backend trusts the Content-Length header on the downgraded request.
If that header is shorter than the body inside the HTTP/2 frame, the backend stops early. The leftover bytes sit on the connection and get parsed as the start of the next request. A content-length of 0 makes the whole body leftover:
:method POST
:path /
:authority target.example
content-length 0
GET /admin HTTP/1.1
Host: target.example
Downgraded, the backend sees a POST with Content-Length: 0 and then a second request line, GET /admin. The length has to be an integer smaller than the body the frame actually carries. A length that does not match that rule is just a malformed request.
Burp’s HTTP/2 inspector is where you edit the pseudo-headers and the leftover body. PortSwigger’s request smuggling material covers detection (timing and response-queue diffs) better than a single payload will. Impact, when the desync is real, is cache poisoning or a request that bypasses a frontend control.
What to look at:
- A CDN, WAF, or load balancer in front of a separate origin
- A frontend and backend that disagree on HTTP/2 versus HTTP/1.1
- Response timing or status codes that change when
Content-Lengthis shorter than the frame body
OAuth and OIDC
When the app sends you to /oauth/authorize or /auth/callback, test the redirect and the state value before you test the API behind the login.
Redirect URI
Compare the registered callback with small variations:
https://app.example/callback
https://app.example/callback/
https://app.example/callback/../../../admin
https://app.example/callback%00.evil.example
https://evil.example?@app.example/callback
https://app.example.evil.example
Weak string matching on the registered URI is the bug. An open redirect on the real callback host can be chained into the same theft.
State
If state is missing or predictable, account-linking flows are CSRF. The victim’s browser can be sent through the provider and come back linked to an account you control.
Where Tokens Leak
A token in the query string is sent to the server and can show up in Referer when the next page loads a third-party resource. A token in the URL fragment is not sent in Referer and is not sent to the server at all. Fragment tokens leak through script on the page, an open redirect that preserves the fragment, or the app copying location.hash into a request of its own.
Scope
Ask for a scope the client did not register, or a broader scope than the user already consented to:
scope=openid profile email admin
Some authorization servers grant the extra scope instead of ignoring it.
Server-Side Template Injection
Anywhere user input is rendered by the server is a candidate: email templates, PDF generators, error pages, preview panes. Detection is a math probe. A 49 in the response means the engine evaluated it.
${7*7}
{{7*7}}
<%= 7*7 %>
#{7*7}
*{7*7}
Identify the engine from the syntax that worked and from error text, then use an engine-specific payload. PayloadsAllTheThings is the cheat sheet.
Framework notes that keep coming up:
- Thymeleaf (Spring) uses expression preprocessing. A typical probe looks like
__${7*7}__::.x. - Pebble uses
{{ }}, in the same family as Jinja2. - Freemarker
?new()can buildfreemarker.template.utility.Executeon versions that still allow that built-in:<#assign ex="freemarker.template.utility.Execute"?new()>${ ex("id") }. Newer incompatible-improvements settings block it. - Handlebars and EJS show up as the gadget once a Node merge bug has polluted options the template call reads.
With decompiled Java from CFR, grep for the template import and follow user input into the render call. SQL injection, unsafe deserialization, and SpEL in that Java code are the follow-up for the Java series, not for this one.
A Practical Workflow
After the baseline scan:
- Map the API. Crawl in Burp, run Arjun for hidden parameters, and request
/graphql,/swagger.json,/openapi.json, and/.well-known/. Run JSFScan on the shipped JavaScript. - Test auth. Decode JWTs and try algorithm confusion and
jku. Check the OAuthredirect_uriandstate. See whether refresh tokens and expired tokens are actually rejected. - Test state changes. Race one-time actions in Turbo Intruder. Try GraphQL aliases on mutations, and Apollo-style batching if the server accepts an array. Send extra fields such as
{"role":"admin"}on profile updates. - Test parsers. Prototype-pollution payloads only count if something merges the JSON. SSTI probes go anywhere the server renders your input. URL fields, webhooks, and PDF fetchers get an SSRF attempt.
- Test the edge. HTTP/2 desync on apps behind a CDN or load balancer. Cache poisoning through unkeyed headers such as
X-Forwarded-HostandX-Original-URL. CORS withAccess-Control-Allow-Credentials: truereflected against an attacker origin.
Quick Reference
| Technique | What to send | A real hit looks like |
|---|---|---|
| JWT alg confusion | HS256 signed with the server’s public-key bytes | Forged token is accepted |
JWT jku |
jku pointing at your JWKS |
Your key verifies the token |
| GraphQL aliases | One mutation, repeated with aliases | The one-time action runs more than once |
| GraphQL batching | JSON array of operations | Second operation skips an auth check |
| Race | About 20 overlapping copies of one request | Limit or balance applied more than once |
| Prototype pollution | __proto__ via a merge, not JSON.parse alone |
Inherited property changes a check |
| SSTI | ${7*7} and the other probes |
Response contains 49 |
| OAuth redirect | Variants of the registered callback | Code or token is sent to your host |
More to come.
If you enjoyed this post please consider subscribing to the feed!