BlockSec 认为,导致此次攻击的根本原因是校验机制的缺陷,而非官方分析报告宣称的诸如单一价格预言机等因素带来的影响。
原文标题:《狸猫换太子:Vee Finance 安全事件再分析》 撰文:BlockSec
在昨天(2021 年 9 月 22 日) Vee Finance 项目发生攻击事件之后,我们第一时间对该事件开展分析并发布了初步的分析报告 (似曾相识燕归来:Vee Finance 安全事件分析)。但在分析过程中,依然存在一些悬而未决的疑点:
在攻击交易中 createOrderERC20ToERC20 函数调用,有一个 cToken 行为比较奇怪,和这个地址相关的交易只有几十条;后续查明这个 cToken 是由攻击者控制的账户创建的。在重新 Review Vee 项目的代码中,我们发现前一篇文章中发现整个发起杠杆交易的 borrowAndCall 调用并没有对第一笔交换的价值进行判断这个分析是不确切的。下文会阐述,在整个调用过程中存在对交换前后的价值进行判断的代码逻辑。在分析攻击交易的 Trace 中,我们发现了一个奇怪的 BTC 代币地址,该地址和前一篇文章所述的攻击过程并无关联。
在我们的分析报告发布之后,项目方也公布了自己的官方分析报告 (The Main Cause of Vee Finance Attack),然而该报告依然无法解释上述疑问。鉴于此,我们针对此次攻击事件对 Vee 项目进行了更为细致的分析和复盘。分析结果表明,导致此次攻击的根本原因是校验机制的缺陷,而非如官方分析报告宣称的,诸如单一价格预言机等因素带来的影响。
深入分析 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 中是怎么检验的呢?我们重述一下这个过程:
1.重新调用 underlying() 函数获得 tokenA 和 tokenB。 2. 从 PriceOracle 获得 ctokenA 和 ctokenB 对应的 Underlying 价格。在这个过程中会再次调用 underlying() 获得 cToken 对应的 Underlying。 3.用第一步获得的 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)必须是在项目方自己控制的白名单内的。
总结来说,项目方的两个疏忽导致了此次攻击:
1.没有对用户创建订单时传入的 cToken 进行验证。任何人都可以创建一个 cToken,然后创建这个 cToken 对应的订单。 2.没有在 Pangolin 创建项目支持 Token 的交易对,或者说没有维持交易对的流动性。只有维持了交易对的流动性,杠杆交易的第一次交换才能换到等值的代币。
关于官方分析报告
在攻击发生不久后,Vee 项目官方发布了分析攻击原因的 Medium (链接:Medium)。其中项目方认为导致攻击的原因有以下几个:
价格预言机只有一个价格来源,因此这个价格受到了市场波动的影响。价格处理时没有考虑不同 Token 的 decimals 可能不同。交易对在交易时没有设立白名单机制。
首先,Vee 项目的价格预言机并没有开源。但由于 Vee 是一个借鉴 Compound 的项目,而 Compound 的预言机是开源的,从源码中可以看出:
Compound 的预言机在计算 Underlying 价格时是考虑了不同 Token 的 decimals 可能不同这种情况的。除非 Vee 项目对 Compound 预言机进行了大幅修改,否则预言机不太可能是导致此次攻击的罪魁祸首。事实上,攻击交易中预言机返回的报价如下图所示:
这里 0x15e1549d1216fe9fc032e7c00000 即 443783124870000000000000000000000,正好是当时 BTC 的价格,可以作为预言机清白的旁证。
同样在之前的分析中可以看出,Vee 是对杠杆交易能够换到什么代币做了严格的白名单检查的。因此白名单检查也不能为此次攻击背锅。
综上所述,真正导致攻击的问题在于校验机制的缺陷:创建订单的 cTokenB (杠杆交易第一笔交易要兑换获得的 Token),其地址是用户(通过 order 参数)可以完全控制的;而直到订单执行的整个过程中,该地址都是被直接使用的,并未经过任何校验。
结语
本次攻击手法隐蔽而巧妙,整个分析过程也是百转千折。当然,在这一过程中我们也有很多收获。安全之路上,「博学之,审问之,慎思之,明辨之,笃行之」,诚哉斯言!