EIP-3779: EVM 更安全的状态控制流
确保 EVM 代码的基本安全级别。
Authors | Greg Colvin (@gcolvin), Greg Colvin <greg@colvin.org>, Brooklyn Zelenka (@expede) |
---|---|
Created | 2021-08-30 |
Discussion Link | https://ethereum-magicians.org/t/eip-3779-safe-control-flow-for-the-evm/6975 |
摘要
我们将安全的 EVM 合约定义为不会遇到异常停止状态的合约。一般来说,我们无法证明图灵完备程序的安全性。但我们可以证明一个有用的子集。
此 EIP 指定有效性规则以确保:
有效合约不会因异常而停止,除非它们
- 抛出
out of gas
或- 递归地溢出堆栈。
此 EIP 不引入任何新的操作码。相反,它限制了现有和提议的控制流指令的使用。这些限制必须在合约初始化时通过提供的算法或其等效项进行验证,而不是在运行时进行验证。此算法必须在合约大小上花费接近线性的时间和空间,以避免成为拒绝服务漏洞。
此规范完全是语义上的。它没有对字节码施加任何进一步的语法,因为不需要任何语法来确保指定的安全级别。以太坊虚拟机字节码就是这样——一系列字节,当执行时会导致机器状态发生一系列变化。我们在此处寻求的安全性只是不,就像它一样,卡住齿轮。
动机
安全
为了我们的目的,我们将安全的 EVM 合约定义为不会遇到异常停止状态的合约。从安全的角度来看,最好不要将不安全的合约放在区块链上。不安全的代码可能会尝试溢出堆栈、下溢堆栈、执行无效指令以及跳转到无效位置。
不安全的合约是等待发生的漏洞。
验证合约安全性需要遍历合约代码。因此,为了防止拒绝服务攻击,所有跳转,包括现有的 JUMP
和 JUMPI
,以及其他提议的跳转——RJUMP
、RJUMPI
、RJUMPSUB
和 RETURNSUB
——都必须在初始化时进行验证,并在代码大小上呈线性的时间和空间内完成。
静态跳转和子例程
EIP-4200 的相对跳转和 EIP-2315 的简单子例程提供了一组完整的静态控制流指令:
RJUMP
offset
- 跳转到 IP+offset。
RJUMPI
offset- 如果堆栈顶部非零,则跳转。
RJUMPSUB
offset- 将 IP+1 推送到返回堆栈上,然后跳转到 IP+offset。
RETURNSUB
- 跳转到从返回堆栈中弹出的地址。
请注意,每个跳转最多创建两条通过代码的控制路径,因此遍历整个控制流图的复杂度与代码大小成线性关系。
动态跳转
动态跳转(其中 JUMP
或 JUMPI
的目标在运行时才知道)是在线性时间内证明有效性的障碍——任何跳转都可以到达代码中的任何目标,可能需要代码大小的二次时间。因此,我们有两个真正的选择。
- 弃用动态跳转。这很容易做到:
为了 EOF 代码验证的目的,将
JUMP
和JUMPI
定义为INVALID
- 约束动态跳转。这需要静态分析。
考虑最简单和最常见的情况。
PUSH address
JUMP
这实际上是一个静态跳转。
JUMP
的另一个重要用途是从子例程实现返回跳转。因此,请考虑以下从最小子例程调用和返回的示例:
TEST_SQUARE:
jumpdest
push RTN_SQUARE
0x02
push SQUARE
jump
RTN_SQUARE
jumpdest
swap1
jump
SQUARE:
jumpdest
dup1
mul
swap1
jump
返回地址 -RTN_SQUARE
- 和目标地址 - SQUARE
- 作为常量推送到堆栈上,并且在堆栈上移动时保持不变,因此只有这些常量会传递到每个 JUMP
。它们实际上是静态的。我们可以在验证时跟踪 data stack
上常量的移动,因此我们不需要不受约束的动态跳转来实现子例程。
以上是足够的 最简单的分析。可以提出更强大的分析,它考虑到更多的用例——速度较慢,但仍然是线性时间。
验证
我们可以使用静态分析来验证合约的安全性,该分析在 code 的大小上花费线性和空间成线性关系的时间,如下所示。既然我们可以验证,我们就应该验证。
性能
在初始化时验证安全控制流具有潜在的性能优势。
- 静态跳转不需要在运行时进行检查。
- 堆栈下溢不需要在运行时进行检查。
规范
有效性
从理论上讲,理论和实践是相同的。在实践中,它们不同。——阿尔伯特·爱因斯坦
我们将 safe EVM 合约定义为不会遇到异常停止状态的合约。我们在初始化时验证 safety 的实际程度。
异常停止状态
每个指令的 execution 在黄皮书中定义为保留 EVM 状态不变性的 EVM 状态的更改。在运行时,如果指令的执行会违反不变性,则 EVM 处于异常停止状态。黄皮书定义了五个这样的状态。
- 燃料不足
- 超过 1024 个堆栈项
- 堆栈项不足
- 无效的跳转目标
- 无效指令
程序只有在没有执行会导致异常停止状态时才是安全的。
我们希望 EVM 程序只有在安全时才有效。
在实践中,我们必须能够在线性时间内验证 code 以避免拒绝服务攻击。我们必须支持动态定价的指令、循环和递归,这些指令、循环和递归可以使用任意数量的燃料和堆栈。
因此,我们的验证不能考虑具体的计算——它仅对 code 执行有限的符号执行。这意味着如果我们检测到任何无效的执行路径,即使这些路径在运行时无法访问,我们也会拒绝程序。我们将把可能并不总是产生正确结果的程序算作有效程序。
我们只能在 validation time 检测到 non-recursive 堆栈溢出,因此我们必须在 runtime 检查前两个状态:
out of gas
和- 堆栈溢出。
我们可以检查 validation time 的其余三个状态:
- 堆栈下溢,
- 无效跳转,以及
- 无效指令。
也就是说:
有效合约不会因异常而停止,除非它们
- 抛出
out of gas
或- 递归地溢出堆栈。
有效代码的约束
- 每个指令都是有效的。
- 每个跳转都是有效的:
- 每个
JUMP
和JUMPI
都是 static 的。 - 没有
JUMP
、JUMPI
、RJUMP
、RJUMPI
或RJUMPSUB
寻址立即数据。
- 每个
- 堆栈始终有效:
data stack
上的项的 number 始终为正数,并且最多为 1024。return stack
上的项的 number 的始终为正数,并且最多为 1024。
- 数据堆栈始终对齐:
- 对于 byte_code 的每个 execution ,当前
stack pointer
和进入最近基本块时的stack pointer
之间的data stack
上的项的 number 对于每个 execution 都是相同的。
- 对于 byte_code 的每个 execution ,当前
如果 JUMP
或 JUMPI
指令的 jumpsrc
参数首先通过 PUSH…
放置在堆栈上,并且自那时以来该值没有更改,即使它可能已通过 DUP…
或 SWAP…
复制,我们则定义该指令为 static。
RJUMP
、RJUMPI
和 RJUMPSUB
指令将其目标作为立即参数,因此它们是 static 的。
总而言之,这些规则允许通过遍历控制流图来验证代码,在代码大小上以线性的时间和空间中进行验证,并且每个边仅跟随一次。
注意:JUMP
和 JUMPI
的 ‘static’ 定义是实现子例程所需的最低限度。可以提出更深入的分析,这些分析将验证更大且可能更有用的跳转集,但代价是更昂贵(但仍然是线性)的验证。
理由
要求所有跳转的 static 目标意味着所有跳转目标都可以在初始化时而不是运行时进行验证。
绑定堆栈指针可以捕获所有 data stack
和非递归 return stack
溢出。
要求一致对齐的 data stack
可以防止堆栈下溢。它还可以捕获由于不可约控制流和使用错误数量的参数调用子例程而导致的堆栈未对齐等错误。
向后兼容性
这些更改会影响 EVM 代码的语义——JUMP
、JUMPI
的使用和堆栈受到限制,因此某些 code 否则会正确运行,但仍然是无效的 EVM code。
参考实现
以下是一个用于预测代码有效性的伪 Go 实现的算法。必须在初始化时运行等效的算法。
该算法执行程序的符号执行,该执行递归地遍历 code,模拟其控制流和堆栈使用情况,并检查是否违反上述规则。
它在程序控制流图中以 O(vertices + edges)
等于的时间运行,其中边表示控制流,顶点表示 basic blocks ——因此该算法花费的时间与 code 的大小成正比。
注意:所有有效的代码都有一个控制流图,该图可以在代码长度上以线性的时间和空间内遍历。这意味着一些其他的静态分析和代码转换,否则可能需要二次时间,也可以编写为以接近线性的时间运行,包括单程和流式编译器。
验证函数
注意:此函数正在进行中,已知以下版本不正确。
为了简单起见,我们假设已经完成了 jumpdest analysis 并且我们有一些辅助函数。
- 如果
pc
指向有效指令,则isValidInstruction(pc)
返回 true - 如果
dest
是有效的跳转目标,则isValidJumpdest(dest)
返回 true immediateData(pc)
返回pc
处指令的立即数据。advancePC(pc)
返回下一个pc
,跳过任何立即数据。removed_items(pc)
返回从pc
处的指令从dataStack
中删除的项数。added_items(pc)
返回pc
处的指令添加到dataStack
的项数。
var bytecode [codeLen]byte
var subMin [codeLen]int
var subMax [codeLen]int
var subDelta [codeLen]int
var visited [codeLen]bool
var dataStack [1024]int
// 验证 pc 处字节码控制流的路径
// 并返回该路径上使用的最大堆栈项数
// 否则返回 PC 和错误
//
// 通过从 pc:=0 开始,递归评估整个程序
//
func validate(pc := 0, sp := 0, rp := 0) int, error {
minStack := 0
maxStack := 0
deltaStack := 0
for pc < codeLen {
if !isValidInstruction(pc) {
return 0,0,0,invalid_instruction // 无效指令
}
// 如果我们之前跳转到这里,则返回以打破循环
if visited[pc] {
// 如果增量不同,则堆栈未对齐
if ??? {
return 0,0,0,invalid_stack // 无效堆栈
}
return minStack, maxStack, sp
}
visited[pc] = true
switch bytecode[pc] {
// 成功终止
case STOP:
return minStack, maxStack, sp
case RETURN:
return minStack, maxStack, sp
case SELFDESTRUCT:
return minStack, maxStack, sp
case REVERT:
return minStack, maxStack, sp
case INVALID:
return 0,0,0,invalid_instruction // 无效指令
case RJUMP:
// 检查有效的跳转目标
if !isValidJumpdest(jumpdest) {
return 0,0,0,invalid_destination // 无效目标
}
// 将 pc 重置为跳转目标
pc += immediateData(pc)
case RJUMPI:
// 递归以验证条件的真的一面
jumpdest = pc + immediateData(pc)
if !isValidJumpdest(pc + jumpdest) {
return 0,0,0,invalid_destination // 无效目标
}
minRight, maxLeft, deltaRight, err =
validate(jumpdest, sp, rp)
case RJUMPSUB:
// 检查有效的跳转目标
jumpdest = immediateData(pc)
if !isValidJumpdest(pc + jumpdest) {
return 0,0,0,invalid_destination // 无效目标
}
pc += jumpdest
// 递归验证子例程调用
minSub, maxSub, deltaSub, err = validate(jumpdest, sp, rp)
if err {
return 0,0,0,err
}
subMin[pc] = minSub
subMax[pc] = maxSub
subDelta[pc] = deltaSub
minStack = min(minStack, sp)
maxStack = max(maxStack, sp)
pc = advancePC(pc)
case RETURNSUB:
maxStack = max(maxStack, sp)
return minStack, maxStack, sp, nil
/////////////////////////////////////////////////////
//
// 以下仅在我们采取
//
// Option 2
//
// 并且不弃用 JUMP 和 JUMPI
//
case JUMP:
// 弹出跳转目标
jumpdest = dataStack[--sp]
if !valid_jumpdest(jumpdest) {
return 0,0,0,invalid_destination // 无效目标
}
pc = jumpdest
case JUMPI:
// 弹出跳转目标和条件
jumpdest = dataStack[--sp]
jumpif = dataStack[--sp]
if sp < 0 {}
return 0,0,0,stack_underflow // 堆栈下溢
}
if !valid_jumpdest(jumpdest) {
return 0,0,0,invalid_destination // 无效目标
}
// 递归以验证条件的真的一面
if !isValidJumpdest(jumpdest) {
return 0,0,0,invalid_destination // 无效目标
}
maxLeft, err = validate(jumpdest, sp, rp)
if err {
return 0,0,0,err
}
// 递归以验证条件的假的一面
pc = advance_pc(pc)
maxRight, err = validate(pc, sp, rp)
if err {
return 0,0,0,err
}
// 两侧都有效,则返回最大值
maxStack += max(maxLeft, maxRight)
return minStack, maxStack, sp
case PUSH1 <= bytecode[pc] && bytecode[pc] <= PUSH32 {
sp++
if (sp > 1023) {
return 0,0,0,stack_overflow // 堆栈溢出
}
maxStack = max(maxStack, sp)
dataStack[sp] = immediateData(pc)
pc = advancePC(pc)
case DUP1 <= bytecode[pc] && bytecode[pc] <= DUP32 {
dup = sp - (bytecode[pc] - DUP1)
if dup < 0 {
return 0,0,0,stack_underflow // 堆栈下溢
}
sp++
if (sp > 1023) {
return 0,0,0,stack_overflow // 堆栈溢出
}
maxStack = max(maxStack, sp)
dataStack[sp] = dataStack[dup]
pc = advancePC(pc)
case SWAP1 <= bytecode[pc] && bytecode[pc] <= SWAP32 {
swap = sp - (bytecode[pc] - SWAP1)
if swap < 0 {
return 0,0,0,stack_underflow // 堆栈下溢
}
temp := dataStack[swap]
dataStack[swap] = dataStack[0]
dataStack[0] = temp
pc = advancePC(pc)
//
/////////////////////////////////////////////////////
default:
// 将其他指令应用到堆栈指针
sp -= removed_items(pc)
if (sp < 0) {
return 0,0,0,stack_underflow // 堆栈下溢
}
minStack = min(minStack, sp)
sp += added_items(pc)
if (sp > 1023) {
return 0,0,0,stack_overflow // 堆栈溢出
}
maxStack = max(maxStack, sp)
pc = advancePC(pc)
}
}
// 成功终止
return minStack, maxStack, sp
}
安全考虑
此 EIP 旨在确保在区块链上部署的 EVM 代码的基本安全级别。
版权
在 CC0 下放弃版权及相关权利。
Citation
Please cite this document as:
Greg Colvin (@gcolvin), Greg Colvin <greg@colvin.org>, Brooklyn Zelenka (@expede), "EIP-3779: EVM 更安全的状态控制流 [DRAFT]," Ethereum Improvement Proposals, no. 3779, August 2021. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-3779.