一、为什么链上治理需要时间锁
你参与过一个DAO投票吗?比如Compound或者Uniswap这种,大家投票通过一个提案,然后合约自动执行。听起来很酷对吧?但这里有个细思极恐的问题:如果提案通过了,执行是一瞬间的事情,万一提案里藏了恶意代码怎么办?更现实的场景是,黑客搞了个看起来人畜无害的提案,大家没仔细看就投了赞成票,然后合约直接执行,所有人的钱瞬间被转走。
为了避免这种“一瞬间暴毙”的风险,聪明人发明了 时间锁。简单说就是:提案投票通过之后,不会立刻执行,而是要等一段时间,比如两天或者七天。这段时间里如果发现问题,大家可以暂停或者取消执行。这就好比你在网上买了个大件,付款之后有个冷静期,可以随时退款。
但时间锁本身并不安全,它也会成为攻击目标。如果你能控制时间锁,或者能绕过时间锁的等待期,那就等于拿到了整个DAO的金库钥匙。另外还有一个更隐蔽的漏洞——投票委托。你以为把投票权委托给一个靠谱的人就万事大吉了?实际上委托机制里也可能藏了坑,权限一不小心就被人劫持了。
下面咱们就从头捋一遍这两个漏洞,用代码说话。
二、时间锁劫持攻击
2.1 攻击原理
时间锁的核心逻辑很简单:有一个queueTransaction(排队交易)函数,把提案的调用数据存起来,记录一个ethereum.timestamp + delay作为可执行时间。然后有一个executeTransaction(执行交易)函数,只有在当前时间大于等于可执行时间时才能调用。正常流程如下:
- 提案通过 -> 调用
queueTransaction-> 等待delay时间 -> 调用executeTransaction执行。 - 如果有紧急情况,可以调用
cancelTransaction取消。
那攻击者怎么劫持呢?常见手法有以下几种:
- 修改时间锁延迟:如果攻击者获得了时间锁合约的管理员权限,就可以把
delay改成0,然后立刻执行任何交易。 - 利用授权漏洞:如果时间锁合约为某些地址授予了
queuedTransactions或者executeTransaction的权限,而那个地址恰好被攻击者控制了,那攻击者就能随意操作。 - 重入+时间锁绕过:通过嵌套调用,在时间锁还没生效前就提走资金。
2.2 具体示例:一个脆弱的时间锁合约
咱们用Solidity写一个简单版的时间锁,看看漏洞在哪。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 一个过于简单的时间锁合约,演示劫持漏洞
contract SimpleTimelock {
uint256 public delay = 7 days; // 默认延迟7天
address public admin; // 管理员地址
mapping(bytes32 => bool) public queuedTransactions; // 已排队交易的哈希
// 只有管理员能调用的修饰器
modifier onlyAdmin() {
require(msg.sender == admin, "Not admin");
_;
}
constructor() {
admin = msg.sender;
}
// 排队一个交易
function queueTransaction(address target, bytes memory data, uint256 value)
external onlyAdmin
returns (bytes32 txHash)
{
txHash = keccak256(abi.encode(target, data, value));
require(!queuedTransactions[txHash], "Already queued");
queuedTransactions[txHash] = true;
// 实际项目中会存储执行时间,这里为了简化直接记录当前时间+延迟
emit TransactionQueued(txHash, block.timestamp + delay);
}
// 执行一个交易
function executeTransaction(address target, bytes memory data, uint256 value)
external onlyAdmin
{
bytes32 txHash = keccak256(abi.encode(target, data, value));
require(queuedTransactions[txHash], "Not queued");
queuedTransactions[txHash] = false; // 防止重入
// 检查时间锁:当前时间必须大于排队时记录的预期时间
// 这里简化:实际需要存储排队时间
(bool success, ) = target.call{value: value}(data);
require(success, "Transaction failed");
}
// 管理员可以修改延迟时间
function setDelay(uint256 newDelay) external onlyAdmin {
delay = newDelay;
}
}
这个合约的漏洞很明显:admin拥有绝对权力,不仅能排队、执行,还能直接修改delay。如果admin地址的私钥泄露,或者攻击者通过某种方式获取了onlyAdmin权限(比如合约升级漏洞、DAO投票被劫持),那时间锁就形同虚设。
真实案例:2022年,某个DeFi项目的时间锁合约管理员权限被攻击者通过治理提案获取,攻击者把delay改成1秒,然后立即执行了一个转账提案,盗走了几百万美元。这就是典型的时间锁劫持。
2.3 如何防御时间锁劫持
- 多签管理:时间锁的管理员不要是一个单地址,而是用多签钱包,比如Gnosis Safe。这样即使一个私钥泄露,攻击者也改不了延迟。
- 延迟固定化:把
delay设为常量,不允许修改。如果需要改,必须通过更严格的治理流程。 - 时间检查的严谨性:排队时必须记录真实的排队时间,执行时从存储中读取,而不是依赖
block.timestamp(容易被矿工操控)。
三、投票委托的权限漏洞
3.1 委托机制是什么
很多DAO允许代币持有者把投票权委托给另一个人。比如你持有100个治理代币,但你懒得研究每个提案,就委托给一个你信任的“大佬”,大佬用你的票去投票。这种机制在Compound、Maker等项目中很常见。委托的好处是提高治理效率,但坏处是——你交出去的不仅是投票权,可能还附带了其他权限。
3.2 漏洞场景:委托后权限被滥用
标准的委托流程:用户调用delegate(delegatee),把投票权给委托方。委托方可以代表用户投票。但问题来了:如果委托方被黑客控制了,或者委托方自己作恶,他可以用你的票投出恶意的提案。更危险的是,有些DAO的治理合约里,委托不仅仅传递投票权,还传递了提案创建权或者执行权。比如一个用户想发起提案,系统检查的是他当前代表的投票权(包括委托给他的票数)。如果黑客先把自己的少量代币委托给一个拥有大量委托票的大户,然后他就可以用那个大户的名义发起提案,而大户根本不知情。
还有一个经典攻击:闪电委托。攻击者在同一个交易里,先把自己所有的票委托给一个恶意合约,恶意合约立刻投票,然后取消委托。这种操作利用时间锁的延迟,让真实用户来不及反应。
3.3 示例:委托权限被窃取
写一个简化版的治理合约,演示委托漏洞。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 一个简单的治理代币,含委托投票功能
contract GovernanceToken {
mapping(address => uint256) public balanceOf;
mapping(address => address) public delegates; // 用户的委托对象
mapping(address => uint256) public delegateVotes; // 被委托者当前获得的总票数
// 将投票权委托给 delegatee
function delegate(address delegatee) external {
// 先减去旧委托的票数
address oldDelegate = delegates[msg.sender];
if (oldDelegate != address(0)) {
delegateVotes[oldDelegate] -= balanceOf[msg.sender];
}
// 增加新委托的票数
delegates[msg.sender] = delegatee;
delegateVotes[delegatee] += balanceOf[msg.sender];
}
// 创建一个提案(假设需要至少1000票)
function createProposal(address target, bytes calldata data)
external view returns (bool)
{
require(delegateVotes[msg.sender] >= 1000, "Not enough votes");
// 实际逻辑省略
return true;
}
}
漏洞在哪里?主要有两点:
- 没有人检查
delegatee是否恶意。如果你把票委托给一个合约,那个合约可以立即用你的票去投票。而你并没有限制他只能投什么。 createProposal依赖于当前delegateVotes,而这个数值可能被攻击者临时操纵。比如攻击者A有900票,他找到B(有200票)暂时委托给自己,瞬间变成1100票,创建提案,然后立刻取消委托。整个过程在一个区块内完成,B甚至不知道自己的票被用了。
真实案例:2021年,某个DeFi项目的治理被攻击,攻击者通过闪电委托(在同一个交易里委托、投票、取消委托),利用时间锁窗口期提交恶意提案,最终成功盗取资金。
3.4 如何防御委托漏洞
- 委托锁定时间:委托后必须等待一段时间才能投票或创建提案,防止闪电委托。
- 限制委托数量:单个地址最多只能委托给一个人,且不能循环委托。
- 提案创建门槛校验:创建提案时,不仅检查当前委托票数,还要检查该地址在过去一段时间内的平均投票权重,防止瞬时操纵。
四、应用场景分析
时间锁和投票委托广泛应用于DAO治理、跨链桥、多签合约中。它们带来的好处是显而易见的:
- 时间锁:给了社区反应时间,防止恶意提案“秒执行”。
- 投票委托:让不活跃的用户也能通过代理人参与治理,提升参与度。
但这两个机制本身也是攻击面。在实际项目中,时间锁往往和治理合约绑定,而委托机制又和代币合约绑定。一旦其中一个环节出问题,整个系统的资金就会暴露。
比如,一个项目如果使用了时间锁但管理员是单地址,那就等于自废武功。如果委托机制没有反闪电委托的保护,那黑客可以轻松提交恶意提案。
五、技术优缺点
时间锁的优点:
- 增加治理安全缓冲期。
- 可以集成取消功能,紧急情况下悬崖勒马。
时间锁的缺点:
- 延迟导致治理效率降低,紧急提案也得等几天。
- 如果管理员权限过大,时间锁反而成了攻击者的工具。
- 时间锁合约本身的代码也可能有bug(比如重入、权限控制错误)。
投票委托的优点:
- 提高投票参与率。
- 允许真正的意见领袖汇聚票数。
投票委托的缺点:
- 委托权可能被滥用,代理人作恶。
- 闪电委托攻击难以防范。
- 委托关系复杂时,造成治理中心化(大户垄断)。
六、注意事项
- 时间锁的权限设计:永远不要给一个地址完全控制时间锁的能力。至少使用多签,并设置延迟修改的额外条件(比如需要经过两次投票)。
- 委托的不可逆性:委托后要让用户清楚自己交出了什么权利。甚至可以考虑委托期间用户自己的投票权被冻结(或者并行投票?)。
- 合并攻击的防范:时间锁和委托可能联动被攻击。比如先利用委托漏洞获得大量票,然后通过提案修改时间锁延迟,再执行恶意交易。这种情况需要全局治理流程审计。
- 代码审计:时间锁和委托合约复杂度不高,但逻辑细节容易出错。比如排队时是否存储了执行时间、执行时是否考虑了重入、委托时是否处理了余额变更(转账时自动更新委托票数)。建议使用OpenZeppelin等成熟库。
- 事件监控:部署后要监控
queueTransaction、delegate等关键事件,设置预警。一旦发现异常,立即触发紧急暂停机制。
七、文章总结
链上治理的时间锁和投票委托像是一对双刃剑。它们本意是保护用户、提升效率,但如果实现不严谨,反而会变成攻击通道。时间锁劫持的本质是权限控制薄弱,而投票委托漏洞的核心是状态同步滞后以及瞬时操纵。作为开发者,你需要像防贼一样审视每一个权限函数,尤其是那些可以修改延迟、管理员、或者委托关系的函数。记住一个原则:任何可以让你快速改变规则的操作,都应该有更长的等待时间或更严格的权限控制。最后,无论是写合约还是用合约,多看看历史案例,很多坑别人已经踩过了,我们只需要花十分钟看看代码就能避开。
Comments