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:
MirrorProxyis the proxy that actually holds the state.IllusionHouseis the implementation.Setupdeploys 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:
- A fresh
IllusionHouseimplementation (the default, v2 ABI). - A
MirrorProxythat points to that implementation. - The proxy constructor calls
initialize(address)viadelegatecallso
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
patronis passed as a normal argument and must already beRole.Curator.- The proxy sets
roles[address(this)] = Role.Curatorduring initialization, sopatron = housealways works.
- The proxy sets
- The raw patron word must carry a non-zero 96-bit tag in the upper bytes.
- This is only accepted by ABI coder v1.
sigilis a dynamicbytesargument that must be 32 bytes and hash toSIGIL_HASH.- The top 96 bits of
sigilWordbecome a hiddenrankvalue. - If
rank > 0, the caller becomes masked and can callappointCurator.
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.datastarts at 0x20 + 0x20 = 0x40 (word 2)keccak256(sigil)equalsSIGIL_HASHbecause 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 + 96enforces 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
patronis valid.
msg.data[36:68]is the offset word. It must be0x20.
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
0x20points 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_PREIMAGEthere, sokeccak256(sigil) == SIGIL_HASH.
5. Full exploit flow
-
Deploy the v1 implementation (same code, with
pragma abicoder v1;). -
Call
MirrorProxy(house).reframe(address(newImplementation))once. -
Call
admit(address,bytes)with the overlap-encoded calldata. -
maskRank[msg.sender]becomes non-zero from the sigil word. -
Call
appointCurator(visitor)to promote the visitor toRole.Curator.The
isSolvedcheck only requires that the visitor isRole.Curator.
6. Solver walkthrough (script/HouseOfIllusions.s.sol)
The solver does exactly the manual steps above:
- 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
);
- 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:
reframeenables ABI v1 decoding.datais the non-canonical ABI layout that only v1 accepts.appointCuratorsucceeds becausemaskRank > 0after admission.