HomeEIPsNewsletter
EIPsEIP-3779
EIP-3779

Safer Control Flow for the EVM

Ensure an essential level of safety for EVM code.
WithdrawnStandards Track: Core
Created: 2021-08-30
Greg Colvin (@gcolvin), Greg Colvin <greg@colvin.org>, Brooklyn Zelenka (@expede)
Discussions ForumOriginal Proposal LinkEdit
1 min read

EIP-3779 proposes a set of validity rules to ensure that EVM contracts cannot encounter an exceptional halting state, which would make them unsafe. The rules restrict the use of existing and proposed control-flow instructions, but do not introduce any new opcodes. The restrictions must be validated at contract initialization time, not at runtime, by an algorithm that takes time and space near-linear in the size of the contract to avoid being a denial of service vulnerability. The specification is entirely semantic and imposes no further syntax on bytecode. The motivation for this proposal is to improve safety and security by preventing unsafe contracts from being placed on the blockchain. The proposal also aims to enable static analysis and formal verification of contracts, which is currently inhibited by the lack of support for static jumps and subroutines in the EVM. By introducing these features and restricting dynamic jumps, the EVM can be a better target for code generation from other languages, and validations and optimizations can be done at deployment time with near-linear time complexity. The proposal depends on EIP-1702 for a versioning scheme and references EIP-3155 for the EVM trace specification.

Video
Anyone may contribute to propose contents.
Go propose
Original

Abstract

We define a safe EVM contract as one that cannot encounter an exceptional halting state. In general, we cannot prove safety for Turing-complete programs. But we can prove a useful subset.

This EIP specifies validity rules to ensure that:

Valid contracts will not halt with an exception unless they either

  • throw out of gas or
  • recursively overflow stack.

This EIP does not introduce any new opcodes. Rather, it restricts the use of existing and proposed control-flow instructions. The restrictions must be validated at contract initialization time – not at runtime – by the provided algorithm or its equivalent. This algorithm must take time and space near-linear in the size of the contract, so as not to be a denial of service vulnerability.

This specification is entirely semantic. It imposes no further syntax on bytecode, as none is required to ensure the specified level of safety. Ethereum Virtual Machine bytecode is just that -- a sequence of bytes that when executed causes a sequence of changes to the machine state. The safety we seek here is simply to not, as it were, jam up the gears.

Motivation

Safety

For our purposes we define a safe EVM contract as one that cannot encounter an exceptional halting state. From the standpoint of security it would be best if unsafe contracts were never placed on the blockchain. Unsafe code can attempt to overflow stack, underflow stack, execute invalid instructions, and jump to invalid locations.

Unsafe contracts are exploits waiting to happen.

Validating contract safety requires traversing the contract code. So in order to prevent denial of service attacks all jumps, including the existing JUMP and JUMPI, and also the other proposed jumps -- RJUMP, RJUMPI, RJUMPSUB and RETURNSUB -- must be validated at initialization time, and in time and space linear in the size of the code.

Static Jumps and Subroutines

The relative jumps of EIP-4200 and the simple subroutines of EIP-2315 provide a complete set of static control flow instructions:

RJUMP offset

  • Jumps to IP+offset.

RJUMPI offset

  • Jumps if the top of stack is non-zero.

RJUMPSUB offset

  • Pushes IP+1 on the return stack and jumps to IP+offset.

RETURNSUB

  • Jumps to the address popped off the return stack.

Note that each jump creates at most two paths of control through the code, such that the complexity of traversing the entire control-flow graph is linear in the size of the code.

Dynamic Jumps

Dynamic jumps, where the destination of a JUMP or JUMPI is not known until runtime, are an obstacle to proving validity in linear time -- any jump can be to any destination in the code, potentially requiring time quadratic in the size of code. For this reason we have two real choices.

  1. Deprecate dynamic jumps. This is easily done:

Define JUMP and JUMPI as INVALID for the purposes of EOF Code Validation

  1. Constrain dynamic jumps. This requires static analysis.

Consider the simplest and most common case.

PUSH address
JUMP

This is effectively a static jump.

Another important use of JUMP is to implement the return jump from a subroutine. So consider this example of calling and returning from a minimal subroutine:

TEST_SQUARE:
    jumpdest
    push RTN_SQUARE 
    0x02
    push SQUARE
    jump
RTN_SQUARE
    jumpdest
    swap1
    jump

SQUARE:
    jumpdest
    dup1
    mul
    swap1
    jump

The return address -RTN_SQUARE - and the destination address - SQUARE - are pushed on the stack as constants and remain unchanged as they move on the stack, such that only those constants are passed to each JUMP. They are effectively static. We can track the motion of constants on the data stack at validation time, so we do not need unconstrained dynamic jumps to implement subroutines.

The above is the simplest analysis that suffices. A more powerful analysis that takes in more use cases is possible -- slower, but still linear-time.

Validation

We can validate the safety of contracts with a static analysis that takes time and space linear in the size of the code, as shown below. And since we can, we should.

Performance

Validating safe control flow at initialization time has potential performance advantages.

  • Static jumps do not need to be checked at runtime.
  • Stack underflow does not need to be checked for at runtime.

Specification

Validity

In theory, theory and practice are the same. In practice, they're not. -- Albert Einstein

We define a safe EVM contract as one that cannot encounter an exceptional halting state. We validate safety at initialization time to the extent practical.

Exceptional Halting States

The execution of each instruction is defined in the Yellow Paper as a change to the EVM state that preserves the invariants of EVM state. At runtime, if the execution of an instruction would violate an invariant the EVM is in an exceptional halting state. The Yellow Paper defined five such states.

  1. Insufficient gas
  2. More than 1024 stack items
  3. Insufficient stack items
  4. Invalid jump destination
  5. Invalid instruction

A program is safe iff no execution can lead to an exceptional halting state.

We would like to consider EVM programs valid iff they are safe.

In practice, we must be able to validate code in linear time to avoid denial of service attacks. And we must support dynamically-priced instructions, loops, and recursion, which can use arbitrary amounts of gas and stack.

Thus our validation cannot consider concrete computations -- it only performs a limited symbolic execution of the code. This means we will reject programs if we detect any invalid execution paths, even if those paths are not reachable at runtime. And we will count as valid programs that may not always produce correct results.

We can detect only non-recursive stack overflows at validation time, so we must check for the first two states at runtime:

  • out of gas and
  • stack overflow.

The remaining three states we can check at validation time:

  • stack underflow,
  • invalid jump, and
  • invalid instruction.

That is to say:

Valid contracts will not halt with an exception unless they either

  • throw out of gas or
  • recursively overflow stack.

Constraints on Valid Code

  • Every instruction is valid.
  • Every jump is valid:
    • EveryJUMP and JUMPI is static.
    • No JUMP, JUMPI, RJUMP, RJUMPI, or RJUMPSUB addresses immediate data.
  • The stacks are always valid:
    • The number of items on the data stack is always positive, and at most 1024.
    • The number of items on the return stack is always positive, and at most 1024.
  • The data stack is consistently aligned:
    • The number of items on the data stack between the current stack pointer and the stack pointer on entry to the most recent basic block is the same for each execution of a byte_code.

We define a JUMP or JUMPI instruction to be static if its jumpsrc argument was first placed on the stack via a PUSH… and that value has not changed since, though it may have been copied via a DUP… or SWAP….

The RJUMP, RJUMPI and RJUMPSUBinstructions take their destination as an immediate argument, so they are static.

Taken together, these rules allow for code to be validated by traversing the control-flow graph, in time and space linear in the size of the code, following each edge only once.

Note: The definition of 'static' for JUMP and JUMPI is the bare minimum needed to implement subroutines. Deeper analyses could be proposed that would validate a larger and probably more useful set of jumps, at the cost of more expensive (but still linear) validation.

Rationale

Demanding static destinations for all jumps means that all jump destinations can be validated at initialization time, not runtime.

Bounding the stack pointers catches all data stack and non-recursivereturn stack overflows.

Requiring a consistently aligneddata stack prevents stack underflow. It can also catch such errors as misaligned stacks due to irreducible control flows and calls to subroutines with the wrong number of arguments.

Backwards Compatibility

These changes affect the semantics of EVM code – the use of JUMP, JUMPI, and the stack are restricted, such that some code that would otherwise run correctly will nonetheless be invalid EVM code.

Reference Implementation

The following is a pseudo-Go implementation of an algorithm for predicating code validity. An equivalent algorithm must be run at initialization time.

This algorithm performs a symbolic execution of the program that recursively traverses the code, emulating its control flow and stack use and checking for violations of the rules above.

It runs in time equal to O(vertices + edges) in the program's control-flow graph, where edges represent control flow and the vertices represent basic blocks -- thus the algorithm takes time proportional to the size of the code.

Note: All valid code has a control-flow graph that can be traversed in time and space linear in the length of the code. That means that some other static analyses and code transformations that might otherwise require quadratic time can also be written to run in near-linear time, including one-pass and streaming compilers.

Validation Function

Note: This function is a work in progress, and the version below is known to be incorrect.

For simplicity's sake we assume that jumpdest analysis has been done and that we have some helper functions.

  • isValidInstruction(pc) returns true if pc points at a valid instruction
  • isValidJumpdest(dest) returns true if dest is a valid jumpdest
  • immediateData(pc) returns the immediate data for the instruction at pc.
  • advancePC(pc) returns next pc, skipping any immediate data.
  • removed_items(pc) returns the number of items removed from the dataStack by the instruction at pc.
  • added_items(pc) returns the number of items added to the dataStack by the instruction at pc.
var bytecode      [codeLen]byte
var subMin        [codeLen]int
var subMax        [codeLen]int
var subDelta      [codeLen]int
var visited       [codeLen]bool
var dataStack     [1024]int

// validate a path through the control flow of the bytecode at pc
// and return the maximum number of stack items used down that path
// or else the PC and an error
//
// by starting at pc:=0 the entire program is recursively evaluated
//
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 we have jumped here before return to break cycle
      if visited[pc] {

          // stack is not aligned if deltas not the same
          if ??? {
            return 0,0,0,invalid_stack
          }
          return minStack, maxStack, sp
      }
      visited[pc] = true
      switch bytecode[pc] {

      // successful termination
      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:

         // check for valid jump destination
         if !isValidJumpdest(jumpdest) {
            return 0,0,0,invalid_destination
         }
         
         // reset pc to destination of jump
         pc += immediateData(pc)

      case RJUMPI:
      
         // recurse to validate true side of conditional
         jumpdest = pc + immediateData(pc)
         if !isValidJumpdest(pc + jumpdest) {
            return 0,0,0,invalid_destination
         }
         minRight, maxLeft, deltaRight, err =
            validate(jumpdest, sp, rp)
  
     err {
            return 0,0,0,err
         }
         
         // recurse to validate false side of conditional
         pc = advancePC(pc)
         minRight, maxRight, deltaRight, err =
            validate(pc, sp, rp)
         if err {
            return 0,0,0,err
         }
         
         // both paths valid, so return max
         minStack = min(minStack, min(minLeft, minRight))
         maxStack += max(maxLeft, maxRight)
         deltaStack += max(deltaLeft, deltaRight)
         return minStack, maxStack, deltaStack

      case RJUMPSUB:

         // check for valid jump destination
         jumpdest = immediateData(pc)
         if !isValidJumpdest(pc + jumpdest) {
            return 0,0,0,invalid_destination
         }

         pc += jumpdest

         // recurse to validate subroutine call
         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

      /////////////////////////////////////////////////////
      //
      // The following are to be included only if we take
      //
      //    Option 2
      //
      // and do not deprecate JUMP and JUMPI
      //
      case JUMP:
         // pop jump destination
         jumpdest = dataStack[--sp]
         if !valid_jumpdest(jumpdest) {
            return 0,0,0,invalid_destination
         }
         pc = jumpdest
      case JUMPI:
         // pop jump destination and conditional
         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
         }

         // recurse to validate true side of conditional
         if !isValidJumpdest(jumpdest) {
            return 0,0,0,invalid_destination
         }
         maxLeft, err = validate(jumpdest, sp, rp)
         if err {
            return 0,0,0,err
         }
         
         // recurse to validate false side of conditional
         pc = advance_pc(pc)
         maxRight, err = validate(pc, sp, rp)
         if err {
            return 0,0,0,err
         }
         
         // both sides valid, return max
         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:

         // apply other instructions to stack pointer
         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)
      }
   }

   // successful termination
   return minStack, maxStack, sp
}

Security Considerations

This EIP is intended to ensure an essential level of safety for EVM code deployed on the blockchain.

Copyright and related rights waived via CC0.

Further reading
Anyone may contribute to propose contents.
Go propose

Not miss a beat of EIPs' update?

Subscribe EIPs Fun to receive the latest updates of EIPs Good for Buidlers to follow up.

View all
Serve Ethereum Builders, Scale the Community.
Resources
GitHub
Supported by