Skip to content
On this page

Unveiling the Phantom: How ABI Smuggling Emptied a Million-Dollar Vault

Imagine a fortress of digital assets, guarded by a sophisticated authorization system, holding a cool 1,000,000 DVT tokens. This is the SelfAuthorizedVault from Damn Vulnerable DeFi, a contract designed to allow only specific, pre-approved actions by known accounts. Yet, a recent "responsible disclosure" revealed a critical flaw: all funds could be stolen. This is the story of ABI Smuggling, an ingenious attack that leverages subtle differences in how a contract interprets its own call data.

The Fortified Vault: A Closer Look

Our target, the SelfAuthorizedVault, inherits from AuthorizedExecutor. This executor contract is the gatekeeper, featuring a crucial execute function:

solidity
function execute(address target, bytes calldata actionData) external nonReentrant returns (bytes memory) {
    // Read the 4-bytes selector at the beginning of `actionData`
    bytes4 selector;
    uint256 calldataOffset = 4 + 32 * 3; // calldata position where `actionData` begins
    assembly {
        selector := calldataload(calldataOffset)
    }

    if (!permissions[getActionId(selector, msg.sender, target)]) {
        revert NotAllowed();
    }

    _beforeFunctionCall(target, actionData);

    return target.functionCall(actionData);
}

Here's how the AuthorizedExecutor thinks it's securing the vault:

  1. Permissioned Actions: Before any action, execute derives an actionId using the function selector, the msg.sender (the external caller), and the target contract. It checks if this actionId is in its permissions mapping.
  2. Internal Target Check: The SelfAuthorizedVault further restricts actions via its _beforeFunctionCall override, ensuring that the target must always be the vault itself (target == address(this)).
  3. Self-Authorization: Crucially, sensitive functions like sweepFunds (which empties the entire vault) and withdraw are protected by an onlyThis modifier. This modifier ensures that msg.sender for that specific function call must be the vault contract itself (msg.sender == address(this)). This is a common pattern to prevent external accounts from directly calling powerful internal functions.

At first glance, it seems robust. An external attacker calling sweepFunds would fail the onlyThis check. An external attacker trying to execute sweepFunds via the AuthorizedExecutor would fail the permissions check, as they aren't authorized to call sweepFunds directly.

The Chink in the Armor: The ABI Smuggling Trick

The vulnerability lies in a subtle yet critical detail within the execute function, specifically how it determines the selector for permissioning versus how target.functionCall ultimately executes the call.

Notice this line: uint256 calldataOffset = 4 + 32 * 3; // calldata position where actionData beginsassembly { selector := calldataload(calldataOffset) }

This assembly instruction reads the 4-byte function selector for the permission check not from the beginning of the actionData parameter (which would be calldataload(4) if actionData were a simple bytes argument at the start of the calldata), but from a fixed offset within the actionData parameter itself.

Let's break down 4 + 32 * 3:

  • 4: Bytes for the execute function selector itself (0x85fb709d).
  • 32 * 3:
    • 32: Bytes for the target address argument.
    • 32: Bytes for the offset to the actionData dynamic byte array.
    • 32: Bytes for the length of the actionData byte array.

So, calldataOffset refers to the position 100 bytes into the entire transaction calldata. The execute function expects the permissioned selector to be found at this exact offset.

However, the line return target.functionCall(actionData); passes the original actionData parameter directly to the target. When functionCall processes actionData, it always assumes the actual 4-byte function selector is at the very beginning (byte 0 relative to the actionData buffer).

This creates a discrepancy:

  • For permissioning: execute looks for a specific selector at byte 100 of the entire transaction calldata.
  • For execution: target.functionCall uses the selector at byte 4 of the entire transaction calldata (which is byte 0 of the actionData parameter).

The Attack: Crafting the Smuggled Payload

The attacker's goal is to call sweepFunds(recovery, token) but make the AuthorizedExecutor think it's calling an allowed function for which the player has permission.

The initial setup gives the player permission for d9caed12, which is the sweepFunds selector (bytes4(keccak256("sweepFunds(address,IERC20)"))). This is key! The player is authorized for sweepFunds if msg.sender is player and target is vault.

Here's how the manualData (the full msg.data for the external call) is crafted:

  1. AuthorizedExecutor.execute.selector: This is the initial 4 bytes, indicating we are calling the execute function on the vault.
  2. target (the vault's address): The next 32 bytes represent the target parameter, which is the SelfAuthorizedVault itself. This satisfies _beforeFunctionCall's target == address(this) check.
  3. The Smuggled actionData: This is where the magic happens. The actionData parameter is carefully constructed:
    • It begins with enough padding (zeros) to push the actual sweepFunds selector (d9caed12) to the position that calldataload(calldataOffset) (byte 100 overall) will inspect.
    • Simultaneously, the true sweepFunds selector (for execution) and its parameters (recovery address, token address) are placed at the beginning of this actionData data itself.

Let's visualize the manualData (simplified for clarity):

[execute() selector]
[vault address (target)]
[offset to actionData]    // This is where `actionData` theoretically "starts" within calldata
[length of actionData]

[start of actionData (relative to calldata)]
    [PADDING (to reach byte 100 from overall calldata start)]
    [d9caed12 (sweepFunds selector - for permission check)] // <- THIS is read by `calldataload(calldataOffset)`
    [MORE PADDING]
    [d9caed12 (sweepFunds selector - for actual execution by functionCall)] // <- THIS is read by `target.functionCall`
    [recovery address]
    [token address]
    [ETC]

This means:

  • When execute checks permissions, it finds d9caed12 at the expected offset, sees that player is authorized for sweepFunds on vault, and allows the transaction.
  • When target.functionCall(actionData) is executed, it uses the actual actionData provided. The sweepFunds selector and its parameters are correctly positioned at the beginning of this actionData.
  • Finally, the onlyThis modifier on sweepFunds is satisfied because the sweepFunds call is initiated internally by the vault itself (msg.sender == address(this)), not by the external player.

The vault, tricked by the smuggled actionData, proceeds to call its own sweepFunds function, transferring all 1,000,000 DVT tokens to the recovery account!

Key Takeaways for Secure Smart Contract Development

This ABI Smuggling attack highlights several critical lessons:

  1. Calldata Interpretation is Crucial: Be extremely careful when using calldataload with hardcoded offsets, especially in conjunction with target.functionCall. Any discrepancy in how calldata is parsed for different purposes (e.g., authorization vs. execution) can lead to vulnerabilities.
  2. bytes vs. bytes calldata: Understanding how dynamic arrays like bytes calldata are laid out in calldata (with offset and length pointers) is essential. The calldataOffset calculation needs to consider these intricacies.
  3. msg.data vs. Function Arguments: The msg.data of a transaction contains the full encoded call. When you call target.functionCall(actionData), the actionData parameter itself becomes the msg.data for the internal call.
  4. Test with Realistic Calldata: Complex authorization schemes involving manual calldata parsing should be thoroughly tested with various malformed inputs to ensure consistent behavior across all internal and external calls.

ABI Smuggling is a powerful reminder that even seemingly robust authorization mechanisms can be bypassed if the underlying data interpretation is inconsistent. It's a testament to the creativity of security researchers and the ever-evolving landscape of blockchain security.

Built with AiAda