Skip to content

checkImplementationCode admits an EIP-7702 delegated EOA, whose code the account holder can repoint or revoke after clones exist #73

Description

@thedavidmeister

Found by an adversarial mutation-test pass over LibICloneableFactoryV4
(group g2-libicloneablefactoryv4-predi), scanned at
c1c2afd. Filed for triage, not adjudicated.

Unit

LibICloneableFactoryV4.checkImplementationCode,
src/lib/LibICloneableFactoryV4.sol:130-134.

function checkImplementationCode(address implementation) internal view {
    if (implementation.code.length == 0) {
        revert ZeroImplementationCodeSize();
    }
}

Intent oracle

  • ZeroImplementationCodeSize NatSpec: "Thrown when an implementation has zero
    code size which is always a mistake: an EIP-1167 proxy of a codeless
    implementation delegates every call — initialize included — to nothing."
  • ICloneableFactoryV4.predictDeterministicAddressOpenSalt: "A non-zero code
    size at the returned address means this exact (implementation, data, salt)
    has already been deployed by somebody … what occupies it is the clone that
    was asked for, initialized with the bytes that were asked for
    ."

Violated property

An EOA carrying an EIP-7702 delegation designator has EXTCODESIZE == 23 — the
23 bytes are 0xef0100 ++ delegate, not implementation code — so it passes the
guard, and a clone of it works: the EVM resolves the designator on
DELEGATECALL, so initialize runs the delegate's code against the clone's
storage.

That is a category the guard's stated rationale does not cover. A contract's
code is immutable for the life of the account; a delegation designator is not.
The EOA holder can sign a new authorization pointing at different code, or
revoke to empty, at any time and after clones already exist. The state the
guard exists to prevent — "delegates every call to nothing" — is reachable
after the guard has run, on every clone ever made against that implementation,
by a party who is not the deployer and not the clone's users.

It also touches the open-salt argument quoted above. That argument holds
(factory, implementation, salt, data) fixed and concludes that whatever
occupies the predicted address is the intended contract initialized with the
intended bytes. With a delegated EOA as implementation the address still
commits to those four things, but what the clone does is whatever the EOA
holder last delegated to, which is outside the tuple, and can change after the
address is pinned. Two callers reaching "the same" open-salt clone at different
times can be initialized by different code.

Verified repro

test/src/lib/Probe7702.t.sol, run with forge test --match-contract Probe7702 -vv:

// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity =0.8.25;

import {Test} from "forge-std-1.16.1/src/Test.sol";
import {LibICloneableFactoryV4} from "src/lib/LibICloneableFactoryV4.sol";
import {TestCloneFactory} from "test/src/concrete/TestCloneFactory.sol";
import {TestCloneable} from "test/src/concrete/TestCloneable.sol";

contract Probe7702Test is Test {
    function testProbe7702() external {
        TestCloneFactory factory = new TestCloneFactory();
        TestCloneable delegate = new TestCloneable();
        address eoa = address(uint160(uint256(keccak256("eoa"))));
        // The EIP-7702 delegation designator an authorization installs.
        vm.etch(eoa, abi.encodePacked(hex"ef0100", delegate));

        emit log_named_uint("designator code length", eoa.code.length);
        LibICloneableFactoryV4.checkImplementationCode(eoa);
        emit log_string("guard PASSED for the 7702 designator");

        address child = factory.cloneDeterministic(eoa, hex"1234", bytes32(0));
        emit log_named_address("clone of a 7702 EOA deployed", child);
        emit log_named_bytes("sData", TestCloneable(child).sData());
    }
}

Observed:

  designator code length: 23
  guard PASSED for the 7702 designator
  clone of a 7702 EOA deployed: 0xb2606d421b070a17d125b143C11B72879337DB24
  sData: 0x1234

Triage framing

Readings, none of them chosen here:

  1. Out of the guard's remit, docs need a line. The guard is deliberately
    size-only ("the guard validates SIZE, not content"), and no EXTCODESIZE
    check can bind an account's future code — a self-destructing implementation
    pre-Cancun raised the same question. On this reading nothing in src/
    changes and the ZeroImplementationCodeSize NatSpec gains a sentence saying
    that a non-zero code size is checked at deploy time only, and that a
    delegated EOA is accepted with mutable, revocable behaviour.
  2. Cheap guard worth having. Rejecting an implementation whose code begins
    with 0xef01 and is exactly 23 bytes long is three opcodes' worth of check
    and would keep the "clone delegates to real, immutable code" property the
    error's rationale asserts. Costs bytecode on the deploy path and rules out a
    use somebody may want deliberately.
  3. Consumer-side note only. The address-derivation guarantees on
    ICloneableFactoryV4 may want an explicit "the implementation must be a
    contract, not a delegated EOA, for the pinned-address argument to mean what
    it says".

Not adjudicated: whether a delegated EOA is a legitimate implementation is a
protocol decision, not something the test pass can settle.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    adversarialFound by adversarial reviewauditAudit finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions