EIP-150 Vulnerability Patterns

December 17, 2025

EIP-150 was introduced in the Tangerine Whistle hard fork (October 2016) to mitigate denial-of-service attacks that exploited underpriced IO-heavy opcodes. The most significant change is the 63/64 gas forwarding rule: when a contract makes an external call, it can only forward at most 63/64 of its remaining gas to the callee. The remaining 1/64 is reserved for the caller to execute post-call operations.

This article summarizes 3 common EIP-150 vulnerability patterns with 5 real audit cases to help you quickly master EIP-150 auditing.

EIP-150 Core Rule: 63/64 Gas Forwarding Limit

┌─────────────────────────────────────────────────────────────────┐
│                      Before EIP-150                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Contract A (6400 gas remaining)                                │
│  ┌─────────────────┐                                            │
│  │ function foo()  │                                            │
│  │ {               │                                            │
│  │   B.bar();  ────────────► Contract B receives 6400 gas (all) │
│  │   // post code  │          If B uses all gas, A fails too    │
│  │ }               │                                            │
│  └─────────────────┘                                            │
│                                                                 │
│  Problem: Malicious B can consume all gas, preventing A from    │
│           executing any subsequent logic                        │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                      After EIP-150                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Contract A (6400 gas remaining)                                │
│  ┌─────────────────┐                                            │
│  │ function foo()  │     Forward: 6400 × 63/64 = 6300 gas       │
│  │ {               │     ┌──────────────────────┐               │
│  │   B.bar();  ─────────►│ Contract B gets 6300 │               │
│  │   // post code  │     └──────────────────────┘               │
│  │ }               │                                            │
│  └────────┬────────┘     Reserve: 6400 × 1/64 = 100 gas         │
│           │              ┌──────────────────────┐               │
│           └──────────────│ A keeps 100 gas      │               │
│                          │ for post-call code   │               │
│                          └──────────────────────┘               │
└─────────────────────────────────────────────────────────────────┘

Gas Flow Diagram

                      Gas Decay in Call Chain

Transaction starts: 100,000 gas
        │
        ▼
┌───────────────────┐
│    Contract A     │  Available: 100,000 gas
│   calls B.foo()   │
└────────┬──────────┘
         │ forwards 63/64
         ▼
┌───────────────────┐
│    Contract B     │  Receives: 98,437 gas (100000 × 63/64)
│   calls C.bar()   │  A retains: 1,563 gas
└────────┬──────────┘
         │ forwards 63/64
         ▼
┌───────────────────┐
│    Contract C     │  Receives: 96,899 gas (98437 × 63/64)
│   calls D.baz()   │  B retains: 1,538 gas
└────────┬──────────┘
         │ forwards 63/64
         ▼
┌───────────────────┐
│    Contract D     │  Receives: 95,385 gas (96899 × 63/64)
│                   │  C retains: 1,514 gas
└───────────────────┘

Each external call reduces available gas by ~1.56%

The gasleft() Trap

┌─────────────────────────────────────────────────────────────────┐
│  Contract A                      Contract B (external call)     │
│  ┌────────────────────────┐     ┌────────────────────────┐     │
│  │ startGas = 10000       │     │                        │     │
│  │                        │     │ // Inside B:           │     │
│  │ // gasleft() before    │     │ // gasleft() shows     │     │
│  │ // call = 9500         │     │ // gas B received      │     │
│  │                        │     │ // ≈ 9500 × 63/64     │     │
│  │ B.process(startGas); ──────► │ // = 9351             │     │
│  │                        │     │                        │     │
│  │                        │     │ usedGas = startGas     │     │
│  │                        │     │          - gasleft()   │     │
│  │                        │     │         = 10000 - 9351 │     │
│  │                        │     │         = 649  ❌ WRONG │     │
│  │                        │     │                        │     │
│  │                        │     │ // Actually ~500 used  │     │
│  │                        │     │ // but calculates 649  │     │
│  └────────────────────────┘     └────────────────────────┘     │
│                                                                 │
│  💥 Problem: Calculated gas consumption is artificially inflated│
└─────────────────────────────────────────────────────────────────┘

Formula

Gas forwarded to subcall = min(specified gas, remaining gas × 63/64)

                    ┌─────────────────────────────┐
                    │  G_forward = G_remaining    │
                    │            - G_remaining/64 │
                    │                             │
                    │  G_reserved = G_remaining   │
                    │              / 64           │
                    └─────────────────────────────┘

Example:
  Remaining gas = 64000
  Forwarded = 64000 - 64000/64 = 64000 - 1000 = 63000
  Reserved = 1000

Internal Call vs External Call

EIP-150 only applies to external calls (CALL, CALLCODE, DELEGATECALL, STATICCALL opcodes), not internal calls (JUMP opcode).

┌─────────────────────────────────────────────────────────────────┐
│                      Internal Call                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  contract MyContract {                                          │
│      function foo() public {                                    │
│          bar();  ◄─────── Direct call, uses JUMP opcode         │
│      }                    Same execution context                │
│                           EIP-150 does NOT apply                │
│      function bar() internal {                                  │
│          // gasleft() reflects true remaining gas               │
│      }                                                          │
│  }                                                              │
│                                                                 │
│  Characteristics:                                               │
│  • Functions within same contract or inherited                  │
│  • Uses JUMP instruction, not CALL                              │
│  • 100% gas available, no 63/64 limit                           │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                      External Call                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  contract A {                                                   │
│      function foo() public {                                    │
│          B(addr).bar();  ◄─────── Uses CALL opcode              │
│      }                            EIP-150 applies here          │
│  }                                                              │
│                                                                 │
│  contract B {                                                   │
│      function bar() external {                                  │
│          // gasleft() only sees received 63/64 gas              │
│      }                                                          │
│  }                                                              │
│                                                                 │
│  Characteristics:                                               │
│  • Calling another contract's function                          │
│  • Uses CALL/DELEGATECALL/STATICCALL opcodes                    │
│  • Limited by EIP-150, forwards at most 63/64 gas               │
└─────────────────────────────────────────────────────────────────┘

Vulnerability Patterns

1. EIP-150 Gas Calculation Error in Fee Refunds

Prerequisite Knowledge:

EIP-150 (‘Gas cost changes for IO-heavy operations’) introduced a change where only a maximum of 63/64 of the remaining gas can be passed to an external call. The remaining 1/64 is reserved for the execution of the rest of the transaction. The specification states: ‘All calls will now forward at most 63/64 of the remaining gas (instead of all gas)’. This means that when a contract makes an external call, gasleft() inside the called contract will not reflect the actual gas consumed from the original transaction’s perspective.

Root Cause:

The fee calculation logic in processExecutionFee() incorrectly assumes that cache.startGas - gasleft() represents the total gas consumed since startGas was recorded. However, due to EIP-150, gasleft() inside the externally called GasProcess contract only reflects 63/64 of the gas that would have been remaining if the call were internal. This causes the calculated usedGas to be higher than the actual gas consumed, leading to an inflated fee for the keeper and a reduced refund for the user.

Attack Vector:

When a keeper executes a user’s order, the flow records startGas, then externally calls GasProcess.processExecutionFee(). Due to EIP-150, only 63/64 of remaining gas is forwarded, so usedGas = startGas - gasleft() is inflated. The keeper receives more fee than actual gas consumed, and the user receives less refund.

Business Scenario:

┌─────────────────────────────────────────────────────────────────┐
│                 DeFi Perpetual Trading Protocol                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  User submits order with prepaid execution fee (e.g., 200K gwei)│
│                           │                                     │
│                           ▼                                     │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  Keeper (off-chain bot) executes the order              │   │
│  │                                                         │   │
│  │  1. Record startGas at entry point                      │   │
│  │  2. Execute order logic (open position, etc.)           │   │
│  │  3. Call GasProcess.processExecutionFee() [EXTERNAL]    │   │
│  │                                                         │   │
│  │     ┌─────────────────────────────────────────────┐     │   │
│  │     │ Inside GasProcess (external library):       │     │   │
│  │     │                                             │     │   │
│  │     │ usedGas = startGas - gasleft()              │     │   │
│  │     │                                             │     │   │
│  │     │ Problem: gasleft() only sees 63/64 of gas   │     │   │
│  │     │ that was remaining before the external call │     │   │
│  │     │                                             │     │   │
│  │     │ Result: usedGas is INFLATED                 │     │   │
│  │     └─────────────────────────────────────────────┘     │   │
│  │                                                         │   │
│  │  4. Calculate fee distribution:                         │   │
│  │     • Keeper gets: usedGas × tx.gasprice (inflated)    │   │
│  │     • User refund: prepaidFee - keeperFee (reduced)    │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  Impact: User loses ~1.56% of their execution fee refund        │
│          due to the 1/64 gas "phantom consumption"              │
└─────────────────────────────────────────────────────────────────┘

Fee Flow:
┌──────────┐    prepay 200K gwei     ┌──────────┐
│   User   │ ───────────────────────►│ Protocol │
└──────────┘                         └────┬─────┘
     ▲                                    │
     │ refund (reduced due to bug)        │ keeper fee (inflated)
     │                                    ▼
     │                              ┌──────────┐
     └──────────────────────────────│  Keeper  │
           should get more          └──────────┘

Instances:

  • [sherlock/2024-05-elfi-protocol] EIP-150 Gas Calculation Vulnerability in payExecutionFee() (issue-95.md)
    • elfi-perp-contracts/contracts/process/GasProcess.sol

      function processExecutionFee(PayExecutionFeeParams memory cache) external {
              uint256 usedGas = cache.startGas - gasleft(); // VULNERABILITY: EIP-150 gas calculation flaw - doesn't account for 1/64 gas reserved by caller
              uint256 executionFee = usedGas * tx.gasprice;
              uint256 refundFee;
              uint256 lossFee;
              if (executionFee > cache.userExecutionFee) {
                  executionFee = cache.userExecutionFee;
                  lossFee = executionFee - cache.userExecutionFee;
              } else {
                  refundFee = cache.userExecutionFee - executionFee;
              }

2. L2 Precompile Reproducibility Failure Due to EIP-150 Gas Forwarding Limit

Prerequisite Knowledge:

EIP-150 introduced a 63/64 gas forwarding limit for calls. Specifically, when a contract makes a call, it can only forward at most 63/64 of its remaining gas to the callee. This rule applies to all calls, including those made by the PreimageOracle’s loadPrecompilePreimagePart() function on L1. The formula for the minimum required gas is: required_gas = callee_gas_cost * 64 / 63.

Root Cause:

The OP fault proof system assumes that any precompile call executed on L2 can be reproduced on L1 via the PreimageOracle. However, this assumption is violated because EIP-150’s 63/64 gas forwarding rule on L1 means that reproducing a precompile call requires more gas than the precompile itself consumes. For gas-intensive precompiles like bn256Pairing, the overhead of EIP-150 combined with calldata costs makes certain L2 precompile calls impossible to reproduce on L1, breaking the fault proof mechanism.

Attack Vector:

An attacker can deploy a contract on L2 that calls a gas-intensive precompile (like bn256Pairing at address 0x08) with an input size that consumes nearly the entire L2 block gas limit (e.g., 29,353,000 gas). When this transaction is included in an L2 block, it creates a preimage oracle query that cannot be satisfied on L1 because: 1) The loadPrecompilePreimagePart() function needs 29,818,920 gas due to EIP-150’s 63/64 rule, but 2) An EOA caller would spend 662,016 gas on calldata, leaving insufficient gas, and 3) A contract caller is also limited by the 63/64 rule. This prevents invalid state transitions from being challenged, compromising the security of the rollup.

Business Scenario:

┌─────────────────────────────────────────────────────────────────┐
│              Optimism Rollup Fault Proof System                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  L2 (Optimism)                        L1 (Ethereum)             │
│  ┌─────────────────────┐              ┌─────────────────────┐   │
│  │ User's Transaction  │              │  Fault Proof Game   │   │
│  │                     │              │                     │   │
│  │ Call bn256Pairing   │   dispute    │  To challenge, must │   │
│  │ with large input    │ ──────────►  │  reproduce the call │   │
│  │ (29.3M gas)         │              │  on L1              │   │
│  └─────────────────────┘              └─────────────────────┘   │
│                                                                 │
│  Normal Fault Proof Flow:                                       │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ 1. L2 executes transaction (precompile call)            │   │
│  │ 2. Sequencer posts state root to L1                     │   │
│  │ 3. If disputed, challenger reproduces L2 execution on L1│   │
│  │ 4. PreimageOracle.loadPrecompilePreimagePart() called   │   │
│  │ 5. Precompile result verified, fraud proven/disproven   │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  Attack: Make Step 4 impossible due to EIP-150                  │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ L2 precompile uses: 29,353,000 gas                      │   │
│  │                                                         │   │
│  │ L1 reproduction needs:                                  │   │
│  │   29,353,000 × 64/63 = 29,818,920 gas (EIP-150 overhead)│   │
│  │   + 662,016 gas (calldata cost)                         │   │
│  │   = 30,480,936 gas total                                │   │
│  │                                                         │   │
│  │ L1 block gas limit: 30,000,000 gas                      │   │
│  │                                                         │   │
│  │ Result: IMPOSSIBLE to reproduce on L1!                  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  Impact: Attacker can post INVALID state roots that cannot     │
│          be challenged, potentially stealing all bridged funds │
└─────────────────────────────────────────────────────────────────┘

Why EIP-150 breaks reproducibility:
┌────────────────────────────────────────────────────────────────┐
│                                                                │
│  On L2: Direct precompile call                                 │
│  ┌──────────┐  call   ┌──────────────┐                         │
│  │ Contract │ ───────►│ bn256Pairing │  Uses exactly 29.3M gas │
│  └──────────┘         └──────────────┘                         │
│                                                                │
│  On L1: Must go through PreimageOracle                         │
│  ┌──────────┐  call   ┌────────────────┐  call   ┌───────────┐ │
│  │ Verifier │ ───────►│ PreimageOracle │ ───────►│ Precompile│ │
│  └──────────┘         └────────────────┘         └───────────┘ │
│                              │                                 │
│                    EIP-150: can only forward                   │
│                    63/64 of remaining gas                      │
│                              │                                 │
│                    Need 29.3M × 64/63 ≈ 29.8M gas              │
│                    to ensure precompile gets 29.3M             │
└────────────────────────────────────────────────────────────────┘

Instances:

  • [code4rena/2024-07-optimism] [H-04] L2 precompile calls can be impossible to reproduce on L1 (finding-004.md)
    • packages/contracts-bedrock/src/cannon/PreimageOracle.sol

      function loadKeccak256PreimagePart(uint256 _partOffset, bytes calldata _preimage) external {
          uint256 size; // VULNERABILITY: No validation of input size limits
          bytes32 key;
          bytes32 part;
          assembly {
              size := calldataload(0x44) // VULNERABILITY: Unbounded input size from calldata
      
              if iszero(lt(_partOffset, add(size, 8))) {
                  mstore(0x00, 0xfe254987)
                  revert(0x1c, 0x04)
              }
              let ptr := 0x80
              mstore(ptr, shl(192, size))
              ptr := add(ptr, 0x08)
              calldatacopy(ptr, _preimage.offset, size) // VULNERABILITY: Large calldata can exceed L1 tx limits
              part := mload(add(sub(ptr, 0x08), _partOffset))
              let h := keccak256(ptr, size)
              key := or(and(h, not(shl(248, 0xFF))), shl(248, 0x02))
          }
          preimagePartOk[key][_partOffset] = true;
          preimageParts[key][_partOffset] = part;
          preimageLengths[key] = size;
      }
      
      function loadSha256PreimagePart(uint256 _partOffset, bytes calldata _preimage) external {
          uint256 size; // VULNERABILITY: Same issue as loadKeccak256PreimagePart
          bytes32 key;
          bytes32 part;
          assembly {
              size := calldataload(0x44) // VULNERABILITY: Unbounded input size
      
              if iszero(lt(_partOffset, add(size, 8))) {
                  mstore(0, 0xfe254987)
                  revert(0x1c, 4)
              }
              let ptr := 0x80
              mstore(ptr, shl(192, size))
              ptr := add(ptr, 8)
              calldatacopy(ptr, _preimage.offset, size) // VULNERABILITY: No size limits
          }
      }

3. EIP-150 Gas Limit Bypass via Out-of-Gas Revert

Prerequisite Knowledge:

EIP-150 introduced the ‘63/64 rule’ which limits the amount of gas forwarded in a call. Specifically, when a contract makes a call to another contract, only a maximum of (gas_remaining - gas_remaining / 64) gas can be forwarded. This means if a parent contract has G gas remaining, it can only forward at most G - G/64 = 63G/64 gas to the child contract. If the child contract requires more gas than this limit, it will revert with an out-of-gas error.

Root Cause:

The contract incorrectly assumes that any revert from the verification call indicates a failed verification. However, due to EIP-150’s 63/64 gas forwarding rule, the verification call can revert due to insufficient gas rather than an actual verification failure. The try/catch block treats all reverts as verification failures, allowing attackers to manipulate gas limits to trigger out-of-gas reverts and bypass verification.

Attack Vector:

An attacker calls challengeAdd/challengeRemove with a gas limit that is sufficient for the overall transaction but insufficient for the internal verification call after EIP-150’s gas reduction. The verification call reverts due to out-of-gas, the catch block assumes verification failed, and the contract incorrectly rewards the challenger despite having a valid signature.

Business Scenario (Farcaster Optimistic Verifier):

┌─────────────────────────────────────────────────────────────────┐
│           Farcaster Wallet Optimistic Verification              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Purpose: Link Farcaster social identity to Ethereum wallet     │
│           with gas-efficient optimistic verification            │
│                                                                 │
│  Normal Flow:                                                   │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ 1. Relayer submits verification (fid + wallet + sig)    │   │
│  │ 2. 1-day challenge period starts                        │   │
│  │ 3. If no challenge → verification accepted              │   │
│  │ 4. If challenged → on-chain verification executed       │   │
│  │    • Valid sig → challenge fails, challenger loses      │   │
│  │    • Invalid sig → challenge succeeds, challenger wins  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  Attack Flow:                                                   │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ 1. Relayer submits VALID verification                   │   │
│  │                                                         │   │
│  │ 2. Attacker calls challengeAdd() with crafted gas:      │   │
│  │    ┌───────────────────────────────────────────────┐    │   │
│  │    │ Gas limit set so that:                        │    │   │
│  │    │ • Overall tx has enough gas to complete       │    │   │
│  │    │ • But after EIP-150's 63/64 rule:             │    │   │
│  │    │   onchainVerifier.verifyAdd() gets too little │    │   │
│  │    │   gas and reverts with out-of-gas             │    │   │
│  │    └───────────────────────────────────────────────┘    │   │
│  │                                                         │   │
│  │ 3. try { verifyAdd() } catch {} catches out-of-gas     │   │
│  │    Contract thinks: "verification failed"               │   │
│  │    Reality: verification never actually ran!            │   │
│  │                                                         │   │
│  │ 4. Attacker receives challengeReward() ETH             │   │
│  │    Valid verification is invalidated                    │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  Impact: Attacker drains contract's ETH deposit                │
│          Legitimate verifications are falsely invalidated      │
└─────────────────────────────────────────────────────────────────┘

Gas Manipulation Detail:
┌────────────────────────────────────────────────────────────────┐
│  challengeAdd() called with gas limit G                        │
│        │                                                       │
│        ▼                                                       │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ Remaining gas before try block: R                        │  │
│  │                                                          │  │
│  │ try onchainVerifier.verifyAdd(...) ◄── External call     │  │
│  │     │                                                    │  │
│  │     │ EIP-150: forwards R × 63/64 gas                    │  │
│  │     │ Reserves: R × 1/64 gas for catch block             │  │
│  │     ▼                                                    │  │
│  │ ┌────────────────────────────────────────────────────┐   │  │
│  │ │ verifyAdd() receives R × 63/64 gas                 │   │  │
│  │ │ Needs X gas to complete verification               │   │  │
│  │ │                                                    │   │  │
│  │ │ If R × 63/64 < X → OUT OF GAS → revert            │   │  │
│  │ └────────────────────────────────────────────────────┘   │  │
│  │                                                          │  │
│  │ catch {} ◄── Catches the out-of-gas revert               │  │
│  │     │       Has R × 1/64 gas (enough to continue)        │  │
│  │     ▼                                                    │  │
│  │ verificationTimestamp[h] = 0;  // Invalidate             │  │
│  │ msg.sender.call{value: reward}  // Pay attacker          │  │
│  └──────────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────────┘

Instances:

  • [cantina/2025-01-farcasterattestation-monorepo] The challengeAdd and challengeRemove can be exploited to drain rewards (HIGH-169.md)

    • farcaster-resolver/contracts/wallet-verifier/FarcasterWalletOptimisticVerifier.sol

      function challengeAdd(
          uint256 fid,
          address verifyAddress,
          bytes32 publicKey,
          bytes memory signature
      ) public {
          bytes32 h = hash(
              MessageType.MESSAGE_TYPE_VERIFICATION_ADD_ETH_ADDRESS,
              fid, verifyAddress, publicKey, signature
          );
      
          if (verificationTimestamp[h] > 0) {
              try
                  onchainVerifier.verifyAdd(  // VULNERABILITY: External call subject to EIP-150
                      fid, verifyAddress, publicKey, signature
                  )
              returns (bool verified) {
                  if (verified) {
                      revert ChallengeFailed();
                  }
              } catch {}  // VULNERABILITY: Catches out-of-gas as "verification failed"
      
              verificationTimestamp[h] = 0;
      
              // Attacker receives reward even with valid signature
              (bool success, ) = payable(msg.sender).call{value: challengeReward()}("");
              require(success);
          }
      }
  • [code4rena/2024-01-salty] Caller of Upkeep may skip step 11 to save gas (finding-023.md)

Business Scenario (Salty.IO Upkeep):

┌─────────────────────────────────────────────────────────────────┐
│                  Salty.IO Protocol Maintenance                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Token Distribution (100M SALT total):                          │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  52M (52%) → Emissions (mining rewards)                 │   │
│  │  25M (25%) → DAO Reserve (10-year vesting)              │   │
│  │  10M (10%) → Development Team (10-year vesting) ◄───────│───│── Step 11 releases this
│  │   5M  (5%) → Airdrop                                    │   │
│  │   8M  (8%) → Liquidity bootstrapping                    │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  performUpkeep() Incentive Design:                              │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ • Anyone can call performUpkeep()                       │   │
│  │ • Caller receives 5% of DAO's WETH arbitrage profits    │   │
│  │ • This incentivizes bots to maintain the protocol       │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  11 Steps of performUpkeep():                                   │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ Step 1:  Liquidizer operations                          │   │
│  │ Step 2:  Withdraw WETH profits, PAY 5% TO CALLER ◄──────│───│── Caller's reward
│  │ Step 3:  Form USDS/DAI liquidity                        │   │
│  │ Step 4:  Form SALT/USDS liquidity                       │   │
│  │ Step 5:  Convert WETH to SALT rewards                   │   │
│  │ Step 6:  Emit SALT to rewards contract                  │   │
│  │ Step 7:  Distribute SALT by pool profits                │   │
│  │ Step 8:  Distribute staking/liquidity rewards           │   │
│  │ Step 9:  Process DAO's POL rewards                      │   │
│  │ Step 10: Release DAO vesting SALT                       │   │
│  │ Step 11: Release TEAM vesting SALT ◄────────────────────│───│── Target for skipping
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  Why try/catch on each step?                                    │
│  • Prevent one step's failure from blocking entire upkeep       │
│  • If step 5 fails, steps 6-11 should still execute            │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Attack Flow:
┌────────────────────────────────────────────────────────────────┐
│  Honest caller:                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ Gas limit: 2,500,000 (enough for all steps)              │  │
│  │                                                          │  │
│  │ step1 ✓ → step2 ✓ (gets 5% reward) → ... → step11 ✓     │  │
│  │                                                          │  │
│  │ Result: All maintenance done, team receives SALT         │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                │
│  Malicious caller:                                             │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ Gas limit: 2,200,000 (enough for steps 1-10 only)        │  │
│  │                                                          │  │
│  │ step1 ✓ → step2 ✓ (gets 5% reward) → ... → step10 ✓     │  │
│  │                                              │            │  │
│  │                                              ▼            │  │
│  │ ┌────────────────────────────────────────────────────┐   │  │
│  │ │ try this.step11() {}                               │   │  │
│  │ │     │                                              │   │  │
│  │ │     │ Remaining gas ≈ 200K                         │   │  │
│  │ │     │ EIP-150: forwards 200K × 63/64 ≈ 196K        │   │  │
│  │ │     │ step11 needs > 196K → OUT OF GAS             │   │  │
│  │ │     ▼                                              │   │  │
│  │ │ catch { emit UpkeepError("Step 11", error); }      │   │  │
│  │ │     │                                              │   │  │
│  │ │     │ Has 200K × 1/64 ≈ 3K gas (enough for emit)   │   │  │
│  │ │     ▼                                              │   │  │
│  │ │ Function completes successfully!                   │   │  │
│  │ └────────────────────────────────────────────────────┘   │  │
│  │                                                          │  │
│  │ Result: Caller saves gas, still gets 5% reward           │  │
│  │         Team does NOT receive their SALT!                │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                │
│  Why attacker targets step11 specifically:                     │
│  • Step 2 MUST execute (that's where reward is paid)          │
│  • Steps 1-10 must succeed to reach step 11                   │
│  • Step 11 is last, easiest to skip via gas manipulation      │
│  • Skipping middle steps risks failing earlier steps too      │
└────────────────────────────────────────────────────────────────┘
  • src/Upkeep.sol

    // 11. Sends SALT from the team vesting wallet to the team (linear distribution over 10 years).
    function step11() public onlySameContract
    	{
    	uint256 releaseableAmount = VestingWallet(payable(exchangeConfig.teamVestingWallet())).releasable(address(salt));
    
    	// teamVestingWallet actually sends the vested SALT to this contract - which will then need to be sent to the active teamWallet
    	VestingWallet(payable(exchangeConfig.teamVestingWallet())).release(address(salt));
    
    	salt.safeTransfer( exchangeConfig.managedTeamWallet().mainWallet(), releaseableAmount );
    	}
    // Perform the various steps of performUpkeep as outlined at the top of the contract.
    // Each step is wrapped in a try/catch to prevent reversions from cascading through the performUpkeep.
    function performUpkeep() public nonReentrant
    	{
    	// Perform the multiple steps of performUpkeep()
     	try this.step1() {}
    	catch (bytes memory error) { emit UpkeepError("Step 1", error); }
    
     	try this.step2(msg.sender) {}
    	catch (bytes memory error) { emit UpkeepError("Step 2", error); }
    
     	try this.step3() {}
    	catch (bytes memory error) { emit UpkeepError("Step 3", error); }
    
     	try this.step4() {}
    	catch (bytes memory error) { emit UpkeepError("Step 4", error); }
    
     	try this.step5() {}
    	catch (bytes memory error) { emit UpkeepError("Step 5", error); }
    
     	try this.step6() {}
    	catch (bytes memory error) { emit UpkeepError("Step 6", error); }
    
     	try this.step7() {}
    	catch (bytes memory error) { emit UpkeepError("Step 7", error); }
    
     	try this.step8() {}
    	catch (bytes memory error) { emit UpkeepError("Step 8", error); }
    
     	try this.step9() {}
    	catch (bytes memory error) { emit UpkeepError("Step 9", error); }
    
     	try this.step10() {}
    	catch (bytes memory error) { emit UpkeepError("Step 10", error); }
    
     	try this.step11() {} // VULNERABILITY: Step can be skipped if insufficient gas is sent due to EIP150 63/64 gas forwarding rule
    	catch (bytes memory error) { emit UpkeepError("Step 11", error); } // Error emitted but function continues, caller still gets incentive
    	}
  • [code4rena/2024-03-taiko] Malicious caller of processMessage() can pocket the fee while forcing excessivelySafeCall() to fail (finding-019.md)

Business Scenario (Taiko Cross-Chain Bridge):

┌─────────────────────────────────────────────────────────────────┐
│                    Taiko L1 ←→ L2 Bridge                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Purpose: Relay messages and assets between L1 and L2          │
│                                                                 │
│  Why do we need Relayers?                                       │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ Problem: User sends message on L1, but cannot directly   │   │
│  │          execute it on L2 themselves                     │   │
│  │                                                          │   │
│  │ Solution: Relayer (task outsourcing model)               │   │
│  │   • Anyone can become a Relayer                          │   │
│  │   • Relayer monitors L1 message events                   │   │
│  │   • Relayer calls processMessage() on L2 to execute      │   │
│  │   • Relayer receives message.fee as compensation         │   │
│  │                                                          │   │
│  │ This is a "task outsourcing" model:                      │   │
│  │   • User prepays fee                                     │   │
│  │   • Whoever executes the message gets the fee            │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  Message Structure:                                             │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ struct Message {                                         │   │
│  │     address to;        // Target address                 │   │
│  │     uint256 value;     // ETH to send                    │   │
│  │     bytes data;        // Call data                      │   │
│  │     uint256 fee;       // Processing fee for Relayer     │   │
│  │     uint256 gasLimit;  // Gas needed for target call     │   │
│  │ }                                                        │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  processMessage() Flow:                                         │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ 1. Verify message is from source chain                   │   │
│  │ 2. Execute the message call via _invokeMessageCall()     │   │
│  │    • If success → status = DONE                          │   │
│  │    • If failure → status = RETRIABLE                     │   │
│  │ 3. Pay fee to Relayer (REGARDLESS of success/failure!)   │   │
│  │    msg.sender.sendEther(_message.fee)  ← Problem here!   │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Design Flaw: Fee Decoupled from Result
┌────────────────────────────────────────────────────────────────┐
│                     Protocol's Assumption                      │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  Assumption: Relayer has incentive to make message succeed     │
│  • If it fails, message becomes RETRIABLE                      │
│  • Other Relayers can retry and get... wait, fee already taken!│
│                                                                │
│  Reality:                                                      │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ Message succeeds → Relayer gets fee ✓                    │  │
│  │ Message fails   → Relayer STILL gets fee ✓  ← Problem!   │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                │
│  Relayer has NO incentive to ensure message succeeds,          │
│  only needs the transaction to complete                        │
└────────────────────────────────────────────────────────────────┘

How EIP-150 Enables the Attack:
┌────────────────────────────────────────────────────────────────┐
│  Attacker calls processMessage() with precisely calculated gas │
│                                                                │
│  Example: Attacker provides ~10,897,060 gas                    │
│        │                                                       │
│        ▼                                                       │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ Inside _invokeMessageCall():                             │  │
│  │                                                          │  │
│  │ excessivelySafeCall(                                     │  │
│  │     _message.to,                                         │  │
│  │     _gasLimit,                                           │  │
│  │     _message.value,                                      │  │
│  │     _message.data                                        │  │
│  │ )                                                        │  │
│  │     │                                                    │  │
│  │     │ EIP-150: Only forwards 63/64 of gas                │  │
│  │     │ 10,897,060 × 63/64 ≈ 10,726,778                    │  │
│  │     │                                                    │  │
│  │     │ If target call needs > 10,726,778 gas              │  │
│  │     ▼                                                    │  │
│  │ OUT OF GAS → returns false                               │  │
│  └──────────────────────────────────────────────────────────┘  │
│        │                                                       │
│        │ Remaining 1/64 ≈ 170,000+ gas                         │
│        │ Enough to execute:                                    │
│        │ • _updateMessageStatus(RETRIABLE)                     │
│        │ • msg.sender.sendEther(fee)                           │
│        ▼                                                       │
│  Transaction succeeds! Attacker gets fee, message not executed │
└────────────────────────────────────────────────────────────────┘

Complete Attack Flow:
┌────────────────────────────────────────────────────────────────┐
│                                                                │
│  User's Perspective:                                           │
│  ┌──────────┐    1. Send message + fee    ┌──────────┐         │
│  │   User   │ ───────────────────────────►│  Bridge  │         │
│  │          │   Expects: message delivered│  (L1)    │         │
│  └──────────┘                             └────┬─────┘         │
│                                                │               │
│                                                │ 2. Event      │
│                                                │    emitted    │
│                                                ▼               │
│                                           ┌──────────┐         │
│                                           │ Attacker │         │
│                                           │ (Relayer)│         │
│                                           └────┬─────┘         │
│                                                │               │
│       3. Call processMessage()                 │               │
│          with crafted gas limit                │               │
│                                                ▼               │
│                                           ┌──────────┐         │
│                                           │  Bridge  │         │
│                                           │  (L2)    │         │
│                                           └────┬─────┘         │
│                                                │               │
│          ┌─────────────────────────────────────┼───────────────┤
│          │                                     │               │
│          ▼                                     ▼               │
│    excessivelySafeCall()              Status Update            │
│    (target call)                      RETRIABLE                │
│          │                                     │               │
│          │ OOG failure                         │               │
│          ▼                                     ▼               │
│    Message NOT executed ✗              Fee paid to attacker ✓  │
│                                                                │
│  Results:                                                      │
│  • Attacker: Gets fee, saves gas by not executing the message  │
│  • User: Message stuck in RETRIABLE, must pay again to retry   │
│  • Protocol: Trust broken, bridge fees drained                 │
└────────────────────────────────────────────────────────────────┘

Code Flow Detail:
┌────────────────────────────────────────────────────────────────┐
│  processMessage(_message, _proof)                              │
│        │                                                       │
│        ▼                                                       │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ success = _invokeMessageCall(_message, hash, gasLimit)   │  │
│  │     │                                                    │  │
│  │     │ Inside _invokeMessageCall:                         │  │
│  │     ▼                                                    │  │
│  │ excessivelySafeCall(                                     │  │
│  │     _message.to,                                         │  │
│  │     _gasLimit,        ◄── User specified gas limit       │  │
│  │     _message.value,                                      │  │
│  │     _message.data                                        │  │
│  │ )                                                        │  │
│  │     │                                                    │  │
│  │     │ EIP-150 applies here!                              │  │
│  │     │ Only 63/64 of remaining gas forwarded              │  │
│  │     ▼                                                    │  │
│  │ If remaining × 63/64 < required → OUT OF GAS             │  │
│  └──────────────────────────────────────────────────────────┘  │
│        │                                                       │
│        ▼                                                       │
│  if (success) status = DONE                                    │
│  else status = RETRIABLE  ◄── Message fails but tx succeeds   │
│        │                                                       │
│        ▼                                                       │
│  msg.sender.sendEther(_message.fee)  ◄── Relayer paid anyway! │
└────────────────────────────────────────────────────────────────┘
  • packages/protocol/contracts/bridge/Bridge.sol

        if (_invokeMessageCall(_message, msgHash, gasLimit)) {
            _updateMessageStatus(msgHash, Status.DONE);
        } else {
            _updateMessageStatus(msgHash, Status.RETRIABLE); // VULNERABILITY: Message fails but fee is still awarded
        }
    
            // Refund the processing fee
            if (msg.sender == refundTo) {
                refundTo.sendEther(_message.fee + refundAmount);
            } else {
                // If sender is another address, reward it and refund the rest
                msg.sender.sendEther(_message.fee); // VULNERABILITY: Fee awarded even when call fails
                refundTo.sendEther(refundAmount);
            }
    
    (success_,) = ExcessivelySafeCall.excessivelySafeCall(
        _message.to,
        _gasLimit,
        _message.value,
        64, // return max 64 bytes
        _message.data
    ); // VULNERABILITY: Only 63/64 of gas is forwarded, enabling manipulation

EIP-150 Audit Checklist

High-Risk Scenarios: Protocols using Keeper/Relayer/Bot to execute transactions on behalf of users (order execution, bridge relay, protocol upkeep).

# Check Item Related Pattern
Is gasleft() used across external calls for gas accounting? Check if startGas - gasleft() calculation spans external calls Pattern 1
Can L2 operations exceed L1 reproducibility limits? For rollups, verify precompile calls can be replayed on L1 considering 63/64 overhead Pattern 2
Does try/catch handle out-of-gas differently from logic failures? Ensure empty catch blocks don’t treat OOG as verification failure Pattern 3
Are rewards/fees decoupled from success? Verify incentives align with desired behavior (fee should depend on success) Pattern 3
Can attackers control gas to manipulate outcomes? Check if precise gas limits can cause selective failures Pattern 3