x402 Vector 2: partial-signing instruction injection
Sections
The trust split in x402 is unusual. The client builds the entire VersionedTransaction. The facilitator signs as feePayer and submits. The facilitator pays gas; the client picks the recipient, the amount, the compute budget — and the rest of the instructions.
Most facilitators validate that the tx contains a TransferChecked for the agreed-upon (mint, amount, recipient). They do not always validate that nothing else is in the tx. That’s the bug.
Post 3 of the x402 attack surface series.
The trust gap
A typical x402 transaction looks like:
[0] ComputeBudgetProgram::SetComputeUnitLimit(40_000)
[1] ComputeBudgetProgram::SetComputeUnitPrice(5)
[2] TokenProgram::TransferChecked(amount=1000, mint=USDC, src, dst)
The facilitator’s /verify endpoint typically:
- Decodes the tx.
- Checks
feePayer == self.address. - Loops through instructions; finds the
TransferChecked; asserts amount + recipient match. - Returns 200.
What the facilitator usually does not check:
- The presence of additional instructions after the transfer.
- Whether the recipient ATA’s authority is a token-2022 mint with a transfer hook that calls back into a malicious program.
- Whether the
mintfield of theTransferCheckedmatches the protocol-spec’d USDC mint byte-for-byte (the SPL token program checks the mint’s pubkey but doesn’t enforce a particular mint).
Three injection patterns
Pattern A: clawback via post-transfer CPI
Append an instruction that calls a custom program. The custom program runs with the client’s authority on the destination ATA — wait, that’s not how Solana auth works.
Re-think: the client signs only their key. So instructions that are appended cannot use the facilitator’s authority. They can:
- Use the client’s keypair (signing already authorized).
- Use any account the client controls.
- Burn compute units the facilitator pays for.
Useful injection #1 (Pattern A1): Burn the facilitator’s CU budget. Append a 30k-CU compute-burning instruction (e.g., a no-op loop in a custom program). The transfer succeeds at 5k CU; the burn at 30k CU; the facilitator pays for 35k CU instead of 5k CU. Per-tx gas drain magnified ~7×.
Useful injection #2 (Pattern A2): Force a fail after the transfer succeeds. If the facilitator’s verify path checks the transfer instruction is present but doesn’t simulate the whole tx, an instruction that asserts a false condition (e.g., a custom assert_value_equal(0, 1)) fails the entire transaction and rolls back the transfer. The client’s PAYMENT-RESPONSE looks valid (signed, submitted), the facilitator paid gas, but no token actually moved. Combined with the settlement race, this is monetisable.
Pattern B: token-2022 transfer hook
If the destination ATA is a token-2022 mint with a transfer hook program, every transfer to that ATA triggers a CPI into the hook program — which runs with the privileges of the SPL Token-2022 invoker.
The client controls the destination. If the client picks a destination ATA on a mint with a hostile transfer hook, the hook runs after the transfer with arbitrary code. The protocol spec says “use USDC”, but spec compliance is enforced by the facilitator’s validator, not by Solana itself.
Pattern C: minimum-amount string trick
Combined with Vector 9 (amount string parsing): the PAYMENT-REQUIRED says "1000" (= $0.001). The client encodes "01000" in the SPL transfer (“1000” with a leading zero, which the validator’s parseInt accepts). The actual on-chain value transferred is 1000 in raw lamports (or whatever the parseInt evaluates to in the validator vs the SPL program). Some validators round on parse; some don’t. Mismatch = pay less than required.
PoC sketch
// Pseudocode — see repo
fn craft_malicious_tx(facilitator: &Pubkey, client: &Keypair, amount: u64) -> VersionedTransaction {
let mut ixs = vec![
compute_budget::set_unit_limit(40_000),
compute_budget::set_unit_price(5),
spl_token::transfer_checked(...),
];
// Inject a CU-burner that fires AFTER the transfer.
ixs.push(custom_program::cu_burn_30k());
let blockhash = recent_blockhash();
let msg = Message::new_with_blockhash(&ixs, Some(facilitator), &blockhash);
let mut tx = VersionedTransaction { signatures: vec![Signature::default()], message: msg.into() };
// Client signs; facilitator's signature stays default until /settle.
let client_sig = tx.message.serialize().sign(client);
tx.signatures[1] = client_sig;
tx
}
Mitigations
The fix is also small but architecturally pointed:
- Whitelist instruction prefix. The facilitator’s
/verifyshould require the instruction list to be exactly[ComputeBudgetSetUnitLimit, ComputeBudgetSetUnitPrice, TransferChecked], no extras. Reject anything with a 4th instruction. - Pin compute unit limit. Don’t accept client-supplied CU budgets above 5k. Inject your own.
- Pin the mint. Don’t accept any mint in the transfer; require an exact match against the facilitator’s allowlist (
USDC mainnet only). - Simulate before sign. Run
simulateTransactionagainst the partially-signed tx before adding feePayer. If sim fails or returns unexpected logs, reject. - Reject token-2022 mints with hooks unless the hook program is on an allowlist.
(1) and (2) together kill Patterns A and the CU-burn variant. (3) and (5) together kill Pattern B. (4) is good defense in depth.
Why the spec hasn’t fixed this
Probably because the original x402 design assumes the client is benign — they’re paying for content, why would they sabotage their own payment? The threat model that breaks this is “the client is also the merchant” or “the client is also a competing facilitator” or just “the client is a researcher”. Once you accept that the spec must work against malicious clients, Pattern A1 (CU burn) is the single highest-impact fix.
Bibliography
- Dax911/x402_mal/research/instruction-injection/
- Solana Token-2022 Transfer Hook: docs.solanalabs.com
- ComputeBudgetProgram: solana.com/docs
Previous: Settlement race ← · Next: Facilitator gas drain →