EIP-2583: 账户 trie 未命中惩罚
Authors | Martin Holst Swende (@holiman) |
---|---|
Created | 2020-02-21 |
Discussion Link | https://ethereum-magicians.org/t/eip-2583-penalties-for-trie-misses/4190 |
Table of Contents
简单总结
此 EIP 引入了对访问 trie 中不存在账户的 op code 的 gas 惩罚。
摘要
此 EIP 增加了一个 gas 惩罚,用于访问账户 trie,其中要查找的地址不存在。不存在的账户可用于 DoS 攻击,因为它们绕过了缓存机制,从而在操作码的“正常”执行模式和“最坏情况”执行模式之间造成了很大的差异。
动机
随着以太坊 trie 变得越来越饱和,节点为了访问一块状态而需要执行的磁盘查找次数也在增加。这意味着在区块 5
处检查账户的例如 EXTCODEHASH
在 本质上 比在例如 8.5M
处更便宜。
从实现的角度来看,节点可以(并且确实)使用各种缓存机制来应对这个问题,但是缓存存在一个固有的问题:当它们产生“命中”时,它们很好,但是当它们“未命中”时,它们毫无用处。
这是可以攻击的。通过强制节点查找不存在的键,攻击者可以最大化磁盘查找的次数。旁注:即使“不存在”被缓存,下次使用新的不存在的键也很容易,并且永远不会再次命中相同的不存在的键。因此,缓存“不存在”可能很危险,因为它会驱逐“好”条目。
到目前为止,处理这个问题的尝试一直是提高 gas 成本,例如 EIP-150,EIP-1884。
但是,在确定 gas 成本时,由于“顺利路径”和“臭名昭著路径”之间的巨大差异,会出现一个次要问题——我们如何确定定价?
- 假设所有项目都已缓存的“顺利路径”?
- 这样做会低估所有 trie 访问的价格,并可能受到 DoS 攻击。
- 基于实际使用情况的基准的“正常”使用情况?
- 这基本上就是我们现在所做的,但这意味着故意臭名昭著的执行被低估了——这构成了 DoS 漏洞。
- “偏执”情况:像缓存不存在一样对所有内容进行定价?
- 由于 gas 成本的增加,这将严重损害基本上每个合约。此外,如果提高 gas 限制以允许与以前相同数量的计算,则臭名昭著的情况可以再次用于 DoS 攻击。
从工程的角度来看,节点实现者几乎没有选择:
- 为存在实现布隆过滤器。这很困难,尤其是在重组问题以及难以撤消布隆过滤器修改的情况下。
- 实现扁平化的账户数据库。这也是困难的,既因为重组,也因为它需要是除了
trie
之外的 附加 数据结构——我们需要trie
用于共识。所以这是一个大约15G
的额外数据结构,需要保持检查。Geth 团队目前正在追求这一点。
此 EIP 提出了一种缓解这种情况的机制。
规范
我们将常量 penalty
定义为 TBD
(建议 2000
gas)。
对于访问账户 trie 的操作码,只要调用操作的目标 address
在 trie 中不存在,就会从可用的 gas
中扣除 penalty
gas。
详细规范
以下是触发查找主账户 trie 的操作码:
Opcode | 受影响 | 评论 |
---|---|---|
BALANCE | 是 | balance(nonexistent_addr) 将会产生 penalty |
EXTCODEHASH | 是 | extcodehash(nonexistent_addr) 将会产生 penalty |
EXTCODECOPY | 是 | extcodecopy(nonexistent_addr) 将会产生 penalty |
EXTCODESIZE | 是 | extcodesize(nonexistent_addr) 将会产生 penalty |
CALL | 是 | 请参阅下面有关调用变体的详细信息 |
CALLCODE | 是 | 请参阅下面有关调用变体的详细信息 |
DELEGATECALL | 是 | 请参阅下面有关调用变体的详细信息 |
STATICCALL | 是 | 请参阅下面有关调用变体的详细信息 |
SELFDESTRUCT | 否 | 请参阅下面的详细信息。 |
CREATE | 否 | 创建目的地未明确设置,并且假定已不存在。 |
CREATE2 | 否 | 创建目的地未明确设置,并且假定已不存在。 |
关于 Call 衍生物的说明
CALL
触发对 CALL
目标地址的查找。CALL
的基本成本为 700
gas。一些其他特征决定了调用的实际 gas 成本:
- 如果
CALL
(或CALLCODE
)转移价值,则会额外增加9K
作为成本。 1.1 如果CALL
目的地之前不存在,则会在成本中额外增加25K
gas。
此 EIP 以下列方式添加第二个规则:
- 如果调用 不 转移价值,并且被调用者 不 存在,则会向成本中添加
penalty
gas。
在下表中,
value
表示非零价值转移,!value
表示零价值转移,dest
表示目的地已经存在,或者是precompile
!dest
表示目的地不存在,也不是precompile
Op | value,dest | value, !dest | !value, dest | !value, !dest |
---|---|---|---|---|
CALL | 无变化 | 无变化 | 无变化 | penalty |
CALLCODE | 无变化 | 无变化 | 无变化 | penalty |
DELEGATECALL | N/A | N/A | 无变化 | penalty |
STATICCALL | N/A | N/A | 无变化 | penalty |
此 EIP 的规则是否适用于 transactions
中的常规以太币发送有待确定。有关该主题的更多讨论,请参见“向后兼容性”部分。
关于 SELFDESTRUCT
的说明
SELFDESTRUCT
操作码还会触发对 beneficiary
的账户 trie 查找。但是,由于以下原因,它已被排除在具有 penalty
之外,因为它已经花费 5K
gas。
说明:
- 任何操作码的
base
成本都不会被 EIP 修改。 - 无论
self
地址是否存在,此 EIP 均不会修改操作码SELFBALANCE
。
理由
通过此方案,我们可以继续根据“正常”使用情况来定价这些操作,但可以防止尝试最大程度地进行磁盘查找/缓存未命中的攻击。此 EIP 不会修改有关存储 trie 访问的任何内容,这可能与将来的 EIP 相关。但是,存在一些关键差异。
- 存储 tries 通常很小,并且以足够的密度填充存储 trie 使其与账户 trie 处于同一级别的成本很高。
- 如果攻击者想要使用现有的大型存储 trie,例如某些流行的 token,他通常必须进行
CALL
以导致在该 token 中进行查找——例如token.balanceOf(<nonexistent-address>)
。 这增加了大量的额外 gas 障碍,因为每个CALL
都是另一个700
gas,再加上CALL
的参数的 gas。
确定 penalty
今天,具有 10M
gas 的交易会导致约 14K
次 trie 查找。
1000
的penalty
会将查找次数降低到约5800
次查找,为原始查找次数的41%
。2000
的penalty
会将查找次数降低到约3700
次查找,为原始查找次数的26%
。3000
的penalty
会将查找次数降低到约2700
次查找,为原始查找次数的20%
。4000
的penalty
会将查找次数降低到约2100
次查找,为原始查找次数的15%
。
存在 penalty
的顶棚函数。由于 penalty
是从 gas
中扣除的,这意味着恶意合约始终可以调用恶意中继来执行 trie 查找。让我们将其称为“受保护的中继”攻击。
在这种情况下,malicious
每次调用 relay
将花费 ~750
gas,并且需要向 relay
提供至少 700
gas 才能执行 trie 访问。
因此,有效 cost
将在 1500
左右。因此,可以说高于 ~800
的 penalty
不会对 trie 未命中攻击实现更好的保护。
向后兼容性
此 EIP 需要硬分叉。
以太币转移
从一个 EOA 到另一个 EOA 的具有价值的常规 transaction
不会受到影响。
对于目标不存在的价值为 0
的 transaction
,将会受到影响。这种情况不太可能重要,因为这样的 transaction
是无用的——即使成功,它所能完成的也只是花费一些 gas
。使用此 EIP,它可能会花费更多的 gas。
Layer 2
关于第 2 层向后兼容性,此 EIP 的破坏性远小于修改操作码 base
成本的 EIP。对于状态访问,很少有合法的场景,其中
- 合约检查另一个合约
b
的BALANCE
/EXTCODEHASH
/EXTCODECOPY
/EXTCODESIZE
,并且 - 如果这样的
b
不存在,则继续执行
Solidity 远程调用
示例:当在 Solidity 中进行远程调用时:
recipient.invokeMethod(1)
- Solidity 在
recipient
上执行预检EXTCODESIZE
。 - 如果预检检查返回
0
,则执行revert(0,0)
以停止执行。 - 如果预检检查返回非零值,则执行继续并进行
CALL
。
有了此 EIP,’顺利路径’ 将像以前一样工作,并且 recipient
不存在的 ‘臭名昭著’ 路径将花费额外的 penalty
gas,但是实际的执行流程将保持不变。
ERC223
ERC223 Token Standard 在撰写本文时被标记为“草案”,但今天已部署并在主网上使用。
ERC 指定,当调用 token transfer(_to,...)
方法时:
如果
_to
是合约,则此函数必须转移 token 并在_to
中调用函数tokenFallback (address, uint256, bytes)
。 … 注意:检查_to
是合约还是地址的推荐方法是组装_to
的代码。 如果_to
中没有代码,则这是一个外部拥有的地址,否则它是一个合约。
Dexaran 和 OpenZeppelin 的参考实现都使用 EXTCODESIZE
调用来实现 isContract
检查。
这种情况_可能_受到影响,但实际上不应该受到影响。让我们考虑以下可能性:
_to
是合约:然后ERC223
指定调用函数tokenFallback(...)
。- 该调用的 gas 支出至少为
700
gas。 - 为了使
callee
能够执行任何操作,最佳做法是确保该调用至少具有2300
gas。 - 总之:此路径要求至少有
3000
额外的 gas 可用(这不是由于任何penalty
)。
- 该调用的 gas 支出至少为
_to
存在,但不是合约。流程在此处退出,并且不受此 EIP 的影响。_to
不存在:扣除penalty
。
总之,只要 penalty
不超过 3000
gas,ERC223
似乎就不应受到影响。
其他
合约 Dentacoin
将会受到影响。
function transfer(address _to, uint256 _value) returns (bool success) {
... // 为了简洁起见,省略了
if (balances[msg.sender] >= _value && balances[_to] + _value > balances[_to]) { // 检查发送者是否有足够的余额以及是否溢出
balances[msg.sender] = safeSub(balances[msg.sender], _value); // 从发送者那里扣除 DCN
if (msg.sender.balance >= minBalanceForAccounts && _to.balance >= minBalanceForAccounts) { // 检查发送者是否可以支付 gas 以及接收者是否可以
balances[_to] = safeAdd(balances[_to], _value); // 将相同数量的 DCN 添加到接收者
Transfer(msg.sender, _to, _value); // 通知任何正在侦听此转移的人
return true;
} else {
balances[this] = safeAdd(balances[this], DCNForGas); // 将 DCNForGas 支付给合约
balances[_to] = safeAdd(balances[_to], safeSub(_value, DCNForGas)); // 接收者余额 -DCNForGas
Transfer(msg.sender, _to, safeSub(_value, DCNForGas)); // 通知任何正在侦听此转移的人
if(msg.sender.balance < minBalanceForAccounts) {
if(!msg.sender.send(gasForDCN)) throw; // 将 eth 发送给发送者
}
if(_to.balance < minBalanceForAccounts) {
if(!_to.send(gasForDCN)) throw; // 将 eth 发送给接收者
}
}
} else { throw; }
}
合约检查 _to.balance >= minBalanceForAccounts
,如果 balance
太低,则将一些 DCN
转换为 ether
并发送给 _to
。这是一种简化入职的机制,由此,收到一些 DCN
的新用户可以立即创建交易。
在此 EIP 之前:
- 当将
DCN
发送到不存在的地址时,额外的gas
支出将是:- 以太币转移的
9000
- 新账户创建的
25000
- (
2300
将稍后退还给调用者) - 处理这种情况总共需要
34K
gas 的运行时gas
成本。
- 以太币转移的
在此 EIP 之后:
- 除了
34K
之外,还将添加额外的penalty
。- 可能是两个,因为参考实现确实执行了两次余额检查,但是不清楚编译后的代码是否确实会执行两次检查。
- 处理这种情况总共需要
34K+penalty
(或34K + 2 * penalty
)的运行时gas
成本。
可以说,相对于处理这种情况已经需要的其他 34K
gas,2-3K
gas 的额外 penalty 可以认为是微不足道的。
测试用例
需要考虑和测试以下情况:
- 在全新合约的创建过程中,在构造函数中,不应对涉及 self-address 的调用应用
penalty
。 - 待定:在执行了
selfdestruct
的合约中,如何应用penalty
- a)先前在同一调用上下文中,
- b)先前在同一交易中,
- c)先前在同一区块中,
对于
EXTCODEHASH(destructed)
、CALL(destructed)
、CALLCODE(destructed)
等的任何变体。
- 对价值为
0
的transaction
发送到不存在的账户的影响。
安全考虑
请参阅“向后兼容性”
实现
暂不可用。
替代变体
Alt 1:即时退款
将所有 trie 访问量提高 penalty
。 EXTCODEHASH
变为 2700
而不是 700
。
- 如果 trie 访问命中了现有项目,则立即退还 penalty(
2K
)
优点:
- 这消除了“受保护的中继”攻击
缺点:
- 这增加了许多操作的前期成本(CALL/EXTCODEHASH/EXTCODESIZE/STATICCALL/EXTCODESIZE 等)
- 这可能会破坏许多合约。
Alt 2:父级保释
使用如上所述的 penalty
,但是如果子上下文在 gas 方面因 penalty
而超出限制,则将其余部分从父上下文中扣除(递归)。
优点:
- 这消除了“受保护的中继”攻击
缺点:
- 这打破了当前的不变量,即子上下文受到为其分配的任何
gas
的限制。- 但是,不变量并未 完全 抛出,新的不变量变为它限制为
gas + penalty
。
- 但是,不变量并未 完全 抛出,新的不变量变为它限制为
- 这可以被视为“混乱”——因为只有 某些 类型的 OOG(penalties)会被传递到调用链中,而其他类型则不会,例如,由于尝试导致 OOG 而导致 OOG 分配过多的内存。但是,存在区别:
- 由于尚未消耗的资源而产生的 gas 成本不会传递给父级。例如:如果 gas 不足,则实际上不会执行大量分配。
- 由于已经消耗的资源而产生的 gas 成本 会 传递给父级;在这种情况下,penalty 是在 trie 迭代的事后支付的。
版权
通过 CC0 放弃版权和相关权利。
Citation
Please cite this document as:
Martin Holst Swende (@holiman), "EIP-2583: 账户 trie 未命中惩罚 [DRAFT]," Ethereum Improvement Proposals, no. 2583, February 2020. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-2583.