✅2. Escrow
ABI encoding asymmetry
Codebase walkthrough
Setup Contract
The Setup.sol
contract initializes the challenge by:
Deploying a GREY token contract and minting 10,000 GREY to itself
Creating an EscrowFactory
Adding a DualAssetEscrow implementation to the factory
Deploying an escrow with
tokenX = GREY
andtokenY = address(0)
(ETH)Depositing all 10,000 GREY tokens into the escrow
Renouncing ownership of the escrow by burning the NFT
EscrowFactory Contract
The factory uses a clone-with-immutable-args pattern and implements access control through ERC721 NFTs. Key mechanisms:
The factory prevents deploying escrows with identical (implId, args)
combinations by tracking their keccak256 hash.
DualAssetEscrow Contract
The escrow stores two assets (tokenX
and tokenY
) and uses a unique identifier system:
The escrowId
is computed from a constant identifier and the three addresses. Only the owner (holder of the corresponding NFT) can deposit/withdraw funds.
Bug description
The core vulnerability lies in how the system handles parameter encoding versus decoding:
Encoding Stage (abi.encodePacked):
Decoding Stage (calldataload):
The critical insight is that calldataload
always reads exactly 32 bytes from memory starting at the given offset, regardless of whether enough calldata exists. When reading beyond the calldata boundary, it reads from uninitialized EVM memory, which defaults to zero.
Technical Exploitation Details
Original Escrow Parameters:
args = abi.encodePacked(address(grey), address(0))
Produces:
[20 bytes grey][20 bytes zeros]
= 40 bytes totalFactory hash:
keccak256(abi.encodePacked(0, [40 bytes]))
Attack Escrow Parameters:
args = abi.encodePacked(address(grey), bytes19(0))
Produces:
[20 bytes grey][19 bytes zeros]
= 39 bytes totalFactory hash:
keccak256(abi.encodePacked(0, [39 bytes]))
Why Factory Allows Both: The factory hashes are different because abi.encodePacked
produces different byte sequences (40 vs 39 bytes), so the duplicate check passes.
Why Escrow IDs Are Identical: When the escrow calls _getArgAddress(40)
to read tokenY
:
Original case (40 bytes total):
calldataload(40)
reads bytes 40-71Bytes 40-59: The actual 20 zero bytes from
address(0)
Bytes 60-71: Additional calldata or memory
Attack case (39 bytes total):
calldataload(40)
reads bytes 40-71Bytes 40-58: The actual 19 zero bytes from
bytes19(0)
Byte 59: Beyond calldata boundary → reads from uninitialized memory (zero)
Bytes 60-71: Beyond calldata boundary → reads from uninitialized memory (zeros)
Both result in reading 20 bytes of zeros after the shr(0x60)
operation, producing identical address(0)
values and therefore identical escrow IDs:
EVM Memory Model Behavior
The EVM memory model has the following characteristics:
calldataload(offset)
always reads 32 bytes starting fromoffset
No bounds checking: It doesn't validate that sufficient calldata exists
Uninitialized memory defaults to zero: Reading beyond calldata typically returns zeros
Memory expansion: EVM expands memory as needed and zero-initializes new regions
This creates the vulnerability because:
Encoding:
bytes19(0)
vsaddress(0)
produce different byte sequencesDecoding: Both sequences decode to the same
address(0)
value due to EVM memory model
PoC
Last updated