背景
CertiK之前的文章《链上打新局中局,大规模RugPull手法揭秘》中,揭示了一个针对打新机器人的大规模退出骗局自动化收割机地址0xdf1a,该地址在短短两个月左右就完成了超过200次退出骗局(以下统称RugPull),但这个团伙并非只有一种RugPull手法。
之前的文章中以MUMI代币为例描述了该地址背后团伙的RugPull手法:通过代码后门直接修改税收地址的代币余额,却没有修改代币的总供应量,也没有发送Transfer事件,从而使查看了etherscan的用户也无法发现项目方偷偷铸造代币的行为。
今天的文章则是以代币“ZhongHua”为例解析该团伙的另一种RugPull手法:用复杂的税收功能逻辑,掩盖可用于RugPull的转账功能。接下来,我们通过“ZhongHua”代币案例,分析地址0xdf1a的另一种RugPull手法细节。
深入骗局
在该案例中,项目方共计用9,990亿个ZhongHua兑换出了约5.884个WETH,耗干了池子的流动性。为了深入了解整个RugPull骗局,我们从头梳理一下事件脉络。
部署代币
1月18日凌晨1点40分(UTC时间,下文同),攻击者地址(?0x74fc) 部署了名为ZhongHua的ERC20代币(?0x71d7),并预挖了10亿个代币发送给攻击者地址(?0x74fcfc)。
预挖代币数量与合约源码内定义的数量一致。
添加流动性
1点50分(代币创建10分钟后),攻击者地址(?0x74fc)向Uniswap V2 Router授予ZhongHua代币的approve权限,以准备添加流动性。
1分钟后,攻击者地址(?0x74fc)调用Router中addLiquidityETH函数添加流动以创建ZhongHua-WETH流动性池(?0x5c8b),将预挖的所有代币和1.5个ETH添加到流动性池中,最后获得约1.225个LP代币。
从上述代币转账记录中我们可以看到,有一笔转账是攻击者(?0x74fc)发送了0个代币给ZhongHua代币合约自身。
这笔转账不属于添加流动性的常规转账,通过查看代币合约源码发现,其中实现了一个_getAmount函数,该函数负责从转账的from地址扣款并计算所要收取的手续费,然后将手续费发送至代币地址,再触发表示代币地址收到手续费的Transfer事件。
_getAmount函数中会判断转账的sender是不是_owner,若是_owner则将手续费置为0。_owner在Ownable合约部署时由构造函数constructor的输入参数赋予。
而ZhongHua代币合约继承了Ownable合约,并在部署时将部署者msg.sender作为Ownable构造函数的输入参数。
因此攻击者地址(?0x74fc)就是代币合约的_owner。而添加流动性的那笔0代币转账正是通过_getAmount函数发出,因为_getAmount会在transfer和transferFrom函数内被调用。
永久锁定流动性
1点51分(流动性池创建的1分钟内),攻击者地址(?0x74fc)将通过添加流动性获取的全部1.225个LP代币直接发送至0xdead地址,以完成对LP代币的永久锁定。
同MUMI代币案例一样,当LP被锁定后,理论上攻击者地址(?0x74fc)便不再具备通过移除流动性进行RugPull的能力。而在地址0xdf1a主导的针对打新机器人的RugPull骗局中,这一步主要是用于骗过打新机器人的反诈脚本。
至此,在用户看来所有的预挖Token都用于添加到流动性池中,并未有异常情况出现。
RugPull
凌晨2点10分(ZhongHua代币创建约30分钟后),攻击者地址2(?0x5100)部署了专门用于RugPull的攻击合约(?0xc403)。
同MUMI代币的案例一样,项目方没有用部署ZhongHua代币合约的那个攻击地址,且用于RugPull的攻击合约不开源,目的都是为了提高技术人员溯源的难度,大部分RugPull骗局都有这样的特点。
上午7点46分(代币合约创建约6小时后),攻击者地址2(?0x5100)进行了RugPull。
他通过调用攻击合约(?0xc403)的“swapExactETHForTokens”方法,从攻击合约中转出9,990亿个ZhongHua代币兑换出了约5.884个ETH,并耗尽了池子中大部分流动性。
由于攻击合约(?0xc403)不开源,我们对其字节码进行了反编译,结果如下:
https://app.dedaub.com/ethereum/address/0xc40343c5d0e9744a7dfd8eb7cd311e9cec49bd2e/decompiled
攻击合约(?0xc403)的“swapExactETHForTokens”函数主要功能就是先用approve为UniswapV2 Router授予最大数量的ZhongHua代币转账权限,再通过Router将调用者指定数量为“xt”的ZhongHua代币(攻击合约(?0xc403)拥有的)兑换成ETH,并发送给攻击合约(?0xc403)中声明的“_rescue”地址。
可以看到“_rescue”对应的地址正是攻击合约(?0xc403)的部署者:攻击者地址2(?0x5100)。
该笔RugPull交易的输入参数xt为999,000,000,000,000,000,000,对应9,990亿个ZhongHua代币(ZhongHua的decimal为9)。
最终项目方用9,990亿个ZhongHua将流动性池中的WETH耗干,完成RugPull。
和之前文章的MUMI案例一样,我们需要先确认攻击合约(?0xc403)中ZhongHua代币的来源。从前文我们得知ZhongHua代币的总供应量为10亿,而在RugPull结束后,我们在区块浏览器中查询到的ZhongHua代币总供应量依旧是10亿,但是攻击合约(?0xc403)出售的代币数量却是9,990亿,是合约记录的总供应量的999倍,这些远超总供应量的代币从何而来?
我们查看了合约的ERC20转账事件历史,发现和MUMI代币的RugPull案例一样,ZhongHua代币案例中攻击合约(?0xc403)同样没有ERC20代币的转入事件。
在MUMI的案例中,税收合约的代币来自于代币合约中直接对balance的修改,使得税收合约直接拥有远超总供应量的代币。由于MUMI代币合约在修改balance时不对应修改代币的totalSupply,也不触发Transfer事件,因此我们无法看到MUMI案例中税收合约的代币转入记录,仿佛税收合约用来RugPull的代币像是凭空出现的一样。
回到ZhongHua这个案例,攻击合约(?0xc403)中的ZhongHua代币也像是凭空出现的一样,因此我们也去ZhongHua代币合约中搜索“balance”这个关键字。
结果显示整个代币合约仅有三处对balance变量的修改,分别在“_getAmount”、“_transferFrom”和“_transferBasic”函数中。
其中“_getAmount”用于处理收取转账手续费的逻辑,“_transferFrom”和“_transferBasic”则是在处理转账逻辑,并没有出现如下图MUMI代币一般明显地直接修改balance的语句。
更关键的是,MUMI代币合约直接修改税收合约的balance时没有触发Tranfer事件,这也是我们无法在区块浏览器中查询到税收合约的代币转入事件,但税收合约却能拥有大量代币的原因。
然而在ZhongHua代币合约中,无论是“_getAmount”、“_transferFrom”还是“_transferBasic”函数,它们在对balance进行修改后,都有正确触发Transfer事件,这与我们前面查询与攻击合约(?0xc403)相关的Transfer事件时无法发现代币转入的Tranfer事件的情况是冲突的。
难道与MUMI的案例不同,这次攻击合约(?0xc403)中的代币真是凭空出现的?
手法揭秘
攻击合约的代币从何而来
在分析案例的过程中,当我们发现ZhongHua合约中每一次修改balance都正确触发了Transfer事件,却又始终找不到与攻击合约(?0xc403)相关的代币转入记录或Transfer事件时,就需要找到新的分析思路。
我们查询了大量的转账记录,也一度把合约中的“performZhongSwap”函数当作突破口,该函数负责将代币合约中的代币出售,在我们分析的其他的RugPull事件中,存在不少以这类函数作为RugPull后门的案例。
尽管检查了其他函数,结果还是一无所获。于是我们开始将视野放到了“transfer”函数本身,无论攻击者以什么方式进行RugPull,“transfer”函数的实现逻辑一定都包含着最重要的信息。
致命的Transfer
代币合约中“transfer”函数直接调用了“_transferFrom”函数。
看上去“transfer”函数进行代币转账操作,转账完成后会触发Transfer事件。
但在进行代币转账前,“transfer”函数会先用“_isNotTax”函数判断转账的sender是否为免税地址:若不是则用“_getAmount”函数收税;若是则不收税,将代币直接发送到recipient。而问题也正是出在这里。
前文也提到,在“_getAmount”的实现中,代币合约校验了sender的余额,并对sender进行了扣款,然后将手续费发送至代币合约。
而问题在于,“_getAmount”仅在sender不是免税地址的时候被调用。当sender是免税地址时,则直接为recipient的余额加上amount。
此时问题变得十分明确:当免税地址作为sender转账时,代币合约并没有去校验sender的余额是否充足,甚至都没有从sender的balance中减去amount的操作。这也就意味着只要是代币合约定义的免税地址,就可以向任意地址发送任意数量的代币。这就是攻击合约(?0xc403)能直接转出999倍于总供应量的代币的原因。
经检查后发现,代币合约仅在构造函数中将_taxReceipt设置为免税地址,而_taxReceipt对应的地址正是攻击合约(?0xc403)。
自此确定了ZhongHua代币的RugPull的手法:攻击者利用特定的逻辑规避了对特权地址的余额校验,使得特权地址能凭空转出代币,进而完成RugPull。
如何获利
利用上述的漏洞,攻击者地址2(?0x5100)直接调用拥有特权的攻击合约(?0xc403)的“swapExactETHForTokens”完成RugPull。“swapExactETHForTokens”函数中,攻击合约(?0xc403)为Uniswap V2 Router授予了代币转账权限,然后直接调用Router的代币兑换函数,用9,990亿个ZhongHua代币兑换出了池子中的5.88个ETH。
实际上,除了上述这笔进行RugPull的交易之外,项目方还通过攻击合约(?0xc403)在中途出售过11次代币,累计获得9.64ETH;加上最后一笔RugPull交易,共计获得15.52ETH。而成本不过是用于添加流动性的1.5个ETH、用于部署合约的少量手续费以及用于诱导打新机器人而进行主动兑换所花费的少量ETH。
甚至项目方中途还用不同的EOA地址去调用该攻击合约(?0xc403)进行代币出售,看上去是不同的sender在出售代币,以伪装其不断套现的真实意图。
总结
现在回过头来思考整个ZhongHua代币的RugPull案例,发现其手法本身很简单,不过是取消特权地址的代币余额校验而已。但是为什么在分析这个案例的时候却并没有那么顺利?主要原因可能有2点:
1.安全防护和攻击的视野不同。对于安全从业者来说,代码中的余额校验是最基础需要完成的安全保障,因此大多数安全从业者都会潜意识地认为“transfer”函数理所应当地会完成对用户余额的校验,放松对这类漏洞的警惕(或者说认为这类漏洞太基础,攻击者不会采用)。
然而站在攻击者视角,最有效的攻击方式往往是最朴素的:不校验余额作为一种既有效又容易被忽略的RugPull手法,没有不使用的理由。事实也确实如此,至少从案例表征来看,ZhongHua代币案例的RugPull手法留下的痕迹是最少的,追踪起来难度远大于其他类型的RugPull,最后仍需要通过人工审计代码定位代码后门。
2.项目方在有意识地掩盖特权地址不需要校验余额的后门代码。项目方甚至单独为非特权地址实现了一套完整的税收转账计算逻辑、代币地址提现再复投的逻辑,使得代币实现了复杂的转账逻辑看上去也合情合理。而其他普通地址进行转账时也与正常行为无异,在不仔细看代码的前提下完全无法发现任何端倪。
对比这个团队针对MUMI代币和ZhongHua代币的RugPull手法案例,二者都是通过相对隐蔽的方式使得特权地址拥有支配大量代币的权利。
在MUMI代币RugPull案例中,项目方直接修改balance,且不修改totalSupply,也不触发Transfer事件,使得用户无法感知特权地址已经拥有巨额代币。
而ZhongHua代币案例则是更彻底,通过直接不校验特权地址的余额,使得除看源码以外的任何手段都无法发现特权地址已经拥有了无上限的代币(用balanceOf查询特权地址的余额会显示是0,但却可以转出无限的代币)。
ZhongHua代币的RugPull案例反映出代币标准的潜在安全问题,ERC20代币标准在安全性方面只能用来约束君子而无法防范小人。攻击者往往会在实现符合标准的业务逻辑的前提下,藏下令人难以发觉的后门。如果通过将代币行为标准化,虽然减少了功能的灵活性,但避免了隐藏后门的可能性,提供了更多的安全保障。