Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,19 @@ against `METAMORPHIC_OPS` and returns the risky opcodes that are reachable.
`checkNotMetamorphic` reverts with `Metamorphic(riskyOpcodes)` when that result
is non-zero.

Bytecode whose first byte is `0xEF` fails closed: `scanMetamorphicRisk` reports
a bitmap of exactly `1 << 0xEF` without scanning, and `checkNotMetamorphic`
therefore reverts. EIP-3541 reserves that lead byte for protocol features — an
EOF container (`0xEF00`), an EIP-7702 delegation designator
(`0xEF0100 || address`, which the account holder repoints or revokes with one
transaction), or whatever the prefix is assigned next — that a legacy opcode
scan cannot reason about; pre-London deployments and chains without EIP-3541 can
hold `0xEF`-lead legacy code indistinguishable by inspection. The scan keys on
the first byte alone and refuses to vouch rather than misread the bytes as
opcodes. This makes the metamorphic pair total over bytes: it is the one place
that answers the reserved prefix with a verdict, while the raw opcode scans keep
reverting `EOFBytecodeNotSupported` on the `0xEF00` EOF magic.

Both functions also have address-taking entry points that read the account's
code, revert with `CodelessAccount` when there is none, and delegate to the
bytes functions otherwise. An account with no code is the maximally metamorphic
Expand Down
19 changes: 13 additions & 6 deletions src/interface/IExtrospectV1.sol
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,13 @@ interface IExtrospectV1 {
/// @param bytecode The bytecode to check.
function checkNotEOFBytecode(bytes memory bytecode) external pure;

/// @notice Reverts `Metamorphic(riskyOpcodes)` when any metamorphic risk
/// opcode is reachable in `bytecode`, carrying the same bitmap
/// `scanMetamorphicRisk` returns. Reverts `EOFBytecodeNotSupported` when
/// `bytecode` is EOF. Returns nothing when that bitmap is zero.
/// @notice Reverts `Metamorphic(riskyOpcodes)` when `scanMetamorphicRisk`
/// reports a non-zero bitmap for `bytecode`, carrying that bitmap: any
/// reachable metamorphic risk opcode, or a first byte of the EIP-3541
/// reserved `0xEF` (an EOF container, an EIP-7702 delegation designator,
/// or any future assignment of the prefix), which reverts
/// `Metamorphic(1 << 0xEF)`. Never reverts `EOFBytecodeNotSupported`.
/// Returns nothing when that bitmap is zero.
/// @dev See `LibExtrospectMetamorphic.checkNotMetamorphic`.
/// @param bytecode The bytecode to check.
function checkNotMetamorphic(bytes memory bytecode) external pure;
Expand Down Expand Up @@ -127,11 +130,15 @@ interface IExtrospectV1 {
/// @notice Bitmap of the metamorphic risk opcodes
/// (`SELFDESTRUCT`, `DELEGATECALL`, `CALLCODE`, `CREATE`, `CREATE2`) that
/// `scanEVMOpcodesReachableInBytecode` finds reachable in `bytecode`.
/// Reverts `EOFBytecodeNotSupported` when `bytecode` is EOF.
/// Bytecode whose first byte is the EIP-3541 reserved `0xEF` — an EOF
/// container, an EIP-7702 delegation designator, or any future
/// assignment of the prefix — fails closed to a bitmap of exactly
/// `1 << 0xEF` instead of being scanned. Never reverts.
/// @dev See `LibExtrospectMetamorphic.scanMetamorphicRisk`.
/// @param bytecode The bytecode to scan.
/// @return A bitmap, not a count or a score: bit `N` is set when
/// metamorphic opcode `N` is reachable. Zero when none are.
/// metamorphic opcode `N` is reachable, and bit `0xEF` alone is set when
/// the first byte is the reserved `0xEF`. Zero when neither holds.
function scanMetamorphicRisk(bytes memory bytecode) external pure returns (uint256);

/// @notice Removes the last 53 bytes of `bytecode` when they are the exact
Expand Down
3 changes: 3 additions & 0 deletions src/lib/EVMOpcodes.sol
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ uint256 constant HALTING_BITMAP = (1 << EVM_OP_STOP) | (1 << EVM_OP_RETURN) | (1
/// non-zero result. Membership is by opcode alone and is not conditioned on
/// whether the bytecode's own code can in fact be replaced, so bytecode that
/// only deploys child contracts is rejected for CREATE or CREATE2.
/// Bytecode whose first byte is the EIP-3541 reserved `0xEF` never reaches
/// this mask: `scanMetamorphicRisk` fails closed on it first and reports
/// `1 << 0xEF`, a bit that is not a member here.
//forge-lint: disable-next-line(incorrect-shift)
uint256 constant METAMORPHIC_OPS = (1 << EVM_OP_SELFDESTRUCT) | (1 << EVM_OP_DELEGATECALL)
//forge-lint: disable-next-line(incorrect-shift)
Expand Down
87 changes: 65 additions & 22 deletions src/lib/LibExtrospectMetamorphic.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,71 @@ pragma solidity ^0.8.25;
import {LibExtrospectBytecode} from "./LibExtrospectBytecode.sol";
import {METAMORPHIC_OPS} from "./EVMOpcodes.sol";

/// @dev The lead byte EIP-3541 reserves: since the London hard fork no
/// ordinary deployment can produce code whose first byte is `0xEF`. On a
/// post-London chain new code beginning with it comes from protocol
/// features — an EOF container (`0xEF00`), an EIP-7702 delegation
/// designator (`0xEF0100 || address`), or whatever the prefix is assigned
/// next — none of which a legacy opcode scan can reason about; pre-London
/// deployments and chains without EIP-3541 can carry it as plain legacy
/// code the scan cannot tell apart by inspection. The metamorphic scan
/// fails closed on the first byte alone.
uint8 constant EIP3541_RESERVED_LEAD_BYTE = 0xEF;

/// @title LibExtrospectMetamorphic
/// @notice Detection and guarding against metamorphic contract risk. Scans
/// bytecode for reachable opcodes in `METAMORPHIC_OPS`. The bytes-taking
/// entry points are total over bytes and answer only about the bytes given;
/// the address-taking entry points bind the verdict to an account and revert
/// with `CodelessAccount` rather than vouch for an account that has no code.
/// bytecode for reachable opcodes in `METAMORPHIC_OPS`, failing closed on
/// bytecode whose first byte is the EIP-3541 reserved `0xEF`: such code is a
/// protocol-defined format, not legacy opcodes, and the scan reports the
/// `0xEF` byte itself as the risky element rather than vouch for bytes it
/// cannot disassemble. The bytes-taking entry points are total over bytes and
/// answer only about the bytes given; the address-taking entry points bind
/// the verdict to an account and revert with `CodelessAccount` rather than
/// vouch for an account that has no code.
library LibExtrospectMetamorphic {
/// Thrown when metamorphic risk opcodes are reachable in bytecode.
/// @param riskyOpcodes Bitmap of reachable metamorphic opcodes.
error Metamorphic(uint256 riskyOpcodes);

/// Scans bytecode for reachable metamorphic risk opcodes. Reverts with
/// `EOFBytecodeNotSupported` if the bytecode is EOF.
/// Answers only about the bytes given. Use `scanMetamorphicRisk(address)`
/// to bind the scan to an account and reject a codeless one.
/// Scans bytecode for reachable metamorphic risk opcodes. Total over
/// bytes: never reverts, and answers only about the bytes given. Use
/// `scanMetamorphicRisk(address)` to bind the scan to an account and
/// reject a codeless one.
///
/// Bytecode whose first byte is the EIP-3541 reserved `0xEF` fails
/// closed: the scan reports a bitmap of exactly `1 << 0xEF` before any
/// opcode scan, whatever the remaining bytes are. EIP-3541 reserves the
/// prefix for protocol features — an EOF container (`0xEF00`), an
/// EIP-7702 delegation designator (`0xEF0100 || address`, which the
/// account holder repoints or revokes with one transaction), or
/// whatever it is assigned next — that a legacy opcode scan cannot
/// reason about; pre-London deployments and chains without EIP-3541
/// can hold `0xEF`-lead legacy code indistinguishable by inspection.
/// The verdict keys on the first byte alone and vouches for none of
/// it. The reported bit
/// is the `0xEF` lead byte itself, not a reachable opcode, and is not a
/// member of `METAMORPHIC_OPS`. The reserved prefix is the one first
/// byte that never reaches `scanEVMOpcodesReachableInBytecode`, so
/// `EOFBytecodeNotSupported` is never thrown here.
/// @param bytecode The bytecode to scan.
/// @return riskyOpcodes Bitmap of reachable metamorphic opcodes. Zero if
/// no metamorphic risk opcodes are reachable, including when `bytecode` is
/// empty.
function scanMetamorphicRisk(bytes memory bytecode) internal pure returns (uint256 riskyOpcodes) {
riskyOpcodes = LibExtrospectBytecode.scanEVMOpcodesReachableInBytecode(bytecode) & METAMORPHIC_OPS;
/// @return Bitmap of risky elements: the reachable metamorphic opcodes,
/// or exactly `1 << 0xEF` when the first byte is the reserved `0xEF`.
/// Zero if no metamorphic risk opcodes are reachable, including when
/// `bytecode` is empty.
function scanMetamorphicRisk(bytes memory bytecode) internal pure returns (uint256) {
if (bytecode.length > 0 && uint8(bytecode[0]) == EIP3541_RESERVED_LEAD_BYTE) {
//forge-lint: disable-next-line(incorrect-shift)
return uint256(1) << EIP3541_RESERVED_LEAD_BYTE;
}
return LibExtrospectBytecode.scanEVMOpcodesReachableInBytecode(bytecode) & METAMORPHIC_OPS;
}

/// Reverts if any metamorphic risk opcodes are reachable in bytecode.
/// Also reverts with `EOFBytecodeNotSupported` if the bytecode is EOF.
/// Reverts with `Metamorphic` when `scanMetamorphicRisk` reports a
/// nonzero bitmap for the bytecode: any reachable metamorphic risk
/// opcode, or a first byte of the EIP-3541 reserved `0xEF` — an EOF
/// container, an EIP-7702 delegation designator, or any future
/// assignment of the prefix — which reverts `Metamorphic(1 << 0xEF)`.
/// Never reverts `EOFBytecodeNotSupported`.
/// Empty bytecode has no reachable metamorphic opcodes and does not revert,
/// so an account with no code — an externally owned account, an unoccupied
/// `CREATE2` target, or a self-destructed account — passes this check on
Expand All @@ -52,12 +92,14 @@ library LibExtrospectMetamorphic {
/// unoccupied `CREATE2` target, a self-destructed account between
/// incarnations, or an EOA that can gain code by EIP-7702 delegation —
/// and the chain cannot distinguish "can never gain code" from "can gain
/// anything", so a zero bitmap for it would vouch for nothing. Reverts
/// with `EOFBytecodeNotSupported` if the account's code is EOF, as the
/// bytes scan does.
/// anything", so a zero bitmap for it would vouch for nothing. Account
/// code whose first byte is the EIP-3541 reserved `0xEF` — an EOF
/// container or an EIP-7702 delegation designator — reports exactly
/// `1 << 0xEF`, as the bytes scan does.
/// @param account The account whose code to scan.
/// @return riskyOpcodes Bitmap of reachable metamorphic opcodes in the
/// account's code. Zero if none are reachable.
/// @return Bitmap of risky elements in the account's code: the reachable
/// metamorphic opcodes, or exactly `1 << 0xEF` when the code's first
/// byte is the reserved `0xEF`. Zero if none are reachable.
function scanMetamorphicRisk(address account) internal view returns (uint256) {
bytes memory bytecode = account.code;
if (bytecode.length == 0) {
Expand All @@ -71,8 +113,9 @@ library LibExtrospectMetamorphic {
/// `scanMetamorphicRisk(address)`. Reverts with `CodelessAccount`
/// carrying the address when the account has no code, on the same terms
/// as `scanMetamorphicRisk(address)`: no absence check answers "no code"
/// as a pass. Reverts with `EOFBytecodeNotSupported` if the account's
/// code is EOF.
/// as a pass. Reverts with `Metamorphic(1 << 0xEF)` when the account's
/// code begins with the EIP-3541 reserved `0xEF` — an EOF container or
/// an EIP-7702 delegation designator — as the bytes check does.
/// @param account The account whose code to check.
function checkNotMetamorphic(address account) internal view {
uint256 riskyOpcodes = scanMetamorphicRisk(account);
Expand Down
17 changes: 16 additions & 1 deletion test/lib/LibExtrospectionSlow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ uint256 constant SLOW_HALTING_BITMAP =
//forge-lint: disable-next-line(incorrect-shift)
| (uint256(1) << 0xFF);

/// @dev The lead byte EIP-3541 reserves: since the London hard fork no
/// ordinary deployment can produce code whose first byte is `0xEF`. New
/// post-London code beginning with it comes from a protocol feature — an
/// EOF container, an EIP-7702 delegation designator, or whatever the
/// prefix is assigned next — while pre-London deployments and chains
/// without EIP-3541 can carry it as plain legacy code; the slow scan
/// mirrors the first-byte-only fail-closed rule.
uint8 constant SLOW_EIP3541_LEAD_BYTE = 0xEF;

/// @dev Opcode bytes that indicate metamorphic risk: `SELFDESTRUCT`,
/// `DELEGATECALL`, `CALLCODE`, `CREATE`, `CREATE2`.
uint256 constant SLOW_METAMORPHIC_BITMAP =
Expand Down Expand Up @@ -131,8 +140,14 @@ library LibExtrospectionSlow {
return scan;
}

/// KISS implementation of metamorphic risk scan.
/// KISS implementation of metamorphic risk scan. Fails closed on the
/// EIP-3541 reserved prefix: bytecode whose first byte is `0xEF` reports
/// that byte's bit alone, before any opcode scan.
function scanMetamorphicRiskSlow(bytes memory data) internal pure returns (uint256) {
if (data.length > 0 && uint8(data[0]) == SLOW_EIP3541_LEAD_BYTE) {
//forge-lint: disable-next-line(incorrect-shift)
return uint256(1) << SLOW_EIP3541_LEAD_BYTE;
}
return scanEVMOpcodesReachableInBytecodeSlow(data) & SLOW_METAMORPHIC_BITMAP;
}

Expand Down
60 changes: 47 additions & 13 deletions test/src/lib/LibExtrospectMetamorphic.checkNotMetamorphic.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,33 @@ contract LibExtrospectMetamorphicCheckNotMetamorphicTest is Test {
this.checkNotMetamorphicExternal(code);
}

/// EOF bytecode reverts with EOFBytecodeNotSupported.
function testCheckNotMetamorphicRevertsOnEOF() external {
vm.expectRevert(LibExtrospectBytecode.EOFBytecodeNotSupported.selector);
/// EOF bytecode reverts with `Metamorphic` carrying the EIP-3541
/// reserved `0xEF` lead byte's bit, not `EOFBytecodeNotSupported`.
function testCheckNotMetamorphicRevertsOnEOFReservedPrefix() external {
//forge-lint: disable-next-line(incorrect-shift)
vm.expectRevert(abi.encodeWithSelector(LibExtrospectMetamorphic.Metamorphic.selector, uint256(1) << 0xEF));
this.checkNotMetamorphicExternal(hex"EF00010203");
}

/// The EIP-7702 delegation designator `0xEF0100 || address` reverts with
/// `Metamorphic` carrying the EIP-3541 reserved `0xEF` lead byte's bit.
/// Inverts the repro on #54, which pinned a clean pass.
function testCheckNotMetamorphicRevertsOnEIP7702DelegationDesignator() external {
bytes memory designator = abi.encodePacked(hex"ef0100", address(0x1234567890123456789012345678901234567890));
//forge-lint: disable-next-line(incorrect-shift)
vm.expectRevert(abi.encodeWithSelector(LibExtrospectMetamorphic.Metamorphic.selector, uint256(1) << 0xEF));
this.checkNotMetamorphicExternal(designator);
}

/// Fuzz: ANY bytecode whose first byte is `0xEF` reverts with
/// `Metamorphic(1 << 0xEF)`, whatever follows the first byte.
function testCheckNotMetamorphicReservedPrefixFuzz(bytes memory tail) external {
bytes memory bytecode = abi.encodePacked(hex"ef", tail);
//forge-lint: disable-next-line(incorrect-shift)
vm.expectRevert(abi.encodeWithSelector(LibExtrospectMetamorphic.Metamorphic.selector, uint256(1) << 0xEF));
this.checkNotMetamorphicExternal(bytecode);
}

/// Address entry point: a codeless account reverts with `CodelessAccount`
/// carrying the address. The bytes entry point passes the same account's
/// (empty) code, pinned by `testCheckNotMetamorphicCodelessAccount`; only
Expand Down Expand Up @@ -155,20 +176,34 @@ contract LibExtrospectMetamorphicCheckNotMetamorphicTest is Test {
}

/// Address entry point: EOF code etched onto an account reverts with
/// `EOFBytecodeNotSupported` via delegation to the bytes entry point.
function testCheckNotMetamorphicAddressRevertsOnEOF() external {
/// `Metamorphic` carrying the EIP-3541 reserved `0xEF` lead byte's bit,
/// via delegation to the bytes entry point.
function testCheckNotMetamorphicAddressRevertsOnEOFReservedPrefix() external {
address target = address(0xBEEF);
vm.etch(target, hex"EF00010203");
vm.expectRevert(LibExtrospectBytecode.EOFBytecodeNotSupported.selector);
//forge-lint: disable-next-line(incorrect-shift)
vm.expectRevert(abi.encodeWithSelector(LibExtrospectMetamorphic.Metamorphic.selector, uint256(1) << 0xEF));
this.checkNotMetamorphicAddressExternal(target);
}

/// Fuzz: for an account with nonempty non-EOF code, the address entry
/// Address entry point: an account whose code is an EIP-7702 delegation
/// designator reverts with `Metamorphic` carrying the EIP-3541 reserved
/// `0xEF` lead byte's bit, via delegation to the bytes entry point.
function testCheckNotMetamorphicAddressRevertsOnEIP7702DelegationDesignator() external {
address target = address(0xBEEF);
vm.etch(target, abi.encodePacked(hex"ef0100", address(0x1234567890123456789012345678901234567890)));
//forge-lint: disable-next-line(incorrect-shift)
vm.expectRevert(abi.encodeWithSelector(LibExtrospectMetamorphic.Metamorphic.selector, uint256(1) << 0xEF));
this.checkNotMetamorphicAddressExternal(target);
}

/// Fuzz: for an account with nonempty etchable code, the address entry
/// point and the bytes entry point agree: both revert with the same
/// `Metamorphic` bitmap or both pass.
/// `Metamorphic` bitmap or both pass. EOF code and EIP-7702 delegation
/// designators are included: both entry points revert `Metamorphic` with
/// `1 << 0xEF` for them.
function testCheckNotMetamorphicAddressEquivalenceFuzz(bytes memory code) external {
vm.assume(code.length > 0);
vm.assume(!LibExtrospectBytecode.isEOFBytecode(code));
address target = address(0xBEEF);
LibExtrospectTestEtch.assumeEtch(vm, target, code);

Expand All @@ -179,11 +214,10 @@ contract LibExtrospectMetamorphicCheckNotMetamorphicTest is Test {
this.checkNotMetamorphicAddressExternal(target);
}

/// Fuzz: checkNotMetamorphic reverts iff scanMetamorphicRisk is non-zero.
/// Fuzz: checkNotMetamorphic reverts iff scanMetamorphicRisk is
/// non-zero, over ALL bytes: the scan is total and the check adds only
/// the revert.
function testCheckNotMetamorphicFuzz(bytes memory data) external {
// Skip EOF bytecode — both functions revert with a different error.
vm.assume(data.length < 2 || data[0] != 0xEF || data[1] != 0x00);

uint256 risk = LibExtrospectMetamorphic.scanMetamorphicRisk(data);
if (risk != 0) {
vm.expectRevert(abi.encodeWithSelector(LibExtrospectMetamorphic.Metamorphic.selector, risk));
Expand Down
Loading
Loading