背景
EIP 1884 已被纳入即将到来的以太坊 “伊斯坦布尔” 硬分叉中,它将:
· 将 SLOAD 操作码的 Gas 消耗量从 200 提高到 800
· 将 BALANCE 和 EXTCODEHASH 的 Gas 消耗量从 400 提高到 700
· 加入一种新的操作码 SELFBALANCE,Gas 消耗量是 5
背后的理由是,因为状态数据大小的增长以及(相应的)从硬盘中取出状态树的额外读写开销,SLOAD、BALANCE 和 EXTCODEHASH 这几个操作码已经变得 “太过便宜” 了(对一个节点执行的实际工作量而言)。操作码的 Gas 消耗量与底层计算开销的严重不匹配,可能会导致多种问题、埋下网络攻击的种子(就像 2017 年的 “上海攻击” 那样)。
潜在问题
总的来说,给操作码重定 Gas 耗费,总是有可能会破坏那些明确依赖 “Gas 消耗量永远不变” 假设的合约。一直以来大家都认为操作码重定价是一个糟糕的办法,尤其是,一些主要的操作码 已经 在 Tangerine Whistle 中重新定价过了,那时候 SLOAD 的 Gas 耗费从 50 提高到了 200。
不过,在某些情况下还可能出现更严重的问题:default 函数。
默认函数
所谓 default 函数,就是一种合约用来处理无数据调用的方法 —— 用来处理那些完全没有明确调用任何方法的 ETH 转账。它们一般会被来创建一个 event(使用 LOG 操作),然后外部系统就可以探测到这个事件,然后做出相应的操作(例如登记一笔交易已经完成)。
一笔给合约的普通 ETH 转账总是会给接收者至少 2300 gas 作为 “津贴”。这笔 Gas 刚刚好可以让接收者能够发布一个事件,但又不足以让接收者能够更改状态(比如发起另一笔转账或者更新一个存储槽)。
EIP 1884 与默认函数
EIP-1884 可能导致的问题就是,用 2300 gas 调用 default 函数可能会失败,例如因为以下原因:
· 钱包受限:合约仅在 balance(self) 低于一个确定的下限时才接受支付
· 发送者被指定:合约仅接受来自一组预先许可的发送者的支付
· 功能受限:合约仅在一个特定变量(也就是一个 slot)为真时才接受支付
现在,如果 default 函数在 2300 gas 的条件下停止工作,基本上不会有什么问题。例如,如果调用者是一个 EOA(外部所有者账户,也就是终端用户),调用者可以保证在一笔交易中附带比 21000 gas 多一点点的 gas。但别的地方可能出问题,例如:
· 目标账户指定了发送者;
· 发送者是智能合约,而且编程好了只用 transfer,不会附上额外的 gas
在这种情况下,从发送者到目标账户的 ether 会永久丢失、无法复原,除非有其它机制来处理这种情况(比如替换掉发送者)。
调查
我联系了 EthSecurity 社区来帮助研究这种情况。要点如下:
· 没有 payable 默认函数的合约不会受影响
· 当前用 2300 gas 无法运行其默认函数的合约不会受影响,例如,在默认函数里操作 SLOAD 或者转移 ether 的合约
Contract Library 分析
来自 Contract Library 的 Neville Grech,对部分反编译的主网合约做了静态分析。这一分析覆盖了 95% 的主网合约、测试网最近 50 万个块上的合约,40 万份各异的字节码,然后列出了那些可能被影响的合约。
· 分析可以在此处获取,并且会自动更新。
注意,静态分析是一种无需执行程序便可分析所有程序行为的技术。该静态分析是根据下列部署在 contract-library.com 上的、简化的 datalog 技术规范来编写的。
% 约束那些从可能的路径到 fallback 函数的例外情况
FallbackFunctionBlockEdge(from, to) :-
GlobalBlockEdge(from, to),
InFunction(from, f), FallbackFunction(f),
InFunction(to, g), FallbackFunction(g).
% 使用常规的 gas 语义分析 fallback 函数路径
% 取最短的路径
GasCostAnalysis = new CostAnalysis(
Block_Gas, FallbackFunctionBlockEdge, 2300, min
).
% 用升级后的 gas 语义分析 fallback 函数路径
% 取最短的路径
EIP1884GasCostAnalysis = new CostAnalysis(
EIP1884Block_Gas, FallbackFunctionBlockEdge, 2300, min
).
FallbackWillFailAnyway(n – 2300) :-
GasCostAnalysis(*, n), n > 2300.
% 使用额外的 n – m 单位的 gas 后,fallback 函数会失败
EIP1884FallbackWillFail(n – m) :-
EIP1884GasCostAnalysis(block, n), n > 2300,
GasCostAnalysis(block, m),
!FallbackWillFailAnyway(*).
该分析测算了 fallback 函数中的所有可能路径的 Gas 消耗量,使用了 EIP-1884 部署前后的 Gas 设定。如果某个路径可以在旧的 gas 语义下完成,但无法在新的语义下完成,我们就抓出相应的合约。
该分析自动抓取出了主网上的 200 个合约,包括 Kyber Network 的合约和 CappedVault 合约。注意,如果 BALANCE 操作码的 gas 要求稍低一点(比如是 600),CappedVault 合约就还是能正常工作。该分析也发现了多个其它(带余额的)合约会在新的 gas 设定的多种条件下出错:
EbcFund 合约中储存了超过 580 个 eth,在低于 2300 gas 的条件下将不再能接受捐献。
/**
* @dev fallback function to send ether to smart contract
**/
function () public payable {
require(currentStage == Stages.Started);
require(cfgMinDepositRequired <= msg.value && msg.value <= cfgMaxDepositRequired);
if(donateList[msg.sender] == false) {
if(transporter != address(0) && msg.sender == transporter) {
//validate msg.data
if(msg.data.length > 0) {
//init new game
processDeposit(bytesToAddress(msg.data));
}
else {
emit Logger(“Thank you for your contribution!.”, msg.value);
}
}
else {
//init new game
processDeposit(msg.sender);
}
}
else {
emit Logger(“Thank you for your contribution!”, msg.value);
}
}
这份代码的最后一次调用是在 144 天以前。
NEXXO crowdsale 合约也是一样:
modifier onlyICO() {
require(now >= icoStartDate && now < icoEndDate, “CrowdSale is not running”);
_;
}
function () public payable onlyICO{
require(!stopped, “CrowdSale is stopping”);
}
NEXXO 会检查三个存储槽,icoStartDate、icoEndDate 以及 stopped,在新的 gas 规则下总共需要 2400 gas。
Crowd Machine Compute Token crowdsale 合约也是一样的问题:
modifier onlyIfRunning
{
require(running);
_;
}
function () public onlyIfRunning payable {
require(isApproved(msg.sender));
LogEthReceived(msg.sender, msg.value);
}
重要提醒:上述的 crowdsales 合约并没有从根本上被破坏,只是调用者需要添加超过 2300 gas 来参与该 ICO 合约。
Chain Security 分析
来自 ChainSecurity 的 Hubert Ritzdorf 对近期的交易执行了分析。该分析基于主网上发生的实际交易,然后观察哪些交易会在 SLOAD 操作码 Gas 耗费了提升到 800 的时候失败。部分结果在此处 可见。
要点已在此处列明,附带下述评论:
前两种情况发生得更为频繁,其它的则不怎么常见。我们列出了最后一项,虽然在 EIP1884 实施后它仍能工作,但我们不确定这么 “深” 的交易的 gas 值在当前是如何确定的。我们希望引起大家对潜在问题的警惕。
Kyber Network
function() public payable {require(reserveType[msg.sender] != ReserveType.NONE); EtherReceival(msg.sender, msg.value); }
· KyberNetwork 符合了这里所列的多个条件
· 合约 “指定了发送者”
· 主要通过其它合约来调用,这些调用依赖于 transfer 函数(限制在了 2300 gas)
我们联系了 KyberNetwork,虽然免不了有些繁琐的工作要做,但问题是可以解决的
技术上来说,做市商只需要部署新的储备合约
CappedVault
function total() public view returns(uint) {
return getBalance() + withdrawn;
}
function () public payable {
require(total() + msg.value <= limit);
}
在这个合约中,withdrawn 是一个存储槽, limit 也是。
· CappedVault 存有超过 4000 个 ether 和 7 万笔内部交易,同样符合下列条件:
· 功能受限模式
使用了两次 SLOAD 和一次 BALANCE
实现细节:
· 该合约被编写成,只要发送给该合约的 ether 总数超过 33333,合约就 “断开”。也就是说,不管合约中当前有多少 ether,只要发送给合约的 ether 总数超过 3 万 3 千,就不再接受 ether。
这就意味着已经有机制来处理默认函数停止运行的情况了。
· limit 是一个存储 slot,但也可以实现为编译时常量(compile-time constant),从而省下一个 SLOAD。
· balance(self) 在伊斯坦布尔分叉后可重写为 SELFBALANCE
所以本质上,当前的用量是:
200 (sload limit) +200 (sload withdrawn) +400 (balance) = 800 gas
而在 EIP-1884 部署后:
5 (selfbalance) + 800 (sload withdrawn) = 805 gas