Appearance
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:
- Permissioned Actions: Before any action,
executederives anactionIdusing the functionselector, themsg.sender(the external caller), and thetargetcontract. It checks if thisactionIdis in itspermissionsmapping. - Internal Target Check: The
SelfAuthorizedVaultfurther restricts actions via its_beforeFunctionCalloverride, ensuring that thetargetmust always be the vault itself (target == address(this)). - Self-Authorization: Crucially, sensitive functions like
sweepFunds(which empties the entire vault) andwithdraware protected by anonlyThismodifier. This modifier ensures thatmsg.senderfor 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 theexecutefunction selector itself (0x85fb709d).32 * 3:32: Bytes for thetargetaddress argument.32: Bytes for the offset to theactionDatadynamic byte array.32: Bytes for the length of theactionDatabyte 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:
executelooks for a specific selector at byte 100 of the entire transaction calldata. - For execution:
target.functionCalluses the selector at byte 4 of the entire transaction calldata (which is byte 0 of theactionDataparameter).
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:
AuthorizedExecutor.execute.selector: This is the initial 4 bytes, indicating we are calling theexecutefunction on the vault.target(the vault's address): The next 32 bytes represent thetargetparameter, which is theSelfAuthorizedVaultitself. This satisfies_beforeFunctionCall'starget == address(this)check.- The Smuggled
actionData: This is where the magic happens. TheactionDataparameter is carefully constructed:- It begins with enough padding (zeros) to push the actual
sweepFundsselector (d9caed12) to the position thatcalldataload(calldataOffset)(byte 100 overall) will inspect. - Simultaneously, the true
sweepFundsselector (for execution) and its parameters (recoveryaddress,tokenaddress) are placed at the beginning of thisactionDatadata itself.
- It begins with enough padding (zeros) to push the actual
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
executechecks permissions, it findsd9caed12at the expected offset, sees thatplayeris authorized forsweepFundsonvault, and allows the transaction. - When
target.functionCall(actionData)is executed, it uses the actualactionDataprovided. ThesweepFundsselector and its parameters are correctly positioned at the beginning of thisactionData. - Finally, the
onlyThismodifier onsweepFundsis satisfied because thesweepFundscall is initiated internally by the vault itself (msg.sender == address(this)), not by the externalplayer.
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:
- Calldata Interpretation is Crucial: Be extremely careful when using
calldataloadwith hardcoded offsets, especially in conjunction withtarget.functionCall. Any discrepancy in how calldata is parsed for different purposes (e.g., authorization vs. execution) can lead to vulnerabilities. bytesvs.bytes calldata: Understanding how dynamic arrays likebytes calldataare laid out in calldata (with offset and length pointers) is essential. ThecalldataOffsetcalculation needs to consider these intricacies.msg.datavs. Function Arguments: Themsg.dataof a transaction contains the full encoded call. When you calltarget.functionCall(actionData), theactionDataparameter itself becomes themsg.datafor the internal call.- 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.