2022年3月27日,以太坊上的staking DeFi项目 Revest Finance 遭到黑客攻击,损失约200万美元。BlockSecTeam团队第一时间介入分析,并在tweeter上向社区分享了我们的分析成果。事实上,就在我们...
2022年3月27日,以太坊上的staking DeFi项目 Revest Finance 遭到黑客攻击,损失约200万美元。BlockSecTeam团队第一时间介入分析,并在tweeter上向社区分享了我们的分析成果。事实上,就在我们通过tweeter向社区分享我们的分析成果时,我们发现了Revest Finance的TokenVault合约中还存在着一个critical zero-day vulnerability。利用该漏洞,攻击者可以用更加简单的方式盗取协议中的资产。于是我们立刻联系了Revest Finance项目方。在确定该漏洞已经被修复后,我们决定向社区分享这篇blog。
Revest Finance是针对DeFi领域中staking的解决方案,用户通过Revest Finance参与的任何DeFi的staking,都可以直接生成一个NFT,即 FNFT (Finance Non-Fungible Token) , 该NFT代表了这个staking仓位的当前以及未来价值。用户可以通过Revest Finance 提供的3个接口和项目进行交互。质押自己的数字资产,mint 出相应的 FNFT 。
•mintTimeLock : 用户质押的数字资产在一段时间之后才能被解锁。 •mintValueLock : 用户质押的数字资产只有在升值或者贬值到预设数值才能被解锁。•mintAddressLock : 用户质押的数字资产只能被预设的账户解锁。
Revest Finance 通过以下3个智能合约完成对用户存入的数字资产的锁定和解锁。
•FNFTHandler : 继承自ERC-1155 token(openzepplin实现) 。每次执行lock操作时,fnftId会进行自增(fnftId 类似于ERC721中的tokenId)。FNFT在被创建时,用户需要指定它的totalSupply。当用户想要提走FNFT背后的underlying asset,需要burn掉相应比例的FNTF。 •LockManage : 记录FNFT被解锁(unlock)的条件。 •TokenVault : 接收和发送用户存入的underlying asset,并记录每一种FNFT的metadata 。例如fnftId =1的FNFT背后质押的资产类型。
因为此次攻击,黑客攻击的入口是mintAddressLock函数,那么我们以该函数为例,讲述FNFT的生命周期。
User A调用Revest的 mintAddressLock 函数
•unlocker : User X -> 只有User X 可以解锁这笔资产 •recipients : [User A , User B , User C] •quantities : [50 , 25 , 25] -> mint 数量为100 (sum (quantities)) , User A , User B , User C 各拥有50 , 25 ,25 枚 。 •asset : WETH -> mint 出的 FNFT 以 WETH 为抵押品。 •depositAmount : 1e18 -> 每一枚 FNFT 背后的抵押品数量为 1枚WETH ( WETH decimal 为18 )
假设当前系统中没有其他FNFT, User A 通过mintAddressLock 与系统进行交互,FNFTHandler返回的fnftId = 1
LockManger 为其添加相应的记录
•fnftId : 1 •unlocker : User X
Token Vault 为其添加相应的记录
•fnftId : 1 •asset : WETH•depoistAmount : 1e18
接着Token Valut 要从 User A 这里转走 100 * 1e18 数量的WETH 。
最后系统分别给 User A , User B , User C mint 50 , 25 ,25 枚 01-FNFT 。
通过mintAddressLock 函数铸造FNFT 就完成了。
当User X 解锁 01-FNFT 后,用户B 便可以通过withdrawFNFT提走underlying asset 。如图二所示,User B 想要提取自己手中持有的25个 01-FNFT 质押的数字资产。
协议首先检查01-FNTF是否已经 unlock ,如果已经unlock, 那么协议会burn掉User B的25个01-FNFT,并给他转25*1e18数量的 WETH 。此时01-FNFT 的 totalSupply 为 75 。
Revest 合约还提供了另外一个接口,叫做depositAdditionalToFNFT,以便让用户为一个已经存在的 FNFT 添加更多的underlying asset 。下面我们用2张图描述它的“正常”用法。
这里有三种情况
一.quantity == 01-FNFT.totalSupply() 如图三所示
以图二中的场景为上下文,User A 要为 01-FNFT 添加更多的抵押物。
•quantity = 75 -> 为75个 01-FNFT 追加质押 。 •amount = 0.51e18 -> 每一枚 01-FNFT 追加0.51e18 数量的WETH 。
于是User A 需要向 Token Vault 转入 37.51e18 WETH (75 0.51e18) Token Vault 修改系统记账,将depositAmount修改为 1.51e18。现在每一枚01-FNFT 承载的资产为1.5*1e18 WETH 。
此时User C 调用withdrawFNFT ,burn掉他持有的25枚 01-FNFT ,他可以拿走25(1.51e18) = 37.5*1e18 WETH 。
于是,此时01-FNFT 的totalSupply为50 。
二.quantity < 01-FNFT.totalSupply() 如图四所示
以图三中的场景为上下文,User A 继续为01-FNFT添加更多的抵押物。
•quantity = 10 -> 为10枚 01-FNFT 追加质押。•amount = 0.51e18 -> 为10枚 01-FNFT 每一枚追加0.51e18 WETH
由于quantity < 01-FNFT.totalSupply() 于是,User A 向协议支付 5*1e18 WETH 系统将会burn掉 10枚 01-FNFT ,mint 出10枚 02-FNFT ,并将burn掉的10枚01-FNFT承载的资产和User A 新转入的资产,注入到02-FNFT中。于是就有
•fnftId : 2•asset : WETH •depositAmount : 2.01e18 (1.51e18 + 0.5*1e18)
此时
•01-FNFT.totalSupply : 40 01-FNFT.depositAmount : 1.51e18 (逻辑上应该如此,见后文: the New Zero-day Vulnerability) •02-FNFT.totalSupply : 10 02-FNFT.depositAmount : 2.01e18
三.quantity > 01-FNFT.totalSupply()
这种情况,交易会revert。
在理解了mintAddressLock 函数 和 depositAdditionalToFNFT 函数的基本工作流程后,来看一下攻击者使用的重入手法。 假定the lastest fnftId = 1 (不影响理解)
如图五所示 第一步: 攻击者调用 mintAddressLock 函数
•depositAmount = 0 •quantities = [2]
mint 出了 2枚 01-FNFT , 由于攻击者将 depositAmount 设置为0 ,因此他没有转入任何数字资产。相当于01-FNFT 背后承载的underlying asset 为0 。
第二步:攻击者再次调用 mintAddressLock 函数
•depositAmount = 0 •quantities = [360000] 准备mint 36w 枚02-FNFT depositAmount为0 。
在mint 的最后一步,攻击者利用ERC-1155 的call-back 机制重入了 depositAdditionalToFNFT 函数 。(详见下面给出的 _doSafeTransferAcceptanceCheck函数)
在depositAdditionalToFNFT 中 , 攻击者传入
•quantity = 1
•amount = 1*1e18•fnftId = 1
因为 quantity < fntfId.totalSupply(),因此协议会burn掉攻击者1枚01-FNFT, 铸造1枚02-FNFT。(02-FNFT在协议中已经存在,但是fnftId 更新延迟)然后修改fnftId =2 的depositAmount 为 amount。相信你已经发现,这一步,攻击者通过重入将 fnftId = 2 的 depositAmount 从0修改为1.01e18 , 仅仅花费11e18 RENA 就获得了 (360000 +1 ) 11e18 RENA 的系统记账。 /img.learnblockchain.cn/attachments/2022/04/7ykmmV18625d0232f2e82.png
最后攻击者调用withdrawNFNFT函数,burn掉 360,001枚02-FNFT,取走了360,001*1e18 RENA 。
在blockSecTeam团队分析Revest Finance 的代码时,handleMultipleDeposits 函数引起了我们的注意。
当用户调用 depositAdditionToNFT 函数追加抵押物时,该函数会改变 FNFT 的 depositAmount 。从代码中我们可以发现,当newFNFTId != 0 时,该函数既改变了 fnftId 对应的 FNFT 的depositAmount 也改变了 newFNFTId 对应的 depositAmount 。
按照常理,当 newFNFTId !=0 时,系统应该只记录 newNFTId 对应的 depositAmount 。不应该改变 fnftId 对应的depositAmount 。
我们认为这是一个非常严重的逻辑bug ,利用该漏洞,攻击者可以很轻松提走系统中的数字资产。下面用3张图描述模拟攻击的原理。 假定 the latest fnftId = 1
首先攻击者调用 mintAddressLock 函数,mint出360000个01-FNFT 。攻击者将 amount 设置为0 因此他不必转入任何资产到Revest Finance 协议中。 mint 结束后,攻击者拥有360000 枚 depositAmount =0 的 01-FNFT 。
然后攻击者调用 depositAdditionalToFNFT 函数,参数如下
•fnftId = 1•amount = 1 * 1e18•quantity = 1
协议转走攻击者 amount quantity 数量的代币 ,即 1 1e18RENA 协议会burn掉攻击者1枚01-FNFT , 并为其铸造一枚02-FNFT(假定 latest fnftId = 2) 按照 handleMultipleDeposits 函数中的逻辑, fnftId = 2 的资产,其 depositAmount 会被设置为 1.01e18。 但是 fnftId = 1的资产,其depositAmount 也会被设置为 1.01e18 ,而这个值本应该为0!
第三步,攻击者直接提款,将手中所有的 01-FNFT 提现。不考虑gas费,他将净赚 359,999 * 1e18 数量的 REAN 代币。
很显然,使用这种方式进行攻击,比真实的重入攻击更加简单直接。
针对该漏洞,blockSecTeam团队给出了相应的patch方法。
由于TokenVault and FNFTHandler 两个漏洞合约存储了许多关键的状态,无法在短时间内重新部署它们, 为了快速恢复使用, Revest Finance 官方重新部署了Revest 合约 (https://etherscan.io/address/0x36c2732f1b2ed69cf17133ab01f2876b614a2f27#code)的精简版本。该版本关闭了大部分复杂的功能,以避免被进一步攻击。项目方将在未来迁移状态并重新部署修复过的合约。
提升DeFi项目的安全性不是一件容易的事情。除了代码审计,我们认为社区应该采取更加主动的方式,例如项目监控预警、甚至是攻击阻断 使得DeFi社区更加安全。(https://mp.weixin.qq.com/s/o41Da2PJtu7LEcam9eyCeQ).
*[1]: https://blocksecteam.medium.com/revest-finance-vulnerabilities-more-than-re-entrancy-1609957b742f
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!