← Back to Blog

ERC-7579 Liquidation Protection Session Keys Guide

2026-06-29 smart-accounts erc-7579 erc-4337 solidity-security session-keys liquidation-protection smart-contract-audit

ERC-7579 Liquidation Protection Session Keys: Preventing Batch Execution Bypass

In early 2026, DeFi liquidations on EVM-compatible networks resulted in over $43M in user collateral losses, many of which could have been prevented by automated, self-custodial liquidation protection bots. ERC-7579 modular smart accounts enable developers to delegate execution authority to these bots using conditional session keys. However, custom validator modules designed for liquidation protection often suffer from integration vulnerabilities. If a validator validates single executions but fails to handle batch execution modes, attackers can bypass security rules. This article analyzes how session key validators fail under batch execution, provides compilable Solidity code demonstrating the exploit, and details how to mitigate these vulnerabilities.


Technical Background: ERC-7579 and the Validation Flow

To understand why liquidation protection modules fail, we must first examine the architecture of ERC-7579 and its relation to ERC-4337.

ERC-4337 introduces account abstraction by splitting the transaction lifecycle into a validation phase and an execution phase. In the validation phase, the EntryPoint contract calls the smart account's validateUserOp function to verify the signature and gas parameters of a PackedUserOperation.

ERC-7579 modularizes this process. Instead of hardcoding validation logic inside the smart account, the account delegates validation to external modules called Validators.

+-------------+                 +---------------+                 +--------------------+
|  EntryPoint | --validate-->  | Smart Account | --validate-->   | Validator Module   |
| (ERC-4337)  |                |  (ERC-7579)   |                 | (e.g. Session Key) |
+-------------+                 +---------------+                 +--------------------+
       |                                |
   execution                        execution
       v                                v
+---------------+               +---------------+
| Target (Aave) | <-------------| Smart Account |
+---------------+               +---------------+

For automated liquidation protection, a user registers a Session Key validator. The bot is granted a session key—a temporary cryptographic key pair stored in the bot's secure environment. The validation module restricts this session key so it can only execute specific actions under defined conditions:
1. Target restriction: The key can only interact with a specific DeFi protocol (such as the Aave V3 Pool address).
2. Function restriction: The key can only call the repayment function (e.g., repay).
3. Timebounds: The key is only valid within a certain block timestamp range.

When the user’s collateral ratio falls near the liquidation threshold, the bot signs a transaction with the session key to repay the debt and save the collateral.

The ERC-7579 Execution Interface and ModeCode

Unlike legacy smart accounts that use explicit function signatures for different execution pathways (like execute and executeBatch), ERC-7579 unifies execution through a single interface:

function execute(bytes32 mode, bytes calldata executionCalldata) external payable;

The bytes32 mode parameter, known as the ModeCode, dictates how the execution should be handled. It is structured as follows:
- CallType (1 byte): 0x00 for a single call, 0x01 for a batch call, and 0xFE for a delegatecall.
- ExecType (1 byte): 0x00 to revert on failure, 0x01 to use try/catch logic.
- Unused (4 bytes): Reserved for future updates.
- ModePayload (26 bytes): Optional metadata or payload data.

Because the account's execution behavior changes dynamically based on the ModeCode, the validator module must decode and inspect both the mode and the executionCalldata to ensure the session key does not exceed its permissions.


The Vulnerable Session Key Validator

The vulnerability occurs when a validator module assumes that all executions are single calls. If the validator only decodes executionCalldata under the assumption that callType is 0x00 (single call), and fails to reject or handle other call types (like batch calls), it introduces a critical authorization bypass.

Below is a compilable Solidity implementation of a vulnerable conditional session key validator.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

struct PackedUserOperation {
    address sender;
    uint256 nonce;
    bytes initCode;
    bytes callData;
    bytes32 accountGasLimits;
    uint256 preVerificationGas;
    bytes32 gasFees;
    bytes paymasterAndData;
    bytes signature;
}

interface IERC7579Validator {
    function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash) external view returns (uint256);
}

/**
 * @title VulnerableLiquidationProtectionModule
 * @notice A custom validator module designed to restrict session keys to Aave repayments.
 * @dev THIS CONTRACT CONTAINS A CRITICAL SECURITY VULNERABILITY.
 */
contract VulnerableLiquidationProtectionModule is IERC7579Validator {
    using ECDSA for bytes32;
    using MessageHashUtils for bytes32;

    address public constant AAVE_POOL = 0x87870Bca3F12E4A964005b7B9b85992e5c862dff;
    bytes4 public constant REPAY_SELECTOR = 0x573daee0; // repay(address,uint256,uint256,address)

    struct SessionKey {
        address key;
        uint48 validUntil;
        uint48 validAfter;
    }

    // Mapping from smart account address to registered session key
    mapping(address => SessionKey) public sessionKeys;

    function validateUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash
    ) external view override returns (uint256) {
        // 1. Verify Session Key Signature
        bytes32 ethHash = userOpHash.toEthSignedMessageHash();
        address signer = ethHash.recover(userOp.signature);
        SessionKey memory session = sessionKeys[userOp.sender];

        if (signer != session.key) {
            return 1; // SIG_VALIDATION_FAILED (standard ERC-4337 return value)
        }

        // 2. Verify Timebounds
        if (block.timestamp < session.validAfter || block.timestamp > session.validUntil) {
            return 1;
        }

        // 3. Verify Execution Parameters
        // Standard ERC-7579 execute selector: 0xe9ae5c53 -> execute(bytes32,bytes)
        bytes4 selector = bytes4(userOp.callData[0:4]);
        if (selector == 0xe9ae5c53) {
            (bytes32 mode, bytes memory executionCalldata) = abi.decode(
                userOp.callData[4:], 
                (bytes32, bytes)
            );

            bytes1 callType = mode[0];
            if (callType == 0x00) { // Single execution
                (address target, , bytes memory data) = abi.decode(
                    executionCalldata, 
                    (address, uint256, bytes)
                );

                // Restrict interactions solely to Aave's repay function
                if (target != AAVE_POOL || bytes4(data[0:4]) != REPAY_SELECTOR) {
                    return 1;
                }
            }
            // VULNERABILITY: If callType is 0x01 (Batch) or 0xFE (Delegatecall),
            // the module falls through the conditional check and returns 0 (Validation Success). /* HIGHLIGHT */
        } else {
            return 1; // Reject unrecognized selector
        }

        return 0; // Validation succeeds
    }

    /**
     * @notice Registers a new session key for the calling smart account.
     */
    function registerSessionKey(address _key, uint48 _validAfter, uint48 _validUntil) external {
        sessionKeys[msg.sender] = SessionKey({
            key: _key,
            validAfter: _validAfter,
            validUntil: _validUntil
        });
    }
}

Exploit Scenario: Bypassing Validation via Batch Execution

During smart contract audits of early ERC-7579 implementations, auditors flagged validator logic that failed to account for multi-execution formats. If an attacker compromises the session key (e.g., through an off-chain database leak) or exploits a malicious third-party bot provider, they can bypass the target restrictions completely.

Here is the step-by-step breakdown of how the vulnerability in VulnerableLiquidationProtectionModule is exploited:

  1. Targeting the Account: The attacker acquires the private key of a session key registered to a target smart account. Under normal constraints, this session key can only call repay on Aave.
  2. Structuring a Batch Execution: Instead of drafting a single-call payload, the attacker constructs a batch execution payload. In ERC-7579, batch execution expects an array of Execution structs:
    solidity struct Execution { address target; uint256 value; bytes callData; }
    The attacker sets up a batch containing a single execution (or multiple) designed to drain the smart account:
  3. target: The contract address of a valuable ERC-20 token (e.g., USDC).
  4. value: 0.
  5. callData: The signature and parameters for transfer(address recipient, uint256 amount), transferring all funds to the attacker.
  6. Encoding the ModeCode: The attacker sets the ModeCode to use callType = 0x01 (Batch Call).
  7. Generating the User Operation: The attacker wraps this execution payload in a PackedUserOperation where userOp.callData calls execute(mode, executionCalldata) on the smart account.
  8. Validation Bypass:
  9. The ERC-4337 EntryPoint executes the validation phase, calling the module's validateUserOp.
  10. The signature is valid because the attacker signed it with the compromised session key.
  11. The timebounds are valid.
  12. The validator decodes the userOp.callData parameters. It reads callType = mode[0].
  13. Because callType is 0x01, the execution flow completely skips the if (callType == 0x00) branch. No validation is applied to the target or the calldata.
  14. The function returns 0 (Validation Success).
  15. Execution Phase: The transaction proceeds to execution. The smart account unpacks the batch and transfers the user's ERC-20 assets directly to the attacker's address.

Mitigation: Hardening the Session Key Validator

To fix this vulnerability, the validator module must either:
1. Explicitly validate batch executions by iterating over all execution elements in the array and verifying each one.
2. Explicitly reject any call type that is not a single execution (0x00).

Below is the hardened version of the validator module. It implements both checks, ensuring that batch executions are parsed and validated, while unrecognized call types (like delegatecall) are rejected.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

struct PackedUserOperation {
    address sender;
    uint256 nonce;
    bytes initCode;
    bytes callData;
    bytes32 accountGasLimits;
    uint256 preVerificationGas;
    bytes32 gasFees;
    bytes paymasterAndData;
    bytes signature;
}

interface IERC7579Validator {
    function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash) external view returns (uint256);
}

/**
 * @title SecureLiquidationProtectionModule
 * @notice Hardened validator module enforcing strict execution path checks for session keys.
 */
contract SecureLiquidationProtectionModule is IERC7579Validator {
    using ECDSA for bytes32;
    using MessageHashUtils for bytes32;

    address public constant AAVE_POOL = 0x87870Bca3F12E4A964005b7B9b85992e5c862dff;
    bytes4 public constant REPAY_SELECTOR = 0x573daee0; // repay(address,uint256,uint256,address)

    struct SessionKey {
        address key;
        uint48 validUntil;
        uint48 validAfter;
    }

    struct Execution {
        address target;
        uint256 value;
        bytes callData;
    }

    mapping(address => SessionKey) public sessionKeys;

    function validateUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash
    ) external view override returns (uint256) {
        // 1. Verify Session Key Signature
        bytes32 ethHash = userOpHash.toEthSignedMessageHash();
        address signer = ethHash.recover(userOp.signature);
        SessionKey memory session = sessionKeys[userOp.sender];

        if (signer != session.key) {
            return 1;
        }

        // 2. Verify Timebounds
        if (block.timestamp < session.validAfter || block.timestamp > session.validUntil) {
            return 1;
        }

        // 3. Verify Execution Parameters
        bytes4 selector = bytes4(userOp.callData[0:4]);
        if (selector == 0xe9ae5c53) { // execute(bytes32,bytes)
            (bytes32 mode, bytes memory executionCalldata) = abi.decode(
                userOp.callData[4:], 
                (bytes32, bytes)
            );

            bytes1 callType = mode[0];
            if (callType == 0x00) { // Single execution
                (address target, , bytes memory data) = abi.decode(
                    executionCalldata, 
                    (address, uint256, bytes)
                );

                if (target != AAVE_POOL || bytes4(data[0:4]) != REPAY_SELECTOR) {
                    return 1;
                }
            } else if (callType == 0x01) { // Batch execution
                // Decode the execution array representing the batch
                Execution[] memory executions = abi.decode(executionCalldata, (Execution[]));

                // Validate every single execution in the batch
                uint256 length = executions.length;
                for (uint256 i = 0; i < length; i++) {
                    if (executions[i].target != AAVE_POOL || bytes4(executions[i].callData[0:4]) != REPAY_SELECTOR) {
                        return 1;
                    }
                }
            } else {
                // Explicitly reject unsupported execution types (e.g. delegatecall 0xFE)
                return 1;
            }
        } else {
            return 1;
        }

        return 0; // Validation succeeds
    }

    function registerSessionKey(address _key, uint48 _validAfter, uint48 _validUntil) external {
        sessionKeys[msg.sender] = SessionKey({
            key: _key,
            validAfter: _validAfter,
            validUntil: _validUntil
        });
    }
}

Trade-offs: Batch Verification vs. Strict Rejection

Developers designing validation modules must weigh the functional requirements of their bots against gas costs and implementation complexity:


Pre-Deployment Checklist

Before deploying a custom ERC-7579 validator or session key module, ensure your development workflow covers these points:


FAQ

Why does ERC-7579 use ModeCode instead of separate execute and executeBatch functions?

ERC-7579 unifies the execution interface of modular smart accounts to improve interoperability. By routing all executions through execute(bytes32,bytes), validator and executor modules can interact with any compliant smart account using a single, consistent entry point. The specific execution instructions—such as whether a call is a single execution, a batch, or a try/catch execution—are encoded in the standard ModeCode payload.

How much gas overhead does decoding batch executions add during validation?

Decoding batch execution arrays during validation incurs additional gas overhead due to memory expansion and loop execution. For a typical validation transaction, decoding an array of Execution structs adds between 5,000 and 15,000 gas, depending on the compiler optimization configuration and the number of elements in the batch. If the liquidation bot only requires single execution logic, rejecting batch calls entirely is the most gas-efficient and secure option.

What is the risk of allowing delegatecall (0xFE) in a session key validator?

A delegatecall executes the code of the target contract within the storage context of the calling smart account. If a session key validator allows the bot to trigger a delegatecall to an arbitrary address, the target contract can directly modify the smart account's internal state. This allows an attacker to overwrite owner variables, replace installed modules, or bypass all security validation checks completely.


Securing modular smart account components requires rigorous testing and analysis. Verify the security of your custom ERC-7579 modules with ContractScan.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →