LayerZeroFault
wallet security-fixes Updated:

ERC-7579 Session Key Cross-Chain Signature Replay Exploits

VV

Written by

Updated:

Technical Guide

To fix ERC-7579 session key cross-chain replay vulnerabilities, implement EIP-712 typed domain separators containing immutable chainId and account address parameters in all session permission manifests. Without explicit chain binding, session signatures and merkle proofs authorized for micro-transactions on low-cost layer-2 networks can be replayed by malicious relayers on mainnet smart accounts sharing identical deployment addresses and validator module configurations.

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.

[!TIP] Diagnostic Sandbox: Validate session key calldata and decode packed UserOperations locally with the ERC-4337 Calldata Decoder. Learn how to debug reverting hook modules in our ERC-7579 Hook Post-Check Revert Guide.

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).

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.
Interactive Diagnostic Sandbox

Local UserOp & Protocol Security Sandbox

Decode UserOperation calldata, simulate prefund accounting, and test bundler validation rules in your local browser.

Companion Diagnostic Guide ERC-7579 Smart Account 'HookPostCheckFailed' Execution Revert →

Resolve ERC-7579 modular smart account execution reverts caused by HookPostCheckFailed and PreCheckFailed in Biconomy Nexus, Kernel v3, and Rhinestone modules.

Read Guide
Contextual Architecture

Related Diagnostic Guides

View All in Silo →

Related Inquiries

How are ERC-7579 session keys replayed across chains?

When a session key validator uses counterfactual account deployments with matching addresses across chains, an off-chain session signature lacking chainId domain separation can be submitted to execute unauthorized calls on any other chain.

What is the recommended cryptographic pattern for ERC-7579 session permissions?

Hash session parameters using EIP-712 domain separation that incorporates block.chainid, the smart account address, and an account-specific session nonce before verifying the session key's merkle proof.

Can session keys bypass ERC-7579 execution hooks?

No. In compliant ERC-7579 implementations, execution hooks run regardless of whether an execution is triggered by a root owner or a session key validator, ensuring global spending limits remain enforced.

How can I verify if an account is vulnerable to session key replay?

Inspect the session validator module contract to verify whether validateSessionKeyUserOp hashes the permission struct with a dynamic chainId check or relies on static cross-chain proofs.