Fix: ERC-7579 Session Key Cross-Chain Signature Replay Exploits
In the evolving multi-rollup landscape, ERC-7579 Modular Smart Accounts (such as Biconomy Nexus, ZeroDev Kernel v3, and Safe Modular) utilize deterministic CREATE2 factories. This enables users to maintain the exact same account address across every EVM chain:
$$\text{Address} = \text{keccak256}(0xff \parallel \text{Factory} \parallel \text{Salt} \parallel \text{keccak256}(\text{InitCode}))[12..31]$$
To automate decentralized operations—such as intent-based bridge rebalancing, automated DCA swaps, or game interactions—users delegate restricted permissions to ephemeral Session Keys.
However, smart contract security audits in 2026 have uncovered an alarming vector: Cross-Chain Session Signature Replay Attacks. If an ephemeral session key authorized on Arbitrum is intercepted by an MEV searcher or untrusted bundler, it can be replayed verbatim against the counterfactual account on Base or Optimism, draining deposited tokens without triggering transaction reverts.
1. Vulnerability Anatomy: The Counterfactual Trap
In an ERC-4337 execution lifecycle, the top-level UserOperation is hashed according to:
$$\text{UserOpHash} = \text{keccak256}(\text{pack}(UserOp), \text{entryPoint}, \text{chainId})$$
While this prevents an attacker from replaying the outer UserOperation envelope across different chains, Session Key modules introduce a secondary layer of authentication:
UserOperation
├── sender: 0x8a92... (Counterfactual Account, identical across chains)
├── callData: execute(target, value, data)
└── signature: [SessionProof] + [SessionKeySignature]
The Flawed Implementation
When the modular account receives the call, it delegates validation to the installed SessionKeyValidatorModule:
// VULNERABLE SESSION VALIDATOR MODULE
contract VulnerableSessionValidator is IValidator {
bytes32 public constant SESSION_SPEC_TYPEHASH =
keccak256("SessionSpec(address sessionKey,address target,bytes4 selector,uint256 validUntil)");
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash
) external view override returns (uint256) {
// Unpack session specification from userOp.signature
(SessionSpec memory spec, bytes memory proof, bytes memory sessionSig) =
abi.decode(userOp.signature, (SessionSpec, bytes, bytes));
// 1. Verify that the session key approved this specific target and selector
bytes32 leaf = keccak256(abi.encode(
SESSION_SPEC_TYPEHASH,
spec.sessionKey,
spec.target,
spec.selector,
spec.validUntil
// VULNERABILITY: block.chainid IS MISSING FROM THE PERMISSION DIGEST!
));
// 2. Verify Merkle proof against user's stored session root
require(MerkleProof.verify(proof, userSessionRoots[userOp.sender], leaf), "Invalid session proof");
// 3. Verify that the sessionKey signed the userOpHash
address recoveredSigner = ECDSA.recover(userOpHash, sessionSig);
require(recoveredSigner == spec.sessionKey, "Invalid session signature");
return 0; // Validation success
}
}
Why the Exploit Succeeds
- Target Ambiguity: If a user approves an automated swap on Uniswap v3 (
0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45), that identical contract address often exists on both Arbitrum and Optimism. - Missing Chain Binding: The Merkle leaf containing the session permission does not commit to
block.chainid. - Mempool Sniffing: An attacker captures
specandprooffrom an Arbitrum bundler RPC. - Replay on Target Chain: The attacker constructs a fresh
UserOperationfor the target account on Base. Because the target address matches and the user’s root is identical across counterfactually initialized accounts, the session proof passes verification, and the attacker signs the BaseuserOpHashwith the compromised session key or exploits permissive spending thresholds.
2. On-Chain Detection & Error Signatures
When defensive checks detect a replay or when mismatched domain separators clash, the bundler or node outputs:
RPC Error: -32500: EntryPoint simulation failed: AA24 signature error
Reverted with custom error: SessionKeyInvalidChainId(expected: 8453, received: 42161)
Contract: 0x4B3726715b13B8A88E125656114170D48D7F91C1
Account: 0x8a92Fe0363294025a5fCd206019A806Dbcf51E0E
Or, if an execution hook rejects the post-state transition:
Execution reverted: HookPostCheckFailed(0x9E1... "Unauthorized Cross-Chain Intent")
(For details on hook execution diagnostics, review our guide on ERC-7579 Smart Account HookPostCheckFailed Execution Revert Fix).
3. Cryptographic Remediation Strategy
To achieve cryptographic chain-isolation, session permission leaves, domain separators, and Merkle structures must enforce Multi-Dimensional EIP-712 Domain Scoping.
Hardened Session Leaf Structure
Update the session specification to strictly encapsulate chainId and the explicit account address:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract HardenedSessionValidator {
bytes32 public constant SESSION_SPEC_TYPEHASH = keccak256(
"SessionSpec(address account,uint256 chainId,address sessionKey,address target,bytes4 selector,uint256 spendLimit,uint48 validAfter,uint48 validUntil)"
);
error InvalidChainId(uint256 expected, uint256 received);
error InvalidAccountTarget(address expected, address received);
error SessionExpired(uint48 validUntil, uint256 currentTimestamp);
error InvalidSessionProof();
struct SessionSpec {
address account;
uint256 chainId;
address sessionKey;
address target;
bytes4 selector;
uint256 spendLimit;
uint48 validAfter;
uint48 validUntil;
}
function computeSessionLeaf(SessionSpec memory spec) public pure returns (bytes32) {
return keccak256(abi.encode(
SESSION_SPEC_TYPEHASH,
spec.account,
spec.chainId,
spec.sessionKey,
spec.target,
spec.selector,
spec.spendLimit,
spec.validAfter,
spec.validUntil
));
}
function validateSession(
address account,
bytes32 sessionRoot,
SessionSpec memory spec,
bytes32[] memory proof
) public view returns (bool) {
// 1. Strict Chain-ID Assert
if (spec.chainId != block.chainid) {
revert InvalidChainId(block.chainid, spec.chainId);
}
// 2. Strict Account Origin Assert
if (spec.account != account) {
revert InvalidAccountTarget(account, spec.account);
}
// 3. Temporal Validity Verification
if (block.timestamp > spec.validUntil) {
revert SessionExpired(spec.validUntil, block.timestamp);
}
// 4. Verify Cryptographic Merkle Inclusion
bytes32 leaf = computeSessionLeaf(spec);
if (!MerkleProof.verify(proof, sessionRoot, leaf)) {
revert InvalidSessionProof();
}
return true;
}
}
4. Frontend SDK Hardening (Viem & Permissionless.js)
When generating session key proofs in TypeScript, ensure your client passes the active chain’s ID explicitly:
import { encodeAbiParameters, parseAbiParameters, keccak256 } from 'viem';
export function createHardenedSessionLeaf(params: {
account: `0x${string}`;
chainId: bigint;
sessionKey: `0x${string}`;
target: `0x${string}`;
selector: `0x${string}`;
spendLimit: bigint;
validAfter: number;
validUntil: number;
}) {
const TYPEHASH = keccak256(
Buffer.from(
'SessionSpec(address account,uint256 chainId,address sessionKey,address target,bytes4 selector,uint256 spendLimit,uint48 validAfter,uint48 validUntil)'
)
);
return keccak256(
encodeAbiParameters(
parseAbiParameters('bytes32, address, uint256, address, address, bytes4, uint256, uint48, uint48'),
[
TYPEHASH,
params.account,
params.chainId, // Always bound to current publicClient.chain.id
params.sessionKey,
params.target,
params.selector,
params.spendLimit,
params.validAfter,
params.validUntil,
]
)
);
}
5. Security Checklist for Modular Account Deployers
- Never Share Session Roots Across Chains: If deploying an account multi-chain, maintain independent session roots per network or ensure leaf hashes commit to
block.chainid. - Restrict Target Contract Addresses: Never create wildcard session keys (
target: address(0)) across multiple chains. - Bind Spending Limits Per Chain: Token contracts have different decimals and values across chains. Do not reuse a single Merkle leaf for both Ethereum mainnet and L2 testnets.
- Revoke Stale Session Keys Immediately: Use on-chain epoch nonces to invalidate all active session keys if an off-chain server or agent endpoint is suspected of being compromised.