前言
How to hack
系列区别于正经的漏洞分析,旨在通过寻找代码安全因素外的 DeFi
问题,包括但不仅限于权限问题、金融风险和 Oracle
风险等。通过这种方式,希望可以帮助读者们可以快速认识自己投资的协议存在的相关风险,了解可能的问题。本次的主角为:Lido Finance
。So, game start 😀
关于 Lido Finance
Lido Finance
最初是由 P2P Validator
公司开发的用于降低以太坊质押门槛的协议。在通常的质押流程中,用户需要至少抵押32个 ETH
才能开始作为以太坊主网的验证者来参与网络区块的验证。而 Lido Finance
的出现,彻底打破了这种局面,用户可以通过充值任意金额的 ETH
到 Lido Finance
的服务当中,换取对应的 stETH
代币。而 stETH
代币则是一种计息代币,余额会每天不停的增长,并且在后续主网开放提款之后,将可以 1:1 赎回 ETH
代币。在以太坊这边的质押服务大获成功之后,后续 P2P Validator
公司陆续支持了其他质押公链。目前已经支持的公链如下:
Lido Finance 的投票机制
Lido Finance
完全实行了当前流行的 DAO
机制,每一项对协议的操作都需要通过社区投票来完成,而当前用于投票的代币为 Lido Finance
发行的 LDO
代币,持有 LDO
代币,可以进行提案的发起和投票。当前可通过 https://vote.lido.fi/vote
查看历史上 Lido Finance
所有的投票。
当前,Lido Finance
完全采用链上投票的方式,通过 Lido Finance
的 Voting
合约(0x2e59A20f205bB85a89C53f1936454680651E618e
)进行提案的发起(newVote
)、投票(vote
)及执行(executeVote
)。下面我们来逐个分析每一个流程,首先是提案的发起(newVote
)
function newVote(bytes _executionScript, string _metadata) external auth(CREATE_VOTES_ROLE) returns (uint256 voteId) {
return _newVote(_executionScript, _metadata, true);
}
以上就是用于投票发起的函数逻辑,通过函数的修饰器,不难发现函数需要通过 auth(CREATE_VOTES_ROLE)
的检查,说明只有 CREATE_VOTES_ROLE
的地址才可以调用该函数。而通过分析,发现合约中不存在接口获取所有 CREATE_VOTES_ROLE
地址。那么就只能手动找历史上出现过授权 CREATE_VOTES_ROLE
的操作,由于篇幅原因(其实流程是直接找到历史上通过 ACL
合约执行 createPermission
操作的交易),我直接把交易放在这里了:0xe628b61854a48bac89b878c05b2d9ed4ba04d503ebcdd7bbb97861b5934e0e53
结果如下:
通过分析交易,我们找到了符合该权限的地址:Lido: Aragon Token Manager (0xf73a1260d222f447210581DDf212D915c09a3249)
,又由于篇幅原因,我帮大家找遍了合约中是不存在调用 newVote
的接口的,唯一允许外部调用的地方在该合约的 forward
函数,逻辑如下:
function forward(bytes _evmScript) public {
require(canForward(msg.sender, _evmScript), ERROR_CAN_NOT_FORWARD); // 检查 msg.sender 是否可以执行 forward 函数
bytes memory input = new bytes(0); // TODO: Consider input for this
// Add the managed token to the blacklist to disallow a token holder from executing actions
// on the token controller's (this contract) behalf
address[] memory blacklist = new address[](1);
blacklist[0] = address(token);
runScript(_evmScript, input, blacklist); //放入投票时该执行的 _evmScript
}
执行 forward
函数需要满足 canForward
函数中的条件,而这个条件也很简单,就是持有任意数量的 LDO
代币就可以了。然后通过指定 _evmScript
的方式作为新提案的执行脚本(提案通过后要执行的操作),runScript
的逻辑这里不展开,有兴趣的自己可以探究,主要的流程是去调用一个官方指定的 ScriptExecutor
来检查你的 _evmScript
是否合法,_evmScript
被批准的过程也是需要投票的。
OK,到这里我们就明白其实投票的过程是通过 TokenManager
的 forward
进行投票的发起的,而这个发起的条件就是持有任意数量的 LDO
代币。那么我们接下来继续看投票(vote
) 流程。回到 Voting
合约,通过查看 vote
函数的逻辑,如下:
function vote(uint256 _voteId, bool _supports, bool _executesIfDecided_deprecated) external voteExists(_voteId) {
require(_canVote(_voteId, msg.sender), ERROR_CAN_NOT_VOTE);
require(!_supports || _getVotePhase(votes[_voteId]) == VotePhase.Main, ERROR_CAN_NOT_VOTE);
_vote(_voteId, _supports, msg.sender);
}
可以发现投票需要通过 _canVote
函数的检查,该检查主要是检查 voteId
对应的提案是否已经过期和投票者在提案创建所在的区块的前一个区块是否存在不为零的 LDO
代币(防止闪电贷攻击)。然后进行投票选项的检查,在 Lido Finance
的投票周期里,是有限制在哪个流程是不允许投同意票的 :D。在通过以上的检查之后,就正式进入了 _vote
环节,代码如下:
function _vote(uint256 _voteId, bool _supports, address _voter) internal {
Vote storage vote_ = votes[_voteId];
// This could re-enter, though we can assume the governance token is not malicious
uint256 voterStake = token.balanceOfAt(_voter, vote_.snapshotBlock);
VoterState state = vote_.voters[_voter];
// If voter had previously voted, decrease count
if (state == VoterState.Yea) {
vote_.yea = vote_.yea.sub(voterStake);
} else if (state == VoterState.Nay) {
vote_.nay = vote_.nay.sub(voterStake);
}
if (_supports) {
vote_.yea = vote_.yea.add(voterStake);
vote_.voters[_voter] = VoterState.Yea;
} else {
vote_.nay = vote_.nay.add(voterStake);
vote_.voters[_voter] = VoterState.Nay;
}
emit CastVote(_voteId, _voter, _supports, voterStake);
if (_getVotePhase(vote_) == VotePhase.Objection) {
emit CastObjection(_voteId, _voter, voterStake);
}
}
这里的逻辑就比较简单了,其实就是简单地对用户的投票选择进行记录,同意的放在 vote.yea
中,不同意的放在 vote.nay
里。这个是用于后续作为提案是否可以执行的参考数据来的。那到了这里,我们就分析完投票的这个流程了,同时我们也知道,因为 LDO
代币数量获取方式的原因,我们无法通过闪电贷的方式来攻击投票系统。
那么接下来我们分析最后一个流程:执行(executeVote
),代码逻辑如下:
function executeVote(uint256 _voteId) external voteExists(_voteId) {
_executeVote(_voteId);
}
function _executeVote(uint256 _voteId) internal {
require(_canExecute(_voteId), ERROR_CAN_NOT_EXECUTE);
_unsafeExecuteVote(_voteId);
}
executeVote
函数直接调用的 _executeVote
函数,在其内部逻辑中,含有 _canExecute
检查,逻辑如下:
function _canExecute(uint256 _voteId) internal view returns (bool) {
Vote storage vote_ = votes[_voteId];
if (vote_.executed) {
return false; //执行状态检查
}
// Vote ended?
if (_isVoteOpen(vote_)) {
return false;
}
// Has enough support?
uint256 voteYea = vote_.yea;
uint256 totalVotes = voteYea.add(vote_.nay);
if (!_isValuePct(voteYea, totalVotes, vote_.supportRequiredPct)) { //投票比重检查
return false;
}
// Has min quorum?
if (!_isValuePct(voteYea, vote_.votingPower, vote_.minAcceptQuorumPct)) {//投票参与度检查
return false;
}
return true;
}
函数检查了几个条件,我都标注在代码里了,其中最重要的是投票比重的检查和投票参与度的检查,即投 yea
的必须占总投票数的 50% 以上,并且总的支持票所参与的 LDO
代币,必须大于 LDO
总量的一定阀值,目前该值为 5%
在经过上面的一系列检查之后,提案就可以直接执行了,这里执行过程并不需要 TimeLock
合约做操作上的延时。通过上面的分析,我们知道只要满足:投 yea
的必须占总投票数的 50% 以上,并且总的支持票所参与的 LDO
代币,必须大于 LDO
总量的 5%, 就可以无需延时的执行任意的投票。
Hack time
通过分析 Lido Finance
的投票流程,我们知道通过闪电贷攻击投票是不可能的,但是由于执行提案没有延时,以及发起提案没有代币数量上的要求,那么我们其实可以通过收齐满足参与度的代币的数量,然后发起一个恶意的提案,并全部投 yes
就可以了。简单来说,其实只要我控制了总量 5%的 LDO
代币数量,我就完全控制了 Lido Finance
。:D
那么,目前攻击 Lido Finance
可以获得的利润有多少呢?假设我们发起的提案里是用于更新目前的逻辑合约,以无限增发为目的,那么目前我们可以获利的途径有 Curve Finance
和 Balancer
,他们分别都是目前交易 stETH
深度最好的交易池。可获利的 ETH
分别为:
目前这里两个加起来,按目前 ETH
1300 美金的价格来算,价值约在 4 亿美金左右,而我们的攻击成本,总量 5% 的 LDO
,需要的价值是多少呢?
根据 1inch
的最新报价,,总量 5% 的 LDO
,仅需 5000万美金,也就是说,目前 Lido Finance
生态中的这些 Dex Pool
,仅仅由 5000 万美金的 LDO
作为支撑,而根据目前的收益测算,攻击收益是攻击成本的 8倍甚至更多。
OK,那么说明 Lido Finance
目前很危险吗?其实并不,我们再次测算,通过 1inch
,我们可以拿到多少 LDO
代币:
不难发现,5000万美金最多就能拿到 1000 万的 LDO
代币,远远无法满足我们的攻击要求,同时滑点已经来到了 70%,说明 LDO
在市场上的供应量其实只有 1000多万枚,单纯从市场上是无法获取到满足攻击要求的代币数量的。但是通过大户地址排行榜,发现有好几个 vesting
地址的占比是超过 5%的,而且这些地址都是 EOA
地址,你说最近那么多私钥泄漏的问题,如果玩意不小心他们的私钥被泄漏了?唔.. that’s so fun.. 😀
总结
欢乐的时光到这里就结束了,总体上来看,Lido Finance
这个项目目前投票被操控的风险还是很低的,但是由于 LDO
还处于一个派发周期中,同时大户地址持仓比重过大,还是存在一些无法忽视的风险,如果想要要参与该项目,相关的风险不可忽视。
原文始发于微信公众号(蛋蛋的区块链笔记):How to hack Lido Finance — 投票篇