昨天下班的时侯刚好在 etherscan 上查代码,瞄了一眼君士坦丁堡硬分叉的倒计时,发现离激活刚好还差 10000 个区块,抓紧了这 10 秒的时间,截图留念。
今天上午,安全审计团队 ChainSecurity 猝不及防的发布了一份漏洞分析简报。为此,以太坊核心开发团队触发了紧急响应,正式宣布君士坦丁堡升级延期,重启时间未定。
……回想昨天自己发朋友圈时的激动兴奋,感觉被狠狠打了脸。
下午读完 ChainSecurity 的简报,写的很干练,可惜对不熟悉 ETH 的朋友们不够友好,需要梳理半天的逻辑。所以抖个胆,自己写个更详细的拆解,我也是新入行,有错误的话,见谅。
sstore 重入漏洞拆解分析
理解原合约
ChainSecurity 给的被攻击案例很有代表性,完整的被攻击合约点此查看。
这是个 “双人共享收款钱包” 合约(以下简称:钱包合约),我们来举个正常情况下的使用例子:
- 为地址 「小A」、「小B」 登记一个编号为 1 的共享钱袋
调用钱包合约功能:init(钱袋1 , 小A, 小B)
- 设定收到 ETH 后的分账比例为 小A:小B = 7:3
调用钱包合约功能:updateSplit(钱袋1, 70)
// 即「小A」获得 70%,「小B」获得其余部分
- 「小C」向钱包合约地址的钱袋 1 支付 10ETH
「小C」调用钱包合约功能:deposit(钱袋1) + 10ETH
// 此次交互 tx 携带 10ETH 的金额
// 此时钱包合约地址余额:10ETH
// 其中,登记为「钱袋1」的余额:10ETH
- 清算 钱袋1 的余额,合约自动依照设定比例支付给「小A」、「小B」
调用钱包合约功能:splitFunds(钱袋1)
// 最终,以下两个转账函数 (transfer) 将依次执行
A.transfer(「钱袋1余额」 * 「分账比」 / 100);
B.transfer(「钱袋1余额」 * (100 - 「分账比」) / 100);
//「小A」获得 (10ETH * 70 / 100) = 7ETH
//「小B」获得 (10ETH * 30 / 100) = 3ETH
So far, so good.
感叹下以太坊的灵活性,公平公正的分帐,童叟无欺。
攻击搭建
那么,哪里会有问题呢???
ChainSecurity 给出了攻击的具体方式,完整的攻击者合约点此查看
「黑客X」将此合约部署到链上后,可获得一个攻击者合约地址(以下简称:攻击合约)。
我们继续有请善良的「小A」「小B」「小C」,重新操作以上的 [步骤1~步骤3]。
- 「黑客X」登场,为「攻击合约」与「黑客X」登记了钱袋2,并对此钱袋支付 10ETH。
目前的钱包合约状态为:
此时钱包合约地址总余额:20ETH
其中,登记为「钱袋1」的余额:10ETH
登记为「钱袋2」的余额:10ETH
// 钱袋1:属于「小A」与「小B」
// 钱袋2:属于「攻击合约」与「黑客X」
- 「黑客X」调用攻击合约的攻击函数。攻击代码段如下,注意,此函数位于「攻击合约」内:
function attack(address victim) {
// victim 为被攻击的钱包合约地址
PaymentSharer x = PaymentSharer(victim);
// 继承钱包合约的各个函数
x.updateSplit(2, 100);
// 子步骤 1:钱袋2的「分账比」设置为「攻击合约」获取 100%
x.splitFunds(2);
// 子步骤 2:清算 钱袋2 的余额
// 攻击达成:「攻击合约」与「黑客X」各获得 10ETH
}
目前的钱包合约状态为:
此时钱包合约地址总余额:0ETH【他人余额被异常提取】
其中,登记为「钱袋1」的余额:10ETH
【此为账簿数组中的记账参数,非真实余额,目前已被黑客侵吞,无法实际兑付】
登记为「钱袋2」的余额:0ETH
// 钱袋1:属于「小A」与「小B」
// 钱袋2:属于「攻击合约」与「黑客X」
充值 10ETH,提现 20ETH,Happy~
。。。。。。
。。。。。
。。。。
。。。
。。
。
等等。。。啥玩意儿?这就攻击达成了???你啥都没解释啊!!!
别急,我们这就来拆解攻击逻辑:)
攻击逻辑拆解图
由于涉及合约间交互,做个一图流:
按照标号逐步看,也可以结合代码。因该写的比较通俗了。
关键点:上图[流程 3]与[流程 6]的两次转账 transfer()
间,直接默认 “不会发生分账系数的变化” ,没有额外的状态检查。结果被人利用合约漏洞,插入[流程 3-4]后重入,实现了记账总额 200% 的异常提款。
可能还有同学有疑问,为什么 sstore
的 Gas 消耗 5000 时(以太坊现行逻辑)就没出过问题?代码不是一样么,也跑的通啊。
这里还有个额外的知识点,为了让转账函数 transfer()
更具扩展性,交易内额外触发的 transfer()
操作,默认会带上 2300 Gas 的“零钱”处理 fallback 中可能存在的后续操作(拆解图中的 3*)。
以太坊现行逻辑中,单次 sstore
消耗 5000 Gas,“零钱”根本不够扣。合约逻辑虽然不严密,但系统的资源限制事实上给大家兜了底。
君士坦丁堡更新,会令特殊条件下的 sstore
消耗大幅降低到 1700 Gas 的“可能性阈值”以下(用 call
触发还需要消耗 600 Gas),兜底已经失效,变成了真正的攻击入口。
总结
这是以太坊核心层的漏洞么?
我并不这么认为,作为合约的编写者,需要应对的是各种复杂而晦涩的逻辑陷阱。这一漏洞应当在合约编写时就有所防范,做好异常处理,而不是寄希望于“一个预置参数肯定不会变化,所以某些事情不用考虑,绝不可能发生”、“别人都这么做,所以我就 Ctrl-CV 一套”。
这是以太坊核心开发者的过错么?
sstore 的 Gas 计算调整早在 2018 年 8 月 1 日便列入了协议改进提案(EIP-1283),于 11 月被接受为正式改进,如此大幅降低消耗至 1700 Gas 可触发阈值以下的调整,理应更慎重的考虑旧合约的兼容性、安全性。
“无法篡改的技术负债”、“永远的前向兼容”。So good. So bad.