{"id":"hunt-fintech-graphql","name":"hunt-fintech-graphql","summary":"フィンテック特有のGraphQL脆弱性を探す:資金移動の変異(送金、償還、出金、カードチャージアップ)、元帳/残高/ポートフォリオクエリIDOR、小数精度と四捨五入の乱用、二重支出を可能にする冪等性キーのバイパス、KYC/PIIフィールドレベルの認可ギャップ、管理権限...","body":"## Why Fintech GraphQL Is a Different Risk Class\n\nGeneric GraphQL bugs (IDOR, mass assignment, introspection, batching abuse — see `hunt-graphql`)\nstill apply here, but the blast radius changes completely: a resolver bug in a SaaS app leaks\ndata, the same class of bug in a ledger mutation **moves money**. Three properties make fintech\nGraphQL backends a distinct hunting surface:\n\n- **Money-movement mutations are almost always resolvers over a double-entry ledger.** A single\n  GraphQL mutation (`transferFunds`, `redeemRewards`, `withdrawToBank`) can trigger multiple\n  ledger writes (debit + credit + fee) that must be atomic. GraphQL's flexible input shape and\n  alias batching make it easy to desynchronize those writes.\n- **Decimals are attacker-controlled input, not display formatting.** Amounts, exchange rates,\n  interest, and rewards points are usually passed as GraphQL scalars (`Float`, `String`, custom\n  `Decimal`/`Money` scalar). How the resolver parses and rounds that value is exploitable surface\n  in its own right — this barely exists in non-financial GraphQL APIs.\n- **KYC/PII fields sit next to routine account fields in the same type.** `User` or `Account`\n  types commonly expose `ssnLast4`, `routingNumber`, `kycStatus`, `governmentIdUrl`, or\n  `linkedBankAccount` alongside `displayName` and `email` — one missing field-level authorization\n  check on a type used everywhere in the schema fans out to every query that touches it.\n\n---\n\n## Attack Surface Signals\n\n**URL / schema naming patterns (in addition to `hunt-graphql`'s generic `/graphql` list):**\n```\n/graphql/ledger\n/graphql/payments\n/api/wallet/graphql\n/internal/ledger-graphql\n/banking/graphql\n```\n\n**Field/type names worth grepping schema introspection or JS bundles for:**\n```\nbalance, availableBalance, pendingBalance, ledgerEntry, ledgerEntries\ntransferFunds, withdraw, redeem, topUp, reverseTransaction, adjustBalance\nkycStatus, ssnLast4, routingNumber, accountNumber, governmentIdUrl\nquoteExchangeRate, interestAccrued, rewardsPoints, portfolioValue\nidempotencyKey, clientMutationId\n```\n\n**Tech-stack tells specific to this vertical:**\n- Plaid/Stripe/Dwolla/Marqeta wrapped behind an internal GraphQL gateway (`bankLink`, `plaidLinkToken` mutations)\n- Apollo Federation with a dedicated `ledger` or `payments` subgraph — check for the subgraph's own introspection being reachable directly, bypassing the gateway's stitched-down schema\n- Custom `Money`/`Decimal`/`BigDecimal` GraphQL scalar in the schema (`scalar Money`) — the parser for this scalar is worth fuzzing directly\n\nRun `hunt-graphql`'s discovery + introspection methodology first to get the schema; everything\nbelow assumes you already have (or have partially enumerated) a schema with money-movement types.\n\n---\n\n## Step-by-Step Hunting Methodology\n\n1. **Map every mutation that touches balance, whether directly or as a side effect.** Not just\n   `transfer*`/`withdraw*` — also `redeemRewards`, `applyCoupon`, `upgradeTier`,\n   `closeAccount` (often refunds a balance), `disputeTransaction` (often provisionally credits).\n\n2. **For each money-movement mutation, identify the ledger write shape.** Does one mutation call\n   produce one ledger entry or several (debit sender, credit receiver, fee entry)? Multi-entry\n   writes are the ones worth racing — see Stage 4.\n\n3. **Test idempotency-key handling.** Send the identical mutation (same `idempotencyKey` /\n   `clientMutationId`) twice, back-to-back and with a delay. A ledger write on the second call\n   means idempotency isn't enforced server-side — replay = double-execute.\n\n4. **Test decimal/precision edge cases** on every amount-accepting argument — see Payload section.\n   Confirm server-side rounding matches client-displayed rounding; a mismatch is directly\n   monetizable.\n\n5. **Probe cross-account IDOR on account/portfolio node IDs**, same as `hunt-idor`/`hunt-graphql`,\n   but specifically test whether a `transferFunds`-style mutation validates that the\n   **source account belongs to the authenticated caller** — not just that *some* account with\n   that ID exists. This is the fintech-specific IDOR: authz on the *source* of a debit is easy to\n   forget when authz on the *destination* of a credit was correctly implemented (crediting an\n   arbitrary account \"looks safe\" to a developer; debiting one clearly isn't, so it gets checked\n   — but sometimes only one direction does).\n\n6. **Check field-level authorization on KYC/PII fields** by querying the shared `User`/`Account`\n   type from every context that returns it — not just the profile screen. A `transaction` type\n   that embeds `counterparty { ssnLast4 }` is a common place for the check to be missing, because\n   the developer authorized the top-level `transaction` query but didn't re-check field access on\n   the nested `counterparty`.\n\n7. **Look for admin-tier mutations reachable via mass assignment**, not just a missing auth\n   check — e.g. an input object with a client-settable `status` or `override` field that a normal\n   user's mutation shouldn't expose but that the resolver accepts anyway\n   (`updateTransaction(input: {id, status: \"COMPLETED\", amount: \"...\"})`).\n\n8. **Test currency-argument consistency.** Send a transfer/quote mutation with mismatched\n   `sourceCurrency`/`targetCurrency` combinations the UI never generates (e.g. self-transfer with\n   a currency conversion) and check whether the resolver's FX-rate lookup and the ledger write use\n   the same rate — a TOCTOU window here is a direct arbitrage bug.\n\n9. **Combine alias batching with money-movement mutations** to test for double-spend — see\n   `hunt-race-condition` for the parallel-HTTP escalation once alias batching alone confirms the\n   resolver isn't serializing writes per-account.\n\n---\n\n## Payload & Detection Patterns\n\n**Idempotency-key replay test:**\n```graphql\nmutation {\n  transferFunds(input: {\n    idempotencyKey: \"test-key-001\"\n    sourceAccountId: \"acc_1\"\n    destAccountId: \"acc_2\"\n    amount: \"10.00\"\n  }) { transactionId status }\n}\n```\nSend twice with the identical `idempotencyKey`. Two successful, distinct `transactionId` values\n= idempotency not enforced.\n\n**Decimal-precision / rounding probes:**\n```graphql\nmutation { transferFunds(input: {sourceAccountId:\"acc_1\", destAccountId:\"acc_2\", amount: \"0.001\"}) { transactionId } }\nmutation { transferFunds(input: {sourceAccountId:\"acc_1\", destAccountId:\"acc_2\", amount: \"9999999999999999.99\"}) { transactionId } }\nmutation { transferFunds(input: {sourceAccountId:\"acc_1\", destAccountId:\"acc_2\", amount: \"1e2\"}) { transactionId } }\nmutation { transferFunds(input: {sourceAccountId:\"acc_1\", destAccountId:\"acc_2\", amount: \"-50.00\"}) { transactionId } }\n```\nSub-cent amounts test truncate-vs-round handling (repeat N times to accumulate a rounding-error\nbalance drift); scientific notation and oversized values test whether the `Money`/`Decimal`\nscalar parser falls back to a native float/int with overflow or precision-loss behavior; negative\namounts test whether the resolver assumes sign server-side or trusts the client's.\n\n**Alias-batched double-spend probe (confirm before escalating to parallel HTTP):**\n```graphql\nmutation {\n  r1: redeemRewards(input: {rewardId: \"rwd_1\", accountId: \"acc_1\"}) { success }\n  r2: redeemRewards(input: {rewardId: \"rwd_1\", accountId: \"acc_1\"}) { success }\n  r3: redeemRewards(input: {rewardId: \"rwd_1\", accountId: \"acc_1\"}) { success }\n}\n```\nIf more than one alias succeeds against a single-use reward/coupon, the resolver doesn't\nserialize per-account/per-resource writes within a batched request — see `hunt-race-condition`\nfor combining this with parallel HTTP POSTs to confirm real double-spend impact.\n\n**Source-account authorization probe (asymmetric IDOR check):**\n```graphql\nmutation {\n  transferFunds(input: {\n    sourceAccountId: \"VICTIM_ACCOUNT_ID\"\n    destAccountId: \"ATTACKER_CONTROLLED_ACCOUNT_ID\"\n    amount: \"1.00\"\n  }) { transactionId status }\n}\n```\nRun as the attacker's own session/token. Success = the resolver validated the destination is\nattacker-controlled (obviously required) but never validated that the source belongs to the\ncaller.\n\n**Nested field-level PII probe:**\n```graphql\nquery {\n  transaction(id: \"txn_123\") {\n    amount\n    counterparty { displayName ssnLast4 routingNumber kycStatus }\n  }\n}\n```\nQuery as a user with no relationship to the counterparty beyond a shared transaction; success on\nthe nested PII fields is the finding even if the top-level `transaction` query correctly scoped\nthe transaction itself.\n\n**Mass-assignment probe on admin-shaped input fields:**\n```graphql\nmutation {\n  updateTransaction(input: {id: \"txn_123\", status: \"COMPLETED\", amount: \"0.01\"}) { id status }\n}\n```\nSend as a non-admin user against a mutation the client UI never exposes these fields for; a\nschema that accepts them anyway is mass assignment onto ledger state.\n\n---\n\n## Common Root Causes\n\n1. **Client-side amount/fee validation only.** The UI computes and displays the correct amount;\n   the resolver trusts whatever the GraphQL client actually sends, because \"the app always sends\n   the right value.\"\n2. **Non-atomic multi-entry ledger writes.** Debit, credit, and fee entries are written as\n   separate sequential statements instead of inside a single transaction/lock — the race window\n   this creates is exactly what alias batching + parallel HTTP exploits.\n3. **`Money`/`Decimal` scalar falls back to native float parsing** under edge-case input\n   (scientific notation, oversized strings), reintroducing floating-point rounding error into a\n   system that was supposed to guarantee fixed-point precision.\n4. **Idempotency keys are stored but never checked before executing the write** — the key is\n   logged for support/debugging purposes, not used as a dedup gate.\n5. **Field-level authorization implemented per top-level query, not per type.** A `User`/`Account`\n   type's sensitive fields are protected when queried directly (`me { ssnLast4 }`) but not when\n   the same type is returned nested inside an unrelated query (`transaction { counterparty {...} }`).\n6. **Source-account ownership check missing while destination-account existence check is\n   present** — see methodology step 5. Debiting looks dangerous so it gets reviewed; the \"does\n   this account belong to the caller\" check quietly only gets applied to the credited side.\n7. **Admin/internal mutations reuse the same input type as the public mutation**, just with extra\n   optional fields — nothing at the resolver layer strips those fields for non-admin callers.\n\n---\n\n## Gate 0 Validation\n\nMoney-movement findings need a stricter bar than a typical GraphQL IDOR — \"the query returns\nsomeone else's balance\" is real impact; \"I sent a malformed amount and got a 400\" is not.\n\n1. **Did an actual ledger write occur, and can you show it?** Query the account balance before\n   and after — a state change (not just a `200`/success response body) is the proof.\n2. **Is the win deterministic, not a timing fluke?** For race/double-spend findings, reproduce\n   twice from a clean state. If it only works under specific load conditions, document the window\n   honestly rather than claiming guaranteed exploitability.\n3. **Does the finding move value the attacker didn't have, or reveal data they shouldn't see** —\n   not just \"the mutation accepted an unexpected input type and the API returned an error\n   message.\" A verbose GraphQL error leaking a stack trace on a malformed `Money` scalar is a\n   `hunt-source-leak`-class finding, not a fintech-logic one — don't conflate the two in a report.\n\n---\n\n## Related Skills & Chains\n\n- **`hunt-graphql`** — parent skill for generic GraphQL discovery, introspection bypass, node-ID\n  IDOR, and alias-batching mechanics. Load this skill first; `hunt-fintech-graphql` assumes that\n  methodology and only adds the money-movement-specific delta.\n- **`hunt-business-logic`** — coupon/reward double-redemption and other logic-flaw patterns\n  generalize directly to `redeemRewards`/`applyCoupon`-style mutations here.\n- **`hunt-race-condition`** — the escalation path once alias batching alone confirms a\n  money-movement mutation doesn't serialize writes: combine with parallel-HTTP / single-packet\n  attack for a deterministic double-spend PoC.\n- **`hunt-api-misconfig`** — mass assignment and JWT-claim tampering patterns apply directly to\n  admin-shaped GraphQL input objects reachable by normal users.\n- **`hunt-idor`** — the source-account-vs-destination-account asymmetric authz pattern (step 5) is\n  a fintech-specific instance of the general IDOR-on-mutation-argument class.\n- **`evidence-hygiene`** — balance screenshots and ledger-entry PoCs need the same cookie/PII\n  redaction discipline as any other capture, plus care that a real account number/balance from a\n  live financial account is never included verbatim.\n- **`triage-validation`** — apply Gate 0 above before drafting; a fintech program's triage team\n  will kill anything without a demonstrated ledger state change immediately.","author":"@elementalsouls","ownerProfile":null,"authorContacts":null,"sourceUrl":"https://github.com/elementalsouls/Claude-BugHunter/tree/main/skills/hunt-fintech-graphql","license":"MIT","category":"coding","lang":"en","tokens":3065,"stars":0,"calls30d":2,"claimed":false,"visibility":"public","origin":"crawler","version":"0.1.0","createdAt":"2026-08-22","updatedAt":"2026-08-22","files":[],"requires":{"mcp":[],"tools":[]},"safety":{"flags":[],"scannedAt":"2026-08-22","hasScripts":false,"networkEndpoints":[]}}