Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Deprecated transfer() usage — use call() instead

Root + Impact

payable(target).transfer() forwards only 2300 gas stipend

Description

The contract uses payable(target).transfer(amount) to send ETH to recipients. This pattern was historically considered safe because the 2300 gas stipend it forwards is too small to execute most re-entrancy attacks. However, since EIP-1884 (Istanbul hard fork, 2019), the gas cost of SLOAD and several other opcodes was increased significantly. This means that many contract fallback and receive functions now consume more than 2300 gas during normal operation — not because they are malicious, but simply because they read from storage or emit events.

As a result, any time payable(target).transfer() is used and target is a smart contract (such as a multisig wallet, a DAO treasury, a gnosis safe, or any contract with a non-trivial receive function), the transfer will revert unconditionally. This is not a theoretical concern — Gnosis Safe and many other widely-used treasury contracts are affected. If a project treasury or a fee recipient is a multisig, all fund withdrawals will permanently fail, locking ETH in the contract with no recovery path.

The Solidity documentation itself deprecated .transfer() and .send() after EIP-1884, recommending .call{value: amount}("") as the replacement. The low-level call forwards all remaining gas by default and allows the recipient to perform complex operations in their fallback.

function withdraw(address target) external onlyOwner {
payable(target).transfer(address(this).balance);
// @audit - medium if the target didnt have any receive or fallback, what will happen? test it
// it will revert if there's no receive or fallback, however, if the target is multisig or any other contract that need more than 2300 gas, it will revert. And it will always revert because of the gas limit 2300
}

Risk

Likelihood:

This vulnerability is rated Medium likelihood. The issue only manifests when the recipient address is a smart contract with a non-trivial fallback. For EOA recipients this is a non-issue. However, in practice, project treasuries and fee recipients are very commonly multisigs or DAO contracts. The probability of this causing a production failure is moderate to high depending on the ecosystem the project operates in.

Impact:

This vulnerability is rated High impact. If triggered, ETH sent to a contract recipient will revert, and depending on whether the calling function handles the revert, funds may become permanently locked inside the pass contract. There is no administrative override — once ETH is trapped with no fallback withdrawal mechanism, it is irrecoverable without an upgrade. This represents both a direct financial loss and a critical operational failure.

Proof of Concept

The ExpensiveReceiver contract writes to storage inside its receive() function. An SSTORE operation costs at minimum 2900 gas post-EIP-1884, which alone exceeds the 2300 gas stipend forwarded by .transfer(). The transaction reverts with an out-of-gas error even though the recipient contract is not doing anything malicious. Any real-world multisig or contract wallet will exhibit the same behavior.

// Recipient is a Gnosis Safe (uses > 2300 gas in receive()):
address treasury = address(gnosisSafe);
uint256 amount = address(this).balance;
// This will REVERT:
payable(treasury).transfer(amount);
// → Error: out of gas (2300 stipend exceeded)
// Proof: deploy a minimal contract with a storage-writing receive():
contract ExpensiveReceiver {
uint256 public count;
receive() external payable { count += 1; } // ~5000 gas
}
// payable(expensiveReceiver).transfer(1 ether) → reverts

Recommended Mitigation

Replace all .transfer() calls with the low-level .call{value: amount}("") pattern, and always check the boolean return value. Since .call forwards all remaining gas and does not revert automatically on failure, the explicit require(success) is mandatory. Importantly, switching to .call re-opens the re-entrancy attack surface that .transfer()'s gas limit accidentally prevented — so all ETH-sending functions must follow the checks-effects-interactions pattern or use OpenZeppelin's ReentrancyGuard.

// VULNERABLE:
payable(target).transfer(amount);
// FIXED — use low-level call with return value check:
(bool success, ) = payable(target).call{value: amount}("");
require(success, "ETH transfer failed");
// RECOMMENDED — wrap in a safe transfer helper:
function _safeTransferETH(address to, uint256 amount) internal {
(bool ok, ) = payable(to).call{value: amount}("");
require(ok, "ETH transfer failed");
}
// ALSO — add re-entrancy protection if call precedes state changes:
// Use OpenZeppelin ReentrancyGuard or checks-effects-interactions pattern
bool private locked;
modifier nonReentrant() {
require(!locked, "Reentrant call");
locked = true;
_;
locked = false;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!