Alert Source Discuss
🚧 Stagnant Standards Track: Core

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

简单总结

此 EIP 引入了对访问 trie 中不存在账户的 op code 的 gas 惩罚。

摘要

此 EIP 增加了一个 gas 惩罚,用于访问账户 trie,其中要查找的地址不存在。不存在的账户可用于 DoS 攻击,因为它们绕过了缓存机制,从而在操作码的“正常”执行模式和“最坏情况”执行模式之间造成了很大的差异。

动机

随着以太坊 trie 变得越来越饱和,节点为了访问一块状态而需要执行的磁盘查找次数也在增加。这意味着在区块 5 处检查账户的例如 EXTCODEHASH本质上 比在例如 8.5M 处更便宜。

从实现的角度来看,节点可以(并且确实)使用各种缓存机制来应对这个问题,但是缓存存在一个固有的问题:当它们产生“命中”时,它们很好,但是当它们“未命中”时,它们毫无用处。

这是可以攻击的。通过强制节点查找不存在的键,攻击者可以最大化磁盘查找的次数。旁注:即使“不存在”被缓存,下次使用新的不存在的键也很容易,并且永远不会再次命中相同的不存在的键。因此,缓存“不存在”可能很危险,因为它会驱逐“好”条目。

到目前为止,处理这个问题的尝试一直是提高 gas 成本,例如 EIP-150EIP-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 成本:

  1. 如果 CALL(或 CALLCODE)转移价值,则会额外增加 9K 作为成本。 1.1 如果 CALL 目的地之前不存在,则会在成本中额外增加 25K gas。

此 EIP 以下列方式添加第二个规则:

  1. 如果调用 转移价值,并且被调用者 存在,则会向成本中添加 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 相关。但是,存在一些关键差异。

  1. 存储 tries 通常很小,并且以足够的密度填充存储 trie 使其与账户 trie 处于同一级别的成本很高。
  2. 如果攻击者想要使用现有的大型存储 trie,例如某些流行的 token,他通常必须进行 CALL 以导致在该 token 中进行查找——例如 token.balanceOf(<nonexistent-address>)。 这增加了大量的额外 gas 障碍,因为每个 CALL 都是另一个 700 gas,再加上 CALL 的参数的 gas。

确定 penalty

今天,具有 10M gas 的交易会导致约 14K 次 trie 查找。

  • 1000penalty 会将查找次数降低到约 5800 次查找,为原始查找次数的 41%
  • 2000penalty 会将查找次数降低到约 3700 次查找,为原始查找次数的 26%
  • 3000penalty 会将查找次数降低到约 2700 次查找,为原始查找次数的 20%
  • 4000penalty 会将查找次数降低到约 2100 次查找,为原始查找次数的 15%

存在 penalty 的顶棚函数。由于 penalty 是从 gas 中扣除的,这意味着恶意合约始终可以调用恶意中继来执行 trie 查找。让我们将其称为“受保护的中继”攻击。

在这种情况下,malicious 每次调用 relay 将花费 ~750 gas,并且需要向 relay 提供至少 700 gas 才能执行 trie 访问。

因此,有效 cost 将在 1500 左右。因此,可以说高于 ~800penalty 不会对 trie 未命中攻击实现更好的保护。

向后兼容性

此 EIP 需要硬分叉。

以太币转移

从一个 EOA 到另一个 EOA 的具有价值的常规 transaction 不会受到影响。

对于目标不存在的价值为 0transaction,将会受到影响。这种情况不太可能重要,因为这样的 transaction 是无用的——即使成功,它所能完成的也只是花费一些 gas。使用此 EIP,它可能会花费更多的 gas。

Layer 2

关于第 2 层向后兼容性,此 EIP 的破坏性远小于修改操作码 base 成本的 EIP。对于状态访问,很少有合法的场景,其中

  1. 合约检查另一个合约 bBALANCE/EXTCODEHASH/EXTCODECOPY/EXTCODESIZE并且
  2. 如果这样的 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 中没有代码,则这是一个外部拥有的地址,否则它是一个合约。

DexaranOpenZeppelin 的参考实现都使用 EXTCODESIZE 调用来实现 isContract 检查。

这种情况_可能_受到影响,但实际上不应该受到影响。让我们考虑以下可能性:

  1. _to 是合约:然后 ERC223 指定调用函数 tokenFallback(...)
    • 该调用的 gas 支出至少为 700 gas。
    • 为了使 callee 能够执行任何操作,最佳做法是确保该调用至少具有 2300 gas。
    • 总之:此路径要求至少有 3000 额外的 gas 可用(这不是由于任何 penalty)。
  2. _to 存在,但不是合约。流程在此处退出,并且不受此 EIP 的影响。
  3. _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) 等的任何变体。
  • 对价值为 0transaction 发送到不存在的账户的影响。

安全考虑

请参阅“向后兼容性”

实现

暂不可用。

替代变体

Alt 1:即时退款

将所有 trie 访问量提高 penaltyEXTCODEHASH 变为 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.