Unstoppable
This is the first from a series of blog posts in which I would document my Damn Vulnerable DeFi journey. Hope you find it helpful.
Goal
In this first challenge we are presented with a tokenized vault. The vault has 1 000 000 DVT tokens deposited. We (as a player) start with a balance of 10 DVT tokens. The vault offers free flash loans for a grace period of 30 days. Our goal is to somehow break the vault so it stops offering flash loans.
Prerequisites
This challenge assumes some knowledge on ERC-3156: Flash Loans and ERC-4626: Tokenized Vault. The smart contracts in this challenge also use the OpenZeppelin and Solmate libraries. These are popular open-source frameworks which help engineers build secure and reliable smart contracts.
If you are not familiar with these standards and libraries, I would recommend that you get acquainted with them at least on a basic level before diving in.
Rundown
Let’s first explore the contracts in this challenge before diving into an in-depth analysis and coming up with any attack vectors. There are 2 contracts - UnstoppableVault
and ReceiverUnstoppable
. Let’s start with the latter since it’s the simpler of the two.
ReceiverUnstoppable
This contract is a minimal implementation of a flash loan borrower. Nothing interesting is really going on here. The contract implements OpenZeppelin’s IERC3156FlashBorrower
interface. The onFlashLoan
callback function performs a set of validations and approves the return of the borrowed funds as per ERC-3156.
It also has an external function called executeFlashLoan(amount)
which is pretty self-explanatory. It’s restricted to the contract owner
.
UnstoppableVault
This is the contract that our attack should target.
contract UnstoppableVault is IERC3156FlashLender, ReentrancyGuard, Owned, ERC4626 {
...
}
From the very beginning of the contract definition, we see that UnstoppableVault
extends Solmate’s ReentrancyGuard
which provides reentrancy protection for functions marked as nonReentrant
. It also extends Solmate’s Owner
which enables the contract to have an owner
and allows certain functions to be restricted only to the owner
via the onlyOwner
modifier.
The contract is also a tokenized vault (ERC4626
). When a user deposits DVT tokens, the vault gives them oDVT tokens in lieu.
We already know from the challenge goal that UnstoppableVault
is a flash loan lender as well. It implements OpenZeppelin’s IERC3156FlashLender
. Upon contract creation, a grace period of 30 days is configured. During this period the flash loan fee is 0%. There is an exception to this rule though - if a user tries to borrow the maximum allowed flash loan, then the fee is 5%. Once the grace period is over, the fee becomes 5% for all flash loans:
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);
}
}
The vault only offers flash loans for the underlying asset and thus maxFlashLoan(address)
returns totalAssets()
if the DVT token address is passed as an argument. Otherwise it returns 0
as per the ERC-3156 spec (which indicates an unsupported flash loan token):
function maxFlashLoan(address _token) public view returns (uint256) {
if (address(asset) != _token)
return 0;
return totalAssets();
}
The vault has a feeRecipient
state variable which as the name implies receives all incurred flash loan fees. The feeRecipent
can only be updated by the contract owner
via the setFeeRecipient(address)
function.
The totalAssets()
function itself is a bit interesting as it contains some logic written in Yul before returning the total amount of assets at the end:
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));
}
I don’t have much experience with Yul but with some googling it was relatively easy to figure out what the code does.
TL;DR: it performs a reentrancy check; if the check fails, the contract reverts with a Reentrant()
error.
Detailed explanation of the Yul code:
sload(0)
loads data from storage slot #0 onto the stack. In our case, this is thelocked
storage variable inherited fromReentrancyGuard
- it checks if the value of
locked
is equal to2
. As per theReentrancyGuard
implementation this indicates the mutex is in a locked state - if the above check is
true
(i.e. reentrancy is detected) thenmstore(0x00, 0xed3ba6a6)
stores0xed3ba6a6
at memory offset0x00
revert(0x1c, 0x04)
reverts the transaction with aReentrant()
error.revert()
halts the contract execution with the error located in memory starting from offset0x1c
to offset0x1c + 0x04
(which is0x1f
). This is the datamstore
previously stored into memory -0xed3ba6a6
. It is the 4-byte signature of theReentrant()
error (you may want check it yourself at 4byte.directory). At this point, you might be slightly confused and wondering whymstore
stores it at offset0x00
but we query it at offset0x1c
. Well, the reason is that the EVM memory layout uses 32-byte words. It also uses big-endian order of bytes. In this case,mstore
stores 4 bytes of data. This means the data only occupies the last 4 bytes of the 32 bytes word. The leading bytes are padded with zeroes.
Last but not least - the flashLoan()
function. On a high level, it is implemented in a pretty standard way. It contains some input validations and checks. What’s interesting is that it also contains an invariant which makes sure the number of vault tokens is equal to the number of underlying asset tokens:
// ...
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
// ...
Below I would explore the ideas that I came up with on how this challenge could be solved.
Idea #1: Drain all funds
My first idea was that if we could somehow drain all the assets from the vault, it would no longer be able to offer flash loans. A possible attack vector would be a reentrancy attack. After some digging through the code it turned out this would not be possible since the Solmate’s ERC-4626 implementation is (expectedly) impeccable and follows the checks-effects-interactions pattern which prevents reentrancy. What’s more, the beforeWithdraw
and afterDeposit
hooks are secured with a nonReentrant
modifier. These hooks are used in all functions which update the token and asset balances (deposit
, mint
, withdraw
, redeem
).
Idea #2: Break some of the checks in flashLoan
function flashLoan(
IERC3156FlashBorrower receiver,
address _token,
uint256 amount,
bytes calldata data
) external returns (bool) {
if (amount == 0) revert InvalidAmount(0);
if (address(asset) != _token) revert UnsupportedCurrency();
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
uint256 fee = flashFee(_token, amount);
ERC20(_token).safeTransfer(address(receiver), amount);
if (receiver.onFlashLoan(msg.sender, address(asset), amount, fee, data) != keccak256("IERC3156FlashBorrower.onFlashLoan"))
revert CallbackFailed();
ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee);
ERC20(_token).safeTransfer(feeRecipient, fee);
return true;
}
The flashLoan
function contains a couple of checks. If we manage to somehow break any of them, we would be able to stop the vault from offering flash loans. After some code inspection it turns out that three of them are input data validations:
- Requested loan
amount
must be a non-zero value; - Loans can only be requested for the vault’s underlying asset tokens. The underlying asset token is configured during contract creation (in
ERC4626
’s constructor) so we cannot really change it; receiver.onFlashLoan()
must returnkeccak256("IERC3156FlashBorrower.onFlashLoan")
as per the ERC-3156 spec. Technically speaking, this is not exactly an input validation butreceiver
is passed a function param;
We have no control over any of these when someone else calls the contract so not much we could do here.
Idea 3: Break the assets == tokens invariant
As previously mentioned, the flashLoan
function contains an invariant which ensures that the # of vault tokens must be equal to the # of underlying asset tokens. Thus, if we are able to break this invariant, we would achieve our goal.
One possibility is to try to use some combination of ERC4625’s balance impacting functions. Let’s take, for example, the withdraw
function. It takes as an argument a number of assets
tokens a user would like to withdraw. As a result, the function burns the corresponding number of vault tokens to maintain the invariant. Let’s see how the withdraw
function works:
function withdraw(
uint256 assets,
address receiver,
address owner
) public virtual returns (uint256 shares) {
shares = previewWithdraw(assets); // No need to check for rounding error, previewWithdraw rounds up.
if (msg.sender != owner) {
uint256 allowed = allowance[owner][msg.sender];
if (allowed != type(uint256).max) allowance[owner][msg.sender] = allowed - shares;
}
beforeWithdraw(assets, shares);
_burn(owner, shares);
emit Withdraw(msg.sender, receiver, owner, assets, shares);
asset.safeTransfer(receiver, assets);
}
We could see the function uses previewWithdraw
to calculate the number of shares to be burnt. Here is the implementation of previewWithdraw
:
function previewWithdraw(uint256 assets) public view virtual returns (uint256) {
uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero.
return supply == 0 ? assets : assets.mulDivUp(supply, totalAssets());
}
The number of shares to burn is calculated via assets.mulDivUp(supply, totalAssets())
. mulDivUp()
is a function which comes with the FixedPointMathLib
library and it calculates the following expression by rounding the result up:
$$
\frac{assets \times supply}{totalAssets}
$$
Let’s dive deeper into the implementation:
function mulDivUp(
uint256 x,
uint256 y,
uint256 denominator
) internal pure returns (uint256 z) {
assembly {
// Store x * y in z for now.
z := mul(x, y)
// Equivalent to require(denominator != 0 && (x == 0 || (x * y) / x == y))
if iszero(and(iszero(iszero(denominator)), or(iszero(x), eq(div(z, x), y)))) {
revert(0, 0)
}
// First, divide z - 1 by the denominator and add 1.
// We allow z - 1 to underflow if z is 0, because we multiply the
// end result by 0 if z is zero, ensuring we return 0 if z is zero.
z := mul(iszero(iszero(z)), add(div(sub(z, 1), denominator), 1))
}
}
Mathematically speaking, this implementation calculates the amount of tokens to burn using the following formula: $$ tokensToBurn = \left[ \frac{(assetsToWithdraw \times totalTokens) -1}{totalAssets}+1 \right] \times \begin{cases} 1,& \text{if } assetsToWithdraw \times totalTokens \not = 0\newline 0, & \text{otherwise} \end{cases} $$
We are not interested in the case where $assetsToWithdraw \times totalTokens = 0$, thus we could simplify the formula like that: $$ tokensToBurn = \frac{(assetsToWithdraw \times totalTokens) -1}{totalAssets}+1 $$
Unfortunately, I was not able to find any value for $assetsToWithdraw$ that would result in $tokensToBurn \not = assetsToWithdraw$. Thus, idea did not prove to be fruitful.
Breakthrough idea
After some further inspection of the code, I realized that for one of the invariant components - totalAssets
, the contract relies entirely on an external contract. The totalAssets()
function calculates the number of asset tokens in the vault via asset.balanceOf(address(this))
. In other words, the vault token queries the DVT ERC-20 for its own balance. This means that if we simply transfer some of our DVT tokens to the vault address via the DVT ERC-20, we would change the number of its of asset tokens without changing the number of its vault tokens. This would break the invariant in the flashLoan()
function and cause the vault stop offering flash loans.
Here’s is how we solve the challenge at unstoppable.challenge.js
:
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
console.log("[Before] Vault assets: %s DVT", ethers.utils.formatEther(await vault.totalAssets()));
console.log("[Before] Vault tokens: %s oDVT", ethers.utils.formatEther(await vault.totalSupply()));
await token.connect(player).transfer(vault.address, ethers.utils.parseEther("2.0"));
console.log("[After] Vault assets: %s DVT", ethers.utils.formatEther(await vault.totalAssets()));
console.log("[After] Vault tokens: %s oDVT", ethers.utils.formatEther(await vault.totalSupply()));
});
Now if we run yarn run unstoppable
we would see we have successfully solved the challenge.
Outro
I hope you found this write-up useful. Since this is my first CTF write-up, any feedback is more than welcome. If you feel something is missing, incorrect or the wording is clunky, please reach out to me on Twitter or Discord.