reth EIP-7928 report: see how a Hackerbane report is structured. Open the report → (opens in a new tab)

· Oscar Chambers

EIP-7702 security and the ZeroDev Bastion review

EIP-7702 lets an ordinary Ethereum account run contract code, and Bastion uses one owner signature to name both a session address and a token budget. On the commit in the published review, that budget can be refilled, left in place after a second initialization, or copied onto a second session.

EIP-7702 lets an ordinary Ethereum account, the kind that holds a private key, temporarily run contract code. Bastion uses that to build a session wallet: the owner signs one permission, and a second key, the operator, spends inside the limit that permission names.

The published review is HACK-AR-2026-ZERODEV-BASTION. It reads zerodevapp/bastion at fb1e67edf5b956a1fb2b56b1ea4faaad194d1ccc (20 August 2025): Bastion.sol, BastionFactory.sol, and types/Structs.sol. The report date is 16 September 2026. Revision R2, on 5 October 2026, renumbers the document from HB-AR-2026.10 and changes nothing else. The report states that ZeroDev did not hire Hackerbane, and that the file is an opinion about those three files. Kernel, the session-key SDK, and live deployment configuration are out of scope.

What the signature is supposed to do

Bastion does not ask the owner for a separate EIP-7702 popup. The owner signs an EIP-712 typed message, an Approval. That message names a token, an amount, and an operator. The factory recovers the signer from the signature numbers (v, r, s), and it uses those same numbers to compute the session address. One signature is meant to settle who granted the spend and which address will do the spending.

The factory then stores its own budget, allowance[signer][session][token] = approval.amount. That row is a separate cap from the ERC-20 approve on the token. The in-repo test signs an amount of 100 and also approves the factory for 5000. After two spends, the factory row is supposed to read 90, then 80. The README says those allowances are limited and strictly enforced. On that test, 100 is the lifetime cap for pulling tokens out of the owner through consume. The unused 4900 sits on the token contract as slack.

When the operator calls executeWithAllowance, the session asks the factory to debit that row and transferFrom the owner, then runs a list of calls as the session. The 20-byte operator can also move ETH or tokens that already sit on the session address. The report calls that session custody: those tokens have already left the owner through consume, and moving them spends the session's own balance.

The class of bug

The signed number is a cap only if the factory writes it once, on the one address the owner meant. On this commit checkSig does not remember a digest it has already accepted, and it writes that number again. initialize can run a second time on the same session and leaves the original owner's row in place. And the chain id used to derive the session address is a caller argument, while the EIP-712 digest the owner signed does not include that argument. The factory domain still binds the live chain id. The derivation argument is the hole.

Those three gaps are the High and Medium findings. The cover counts are Critical 0, High 2, Medium 1, Low 2, Informational 2.

HIGH-01 / B01 is High. checkSig has no caller gate. On every valid recover it assigns the signed amount again. After a spend, a second call with the same (v, r, s) puts the factory row back to 100. The operator spends again, and can repeat that until the 5000 token approve is empty. The report's control is the same setup without a second checkSig: spend stops at 100, and the next 1-token pull reverts. The extra 4900 is the replay.

The report rates this High. A stranger can refill the factory row. The pull still requires the current 20-byte operator, the owner, the EntryPoint, or the session calling itself, and it still requires the leftover ERC-20 approve of the factory. The official test has both.

HIGH-02 / B02 is High. The session address is fixed by the chain id, the implementation, a nonce of 0, and (v, r, s). A new Approval under the old signature numbers still lands on the same session. initialize has no once-flag. The factory is the only direct caller, and checkSig is public, so that gate stays open. A second Approval, with a new salt and a new operator, overwrites the operator stored on the session. Recovering the new digest yields some other address, an address the caller does not pick. The factory row for the original owner is left in place. consume looks up the budget by the owner argument it is given. After the overwrite, that argument is still the original owner, and the owner stored on the session is the recovered address. The new operator can pull that leftover row from the original owner's wallet.

In the report's walkthrough, a leftover row of 100 takes the owner from 10000 to 9900. After a 10-token spend, the leftover is 90 and the owner goes from 9990 to 9900. Tokens already moved onto the session are the session's own balance. The incumbent operator could already send those, and the report treats that balance as separate from this finding. The report rates this High on the same bound as HIGH-01: the loss stays at the leftover mapping, and the new storage owner is a recovered address.

MED-01 / B07 is Medium. _chainId is an argument to getBastionAddress. The factory does not require it to equal block.chainid. The live chain id and 0, which EIP-7702 treats as a wildcard, produce two Ethereum addresses. Each address gets its own Bastion storage and its own factory row. One Approval, passed once with the live chain id and once with 0, writes two rows of 100. The signed operator can spend both, so the owner loses 200 after signing 100. A stranger can create the second row. Only the signed operator can spend it. HIGH-01 repeats until the token approve is empty. MED-01 adds one extra 100.

Low and informational

LOW-01 / B04 is Low. Approval.amount caps consume of the owner's tokens. Call.value on the session sits outside that cap. If ETH is already on the session, the 20-byte operator can send all of it. The owner account's ETH balance stays where it was. The report's tests place that ETH on the session directly. A normal transfer to a 7702 account after delegation often reverts, because the host has no receive or fallback.

LOW-02 / B05 is Low. validateUserOp receives missingAccountFunds and never deposits that amount to the EntryPoint. The first UserOp can fail with AA21. The report says nothing moves to an attacker. The official test builds the factory with the EntryPoint set to the zero address and never reaches this function on a real call.

INFO-01 / B03 is Informational. checkSig accepts any successful recover, including a raw EIP-7702 authorization signature. A relayer could then pass an operator of their choosing. The report keeps this Informational because the README never asks 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 is Informational. The 64-byte operator path ABI-encodes 224 bytes for the P256 check. RIP-7212 wants 160 bytes, packed. Validation fails closed, so the path does not open a spend. The comment in the source already calls the path a demo.

The review also recorded cases that held on the pin. A new amount needs a new signature. A stranger's consume underflows. A stranger's executeWithAllowance reverts. A high-s signature, or a flipped v, does not mint a second owner session. ERC-2612 and Permit2 type strings are not an Approval. The factory domain binds verifyingContract and the live chain id. An empty host initialize reverts instead of writing a ghost row.

What the report records about the patch

The remediation record says recommended patches for B01 through B07 were sent to the Bastion maintainers. It says PR 2 on zerodevapp/bastion carries a used-digest guard, a wrong-chain-id guard, a delegation-signature guard, one-shot initialize, an owner check on consume, an operator ETH rejection, a UserOp prefund, and packed 160-byte RIP-7212 input. The table dated 16 September 2026 marks independent confirmation as No and the merge as Open. It states that the patch was not on master when the report was published. All seven findings stay open until master carries the patch and a retest is written down. The report's highest priority is HIGH-01 and HIGH-02.

The findings in this note are the ones printed in HACK-AR-2026-ZERODEV-BASTION, for the commit named there.

How a review works. Request an audit.