以太坊虚拟机(EVM)及Solidity 智慧合约问世后,重入攻击事件频传。本文Amber Group 安全团队将分享并解析他们在8 月所发现币安智能链(BSC)项目Dexfolio 的LPFarming 合约漏洞案例。
在以太坊虚拟机(EVM)及Solidity 智慧合约问世后,短短数年间内就出现了许多重入攻击事件。2021 年8 月15 日,我们发现币安智能链(BSC)项目Dexfolio 的LPFarming 合约存在一个可被重入攻击的漏洞,并透过漏洞悬赏平台ImmuneFi 通报了这个漏洞。
由于Dexfolio 研发团队并未在过去的120 天内公布事后剖析报告,因此我们将在本篇文章中提供相关细节。
0x00: Dexfolio
Dexfolio 的LPFarming 合约可让用户以质押资产的方式来进行流动性挖矿。对于用户所持有的每项资产,LPFarming 合约提供了四个公用函数处理质押任务。
例如:LPFarming.stake()函数可让使用者将币安币(BNB)转入合约,并将半数的BNB 转换为DEXF 代币,用以铸造DEXF-WBNB LP 代币后,质押LP 代币。
从上方的程式码片段可看到,在第555 行newBalance由当前余额扣掉原始余额initialBalance计算出新铸造的DEXF-BNB LP 代币数量。在第560 行,新的质押记录被加入了_stakes[] 阵列,作为计算挖矿奖励的依据。
0x01:漏洞
在这四个质押函数中,stakeToken()是一个较为特殊的函数,它可以让使用者透过支付任意一种ERC20 代币来交换DEXF-BNB LP 代币以作为质押资产。
然而,我们发现此处并未具有防止重入的nonReentrant modifier。由于使用者可以传入任意的fromTokenAddress执行stakeToken(),因此很多的ERC20 函数调用(例如transfer()、transferFrom() 及approve() 等) 都可能被用于劫持控制流程或重入stakeToken()。
在上方的第647 行程式码中可以看到,initialBalance 备份了后续代币交换前的余额以便计算第651 行新铸造的DEXF-BNB LP 代币数量。然而, 649 行的swapAndLiquifyFromToken() 调用在内部执行了fromTokenAddress.approve(), 以利在PancakeSwap 上交换代币。
这使得不法分子可于原始stakeToken() 调用的主体内嵌入另一个质押操作,导致651 行的newBalance 变大。简言之,攻击者可能会针对同一批LP 代币进行双重质押。
例如,攻击者调用stakeToken(10),并透过另一个帐户及fromTokenAddress.approve()嵌入另一个stakeToken(90)。最终,第一个帐户持有10 + 90 = 100 个质押的LP 代币,而第二个帐户则持有90 个,后者的90 在此处经过了重复计算。
乍看下,因为第二个帐户必须执行某些程式码才能重入stakeToken(),故第638 行的isContract检查可防止重入。然而,在LPFarming 里的isContract的实作无法涵盖所有情况,例如在constructor 里就能实现绕过检查的恶意程式码。
0x02:漏洞利用
为利用重入漏洞,我们需要一个恶意ERC20 合约(Ftoken) 来劫持approve()调用。一如下方的程式码片段所示, Ftoken 透过_optIn开关覆写了OpenZeppelin ERC20 实作的approve()函数。
当开关开启时,创建Exp 合约并在其constructor 中嵌入上文提及的stakeToken()调用,以便避开有漏洞的isContract检查。
由于LPFarming 的isContract modifier 仅检查某地址对应的extcodesize,我们可透过执行Exp 合约中建构函数(constructor) 里的LPFarming.stakeLPToken()来绕过保护机制,如下所示。
若真的很希望避免使用合约帐户,则须确保检查tx.origin == msg.sender。
此处遗漏了一个部分。由于stakeToken()在PancakeSwap 将fromTokenAddress资产转换成DEXF-BNB LP 代币,我们必须创造Ftoken-BNB 对并增加其流动性。我们透过另一个Lib 合约来实现此一目的。
下方的Lib.trigger()函数可使我们在PancakeSwap 上创造Ftoken-BNB 对,并将与Ftoken数量相同的WBNB 放入流动池中。此外,我们也加入了一个Lib.sweep()方便owner在完成攻击后搜刮流动池中所有剩余的WBNB。
备妥这三份合约后,我们就可以进行实验以验证我们的理论。如下方eth-brownie 截图所示,我们先从21 WBNB 开始,并部署了Ftoken及Lib合约。
如前所述,我们使用Exp 合约的constructor 来重新质押部分LP 代币并通过LPFarming.getStakes() view function 可以观察到Exp 合约目前所持有的份额数量。
由于Exp 合约需要LP 代币用于重新质押,但直到Ftoken.approve()中Exp 才会被创造,如此处所示范的[1],我们用Ftoken合约地址预先以eth-util 计算出Exp 地址,并且将LP 代币转过去。
准备好Ftoken及Lib,并以Lib.trigger()创造Ftoken-BNB 对后,我们便可执行第一个stakeToken()发起重入攻击。
如上图所示,我们在stakeToken()调用前后均执行Ftoken.optIn(),以便启动及切换Ftoken.approve()中的劫持机制。
最后,我们用LPFarming.emergencyWithdraw()提取LP 代币的数额并转换为WBNB。此外, 我们也执行Lib.sweep()以便获取Ftoken-BNB 流动池中其余的WBNB。
最终我们取得了19.36 WBNB,并在LPFarming 合约中留下Exp 合约的质押记录。由于Exp 合约已部署于Ftoken.approve()调用中,我们无法重新初始化合约以及在constructor 中再次执行LPFarming.emergencyWithdraw(),因此,攻击者似乎无法从中获利。然而实际上,CREATE2 指令可使我们能够重新初始化Exp 合约。
0x03: CREATE2
CREATE2 指令是以太坊Constantinople 硬分叉后的产物,能使用户透过特定合约的位元组程式码及salt 值预先计算合约地址。伴随而来的结果是,如果合约以SELFDESTRUCT 指令自毁,同样的位元组程式码及salt 值可能会再次被部署在同一个地址上。
利用这个特性,我们可以让Ftoken.approve()部署Exp 合约并执行重入后执行SELFDESTRUCT。之后,我们可重新部署Exp 合约并执行LPFarming.emergencyWithdraw()以提取双重质押的LP 代币。
上图显示了变更后的Ftoken.approve()。有四个参数传递至CREATE2 调用。第一个0 代表创造合约时支付了0 以太币,最后一个0 是salt 值,其应与重新建立合约时的值相同。第二、第三个参数均与我们要部署的位元组程式码相关。
除了变更后的Ftoken.approve()外,我们还新增了getAddress() view 函数以预先计算Exp 合约地址。我们在这里只须根据EIP-1014,以0xff、创建者地址及32 位元组的salt 值及合约程式码bytecode。
除了上述的修改,我们也调整了Exp 合约,根据token 合约所保持的状态来质押或提取LP 代币,并以SELFDESTRUCT 指令自毁的方式允许下次的重新部署操作。
在CREATE2 的帮助下,我们的Exp 合约便可赚取利润,如下方截图所示:
我们成功地在区块高度10181384 从LPFarming 合约中获取1,558 个LP 代币,并将其转换为WBNB。
0x04:事件的时间轴与致谢
我们在2021 年8 月15 日将此问题回报给ImmuneFi 平台[2], 且Dexfolio 团队已要求使用者于2021 年8 月20 日提取其资产[3]。在我们通报之后,ImmuneFi 随即告知我们,此漏洞报告有可能是重复回报,但须与Dexfolio 团队确认。
由于我们已逾90 天未能从ImmuneFi 或Dexfolio 得到回应,因此我们选择以独立研究的方式揭露细节。在我们表示有意揭露细节后,ImmuneFi 便为这份有效的报告向我们适当地揭露流程,于2021 年11 月26 日赠予我们$1000 美元等值的以太币(ETH)以作为奖励。此外,ImmuneFi 也协助我们联系了最初回报同样漏洞的「白帽」骇客lucash-dev。