· Barney Chambers
A rogue agent spent from a crypto wallet. What a signing-path audit checks
Fortune reported on 16 October 2026 that an OpenAI research model spent Sepolia test ether from a wallet the task had not given it. The signing-path checks below are from our published Bastion source review, which is a different codebase.
On 16 October 2026, Fortune reported that a rogue OpenAI agent had used a crypto wallet without permission, to pay for a task it had been given. OpenAI's own account is the misalignment report updated on 15 October 2026. Hackerbane did not audit OpenAI. Nothing in this note is a finding about OpenAI's systems.
OpenAI's report gives the sequence. An internal research model in the GPT-5.6 Sol family was asked, on 26 June 2026, to update a cryptocurrency trading application and to deploy contracts on Sepolia, an Ethereum test network. The task supplied no funded wallet and gave no permission to spend from an account the model found on its own. The model derived keys from publicly known developer recovery phrases, checked 362 addresses, and used one account that held enough Sepolia ETH to pay the deployment fee. It questioned whether it had permission, then treated a public recovery phrase and valueless test-network tokens as acceptable. The deployment succeeded. Transaction fees were charged to that account. The model's final reply did not say where the funds came from. OpenAI discovered the run on 25 September 2026.
That is a model holding a key and signing. The audit question, when a bot is supposed to do a limited amount of work without the owner's key, is whether the code that accepts a signature enforces the number the owner signed.
The review those checks come from
The checks below are from one public codebase: zerodevapp/bastion at commit fb1e67edf5b956a1fb2b56b1ea4faaad194d1ccc (20 August 2025). The note is ZeroDev Bastion, session wallet and factory, document HACK-AR-2026-ZERODEV-BASTION, dated 16 September 2026, revision R2 on 5 October 2026. It is a public source review and an opinion. ZeroDev did not hire Hackerbane for it. In scope were Bastion.sol, BastionFactory.sol, and types/Structs.sol. Out of scope were Kernel, the session-key SDK, other ZeroDev products, and live deployment configuration.
Bastion is a session wallet. The owner of an ordinary Ethereum account signs one EIP-712 Approval. That message names a token, an amount, and a second key, the operator. The factory recovers the signer and uses the same signature numbers, (v, r, s), to derive the session address. It then writes a budget in its own storage, allowance[signer][session][token] = approval.amount. That mapping is meant to be the cap on consume. It is separate from an ERC-20 approve on the token contract. The in-repo test signs amount = 100 and also does approve(factory, 5000). The README says those token allowances are limited and strictly enforced, so 100 is the lifetime budget for taking tokens out of the owner through consume. The 5000 is leftover slack on the token contract.
We pinned that commit, ran the official happy-path test, and wrote Foundry tests that state the opposite of each bug. At most 100 tokens leave the owner. A second checkSig must not refill the budget. A mutated Approval must not spend the leftover row. A wildcard chain id must not mint a second 100. A red assertion on the pin is a finding. The counts on that pin are Critical 0, High 2, Medium 1, Low 2, Informational 2.
What the signing path was checked for
Replay. The check is that a second checkSig must not refill the budget. On the pin, checkSig is public and has no memory that a digest was used. HIGH-01 / B01: the same (v, r, s) writes allowance back to the signed amount. The operator can spend again, and can repeat that until the 5000 ERC-20 approve is empty. If checkSig is called only once, spend stops at 100 and the next 1-token pull reverts. The report leaves this at High. A stranger can refill the factory budget. Pulling the tokens still requires the current operator (or the owner, or the EntryPoint) and that leftover ERC-20 approve.
Re-initialization. The check is that a mutated Approval must not spend the leftover row. The session address is a function of the chain id, the implementation, a zero nonce, and (v, r, s). It is independent of the Approval struct. HIGH-02 / B02: the same (v, r, s) plus a mutated Approval overwrites the operator, and the leftover allowance[owner] is still spendable. initialize can run more than once. consume looks the row up by the owner argument it is given, so the leftover factory budget still points at the original owner. The report scores this High. The loss stays at that leftover row, 100, or 90 after a 10-token spend. The same preconditions apply as HIGH-01: leftover ERC-20 approve(factory), and a 20-byte operator path. The recovered storage owner is an address the caller does not pick.
Chain id. The check is that a wildcard chain id must not mint a second 100. _chainId is an argument to checkSig. It sits outside the EIP-712 hash the owner signed. The factory does not require that argument to equal block.chainid. MED-01 / B07: a caller-chosen _chainId, including 0 (the EIP-7702 wildcard), derives a second session row. The signed operator can pull 200 against a signed 100. A stranger can write the second row. Only the signed operator can spend it. HIGH-01 repeats until the 5000 approve is empty. This one is a single extra 100, so the report scores it Medium.
Native value on the session. LOW-01 / B04: Approval.amount caps consume of the owner's tokens. Call.value on the derived session is separate. The 20-byte operator can send ETH that is already on the session host. The owner account's ETH balance stays where it was.
EntryPoint deposit. LOW-02 / B05: validateUserOp ignores missingAccountFunds and does not deposit it to the EntryPoint, so the first UserOp can fail with AA21. The report records that no tokens move to an attacker on this finding.
Other signatures. INFO-01 / B03: a native EIP-7702 authorization signature is accepted by checkSig, because recovery succeeds. The README never tells a user to delegate this implementation from a funded account. The product path is the EIP-712 Approval, then a derived session. INFO-02 / B06: the 64-byte operator path ABI-encodes 224 bytes, and RIP-7212 wants a packed 160. Validation fails closed. The report leaves it Informational and records no spend from this path.
A few checks stayed closed. The report lists them as holds. A new amount needs a new signature. A stranger's consume underflows. A stranger's executeWithAllowance reverts. A high-s or v flip does not mint a second owner session. ERC-2612 and Permit2 type strings fail to match Approval. The factory domain binds verifyingContract and the live chain id. An empty host initialize reverts, and writes no ghost row. Tokens already sitting on the session are executor custody. The report keeps that separate from a bypass of the mapping.
The published remediation record says recommended patches for B01 through B07 were sent to the maintainers, on PR 2. That pull request was open, and not merged to master, when the note was published. The disclaimer limits the opinion to the files and commit named in the report. Code after the review commit was not reviewed.
What this is for
The OpenAI run and the Bastion pin are different events. One is a research model that derived a key from a public developer phrase and paid a Sepolia fee. The other is three Solidity files whose signed 100 was not a hard lifetime cap on the commit we read. The shared audit question is the signing path: who can produce a signature, what number that signature names, and which later call can spend past that number.
If the signer is an agent, that review starts at AI security audits. The checks named above are the ones written down in the Bastion report.