区块链安全

为什么 Venus protocol Governance 不会受到闪电贷的影响

Venus Governance 机制分析

The below parts(above the dividing line) are written by my counterpart Sebastian. So let’s follow Sebastian’s analysis to get a preliminary understanding of Venus Voting Breakdown.

Venus Protocol Governance Doc:

https://docs.venus.io/docs/governance#introduction

Contract Addresses involved: 

  1. Main Voting Proxy contract: Venus Governor Bravo Delegator 0x2d56dc077072b53571b8252008c60e945108c75a 
    1. Logic contract: GovernorBravoDelegate 0x5062a23fbe5cb462dce1d06a07fe9c5e255ca17b
  2. XVS Vault Proxy:
    0x051100480289e704d20e9db4804837068f3f9204
    1. Logic contract: XVS Vault
      0xa0c958ca0ffa25253de0a23f98ad3062f3987073
  3. Timelock: set at 48 hours (can only be called by Governor Bravo) *cannot be changed on the Governor Bravo
    0x939bd8d64c0a9583a7dcea9933f7b21697ab6396
  4. Venus (XVS) token (current price is ~$5)
    0xcf6bb5389c92bdda8a3747ddb454cb7a64626c63 

Main Flow: 

Important parameters of Governor Bravo:

  1. proposalThreshold_ (number of XVS needed to start a proposal)
    1. => can only be between 150,000 Xvs to 300,000 Xvs (currently set at maximum)
  2. votingDelay in terms of # blocks (number of blocks before the proposal starts)
    1. => set from 1 to 201600 (currently set at minimum)
  3. votingPeriod in terms of # blocks (block duration for voting a proposal)
    1. => set from 3600 to 403200 (currently set as 28800 blocks)
  4. quorumVotes (once forVotes >= quorumVotes, proposal will pass)
    1. => 600,000 XVS tokens *cannot be changed
  5. Admin / Guardian belong to same account 0x1c2cac6ec528c20800b2fe734820d87b581eaa6b , a multisig (2/3 threshold)

Main Functions Checks:

Propose a new Proposal:

  • Checks it is initialized properly from previous Governor contract
  • Checks the proposer’s number of Xvs staked in XVS Vault at the previous block >300,000 (deterrent to flash loan)
  • Checks the length of arrays for the proposal calls, and number of operations cannot be 0 and max 30
  • Checks his very last proposal has ended 

Eg:https://bscscan.com/tx/0xa001ad08ff19086733178a91bde04d0b4679847d87470ddd62ec538dc4568004 

Queueing of Proposal:

  • Checks that there is no repeated proposal action for that eta then adds to time lock queue

Eg:https://bscscan.com/tx/0x7d0f4c8f43284db4976671a0419326d0dff5875189f0d4f780967d277735c028 

Execution of Proposal:

  • Checks proposal id is queued
  • Sets proposal executed to be true (prevents multiple queueing of the same successful proposal)

Eg:https://bscscan.com/tx/0xf1ac8a15dc6fa5d4aa788f5f414219e4a5996df5a8e585bf57dd2b4b6e528785 

Canceling of Proposal:

  • Checks proposal id is not yet executed (able to cancel at any stage before execution)
  • Checks caller is guardian / proposer / proposer drops below the proposalThreshold_

Voting algorithm:

  • Checks if proposal is active, if valid vote type (0=against, 1=for, 2=abstain, if he has voted) *No minimum XVS needed for voting
  • When casting votes, it checks the voter’s amount at the proposal’s startBlock
  • Once the proposal has passed its endBlock
    if (proposal.forVotes <= proposal.againstVotes || proposal.forVotes < quorumVotes) then the proposal did not pass. Else, it succeeded and can be added to the queue

Venus Governance 闪电贷操纵分析

看完了这个 voting 机制的设计,我目前明白了:
一个提案 create 之后 1 个block,才会触发提案 start。一旦提案 start,任何用户就可以去给这个提案投赞成或反对票,其投票权是按照 proposal start 这个 block 去计算的,也就是用户在 proposal start 这个区块所持有的 xvs 代币数量。然后我就开始好奇,用户是否能通过闪电贷去获得较大的 votes 去左右一个提案的投票结果

让我们跟随一下用户投票函数的执行流,去看一下合约中是如何计算用户的投票权的:
STEP 1: 用户去调用 GovernorBravoDelegate.castVote() 相关的函数:

STEP 2: 进入 GovernorBravoDelegate.castVoteInternal() 函数:

其中通过 xvsVault 合约的 getPriorVotes() 函数来计算用户的 votes。通过查询 GovernorBravoDelegate 的 proxy 合约,我们能获得 xvsVault 合约的地址为 0x051100480289e704d20e9db4804837068f3f9204

STEP 3: 进入这个 xvsVault 合约来看看 getPriorVotes() 函数的实现:

可以看到,是获得在 proposal.startBlock 这个 blockNumber 或之前的最近一次的 checkPoint 的 votes。那么这个 checkPoints 是怎么存入 items 的呢?
STEP 1: 用户通过调用 XVSVault.deposit() 函数,导致 XVSVault._moveDelegates() 函数的调用。

STEP 2: XVSVault. _moveDelegates() 函数会调用 _writeCheckpoint() 函数。

STEP 3: 最终 XVSVault._writeCheckpoint 函数在当前区块写入 checkPoint。votes 是 msg.sender 的 $XVS 余额。

总结来说,当用户调用 deposit() 函数给 XVSVault 合约转入 $XVS,就会给用户创建一个 checkpoint,其 blockNumber 为调用 deposit() 函数的 block,金额为用户此时的 $XVS 的持仓。

如此说来,假设 id 为 x 的 proposal 创建于block 1000,那么可以试想整个攻击过程如下:

  1. 恶意用户监听到 proposalCreated 事件发生在区块 1000
  2. 恶意用户发起闪电贷,把 Gas price 设置的很大,这样很大可能让闪电贷交易能够发生在未来的第一个区块,也就是1001。在闪电贷内,用户会做如下操作:
    • swap 得到一大笔 $XVS
    • 调用 XVSVault.deposit() 将这一大笔 $XVS 存入 XVSVault 合约内
    • 就能在 proposal start 的 block 1001 写入一个 checkpoint 点
    • 此时用户 <= proposal.startBlock 的最近一个 checkpoint 的持仓就变成了极大的 votes
    • 用户可以以这个 checkPoint 的 votes 去发起投票,因为哪怕是之后的区块去发起投票,也只会检测 proposal.startBlock 这一区块的用户持仓

这么看起来逻辑是通顺的,因为本来此处能否使用闪电贷进行 votes 的放大,关键在于:因为闪电贷交易中借钱和还钱的动作是在一个交易里完成的,所以 attacker 不能让这个系统读投票数的事件发生在借钱和还钱之间。但此处,system capture 的事件可以由 attacker 触发的话,就可以走得通。相当于:把传统的闪电贷模型“贷出-获利-归还”变为了”贷出-voting获利-归还”。

如此,解决这个问题的方法就也很明显了,只要要求 getPriorVotes() 的 blockNumber < proposal.start 的 blockNumber 就行。

于是就开始信心满满的梳理攻击流程,但最终发现一个问题:

deposit() 的时候会写一个 checkpoint,既然是闪电贷,那么需要调用 withdraw() 函数来还 ,会写多一个 checkpoint (同样的区块)。那么如果是在这个 block 之后要 getPriorVotes() 的话,那个区块(<= proposal.startBlock 的最近一个区块) 的 checkpoint 应该是 0。

但是也有解决办法,也就是如果用户去 castVote() 在 withdraw 之前,在同一个闪电贷交易内。那么此时就不会新增一个 checkPoint 了。
于是新的攻击流程变成了:

  • 恶意用户监听到 proposalCreated 事件发生在区块 1000
  • 恶意用户发起闪电贷,把 Gas price 设置的很大,这样很大可能让闪电贷交易能够发生在未来的第一个区块,也就是1001。在闪电贷内,用户会做如下操作:
    1. swap 得到一大笔 $XVS
    2. 调用 XVSVault.deposit() 将这一大笔 $XVS 存入 XVSVault 合约内
    3. 就能在 proposal start 的 block 1001 写入一个 checkpoint 点
    4. 此时用户去调用 castVote() 发起投票,调用 GovernorBravoDelegate.castVote(),就会检查 <= proposal.startBlock 的最近一个 checkpoint 的持仓,就能够以极大的 votes 发起投票
    5. 还回闪电贷的借款

关键是step4 castVote(),如果可以在闪电贷交易内执行,就 ok。如果不能,那么 latest checkpoint 就会变成 votes 为 0。

于是又去看了代码,GovernorBravoDelegate.castVote()->xvsVault.getPriorVotes(voter,proposal.startBlock) 这个函数里面要求 proposal.startBlock < 发起投票时的当前 block.number。


也就是说,用户必须在 proposal.startBlock 之后的区块才能去 castVote()。
这个 voting 系统通过要求用户“发起投票”和“计算用户投票权“不在一个 block 里面完成,以此减少了被闪电贷⚡️攻击的可能性。

其实其他的分离还有:

提案创建和提案开始(计算用户投票权)的区块分离。所以也就是说提案创建、计算用户投票权、用户发起投票这三个事情两两都不能在同一个 block 内完成。因此也保障了这个 voting 系统免受闪电贷的干扰影响投票公平。

彩蛋

还记得说到的闪电贷的还钱吗,也就是需要调用 XVSVault.withdraw()。

最后我的同事 Sebastian 发现在我们这个池子中,一旦 deposit() 进去,要 withdraw(),必须等到 lockPeriod 之后。在这个池子中是必须等到7天以后。所以是根本不能进行闪电贷的。

但其实关于这个 lockperiod 参数,是 admin 在 add 池子时候设置的,没有 minimal 的限制,也是可以等于 0 的。

但因为上述谈到的一些函数调用的 block 分离机制,导致这个 Governance 系统中,哪怕撤回锁定期为 0 的池子也不会受到闪电贷对其公平性的影响。也是很棒的设计了。