LayerZeroFault
wallet security-fixes

Fix: ERC-7579 Session Key Cross-Chain Signature Replay Exploits

VV

Written by

Fact-Checked on September 11, 2026

Verified Expert

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.

Placeholder: Cross-Chain Session Key Replay Attack Flowchart Diagram


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

  1. Target Ambiguity: If a user approves an automated swap on Uniswap v3 (0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45), that identical contract address often exists on both Arbitrum and Optimism.
  2. Missing Chain Binding: The Merkle leaf containing the session permission does not commit to block.chainid.
  3. Mempool Sniffing: An attacker captures spec and proof from an Arbitrum bundler RPC.
  4. Replay on Target Chain: The attacker constructs a fresh UserOperation for 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 Base userOpHash with 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).

Placeholder: Diagram of Counterfactual Multi-Chain CREATE2 State Overlap


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

  1. 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.
  2. Restrict Target Contract Addresses: Never create wildcard session keys (target: address(0)) across multiple chains.
  3. 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.
  4. 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.
Partner Spotlight: Gate.io

Trade Securely on Gate.io

Don't risk your assets on centralized silos or unverified endpoints. Trade securely on Gate.io with deep liquidity and institutional-grade security protocols.

Claim $100 Sign-up Bonus

Official Partner Referral Link

Related Inquiries

How does a cross-chain session key replay attack work on smart accounts?

Because ERC-4337 and ERC-7579 modular accounts are deployed counterfactually using deterministic CREATE2 factories, a user possesses the exact same smart account address across Ethereum, Arbitrum, Base, and Optimism. If a session key module signs an off-chain authorization payload that omits or improperly validates block.chainid, an attacker who intercepts the signed UserOperation on one network can broadcast the identical authorization to drain funds on another network.

Why do standard EIP-712 domain separators fail to protect some session key validators?

While the top-level UserOperation hash incorporates the EntryPoint's chainId, session key validator modules (such as Rhinestone Smart Sessions or ZeroDev SessionKeyValidator) verify permissions independently inside their own validateUserOpModule() method. If the internal session permission leaf or Merkle tree proof does not explicitly include the verifying chainId in its hashed leaves, the session delegation itself remains cross-chain replayable.

What is the recommended cryptographic pattern to enforce chain-isolation in ERC-7579 modules?

Smart account architects must bind block.chainid directly into the leaf hash of the session permission tree: keccak256(abi.encode(SESSION_LEAF_TYPEHASH, sessionKey, target, selector, spendLimit, block.chainid)). Furthermore, session key validators must verify that the EntryPoint address and active account match the deployment instance.