在区块链短短的历史上发生过跟智能合约相关的攻击事件中,重入攻击无疑是最广为人知的一种类型,在以太坊草创时期,2016 年7 月的TheDAO 事件甚至直接造成了以太坊硬分叉为以太经典(ETC) 及现在大部分人熟知的以太坊(ETH)。在TheDAO 事件给以太坊重击之后,开发者也多了一些方法来防范重入攻击,例如,Checks-Effects-Interactions 以及 Reentrancy Guard。然而,许多重入攻击仍然持续发生,攻击的形式也从通过 fallback 函数重入同函数转变成通过不同的外部函数进入智能合约,造成合约状态混乱以达成有效攻击。本文将介绍及复现发生于 2020 年 4 月 UniswapV1 的重入攻击,2021 年 7 月发生在 BSC 上 DeFiPIE 项目的重入攻击,以及近期发生于 C.R.E.A.M. 项目的 AMP 代币重入攻击。

在进入案例分析之前,我们先介绍下重入攻击的基本概念。下面是 Solidity 网站上面介绍重入攻击给的简单案例,事实上这个 Fund 合约,就是简化过的 TheDAO 合约,在 withdraw() 函数裡我们可以看到 shares[msg.sender] 数量的 ETH 会通过 msg.sender.send() 发给 msg.sender,也就是 Fund.withdraw() 的 caller。其中 shares[] 裡头存的是每个 user 存入合约的 ETH 额度,因此在 user 成功取出 ETH 之后,shares[msg.sender] 会被清零,这个程序逻辑看起来没有任何问题。

然而,上述的caller (msg.sender) 可以是个恶意合约地址,如果恶意合约里写了fallback function ,则Fund.withdraw() 里的msg.sender.send() call 就可以被hijack,在这个fallback function 里如果再次调用了Fund.withdraw() 则shares[msg.sender] 数量的ETH 就会在被清零之前被多次发送给msg.sender,下面是一个示意图:

攻击者部署一个Evil 合约,在Evil.receive()(即fallback function)检查Fund 的ETH 足够的情况下连续调用Fund.withdraw() ,即可将Fund 合约的ETH 抽光,直到最后一次调用,shares[msg.sender] 才会被真正的清零。

这个简单的重入攻击案例有一个关键点:「清零」发生在「转帐」之后。虽然这样的写法比较符合人类的逻辑,即「确认转帐成功了,再把纪录清掉」,但是在EVM 的世界里有点不同。其实先清零再转帐也没有什么问题的,如果转帐失败了,清零的操作会自动回滚(revert)。而且把转帐放到清零之后,反而可以避免重入攻击,也就是Checks-Effects-Interactions pattern。shares[msg.sender] 是effects,msg.sender.send() 是interactions,只要所有的interactions 都在effects 之后,即使重入了Fund.withdraw() 也不会造成什么影响。

接下来,我们将介绍一个类似的案例,只是漏洞利用方式稍微复杂一点。2020 年4 月18 日下午,Twitter 上开始出现了关于Uniswap imBTC pool 被攻击的消息:

Uniswap 的创始人 Hayden Adams 提到了 UniswapV1 不支持 ERC-777 并且附上了一个 ConsenSys Diligence blog 的链接。事实上,这次攻击符合 ConsenSys Diligence blog 裡的描述,而且这篇 blog 是差不多刚好一年之前写的 (2019-4-20)。

关键点在UniswapV1 的tokenToEthInput() 函数与ERC-777 token 的兼容性问题,从下面程序代码片段可以看到,tokenToEthInput() 函数基本上是符合Checks-Effects-Interactions 的写法,第208 行合约给用户发送ETH,第209 行用户给合约发送token,都在函数的最后面执行,如果从UniswapV1 本身来看是没有任何问题的。然而,DeFi 世界就像是一个金融乐高游乐场,209 行发送的token 本身也是一个智能合约,在这个合约肚子里存在一个effects after interactions 的场景。

下面是某个ERC-777 token contract 的transferFrom() 函数底层实现,第866 行有一个callback interface 可以用来通知holder ,只要holder 是一个合约地址,并且按照ERC-1820 注册了tokensToSend() 函数。而第868 行的_move() 才是真正更新token balances 的地方。因此,如果攻击者在_callTokensToSend() 时重入了UniswapV1 的tokenToEthInput(),可以造成UniswapV1 pool 本身token balance 增加之前,多次兑换成ETH。即第204 行的token_reserve 永远不变。

简单的说,在重入攻击发生的情况下,第204 取出的token_reserve 可能跟上一层调用是一样的,在Uniswap xy=k 的设定下,如果可以用同样的token_reserve 多次交易,等于是可以持续用较高的价格卖出token 把流通性提供方(LP) 的代币消耗殆尽。

下面是我们利用eth-brownie 回到案发之前的2020-2-15 区块高度9488451 复现这次攻击的程序代码:

首先是通过ERC1820 合约注册tokensToSend() callback function,注册完成之后所有兼容ERC-777 的token transfer 发生时,如果目标地址是攻击合约本身,则合约的tokensToSend() external function 会被调用。接下来介绍攻击发起函数trigger():

上面这短短10 行代码只做了四件事,第38 行将ETH 换成token,第39 行将上一步换出来的token 又换回ETH,第40 行将一部分ETH 换成token,第43-44 行将所有的ETH 及token 转给owner,也就是攻击者钱包地址。其中,第39 行有一个比较特别的点,只有1/32 的token 被换回ETH,按照这样的写法肯定是会亏钱的。其实另外的31/32 置换是在上述的callback function 里头完成,程序代码如下:

从上面的程序代码可以看到entry 会计算现在是第几次进入tokensToSend() 然后在第57 行完成另外31 次兑换,每次也是1/32 的token balance。通过这31 次重入,攻击者可以用较好的价钱卖出token 并且破坏UniswapV1 pool 里的平衡状态,即xy=k 的k 值改变,因此最终pool 里的ETH 会变得很少token 很多,ETH 相对于token 的价值极高,所以上面trigger() 函数的第40 行,攻击者可以用很少的ETH 把大部分pool 里的token 买回来。下面是攻击代码执行的结果:

原本pool 里头有718 ETH + 19.59 imBTC,攻击完成之后只剩下0.013 ETH + 0.019 imBTC,几乎是掏空了pool。

上述UniswapV1 + ERC-777 的例子其实跟TheDAO 的案例类似,都属于同一个函数的重入,下面介绍一个多函数参与的案例,是近期发生在BSC 上的DeFiPIE 攻击事件。在第一眼看到DeFiPIE 代码时,有一种熟悉感,与老牌DeFi 项目Compound 有87% 的相似度,直觉联想起了2020-4-19 的Lendf.Me $25M Better future事件,仔细分析之后发现,问题的根源确实如出一辙,都是通过重入攻击造成内部记帐错误,达成获利。

从上面DeFiPIE 的PToken 合约程序代码片段中可以看到,borrowFresh() 函数会在把资产发给borrower 之后才将因为这次借款造成的状态改变写入合约的storage,所以又是一个effects after interactions 的案例。由于借款的上限取决于抵押资产的价值,正常情况下,某一次借款把额度用完之后,在归还借款之前应该就借不出任何资产了。但由于上述情况数据没有及时更新,重入后的第二次借款仍然可以使用跟第一次借款发生前一样的额度,因此理论上是可以无限嵌套,多次利用有限额度,最终攻击者通过清算自己以较低成本创造的负债获利。

在Lendf.Me 事件中,攻击者是通过imBTC 的ERC-777 内建机制拦截transferFrom() 完成重入攻击。在DeFiPIE ,对于token 本身并没有任何限制,可以随意创建token 合约纳入借贷体系。如上图所示,任何人都可以创建一个恶意的EvilToken 并且人工制造一个拦截transfer() 的机制以达成重入攻击,下面介绍我们如何reproduce 针对DeFiPIE 的攻击,由于这个攻击比较复杂,我们会依序从各个模块介绍,最后介绍如何组装使用。

先从恶意token contract 开始,现在要写一个ERC20 合约基本上只要继承OpenZeppelin的template自行修改 token name 以及 symbol 就行。在上面的X token contract 里可以看到,第233 行的transfer() 我们加入了一个开关optIn,在开关打开的情况下(optIn == true),Lib.shellcode() 会被调用执行重入攻击任务,这就是上面说到的人工创建拦截transfer() 的机制。其他如mint(), setup(), start() 就是一些方便使用的外部函数。

第二个模块是Lib.shellcode() 函数,也就是上述transfer() 被拦截后发起重入攻击的地方,在这次模拟中,我们嵌套了三层,依序调用了自行创建的PToken (pX[1], pX[2]) 并且在第三层从pBUSD 真正的借出了21,000 BUSD,在这过程中实现了「三个坛子一个盖」。

第三个模块是获利的关键,清算者(Liquidator)。在上面的Liquidator.trigger() 函数可以看到,清算者使用x 代币调用pX 合约的liquidateBorrow() 获取质押品colleteral(即pCAKE),随后在第66-67 行将pCAKE 换成CAKE 并转给owner(即Lib 合约)。mint() 函数的作用是提供足够的x 给pX 合约,让上述Lib 合约能够调用pX.borrow() 借出资产。

接下来就是组装上面三个模块搭配闪电贷取得获利,首先是创建三个X tokens 及Lib 合约。Lib 合约的constructor 创建了Liquidator 合约。第272-278 行铸造了X tokens 给Liquidator 及Lib,第280-284 行将X tokens 与Lib 互相关联上。第285 行触发Lib 合约启动后续流程,最后在第288 行将获利的WBNB 转给owner(即攻击者钱包地址)。

Lib.trigger() 实际上就做了一个两层的PancakeSwap 闪电贷,第116 行可以看到154.5 WBNB 被借出,在回调函数pancakeCall() 里又借了2,900 CAKE。主要的攻击流程在pancakeCall() 的后半段。

在进入第二层pancakeCall() 时,就是真正攻击流程的开始,首先是使用x[0], x[1], x[2] 这三个X tokens 创建三个pToken (pX[0], pX[1], pX[2])。要创建pToken 需要预先在Uniswap 创建交易对并且注入流通性(第136-142 行),pX[i] 创建完毕后,即可取出流通性(第149 行)以方便重复使用前面借出的WBNB,最后触发Liquidator 存入足够的x[i] 让pX[i] 能够被borrow()(第152 行)。

第二步是触发pX.borrow() 前的准备工作,第156-162 行调用了Controller.enterMarkets() 将pX[0], pX[1], pX[2], pCAKE 等pToken 纳入DeFiPIE 体系,以便后续操作。第166 行将前面闪电贷借出的2,900 CAKE 全数注入pCAKE 合约充当后续借贷的抵押品。

第三步打开x[0], x[1], x[2] 的transfer() 拦截机制(第170-172 行),并且触发pX[0].borrow(),由于上述Lib.shellcode() 的作用下,最终会拿到21,000 BUSD,并且创造了不良资产。

第四步触发Liquidator 清算不良资产,获得CAKE。

清偿完闪电贷后,在测试环境中最终获利66 WBNB。虽然数额不大,但这个案例涉及到代币合约,清算合约等较复杂的漏洞利用过程,值得研究分享。

2021 年8 月30 日下午,就在这篇文章完稿之际,CREAM 项目传出了遭遇攻击损失 1,800 万美元 。笔者短暂分析攻击交易后发现这次攻击与上述DeFiPIE 遭遇的攻击手法极其类似,决定复现此案例并加入本文。

漏洞的原理其实不需要赘述,跟DeFiPIE 基本是一样的,攻击者通过AMP 代币自身的回调机制实现了「两个坛子一个盖」,用同一笔ETH 质押品借出了AMP 及ETH,最终通过另一个合约清算自己的不量债务获利。下面直接介绍攻击合约的各个模块以及最后的组装使用:

首先是注册callback function,跟前面UniswapV1 的情况类似,攻击者通过ERC-1820 合约注册一个tokensReceived() 函数,当有人往攻击合约发送AMP tokens 时,callback function 会被触发。

而callback function 本身就是一个针对crETH 合约的borrow() 调用,攻击者的预期是在crAMP.borrow() 的调用过程中利用同样的抵押品再借一笔ETH。

第三个模块是Liquidator 合约,与上述DeFiPIE 的Liquidator 类似,在上图Liquidator.trigger() 函数里,攻击者用AMP 清算了自身创造的不良资产获得crETH 抵押品(第60 行),随后将crETH 换成ETH(第61 行),并发回给owner,即攻击合约。

最后就是组装执行攻击了,上图是Exp.trigger() 函数,在第94 行先是一个UniswapV2 的闪电贷,借出了500 WETH,后面的uniswapV2Call() 函数才是真正的流程。

首先是一些准备工作,由于闪电贷借的是WETH 而crETH 需要使用ETH 才能铸造,因此在第105 行,先将WETH 换成ETH,接下来将换出的ETH 全数发给crETH 合约铸造出crETH cTokens。与前面DeFiPIE 攻击一样,需要调用一次Comptroller.enterMarkets() 将crETH 激活以便后续的操作。

第二步就是利用上面存入的500 ETH,借出AMP tokens,在crAMP.borrow() 的过程中crAMP 合约把AMP 转给攻击合约,由于前面ERC-1820 的机制,这次转帐会被拦截并另外借出355 ETH。

第三步通过Liquidator 合约清算债务,将部分质押品取回。从上图可以看到攻击者将前面借出的一半AMP 发给Liquidator,换回足够支付闪电贷的ETH,保留剩下的AMP。

最终将ETH 都换成WETH 支付闪电贷后,带走41 WETH + 9.74M AMP。

若以「币圈一天,人间一年」给区块链世界计时,重入攻击算是上古时期的物种了,开发者还需多从历史上发生过的案例中吸取经验,形成肌肉记忆,避免受到伤害。