揭秘四年陈酿漏洞
冒险的第一步
发现一个漏洞需要什么?在一个经受住时间考验的合约中发现漏洞又需要什么?通往关键漏洞的旅程并不总是直截了当,可能在你最意想不到的时候开始。
几周前,我发推文谈到了我发现的一个关键错误。这个漏洞影响了超过四年的合约,这些合约现在管理着超过十亿美元的资产。今天,我将告诉你这个故事是如何发生的。
白兔的出现
午餐后不久,我收到了一个以太坊安全群聊的推送通知。这立即引起了我的注意,原因有二:
- 大多数go-ethereum(geth)用户通常永远不会遇到错误,因为该软件被编写得极具弹性
- 这个错误发生在最新版本的geth上,意味着它拥有所有最新的安全补丁
由于我自己最近在geth中发现了一个错误,我不禁想知道是否正在进行攻击。
Anatol确认该区块确实在主网上,并分享了他的日志文件以帮助调试。
|
|
这个错误说明区块头声称所有交易总共使用了12458355 gas,但当节点执行所有交易时,只使用了11152534 gas。我首先双重检查了我自己的节点是否知道这个可疑区块。
|
|
鉴于区块哈希匹配,我知道这不是一个漏洞利用,而是Anatol节点的某种错误。错误日志告诉我们每个交易使用的gas,所以我快速编写了一个脚本来检查区块中的每个交易,并将实际使用的gas与Anatol节点报告的内容进行比较。这让我找到了有问题的交易。
我知道Anatol的节点和主网之间肯定存在某种状态差异,所以我通过DM向他请求RPC访问权限,以便检查有问题的交易与之交互的合约。
深入兔子洞
既然眼前的谜团已经解决,我将注意力转向了损坏的合约。在调试交易时,我认出该合约是EToken2发行的代币。我很早以前就审查过那个特定平台,但那是在我获得现在所有经验之前。有时候只需要一种新的方法,我相信这次情况会有所不同。
我首先定义了我的目标。由于这是一个ERC20合约,任何获取免费代币的方式(无论是通过窃取他人的代币还是自己铸造代币)都会产生最大的影响。接下来是任何形式的干扰,例如烧毁我不拥有的代币或使转移代币变得不可能。
现在我可以开始审查了。代币合约本身将诸如transfer之类的ERC20功能委托给一个实现合约,但有趣的是,实现可能因用户而异。本质上,代币包含一个默认实现(最新版本),所有者可以随时升级。如果用户不同意升级,他们可以选择退出并将其特定实现固定到当前版本,而不是被拖到新实现中。这是一个极其有趣的机制,但我很快确认我无法为自己设置自定义实现,所以我继续前进。
接下来是默认实现合约。因为代币没有使用delegatecall而是使用call,它需要告诉实现合约原始调用者是谁。如果实现合约没有正确验证代理,那么就有可能伪造转移。不幸的是,快速检查显示实现合约也是安全的。
在通过主代币合约再进行一次跳转后,我终于到达了EToken2平台合约,这是所有ERC20逻辑真正实现的地方。如果之前的合约是开胃菜,那么这就是主菜,我很兴奋地开始深入研究。
蛋糕和饮料
之前我注意到,为了符合ERC20标准,每个代币包含两个函数,这些函数发出Transfer和Approval事件,只能由EToken2合约调用。如果我能以某种方式欺骗EToken2合约发出虚假事件,那将非常有趣,所以我首先关注这一点。
EToken2合约中只有一个地方触发了代币发出事件,那就是在_proxyTransferEvent中。
|
|
但有趣的是,该函数没有直接获取代理,而是间接获取符号。这是一个经典的间接攻击例子,因为如果我能改变proxies[_symbol]的值,我就能够在我想要的任何目标上触发Transfer事件。
对_proxyTransferEvent有三个调用:铸造会触发从address(0)到msg.sender的事件,销毁会触发从from到address(0)的事件,转移会触发从from到to的事件。前两个不是特别有用,因为我最多只能控制两个参数中的一个,但最后一个很有趣,因为我可以潜在地触发从任意地址到任意地址的Transfer事件。
不幸的是,在这里我遇到了一个问题。到达_transfer函数的唯一方法是通过proxyTransferFromWithReference函数,而该函数只允许在proxies中指定的地址触发特定符号的转移。这意味着如果我拥有一个代币并将我的代理更新为另一个代币的代理,我将无法再触发转移以进行利用。
看起来这个潜在的利用已经失败了,但我不愿意放弃,所以我继续滚动查看。那时我才更仔细地查看了_transfer的函数签名。
|
|
我完全忽略了checkSigned修饰符,因为它在默认配置中显然是一个无操作。如果它很重要,那么用户将不得不为每次转移提交某种签名,而这显然没有发生。现在,无处可去,我决定研究一下checkSigned到底在做什么。
|
|
不可思议!事实证明,如果为用户设置了Cosigner,checkSigned实际上会执行一个外部调用。这可以解释为什么我从未看到任何用户为简单转移提供任何签名。同时,这个安全特性正是我完成利用所需要的。
胡椒
这是一个好的开始,但影响并不是我想要的。这是因为EToken2是一个白名单平台,只有中心化机构才能发行新代币。如果我是攻击者并想实施此攻击,我将必须注册并可能通过KYC/AML,这显然不理想。也不清楚谁可能受到此漏洞的影响,我无法真正自己去测试它。
我没有气馁,将这个发现收起来,继续寻找其他东西。在扫描合约时,我注意到了一些有趣的逻辑,用于将访问权限从一个用户转移到另一个用户。
|
|
事实上,我太专注于弄清楚如何交换代理,以至于没有注意合约中实际发生了什么。注意在_transfer的签名中,from和to值实际上是uint,而不是address。EToken2合约维护了用户(地址)到持有者ID(uint)的映射,这是将你的持有者ID授予另一个地址的逻辑。
当我意识到这一点时,我开始认真研究这个函数。在高层面上,这个函数本质上将某些数据的所有权从一个实体转移到另一个实体,而在实现这种内部所有权转移时,一个常见的错误是正确更新接收者但忘记使发送者失效。如果接收者没有正确更新,任何测试都会立即发现问题。如果发送者没有正确失效,简单的测试可能不会注意到任何问题。
仔细观察_grantAccess的实现,我看到它被故意设计成可以确定以前拥有特定持有者ID的地址。这旨在允许意外将访问权限授予错误钱包的用户仍然保持所有权,但直觉上我知道这里有些不对劲。
即便如此,我一眼看不出来那是什么。毕竟,如果攻击者将其持有者ID的访问权限授予另一个用户,那只是将自己的资金置于风险之中。为什么会有人想这样做?
在思考了一会儿之后,我意识到我看待它的方式错了。关键是,虽然其他用户将完全拥有"我的"持有者ID,但我也将完全拥有"他们的"持有者ID。换句话说,我将能够后门任何EToken2平台的新用户,并完全访问他们的账户,这转化为对他们拥有的任何EToken2发行代币的控制。
从这里到弄清楚如何武器化这个利用只是一步之遥。我需要在用户首次使用EToken2平台之前授予他们访问权限,但我不能随便授予随机地址访问权限。解决方案很明确——我可以扫描内存池中会导致用户注册到EToken2平台的交易,然后抢跑我发现的任何交易。更重要的是,由于EToken2的架构,我不会只攻击单个代币,而是攻击多个具有数百万美元市值的代币。
在这一点上,我知道我已经找到了我想要的东西,是时候联系项目方了。
修复漏洞
发现漏洞是一回事,但修复它是另一回事。EToken2被设计为不可升级,但强制每个在EToken2上发行的代币将其数据迁移到新合约将是不必要的负担。Oleksii, fellow white hat 也是Ambisafe的首席架构师,同意了。我们必须找到另一种方法。
在_grantAccess中没有多少东西可以回滚。事实上,只有一个。函数中的最后一行使用eventHistory合约记录了一个事件。如果这个调用回滚,那么对_grantAccess的调用也会回滚,任何人都不可能后门另一个账户。然而,EventsHistory合约不允许管理员重新定义事件的处理程序。
|
|
看起来我们陷入了僵局,但我们很快意识到情况并没有看起来那么糟糕。尽管EventsHistory合约旨在不可变,但它也有一个可以被管理员"利用"的错误。
每个发射器都使用delegatecall处理,这导致代码在当前合约的上下文中执行。如果我们想对EventsHistory合约执行升级,我们可以注册一个假发射器,通过直接写入存储来执行我们想要的升级。
Oleksii迅速编写了几个合约来修补我发现的漏洞。第一个将替换emitRecovery函数的处理程序,并要求任何被授予访问权限的地址明确选择加入被授予访问权限。第二个将替换所有ERC20相关事件的处理程序,并要求给定符号的代理合约是预期的代理。第三个将通过替换处理程序在EventsHistory合约上执行升级,然后永久禁用注册新发射器的能力,使合约真正不可变。
在4月6日,仅仅在我迈出第一步的4天后,升级被部署,这个传奇永远铭刻在区块链上。
特别感谢Anatol在其中扮演的非常特殊的角色,以及Oleksii为了与我一起解决这个问题而牺牲了他的周末。