在昨天(2021年9月22日) Vee Finance 项目发生攻击事件之后,我们第一时间对该事件开展分析并发布了初步的分析报告(似曾相识燕归来:Vee Finance 安全事件分析)。但在分析过程中,依然存在一些悬而未决的疑点:
在攻击交易中createOrder ERC20 ToERC20函数调用,有一个cToken行为比较奇怪,和这个地址相关的交易只有几十条;后续查明这个cToken是由攻击者控制的账户创建的。
在重新Review Vee项目的代码中,我们发现前一篇文章中发现整个发起杠杆交易的borrowAndCall调用并没有对第一笔交换的价值进行判断这个分析是不确切的。下文会阐述,在整个调用过程中存在对交换前后的价值进行判断的代码逻辑。
在分析攻击交易的Trace中,我们发现了一个奇怪的BTC代币地址,该地址和前一篇文章所述的攻击过程并无关联。
在我们的分析报告发布之后,项目方也公布了自己的官方分析报告(The Main Cause of Vee Finance Attack: https://veefi.medium.com/the-main-cause-of-vee-finance-attack-7a8475085ec5),然而该报告依然无法解释上述疑问。鉴于此,我们对Vee项目和此次攻击事件进行了更为细致的分析和复盘。分析结果表明,导致此次攻击的根本原因是校验机制的缺陷,而非如官方分析报告宣称的,诸如单一价格预言机等因素带来的影响。
0x1. 深入分析Vee项目代码
在后续对Vee项目方的代码进行深入分析的过程中,我们发现上述的检验过程其实是存在的,只是在攻击者巧妙利用下检查被绕过。下面我们来分析如何检查和攻击者如何绕过的过程:
首先在createOrderERC20ToERC20函数中,红色箭头标注的getAmountOutMin调用会对交换前后的价值进行检查。当然,我们首先注意到在整个函数调用中,并没有对cToken的真实性做检查。也就是说,攻击者可以自己创建一个cToken并调用createOrderERC20ToERC20函数创建一个订单。这为攻击者的攻击埋下了伏笔。
在getAmountOutMin函数中,对第一次交换前后的价值是这样做判断的:
首先获得传入和传出的cToken(ctokenA和ctokenB),从PriceOracle中调用getUnderlyingPrice获得其Underlying代币的价格。
计算调用calcSwapAmount,扣除交易费用,计算真正的swapAmountA = amountA * leverage * (1 - serviceFee)。
计算由Oracle返回的应该转出的tokenA的估计量,即amountFromOracle = (priceA * swapAmountA) / priceB。
调用getAmountOut,返回从 Pangolin 交易所真正返回的转出tokenA的数量amountOut。
对比Oracle返回的tokenA转出估计量amountFromOracle和具体数量amountOut。如果amountFromOracle * 0.95 > amountOut,代表真正交易获得的tokenB过少,则需要拒绝这笔交易。
第二次Review整个逻辑实现,我们注意到这里有几次调用cToken的underlying()函数的过程:
第一次在createOrderERC20ToERC20函数中,获得了tokenA、tokenB,后续函数中几乎所有需要用到Underlying的地方传入的都是这两个Token。
第二次在createOrderERC20ToERC20的getAmountOutMin调用中。如上文所述,这个调用的主要目的是检验此次Swap前后的价值是否一致,项目方本身是否受损。
那么在getAmountOutMin中是怎么检验的呢?我们重述一下这个过程:
重新调用underlying()函数获得tokenA和tokenB。
从PriceOracle获得ctokenA和ctokenB对应的Underlying价格。在这个过程中会再次调用underlying()获得cToken对应的Underlying。
用第一步获得的tokenA和tokenB,去Pangolin查询能换得的tokenB数量,并与PriceOracle的数量进行比较。
一般来说这个过程是没有问题的,这是由于正常的cToken合约,其Underlying是固定的。但是在整个过程中没有对cToken是否真实进行验证,这导致攻击者可以传入自己设置的cToken合约。
而攻击者又是如何巧妙运用这个不一致性的呢?
在createOrderERC20ToERC20调用开头,让underlying()函数返回 LINK 代币。因此后续真正执行的交易是WETH兑换LINK。
在getAmountOutMin函数中,让underlying()函数返回BTC代币,使这一步兑换价值校验能够通过。
更为巧妙的是,由于swapERC20ToERC20的第四个参数(如下图所示)依赖getAmountOutMin函数返回的结果,因此攻击者选取了BTC这个价值较高的代币,使得合约对交换获得的代币数量的下限要求很低。
校验完成后,真正执行的交易是在攻击者创建的不平衡交易对中,将WETH兑换为LINK的交易,成功用少量的LINK套出了Vee合约的WETH。
通过巧妙地设计了underlying()函数,攻击者成功地“狸猫换太子”,将(Vee合约认为的)BTC替换成了LINK。再根据之前所述的过程将Vee合约中锁仓的流动性套出,完成了此次攻击。
当然,项目方还是做了许多检查。在createOrderERC20ToERC20中,合约调用了一个检查函数进行检查,其中对转出Token做了检查:
也就是说,每个杠杆交易的第一步交易,转入的Token(也就是代理合约使用杠杆借入的资金换得的Token)必须是在项目方自己控制的白名单内的。
总结来说,项目方的两个疏忽导致了此次攻击:
没有对用户创建订单时传入的cToken进行验证。任何人都可以创建一个cToken,然后创建这个cToken对应的订单。
没有在Pangolin创建项目支持Token的交易对,或者说没有维持交易对的流动性。只有维持了交易对的流动性,杠杆交易的第一次交换才能换到等值的代币。
0x2. 关于官方分析报告
在攻击发生不久后,Vee项目官方发布了分析攻击原因的报告(https://veefi.medium.com/the-main-cause-of-vee-finance-attack-7a8475085ec5)。其中项目方认为导致攻击的原因有以下几个:
价格预言机只有一个价格来源,因此这个价格受到了市场波动的影响。
价格处理时没有考虑不同Token的decimals可能不同。
交易对在交易时没有设立白名单机制。
首先,Vee项目的价格预言机并没有开源。但由于Vee是一个借鉴 Compound 的项目,而Compound的预言机是开源的,从源码中可以看出:
Compound的预言机在计算Underlying价格时是考虑了不同Token的decimals可能不同这种情况的。除非Vee项目对Compound预言机进行了大幅修改,否则预言机不太可能是导致此次攻击的罪魁祸首。事实上,攻击交易中预言机返回的报价如下图所示:
注意这里的16进制值0x15e1549d1216fe9fc032e7c00000对应的十进制值为443783124870000000000000000000000,正好是当时BTC的价格,可以作为预言机清白的旁证。
同样在之前的分析中可以看出,Vee是对杠杆交易能够换到什么代币做了严格的白名单检查的。因此白名单检查也不能为此次攻击背锅。
综上所述,真正导致攻击的问题在于校验机制的缺陷:创建订单的cTokenB(杠杆交易第一笔交易要兑换获得的Token),其地址是用户(通过order参数)可以完全控制的;而直到订单执行的整个过程中,该地址都是被直接使用的,并未经过任何校验。
0x3. 结语
本次攻击手法隐蔽而巧妙,整个分析过程也是百转千折。当然,在这一过程中我们也有很多收获。安全之路上,“博学之,审问之,慎思之,明辨之,笃行之”,诚哉斯言!