Transfer 2300 Gas Stipend Vulnerability
Solidity’s transfer() and send() methods forward a fixed 2300 gas stipend to the recipient. This design was intended to prevent reentrancy attacks but causes ETH transfers to fail in several scenarios.
This article summarizes 1 transfer gas stipend vulnerability pattern with real audit cases to help you understand the risks of using deprecated ETH transfer methods.
The 2300 Gas Stipend Problem
┌─────────────────────────────────────────────────────────────────────────┐
│ ETH TRANSFER METHODS IN SOLIDITY │
└─────────────────────────────────────────────────────────────────────────┘
Solidity provides three ways to send ETH:
┌────────────────────────────────┬──────────────┬──────────────────────────┐
│ Method │ Gas Limit │ On Failure │
├────────────────────────────────┼──────────────┼──────────────────────────┤
│ transfer(amount) │ 2300 gas │ Reverts │
│ send(amount) │ 2300 gas │ Returns false │
│ call{value: amount}("") │ All gas │ Returns (false, "") │
└────────────────────────────────┴──────────────┴──────────────────────────┘
↑
Fixed stipend!
┌─────────────────────────────────────────────────────────────────────────┐
│ WHAT IS THE 2300 GAS STIPEND? │
└─────────────────────────────────────────────────────────────────────────┘
Important: 2300 gas is a "stipend" (allowance) forwarded to receiver's
receive()/fallback(), NOT the total gas consumed by transfer().
Think of it like this:
• You (caller) pay ~11,600+ gas from YOUR transaction to execute transfer()
• Out of that, exactly 2300 gas is given to the RECIPIENT as a "budget"
• The recipient's receive() can only use this 2300 gas budget
• These are two separate gas pools!
┌─────────────────────────────────────────────────────────────────────────┐
│ HOW ETH TRANSFER GAS WORKS │
└─────────────────────────────────────────────────────────────────────────┘
When you call: recipient.transfer(1 ether)
┌─────────────────────────────────────────────────────────────────────────┐
│ Caller pays (from transaction gas): │
│ • CALL opcode overhead: ~2600 gas │
│ • Value transfer: 9000 gas (if recipient is new account) │
│ • Other execution costs │
│ │
│ These are NOT part of the 2300 stipend! │
└─────────────────────────────────────────────────────────────────────────┘
│
│ Forwards exactly 2300 gas to recipient
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Recipient receives 2300 gas stipend for receive()/fallback(): │
│ │
│ • If EOA (no code): nothing executes, 2300 gas unused → OK │
│ • If contract: receive() must complete within 2300 gas │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ WHY 2300 GAS? (ANTI-REENTRANCY) │
└─────────────────────────────────────────────────────────────────────────┘
The 2300 gas limit was designed to prevent reentrancy attacks:
┌─────────────────┬─────────────┬───────────────────────────┐
│ Operation │ Gas Cost │ Fits in 2300 gas? │
├─────────────────┼─────────────┼───────────────────────────┤
│ Receive ETH │ 0 gas │ ✓ │
│ LOG (event) │ ~375 gas │ ✓ │
│ SLOAD │ 800 gas │ ✓ (2-3 times max) │
│ SSTORE │ 5000+ gas │ ✗ │
│ External CALL │ 2600+ gas │ ✗ ← KEY: Can't call! │
└─────────────────┴─────────────┴───────────────────────────┘
2300 gas < 2600 gas (CALL overhead) → Cannot make external calls!
This blocks The DAO-style reentrancy:
Victim Contract: Attacker Contract:
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ function withdraw() { │ │ receive() external payable {│
│ uint bal = balances[msg]; │ │ victim.withdraw(); // ! │
│ msg.sender.call{value:bal}│─────►│ } │
│ balances[msg] = 0; │ │ // Can't re-call with only │
│ } │ │ // 2300 gas! │
└─────────────────────────────┘ └─────────────────────────────┘
With transfer(): With call():
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ receive() gets 2300 gas │ │ receive() gets ALL gas │
│ victim.withdraw(); // FAILS! │ │ victim.withdraw(); // WORKS! │
│ // Attack blocked! │ │ // Reentrancy possible! │
└──────────────────────────────┘ └──────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ THE PROBLEM: WHEN 2300 ISN'T ENOUGH │
└─────────────────────────────────────────────────────────────────────────┘
Scenario 1: Sending ETH to a contract with receive() logic
─────────────────────────────────────────────────────────────
contract Receiver {
uint256 public count;
receive() external payable {
count++; // Needs SLOAD + SSTORE = 5800+ gas → FAILS!
}
}
Scenario 2: Sending ETH on zkSync (even to EOA!)
────────────────────────────────────────────────
On Ethereum L1:
→ EOA has no code, receive() doesn't execute → 2300 gas unused → OK
On zkSync (Account Abstraction):
→ All accounts can have code (even "EOAs" are smart contract wallets)
→ Account validation logic may consume more than 2300 gas
→ zkSync docs: "Use .call() over .send() or .transfer()"
┌────────────────┬─────────────────────────────┬─────────────────────┐
│ Network │ What happens on receive? │ 2300 gas enough? │
├────────────────┼─────────────────────────────┼─────────────────────┤
│ Ethereum L1 │ EOA: nothing executes │ ✓ (for EOA only) │
│ zkSync │ May run account logic │ ✗ May fail! │
└────────────────┴─────────────────────────────┴─────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ RECOMMENDED PATTERN │
└─────────────────────────────────────────────────────────────────────────┘
// ❌ Deprecated (fixed 2300 gas)
_to.transfer(amount);
_to.send(amount);
// ✅ Recommended (forwards all gas)
(bool success, ) = _to.call{value: amount}("");
require(success, "Transfer failed");
Note: When using call(), be aware of reentrancy risks.
Use ReentrancyGuard or checks-effects-interactions pattern.
Vulnerability Patterns
1. ETH Transfer Gas Limit Vulnerability
Prerequisite Knowledge:
The transfer() and send() methods forward a fixed 2300 gas stipend to the recipient’s receive()/fallback() function. This 2300 gas limit was designed to prevent reentrancy but causes failures when: (1) recipient contracts have any storage logic in receive(), or (2) deployed on L2s like zkSync where the gas model differs from Ethereum L1. zkSync documentation explicitly states: ‘Use .call() over .send() or .transfer() for transferring ETH’.
Root Cause:
The contract assumes that transfer() will always succeed for sending ETH, relying on the legacy 2300 gas stipend. This assumption is violated on networks where gas costs are higher than what the stipend can cover, particularly on zkSync where the gas model differs from Ethereum L1.
Attack Vector:
A user calls the withdraw function to retrieve their ETH. If the recipient is a contract with a receive/fallback function, or even an EOA on zkSync, the transfer will fail due to insufficient gas, causing the entire transaction to revert and making the ETH permanently stuck in the contract.
Source:
- [sherlock/2024-05-pooltogether] Potential ETH Loss Due to transfer Usage in Requestor Contract on zkSync (
issue-80.md)pt-v5-rng-witnet/src/Requestor.solBusiness Context: PoolTogether Witnet RNG on zkSync
┌─────────────────────────────────────────────────────────────────────────┐ │ WHAT IS POOLTOGETHER? │ └─────────────────────────────────────────────────────────────────────────┘ PoolTogether is a "no-loss" lottery protocol where users deposit funds, earn yield collectively, and winners are randomly selected to receive the pooled yield as prizes. ┌─────────────────────────────────────────────────────────────────────────┐ │ HOW DOES IT WORK? │ └─────────────────────────────────────────────────────────────────────────┘ 1. Users deposit funds → Protocol stakes in yield-generating protocols 2. Yield accumulates → Creates prize pool 3. Random draw → Winner takes the prize pool 4. Everyone else → Gets their deposit back (no loss!) ┌─────────────────────────────────────────────────────────────────────────┐ │ WHY RANDOM NUMBERS? │ └─────────────────────────────────────────────────────────────────────────┘ Random number generation is CRITICAL for fair winner selection. PoolTogether uses Witnet (decentralized oracle) for randomness. Draw Flow: ┌──────────────┐ startDraw() ┌──────────────┐ │ Draw Bot │ ───────────────────► │ RngWitnet │ │ (sends ETH) │ + ETH for gas │ │ └──────────────┘ └──────┬───────┘ │ ▼ Request random number from Witnet Oracle (costs ETH) │ ▼ ┌───────────────────────┐ │ Unused ETH sent to │ │ Requestor contract │ │ for later withdrawal │ └───────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ THE VULNERABILITY │ └─────────────────────────────────────────────────────────────────────────┘ When Draw Bot tries to withdraw unused ETH: ┌──────────────┐ withdraw() ┌──────────────┐ │ Draw Bot │ ◄────────────────── │ Requestor │ │ (contract) │ uses transfer()! │ │ └──────────────┘ └──────────────┘ │ ▼ On zkSync: transfer() only forwards 2300 gas │ ▼ Draw Bot's receive() may need more gas │ ▼ OUT OF GAS! ETH stuck forever! ┌─────────────────────────────────────────────────────────────────────────┐ │ REAL-WORLD PRECEDENT │ └─────────────────────────────────────────────────────────────────────────┘ This is NOT theoretical! 921 ETH was stuck in a contract on zkSync Era due to the same transfer() issue: https://medium.com/coinmonks/gemstoneido-contract-stuck-with-921-eth-an-analysis-of-why-transfer-does-not-work-on-zksync-era-d5a01807227d ┌─────────────────────────────────────────────────────────────────────────┐ │ FIX │ └─────────────────────────────────────────────────────────────────────────┘ Replace transfer() with call(): // Before (vulnerable) _to.transfer(balance); // After (safe) (bool success, ) = _to.call{value: balance}(""); require(success, "Transfer failed");Vulnerable Code:
function withdraw(address payable _to) external onlyCreator returns (uint256) { uint balance = address(this).balance; _to.transfer(balance); // VULNERABILITY: Uses deprecated transfer() with fixed 2300 gas, can fail on zkSync and when transferring to contracts return balance; }
Transfer 2300 Gas Audit Checklist
When auditing contracts that transfer ETH, check for these red flags:
| # | Check Item | Related Pattern |
|---|---|---|
| ✅ | Uses transfer() or send() for ETH transfers Fixed 2300 gas stipend may be insufficient |
Pattern 1 |
| ✅ | Deploys on L2 (zkSync, Arbitrum, etc.) L2 gas models differ; zkSync EOAs may need >2300 gas | Pattern 1 |
| ✅ | Recipient can be a contract Any receive() with storage ops will fail | Pattern 1 |
| ✅ | Uses call() without reentrancy protection call() forwards all gas, enabling reentrancy |
Pattern 1 |
InfiniteSec