原标题:Exploiting Primitive Finance Approval Flaws

来源:Amber Group 区块链安全专家吴家志博士

事件摘要

224日,一篇关于 Primitive Finance (以太坊链上衍生品协议)漏洞分析报告在圈内引发关注,报告描述了三个白帽攻击(以安全漏洞排查为目的的黑客攻击)以及漏洞原理。一个多月后的 414日,以 Amber Group 区块链安全专家吴家志博士为代表的团队,发现了一个钱包地址有超过 100 万美金的资产( 500  WETH)存在风险。在本地复现攻击后,其团队通过 Immunefi DeFi 漏洞悬赏平台)联系了 Primitive 项目方,并成功协助潜在的受害者重置了 WETH 授权,解除危机。这篇文章将介绍该团队如何在模拟环境中利用此漏洞,以及如何通过区块链数据分析找到潜在的受害者钱包地址。

原理:智能合约中的裂隙

在目前 EVM (以太坊虚拟机) ERC-20 (以太坊智能合约的一种协议标准)的架构中,当用户与智能合约交互时,智能合约本身缺少一个能从代码层面捕捉到 ERC-20 转账事件的回调机制。例如当 Alice Bob 100 XYZ 代币时,Bob XYZ 余额会被更新在 XYZ 合约里。但是 Bob 如何知道他的 XYZ 变多了呢?他可以查 Etherscan(以太坊浏览器) 或者其钱包 App 自动从以太坊节点取得的最新余额。如果 Alice 100 XYZ 发给一个智能合约 CharlieCharlie 如何得知他的 XYZ 余额增加了呢?


事实上 Charlie 没办法在收到 100 XYZ 的当下主动取得他最新的余额,原因是这个转账是在 XYZ 合约上发生的,不在 Charlie 合约。智能合约部署完成后就像操作系统一样,是一堆代码放在某个地方,需要被调用了才会发生作用。为了解决这个难题,在 ERC-20 标准有一个被广泛使用的机制 — approve()/transferFrom()


举例来说,当 Alice 需要往 Charlie 存入 100 XYZ 代币时,Alice 可以事先授权 Charlie 使用她的 100 XYZ 额度,此时 Charlie deposit() 函数就可以在一个交易里通过 transferFrom() 主动将 Alice 钱包里的 100 XYZ 取出,并且更新 Charlie 合约的状态(例如增加 Alice XYZ 存款余额 cXYZ)。为了减少摩擦,很多 DApp 甚至会让用户授权无限多的 XYZ 额度给项目方地址,这样可以让后续的 transferFrom() 调用直接成功,免除掉多次授权的点击以及手续费,这等同于将 Charlie 加白。这个方案留下了一个隐患,万一 Charlie 作恶或是被攻击了,Alice 的资产就会有危险。

这个发生于 2020 6 18 日的意外证实了一个被控制或存在问题的智能合约可以如何被利用并且造成资产损失,如下代码所示,safeTransferFrom() 虽然名为 safe transferFrom 却意外被宣告成公开函数,导致任何人都可以使用 Bancor 合约的身份转移任意用户(_from)任意数量(_value)的任意资产(_token)到任意的地址(_to)。


简单举例来说,如果 Alice 正好使用过 Bancor 并且授权 Bancor 无限额度使用她的 DAI,则一旦她的钱包里 DAI 余额大于零时,黑客就可以立即把她的 DAI 转走。 

诊断:黑客是怎样绕开安检的?


根据上文的漏洞分析报告所述,这个外部函数有一个类似的漏洞,但无法像 Bancor 的漏洞一样被直接利用。事实上,攻击者需要伪造两个 ERC20 代币合约,一个 Uniswap 资金池,并且发起一笔 Uniswap 闪电贷绕过下图标注的 msg.sender == address(this) 检查。听起来复杂,但对于有经验的黑客来说,这并不是太困难。


Primitive 为何需要实现 flashMintShortOptionsThenSwap() 这样一个接口呢?其实是有特定使用场景的,在 openFlashLong() 函数可以看到,flashMintShortOptionsThenSwap() 会被封装在一个 Uniswap flash-swap 调用参数里,在第 1371 行触发 flash-swap 之后,由回调函数 UniswapV2Call() 调起。此时由于 UniswapV2Call()  Primitive 合约里,便可以通过上述 msg.sender == address(this) 检查。




值得注意的是,在 openFlashLong() 函数里,第 1360 行写的是 msg.sender ,表示在正常的情况下,Primitive 只能使用调用者本身的资金,然而攻击者可以通过伪造的 pair 以及 params 用类似于 1371 行的方式直接调用 Primitive 合约的 UniswapV2Call() 并绕过 flashMintShortOptionsThenSwap() 的检查。由于 params 在这情况下可以完全被控制,1360 行的 msg.sender 便可以被替换成任意曾经授权 Primitive 的钱包地址,然后通过 flashMintShortOptionsThenSwap() 里的 transferFrom() 调用盗取资产。



追踪:找出可能的受害者

如果一个黑客碰巧知道某位大户曾授权有问题的合约,他可以轻易利用这个漏洞盗取受害人大量的资金。然而,这件事情如果仅使用区块浏览器是很难做到的,尤其在合约已经部署了较长时间,并有大量用户量的情况下。其中需要分析的数据并非是靠人工搜索 Etherscan 能够实现的。

Google Cloud Public Datasets (由 Google 托管在BigQuery的数据集)在此时可发挥作用。由于每一个成功的 approve() 调用都会在以太坊上发出一个 Approval() 事件,我们可以通过 BigQuery Google的云数据仓库解决方案,用于处理“大数据”报告)服务找出所有事件并且通过一些方法过滤出我们感兴趣的部分,例如 _spender Primitive 合约的所有事件。


下面是我们在 GCP 上实际用来找出潜在受害者使用的 SQL 语句,其中第五行可以看到我们限定搜索的以太坊数据库及记录事件的表,第七行过滤出 Approval() 事件,第八行过滤了特定的 _spender。此外,第六行将区块高度范围设定在 Pirmitive 合约部署之后,这可以大幅降低 BigQuery 扫过的数据量,这类的 SQL 优化会直接反应在你的 GCP 账单里。


接下来,我们可以进一步优化 SQL 查询将已经通过 approve(_spender, 0) 重置授权的账号从清单中刨除,得到最终的账号列表。有了最终的列表,我们利用一个脚本监控着这些账号,并且在这些危险账号收到大量资产时发出预警,因为这很可能会造成严重的损失。

在一个星期三的清晨,机器人发出了预警,有一个可能的受害人在北京时间 4 13 日清晨 5 24 分收到了将近 500 WETH 的资产,价值超过一百万美金。相较于已公开的三次白帽攻击,这个受害人如果被成功攻击,所损失的金额将高于稍早的三个案例的总和。


我们在北京时间 9:32 紧急联系了 Primitive 项目的漏洞赏金计划运营方 Immunefi 并且向他们展示我们如何(重新)利用这个漏洞在模拟环境中盗取受害人的 500 WETH,并且提供包括下面的截屏等证据。


Primitive 团队的帮助下,潜在的受害人于 10:03 WETH 授权重置,解除危机。


两天后,Primitive 团队也针对此发现给予漏洞奖励并发布公开致谢。该笔赏金发稿前已捐助给CryptoRelief(一家致力于援助印度新冠疫情的救济基金)。

复现:分步拆解漏洞的利用

漏洞利用的第一步,我们需要准备两个 ERC20 合约:Redeem Option


其中 Redeem 合约是一个标准的 ERC20,我们只需要基于 OpenZeppelin 的实现将 mint() 接口暴露出来,方便我们控制代币数量,如下所示:


Option 合约会相对复杂一点,从下面的代码片段可以看到,我们需要刻意构造一些全局变量(例如 redeemToken),以及公开函数(例如 getBaseValue()),这些都是在 Primitive 的业务逻辑会用到的。此外,我们还需要传入三个参数来初始化 Option 合约:

·        redeemToken : 稍早构造的 Redeem 合约地址

·        underlyingToken : 攻击目标账号所持有的资产合约地址

·        beneficiary : 受益人地址,也就是攻击成功后将受害人资产转移的目标地址


这里需要特别说明的是 mintOptions() 这个函数,从上面的代码可以看到,它会直接把所有的 underlyingToken 发给 beneficiary 地址。这是因为下面的内部函数 mintOptionWithUnderlyingBalance() 函数在被 flashMintShortOptionsThenSwap() 时会将 underlyingToken 发给 Option 代币合约,并且通过 mintOptions() 调用铸造 Option 代币。因此,我们在伪造的 Option 合约里,可以直接把 mintOptions() 当作一个提币调用,将 underlyingToken 发给 beneficiary(也就是发起攻击的地址),用于之后归还闪电贷的资金。



接下来,我们可以用刚刚创建的 Redeem Option 代币创建一个 Uniswap 的流动性池子,这个池子的地址将用来接收从受害人钱包转出的资金。事实上,每个 Uniswap 池子里有等价的两种资产,例如 WETH Redeem(也就是 Option.redeemToken()),为了完成漏洞利用,我们必须为池子注入流通性。Redeem 是我们自己创建的,可以铸造无限量的代币,但 WETH 呢?



在闪电贷的帮助下,我们基本上可以利用无限数量的资金来做如何事情,只要确保能在一个交易中归还资金即可。在这个案例中,我们使用 Aave V2 的闪电贷借了相当于受害人总资产 99.7% 的资金存入上述的流动性池。



根据 Aave 的设计,需要实现一个回调函数 executeOperation() 执行获得贷款资金后的操作(例如调用 Lib.trigger()),并且在最后通过 approve() 调用授权 Aave 合约取走闪电贷的资产以及手续费。



总结

在基于 EVM 的智能合约世界里,approve()/transferFrom() 是长久以来存在的固有问题。对于 DeFi 用户而言,需要多留意你的钱包地址是否正允许着其他人使用你的资产,并且定期重置资产使用权。对于项目方而言,需要在上线之前花更多心思和时间从各种可能的角度测试,甚至攻击你的代码,因为你正在编程的,是每位用户的真金白银。