Skip to content

Uninitialized immutable internal fn pointer burns all gas #16728

@researchzero-sec

Description

@researchzero-sec

Description

Calling an uninitialized internal function pointer produces three different failure shapes depending purely on the pointer's storage class:

Pointer storage class Failure shape returndata gas
STORAGE internal fn ptr Panic(0x51) 0x24 bytes ~normal
LOCAL internal fn ptr Panic(0x51) 0x24 bytes ~normal
EXTERNAL fn ptr (uninit/deleted) bare revert (extcodesize) 0 cheap
IMMUTABLE internal fn ptr bare revert (INVALID JUMP) 0 ALL gas burned

For a storage or local internal function pointer, codegen emits a zero-pointer guard before the indirect jump, and calling the uninitialized pointer reverts with a typed, catchable Panic(0x51) ("zero-initialized internal function"), 0x24 bytes of returndata, at roughly normal gas cost.

For an immutable internal function pointer that is never assigned in the constructor (which compiles — there is no compile-time check that a constructor assigns every immutable), the placeholder is substituted into the runtime bytecode as 0. The immutable-fn-ptr call path does not emit the Panic(0x51) zero-guard; it performs the indirect JUMP directly. JUMP to offset 0 is an INVALID JUMP, so the EVM consumes all remaining gas and reverts with empty returndata (sub-call gas observed ~1.06e9 when unbounded).

Consequences of this divergence:

  • The failure is uncatchable as a typed Panic: try/catch Panic(uint) will not fire (empty returndata); only catch (bytes) / catch {} sees it.
  • It is an all-gas griefing / DoS primitive: any caller hitting an unset immutable internal fn ptr loses its entire gas budget — the opposite of the cheap external bare-revert.
  • It is a silent refactor footgun: changing a function() internal from a storage variable to immutable (a common "deploy-time constant" optimization) silently changes the uninitialized-call failure from a cheap, catchable Panic(0x51) to an uncatchable all-gas invalid jump, with no warning at compile time.

Expected: calling an uninitialized internal function pointer should produce the documented Panic(0x51) ("zero-initialized internal function") regardless of whether the pointer is stored in storage, a local, or an immutable — i.e. the same catchable, bounded-gas failure shape — and/or the compiler should reject (or warn about) an immutable internal function pointer that is not assigned on every constructor path.

Note: the verified behavior above is the legacy codegen path (Foundry default, via_ir = false). Under --via-ir the same source instead yields a cheap, catchable Panic(0x51) for the immutable case (the IR internalDispatch guard applies uniformly). The storage/local Panic(0x51) case is backend-invariant.

Environment

  • Compiler version: 47b9ded
  • Compilation pipeline (legacy, IR, EOF): legacy (Foundry default, via_ir = false)
  • Target EVM version (as per compiler settings):
  • Framework/IDE (e.g. Foundry, Hardhat, Remix): Foundry (forge)
  • EVM execution environment / backend / blockchain client:
  • Operating system:

Steps to Reproduce

Compile and run with the legacy pipeline (Foundry default, via_ir = false).

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

contract C {
    // Immutable internal function pointer that is NEVER assigned in the
    // constructor. This COMPILES (no compile-time assign-coverage check
    // for immutables) and no warning is emitted.
    function() internal returns (uint) immutable f;

    constructor() {
        // f is intentionally never assigned.
    }

    // Calling f() is a JUMP to the substituted-0 offset => INVALID JUMP
    // => EVM burns ALL remaining gas and bare-reverts with EMPTY returndata.
    // No Panic(0x51) guard is emitted for the immutable representation.
    function callImmutable() external returns (uint) {
        return f();
    }
}

// Contrast: the SAME logical mistake with a storage-class internal fn ptr
// reverts with a typed, catchable Panic(0x51) (0x24 bytes), ~normal gas.
contract D {
    function() internal returns (uint) g; // storage, never assigned

    function callStorage() external returns (uint) {
        return g(); // -> Panic(0x51), 0x24-byte returndata
    }
}

Calling C.callImmutable() reverts with returndata length 0 and consumes all remaining gas (an INVALID JUMP to offset 0), whereas calling D.callStorage() reverts with 0x24 bytes of returndata and panic code 0x51 at roughly normal gas cost — three distinct failure shapes for the same logical mistake (calling an uninitialized internal function pointer), differing purely by the pointer's representation.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions