← All writing

Security Research / September 30, 2026

The Signature Is the Payload: How an AI Agent Wallet Paid Its Attacker 

A hostname-prefix allowlist bypass, an autopilot payment path missing its guards, and a signed EIP-3009 USDC authorization handed straight to the attacker.

AI agent wallets are becoming payment infrastructure: give the model a tool, let it pay for API calls and services autonomously, and rely on configuration to keep the spending inside boundaries. During recent research into one such framework, we mapped a chain where every one of those boundaries fails in sequence, ending with the wallet cryptographically authorizing an attacker-selected USDC transfer.

The finding was reported privately through a coordinated disclosure channel and triaged as a duplicate, so this post deliberately withholds the affected vendor. The technique is worth dissecting anyway, because the failure pattern generalizes to any agent framework that pairs an allowlist with an autonomous payment path: each flaw is unremarkable alone, and the chain only exists because they were all true at once.

Headline: a URL that merely starts with an allowlisted origin, an automatic payment action that skips the spending guards, and a signed EIP-3009 authorization delivered back to whoever answered the HTTP request.

The setup: what the wallet promises

The framework we examined documents its x402 payment provider with four guarantees:

  • only registered service URLs can be called;
  • payments are restricted to USDC;
  • a maximum payment amount is enforced per request;
  • the payment network must be compatible with the wallet.

Two payment paths exist. The reviewed path is a two-step, confirmation-gated flow, and it genuinely implements all four checks. Alongside it sits an automatic, direct-payment action, documented as skipping the human confirmation flow so the agent can pay without a prompt.

The security question is what “skipping confirmation” means. If it means “don’t ask the human,” fine. In practice it meant “don’t evaluate the administrator’s allowlist, asset restriction, spending cap, or network restriction.”

Root cause 1: the allowlist that matches too much

The registered-service validator compares the requested URL against each configured service using raw string prefix matching, in both the TypeScript and Python SDKs:

const parsed = new URL(url);
const origin = parsed.origin;

for (const registered of registeredServices) {
    if (origin === registered || url.startsWith(registered)) {
        return true;
    }
}
if origin == registered or url.startswith(registered):
    return True

The parsed origin is computed and then ignored. With an origin-form registration, which is exactly the format the framework’s own configuration examples use:

registered:  https://api.example.com
candidate:   https://api.example.com.attacker.example/pay

candidate.startsWith(registered) == true
parsed.origin                    == https://api.example.com.attacker.example
strict origin equality           == false

Anyone who can register api.example.com.attacker.example passes the boundary without ever touching api.example.com. No compromise of the real service is required, just DNS.

Root cause 2: the autopilot skips the guards

The confirmed two-step flow enforces USDC-only assets, validates the amount against maxPaymentUsdc, and checks that the payment network matches the wallet. The automatic direct action contains none of those checks before invoking the payment-enabled request. The PoC asserted the divergence from the pinned public source in both SDKs:

ControlConfirmed two-step pathAutomatic direct path
USDC-only asset validationPresentAbsent
Maximum payment validationPresentAbsent
Wallet/network compatibility validationPresentAbsent

Skipping human confirmation should not disable administrator-configured policy. The fact that the identical controls exist a few functions away shows they were intended restrictions, not presentation-layer conveniences.

Root cause 3: the payment client trusts the server

The underlying x402 client, when no specific networks are configured, registers its EVM scheme against a wildcard network selector (eip155:*). From there, the server is in charge. An x402 endpoint responds to a paid request with a 402 Payment Required carrying paymentRequirements, and the client builds the EIP-3009 signing payload directly from those fields:

const authorization = {
    from: signer.address,
    to:    getAddress(requirements.payTo),   // server-controlled
    value: requirements.amount,              // server-controlled
    validAfter: ..., validBefore: ..., nonce,
};

// EIP-712 domain, also server-selected:
//   verifyingContract: getAddress(requirements.asset)
//   chainId:           getEvmChainId(requirements.network)

So for a USDC asset on an EVM network the wallet supports, a requirement of 100 USDC to an attacker-chosen address becomes exactly that: a signed authorization for 100 USDC to the attacker, even when the administrator configured maxPaymentUsdc = 1, because the automatic path never evaluates the cap.

Root cause 4: the signature leaves the building

The payment client encodes the signed payload and sends it to the server that supplied the requirements, as the value of the request’s payment header (PAYMENT-SIGNATURE). The signature is not retained internally; it is transmitted to the counterparty. And the counterparty does not need the wallet’s private key to act on it, because USDC’s contract publicly exposes the EIP-3009 settlement primitive:

function transferWithAuthorization(
    address from,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    ...
) external

A valid authorization binds from, to, value, the validity window and a nonce. Whoever holds it can settle the transfer on-chain during its validity period, limited only by the wallet’s balance.

The chain, end to end

attacker-influenced URL
        |
        v
allowlist prefix bypass  (startsWith passes, origin differs)
        |
        v
automatic payment action  (no USDC check, no cap, no network check)
        |
        v
attacker-controlled 402 requirements
        |   payTo   -> signed recipient
        |   amount  -> signed value
        v
wallet signs EIP-3009 TransferWithAuthorization
        |
        v
signed payload returned in PAYMENT-SIGNATURE
        |
        v
transferWithAuthorization() settles on-chain
        |
        v
potential loss of wallet funds

Reproducing it safely

Our reproduction was entirely non-destructive, and the same structure works for auditing any similar framework:

  1. Pin and retrieve public source. Download the exact public revisions under test; never reason from memory of a codebase.
  2. Assert the predicate confusion. Feed https://api.example.com.attacker.example/pay through the real validator logic: the prefix test passes while strict origin equality fails.
  3. Assert the control divergence. Extract the confirmed-flow and automatic-flow function bodies and check which guards each contains, in every supported language.
  4. Trace the field mapping. Confirm from source that server-controlled requirement fields land directly in the signed authorization and the EIP-712 domain.
  5. Demonstrate signature validity locally. Using a disposable, publicly known private key, sign a TransferWithAuthorization for a placeholder recipient and recover the signer from the signature. Recovery matching proves the structure is cryptographically sound.

No funded wallet, no RPC endpoint, no production payment endpoint, and no on-chain transaction were used at any point. The generated authorization was never submitted; disclosure of a cryptographically valid transfer authorization to an attacker-controlled server is the vulnerability, and demonstrating validity locally is enough to prove it.

Impact and preconditions

The chain bypasses two independent administrator-defined financial boundaries at once: the service allowlist and the per-request payment cap. The output is not a policy violation or an HTTP oddity, it is a cryptographically valid token-transfer authorization carrying the victim wallet, an attacker-chosen recipient, an attacker-chosen amount, a validity window, a nonce and a signature, delivered to the attacker’s server. Maximum single-transfer impact is the attacker-selected amount, bounded by the wallet’s balance; repeated exploitation can drain it.

The practical preconditions are modest: the affected integration must expose the automatic payment action, an attacker must be able to influence the URL that action is pointed at (which is the normal state of an agent browsing or calling tools on untrusted input), and the wallet needs balance worth taking.

Fixing it

Three changes close the chain, and they are worth implementing in that order:

  1. Compare origins, not prefixes. Parse both the registration and the candidate, then compare scheme, hostname and port. If path-scoped registrations are supported, validate the origin first and compare normalized path components. Redirect destinations need the same treatment before any payment material is created or forwarded.
  2. Centralize payment policy. The allowlist, asset restriction, spending cap and network check should live inside the payment client itself, evaluated before any signing operation, so that no action wrapper can accidentally bypass them. “Skip the confirmation prompt” and “skip the policy” must be different code paths.
  3. Fail before signing. Any failure of service, asset, amount or network validation must abort before signTypedData() is ever called.

Regression tests worth shipping: the prefix variant must be rejected; the automatic path must refuse over-cap amounts, non-USDC assets and incompatible networks; and in every rejection case, the signing function must never be invoked. Test it in every language the SDK ships.

Closing

The lesson generalizes beyond this one framework. In an agentic payment system, the dangerous primitive is not the chat, it is the signature, and every guard that stands between untrusted input and that signature has to be enforced at the primitive itself. A wrapper action that “skips confirmation” while also skipping the allowlist, the cap and the network check is not a convenience feature; it is a remittance API with the model holding the pen. If your agent can be talked into visiting a URL, assume the URL’s owner can eventually be handed your wallet’s signature, and design the payment stack so that the answer to that sentence is no.