Christmas Dinner

First Flight #31
Beginner FriendlyFoundrySolidity
100 EXP
View results
Submission Details
Severity: medium
Valid

ETH Transfer Gas Limit Vulnerability in ChristmasDinner.sol

Summary

The ChristmasDinner contract uses .transfer() for ETH refunds, limiting gas to 2300 units. This can permanently lock funds when recipients are smart contract wallets requiring >2300 gas.

Vulnerability Details

src/ChristmasDinner.sol#233-238

function _refundETH(address payable _to) internal {
uint256 refundValue = etherBalance[_to];
_to.transfer(refundValue); // Limits gas to 2300
etherBalance[_to] = 0;
}

Proof of concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.27;
import {Test, console} from "forge-std/Test.sol";
import {ChristmasDinner} from "../src/ChristmasDinner.sol";
import {ERC20Mock} from "../lib/openzeppelin-contracts/contracts/mocks/token/ERC20Mock.sol";
contract GasHungryWallet {
uint256 public counter;
receive() external payable {
// Each storage write costs ~5000 gas
// This ensures we exceed 2300 gas limit
counter += 1;
}
function getGasLeft() external view returns (uint256) {
return gasleft();
}
}
contract ChristmasDinnerETHTest is Test {
ChristmasDinner cd;
ERC20Mock wbtc;
ERC20Mock weth;
ERC20Mock usdc;
GasHungryWallet wallet;
function setUp() public {
wbtc = new ERC20Mock();
weth = new ERC20Mock();
usdc = new ERC20Mock();
cd = new ChristmasDinner(address(wbtc), address(weth), address(usdc));
wallet = new GasHungryWallet();
cd.setDeadline(7 days);
}
function test_ETHTransferGasLimit() public {
// Setup & deposit
vm.deal(address(wallet), 2 ether);
vm.prank(address(wallet));
(bool success,) = address(cd).call{value: 1 ether}("");
assertTrue(success, "Deposit should succeed");
// Verify initial state
assertEq(address(cd).balance, 1 ether);
// Try refund - should fail with out of gas
vm.prank(address(wallet));
vm.expectRevert(bytes(""));// Empty revert data for out of gas
cd.refund();
// Verify ETH remains stuck
assertEq(address(cd).balance, 1 ether);
}
}

Result

forge test --match-test test_ETHTransferGasLimit -vvvv
[⠆] Compiling...
No files changed, compilation skipped
Ran 1 test for test/ChristmasDinnerETHTest.t.sol:ChristmasDinnerETHTest
[PASS] test_ETHTransferGasLimit() (gas: 97339)
Traces:
[97339] ChristmasDinnerETHTest::test_ETHTransferGasLimit()
├─ [0] VM::deal(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 2000000000000000000 [2e18])
│ └─ ← [Return]
├─ [0] VM::prank(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1])
│ └─ ← [Return]
├─ [24234] ChristmasDinner::receive{value: 1000000000000000000}()
│ ├─ emit NewSignup(: GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], : 1000000000000000000 [1e18], : true)
│ └─ ← [Stop]
├─ [0] VM::assertTrue(true, "Deposit should succeed") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(1000000000000000000 [1e18], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
├─ [0] VM::prank(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1])
│ └─ ← [Return]
├─ [0] VM::expectRevert(custom error 0xf28dceb3: )
│ └─ ← [Return]
├─ [52327] ChristmasDinner::refund()
│ ├─ [7310] ERC20Mock::transfer(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 0)
│ │ ├─ emit Transfer(from: ChristmasDinner: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], value: 0)
│ │ └─ ← [Return] true
│ ├─ [7310] ERC20Mock::transfer(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 0)
│ │ ├─ emit Transfer(from: ChristmasDinner: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], value: 0)
│ │ └─ ← [Return] true
│ ├─ [7310] ERC20Mock::transfer(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 0)
│ │ ├─ emit Transfer(from: ChristmasDinner: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], value: 0)
│ │ └─ ← [Return] true
│ ├─ [2258] GasHungryWallet::receive{value: 1000000000000000000}()
│ │ └─ ← [OutOfGas] EvmError: OutOfGas
│ └─ ← [Revert] EvmError: Revert
├─ [0] VM::assertEq(1000000000000000000 [1e18], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 312.73µs (74.81µs CPU time)
Ran 1 test suite in 611.28ms (312.73µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Contract receives 1 ETH deposit from wallet

Refund attempt triggers OutOfGas error in wallet's receive()

ETH remains stuck in contract (balance stays 1 ETH)

Impact

ETH permanently locked in contract

Smart contract wallets unable to receive refunds

No fallback mechanism to recover stuck funds

Tools Used

Manual review

Foundry test framework

Recommendations

function _refundETH(address payable _to) internal {
uint256 refundValue = etherBalance[_to];
(bool success,) = _to.call{value: refundValue}("");
require(success, "ETH transfer failed");
etherBalance[_to] = 0;
}
Updates

Lead Judging Commences

0xtimefliez Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

transfer instead of call

0xtimefliez Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

transfer instead of call

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.