回天乏术,一开始就注定失败的YAM投票拯救行动!
36小时内,眼看他起高楼,几分钟内,眼看他楼塌了。
北京时间08月13日上午03时整,备受瞩目的 DeFi 项目 YAM Finance 宣布启动流动性挖矿,仅仅一天时间锁仓资产价值就超过了6亿美元,其锁定资产增量和增速都达到了近乎癫狂状态。且照此发展,一些早期给池子注入流动性的羊毛党年利率甚至可逼近200倍,其疯狂程度可见一斑。
不过,正当大家都陷入挖矿狂欢的时候,意外发生了。
北京时间08月13日凌晨,YAM Finance发现其智能合约的弹性供应机制(rebase)存在漏洞,导致合约第二次 rebase 触发时会铸造大量额外代币,这意味着未来社区将无法获得足够的代币来执行任何治理操作,YAM 将成为一个失控的机器,最终将彻底失去社区用户的信任。
该如何拯救我们的 YAM 小红薯呢?
在发现漏洞后,YAM 团队发起了“拯救行动”,称他们需要16万委托投票才能提交治理提案,于是向社区发起呼吁投票。很快,这场轰轰烈烈的社区投票行动就完成了。
然而,就在大家以为只是虚惊一场的时候,北京时间08月13日下午16时01分,YAM 创始人 Brock Elmore 却发推特称,对不起大家,我失败了。这究竟是怎么回事呢?
PeckShield 安全人员介入分析后,迅速定位到问题的本质在于:弹性供应机制(rebase)存在一个代码公式的错误,致使第二次 rebase 触发时系统会自动增发10 ^ 18个新代币,如果行情一直保持高位的话,那么以后的每次 rebase 触发时都会进行指数级的增发,这将使小红薯 YAM 的数量变成一个可怕的天文级数字。这意味着,无论后期社区怎样委托投票,都无法获得足够的投票量对系统进行控制,整个系统将陷入失控无主状态。
本来 YAM 官方号召广大 YAM 持有人通过代理投票的方式,一起完成此次投票“拯救行动”,以修复这个存在的漏洞。然而,PeckShield 安全人员进一步分析发现,当YAM 官方开始发出呼吁的时候,这次拯救行动其实就已经注定失败了。
原因有二:
1) 时间来不及: YAM 官方或许忽略了一点,在其提案投票拯救行动准备工作完成后,也需要至少12.5个小时才能被执行生效,而按照现在的时间节奏,当其执行生效时,第二次 rebase 早已触发。
2) 新部署治理合约无法被有效执行:由于第二次 rebase 触发,因此官方原先预期要执行的新治理合约到了执行时间后,却发现由于投票总量远远无法达到合约约定的总量的4%,故而无法被有效执行。
究竟是为何呢?接下来上技术干货。
技术概要
首先介绍下 YAM 智能合约的弹性供应机制(rebase):
1)系统会根据市场价格浮动来动态调整代币的供应量,当市价上涨时则按比例增发代币,以降低单位代币的价值,直至降至1美元。
2)每天分别执行两次 rebase,每次 rebase 会改变代币供应量,根据市场现价增发或销毁一定量的代币。
再说一个实施提案的关键因素:持有者进行委托投票,投票数超过总量的1%,则提案才可以进行执行排列,且按合约约定执行排列时长需要等待12.5小时,而提案执行时,则投票需要超过总量的4%。如此新治理合约才能执行生效,项目才能继续正常运转。
有了以上几个技术要点的铺垫,我们再来看一下,YAM 官方的跟进时间表,就能明白此次拯救行动为什么注定会失败。
如下图时间线所示:
③是官方宣布提议部署新治理合约的时间,在此之后社区开始启动投票。
④是投票目标初步完成,新治理合约进入执行排列的时间,自此等待执行12.5小时合约正式执行。
⑤是第二次 rebase 的触发时间。
⑦是其新治理合约投票通过后正式执行的时间。
⑥ 在第二次 rebase 触发后的第31分钟时,或许是项目方发现了已经无力回天了,提案取消成功,项目方正式宣布 YAM 失败。
①之后的绿色区域是投票和提案拯救行动可以成功的“黄金急救期”,需要整个拯救行动准备工作在第一次 rebase 触发之前半小时内完成。(即蓝色虚线应提前到绿色区域内)。
这意味着,YAM 官方应在第一次 rebase(北京时间08月13日凌晨04:08)之前就应该发现这个漏洞,并且留有足够的时间完成新治理合约部署和投票。
可是事与愿违,官方发现漏洞并披露呼吁投票的时间还是太晚了,错过了唯一能够成功的黄金急救期。而更糟糕的是,按照官方的时间节奏,当新治理合约到了⑦执行的时间后,投票数要超过总量的4%才行,而此刻的总量已经扩大了10^18*10^18,此前累积的投票数已然杯水车薪,根本无济于事。
所以,这次拯救行动一开始就注定了会失败。
下面我们会对此次事件做下详细分析:(项目方github地址 https://github.com/yam-finance/yam-protocol)
详细过程分析
首先我们看下当第一次 rebase 发生了什么:
图1. 第一次 rebase 资产变化
如上面链上信息(https://oko.palkeo.com/0x7b9017ec92b0200455e5269380195fbecfbf91c8acda30985cc1dc413d215076/)所示,当第一次 rebase 之后,totalSupply从3,500,000* 10^18暴涨到一个极大值。
我们进一步分析代码,看下在代码中发生了什么:首先从链上信息我们能看到 rebase 操作调用的是 YAMRebaser 合约的 YAMRebaser::rebase() 函数(我们先跳过这个函数稍后再讲),我们最终发现它通过调用 YAM合约(0xa923af6d05993495257a872ec69dbbf01501eb0e)的 rebase() 函数重新计算totalSupply(代码逻辑如下图2中所示),在第340行的 totalSupply 赋值操作可以看到,这一行代码有个明显的错误——没有除 BASE,从而导致 totalSupply 的值暴增了10^18倍。
YAM 官方在第一次 rebase 以后发现了这个问题,于是披露 rebase bug 事件启动了投票拯救行动。
图2. YAMToken::rebase() 得到一个异常大的totalSupply值
而在12小时之后,YAM 又触发了第二次 rebase(https://oko.palkeo.com/0x32735e9e9aac51739b5725a225be6c7a3851f422be986d0f4f4bc0ec475ee286/),这个数据又是以基于错误的 totalSupply 来计算的,从而导致 initSupply 的数值同样出现了异常。
图3. 第二次 rebase 资产变化
我们继续分析造成 initSupply 异常的成因,关键在上面提到过 YAMRebaser::rebase() 函数,这个函数实现的主要逻辑:先基于 yam.totalSupply() 计算出本次 rebase 需要增发的 YAM 数额 mintAmount,在 afterRebase() 函数经过数层调用后进入 YAM 的 _mint() 函数,基于异常的 mintAmount 给 initSupply 进行赋值。由于在第一次 rebase 中,totalySupply 已经变成一个极大值,所以基于此异常值的后续一列操作(如图4中红色箭头所示)最终导致 initSupply 也计算错误,变成了一个天文级的数值。
图4. YAMRebaser::rebase() 用错误的totalSupply计算initSupply
当第一次 rebase 出现异常时,项目方已经发现问题并决定提出一个修复系统的提案(proposal),希望通过投票的方式将此提案排入执行队列并且执行。当此题案收到足够多的投票,治理合约(Governor)允许任何人通过调用 GovernorAlpha::queue() 函数将此题案排入执行队列。但由于此治理合约代码逻辑的实现,导致无论是在第二次 rebase之 前或是之后进行修复,都无法正确执行这个拯救行动。为什么说项目方准备工作完成的太晚了?
我们看下图中的 GovernorAlpha::queue() 代码,我们注意到了在调用 _queueOrRevert 函数之前的第224行中设置变量 eta = current timestamp + timelock.delay(12.5 小时),这就使得生效时间必然在加入队列的12.5小时以后,而第二个 rebase 时间是与第一次间隔12小时,这就意味着要执行成功需要将拯救行动提前到第一次 rebase 之前至少半小时以上,否则将永远无法执行。
图5. GovernorAlpha::queue() 函数设置eta(生效时间)
又为什么说已经做出的拯救行动,根本无济于事呢?当触发合约 GovernorAlpha::execute() 时首先会先执行 state 函数来获得当前提案状态。
图6. GovernorAlpha::execute() 检测提案状态
在下面的 state() 函数 第330行,如果 proposal.forVotes <= againstVotes() ,提案状态被设置为失败。
图7. GovernorAlpha::state() 执行返回Defeated错误
从代码中能看出来,项目方在设计系统时,投票数被设计为必须大于 initSupply 总量的4%,此提案才能是合法的状态,如下图所示。然而,当第二次执行 rebase 以后,initSupply 已经被搞成一个极大值。这就导致了,投票票数(forVotes)永远不可能 >= quorumVotes(),从而总是返回 Defeated。
图8. GovernorAlpha::quorumVotes() 返回一个错误的异常值
除了提案状态异常的问题之外,如图9、图10所示,当第二次 rebase 发生以后,由于 GovernorAlpha :: propose() 检查投票数必然小于 proposalThreshold(1%的initSupply),因此新的提案也再也无法被提出,更遑论要投票执行了。
图9. GovernorAlpha::propose() 检测投票数是否大于1% initSupply
图10. GovernorAlpha::proposalThreshold() 返回1% initSupply
总结
综上分析,PeckShield(派盾) 想借此劝诫诸君, 在区块链世界里,务必要对每一行合约代码保持敬畏,因为任何细微的疏漏都可能造成无法挽回的局面 。毕竟,代码是人写的,漏洞也很难被彻底避免,因此需要项目方在合约部署上线前就做好充分的测试和第三方安全审计工作,这会帮助其更早发现并排查合约代码潜在的安全漏洞,不至于等到,漏洞发生后,亡羊补牢,为时已晚