在以太坊虚拟机(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 代币以作为质押资产。然而,我们发现此处并未具有防止重入的nonReentrantmodifier。由于使用者可以传入任意的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 的isContractmodifier 仅检查某地址对应的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。

在lucash-dev 的协助下,我们改善了漏洞利用问题,并证实了此一发现的重要性(亦即,不法分子可能利用此漏洞扫光LPFraming 池)。lucash-dev 是拯救了Dexfolio 使用者的超级英雄!