撰文:Certik
背景
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)不开源,我们对其字节码进行了反编译,结果如下。
攻击合约(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 代币标准在安全性方面只能用来约束君子而无法防范小人。攻击者往往会在实现符合标准的业务逻辑的前提下,藏下令人难以发觉的后门。如果通过将代币行为标准化,虽然减少了功能的灵活性,但避免了隐藏后门的可能性,提供了更多的安全保障。


