Wrapped Ether is one of the oldest and most widely used primitives in DeFi. WETH9 — deployed in 2017 — lets ETH participate in ERC-20 token flows by letting anyone deposit ETH to mint an equal amount of WETH, and withdraw WETH to receive ETH back.
The duality is convenient: protocols that need to handle both native ETH and ERC-20 tokens can accept either. But this convenience introduces a class of bugs that auditors consistently flag as some of the most subtle in the entire DeFi codebase. Protocols that accept both ETH and WETH create two code paths that look similar but behave differently at the accounting layer, the reentrancy surface, and even the address level across chains.
This post covers six WETH integration vulnerability classes: what each one looks like, how an attacker exploits it, and what a safe implementation does instead.
1. ETH and WETH Dual-Path Accounting Mismatch
Protocols that accept both ETH and WETH often route deposits through a single function with a boolean flag. The ETH path relies on msg.value, the WETH path calls transferFrom. Both then call totalDeposited += amount, where amount is a function parameter — not derived from the actual transfer.
// VULNERABLE: amount parameter is not verified against actual value received
function deposit(uint256 amount, bool useWETH) external payable {
if (useWETH) {
weth.transferFrom(msg.sender, address(this), amount);
}
// ETH path: msg.value is completely ignored
// Caller can pass amount = 1000e18 with msg.value = 0
totalDeposited[msg.sender] += amount;
}
The attack is straightforward. An attacker calls deposit(1_000_000e18, false) with msg.value = 0. The ETH path executes, no WETH is pulled, no ETH arrives, but totalDeposited is credited for one million tokens. The attacker can then withdraw against that phantom balance.
The reverse path matters too: a caller sends msg.value = 1 ether through the WETH path, which calls transferFrom for amount. If amount < 1 ether, the excess ETH is stranded. If amount > msg.value, the shortfall is silently ignored if the protocol's balance covers it.
// SAFE: each path uses the actual received value
function deposit(uint256 amount, bool useWETH) external payable {
if (useWETH) {
require(msg.value == 0, "ETH sent with WETH deposit");
bool ok = weth.transferFrom(msg.sender, address(this), amount);
require(ok, "WETH transfer failed");
totalDeposited[msg.sender] += amount;
} else {
// Use msg.value directly — ignore the amount parameter entirely
require(amount == 0, "amount ignored in ETH path");
totalDeposited[msg.sender] += msg.value;
}
}
The core rule: in the ETH path, use msg.value as the source of truth. In the WETH path, verify transferFrom returns true and credit only what was actually transferred.
2. msg.value Reuse in a Loop
msg.value in Solidity is a transaction-level value. It does not decrease as ETH is spent inside a function. Every iteration of a loop sees the same msg.value, which means a single ETH payment can be counted multiple times if the loop uses it to disperse funds.
// VULNERABLE: msg.value is reused across every loop iteration
function distributeETH(address[] calldata recipients) external payable {
for (uint256 i = 0; i < recipients.length; i++) {
// msg.value is the same for all iterations
(bool ok, ) = recipients[i].call{value: msg.value}("");
require(ok, "Transfer failed");
}
}
An attacker assembles a list of five controlled addresses and calls distributeETH with msg.value = 1 ether. The function sends 1 ETH to each recipient — five ETH total — drawing the shortfall from the contract's existing reserves. Every extra address in the array drains one ETH from the protocol.
// SAFE: use a per-recipient amount computed before the loop
function distributeETH(
address[] calldata recipients,
uint256 amountEach
) external payable {
require(msg.value == amountEach * recipients.length, "Incorrect ETH");
for (uint256 i = 0; i < recipients.length; i++) {
(bool ok, ) = recipients[i].call{value: amountEach}("");
require(ok, "Transfer failed");
}
}
The fix has two parts: compute a fixed amountEach before the loop, and require that the total msg.value covers all recipients exactly. msg.value is only read once, before the loop, so its constant nature cannot be exploited.
3. WETH Unwrap Reentrancy
WETH9's withdraw function transfers ETH to the caller via a low-level call. This triggers the caller's receive() function, creating a reentrancy surface. If the protocol has not updated its internal balance before calling weth.withdraw, an attacker can reenter and withdraw again before the state reflects the first withdrawal.
// VULNERABLE: balance is updated after the external call
mapping(address => uint256) public balances;
function withdrawWETH(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// Unwrap: ETH is sent to this contract, triggering receive()
weth.withdraw(amount);
// Send ETH to user — this call can be reentered
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "ETH transfer failed");
// Balance updated AFTER external calls — too late
balances[msg.sender] -= amount;
}
The attacker deploys a contract whose receive() calls withdrawWETH again. Since balances[msg.sender] has not been decremented yet, the check passes. The attacker can drain the contract's WETH reserves in a single transaction.
// SAFE: checks-effects-interactions + pull pattern
mapping(address => uint256) public balances;
mapping(address => uint256) public pendingETH;
function withdrawWETH(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// Update state first (effects before interactions)
balances[msg.sender] -= amount;
pendingETH[msg.sender] += amount;
// Unwrap WETH to ETH
weth.withdraw(amount);
// ETH is now held by this contract
}
// Separate pull function — user initiates the ETH transfer
function claimETH() external {
uint256 amount = pendingETH[msg.sender];
require(amount > 0, "Nothing to claim");
pendingETH[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "ETH transfer failed");
}
The pull pattern separates the state update from the ETH transfer into two distinct transactions, eliminating the reentrancy window entirely.
4. Assuming WETH.balanceOf Is Protocol Balance
ERC-20 tokens can be transferred to any address, including contracts that did not request them. A protocol that reads weth.balanceOf(address(this)) as its canonical treasury balance is vulnerable to donation attacks: anyone can send WETH directly to the contract and inflate that number.
// VULNERABLE: external balanceOf is used as internal accounting
function totalAssets() external view returns (uint256) {
// Anyone can donate WETH to inflate this value
return weth.balanceOf(address(this));
}
function sharePrice() public view returns (uint256) {
// Share price is inflated when WETH is donated
return totalAssets() * 1e18 / totalShares;
}
An attacker donates a large amount of WETH directly to the contract, inflating totalAssets() and thus sharePrice(). If the protocol uses sharePrice() to compute how many shares to mint for a deposit, a donation before a deposit mints fewer shares than expected. If it is used for redemption, it allows over-redemption of underlying assets.
// SAFE: internal accounting tracks actual deposits and withdrawals
uint256 private _totalWETH;
function deposit(uint256 amount) external {
weth.transferFrom(msg.sender, address(this), amount);
_totalWETH += amount;
// mint shares...
}
function withdraw(uint256 amount) external {
_totalWETH -= amount;
weth.transfer(msg.sender, amount);
}
function totalAssets() external view returns (uint256) {
// Internal tracker — immune to donation attacks
return _totalWETH;
}
The internal _totalWETH tracker is only modified by controlled deposit and withdraw functions. Donations to the contract have no effect on the accounting.
5. ETH Dust Left After WETH Conversion
When a protocol unwraps WETH to ETH and passes that ETH into a swap, the swap rarely consumes the entire amount. Router contracts return the unused portion, but only if the protocol explicitly handles it. Dust that accumulates in a contract with no sweep function becomes permanently locked.
// VULNERABLE: swap may not use all ETH; remainder is silently trapped
function swapWETHForToken(uint256 wethAmount, address token) external {
weth.transferFrom(msg.sender, address(this), wethAmount);
weth.withdraw(wethAmount); // Contract now holds wethAmount ETH
// Router may use less than wethAmount due to slippage/rounding
uint256 amountOut = router.swapExactETHForTokens{value: wethAmount}(
0,
getPath(WETH, token),
msg.sender,
block.timestamp
);
// Remaining ETH has no path out of the contract
}
Over many transactions, small amounts of ETH accumulate. There is no withdraw function for ETH, no sweep mechanism, and no way for users to recover it. In high-volume protocols this can total significant value.
// SAFE: track pre/post balance to return dust to the caller
function swapWETHForToken(uint256 wethAmount, address token) external {
weth.transferFrom(msg.sender, address(this), wethAmount);
weth.withdraw(wethAmount);
uint256 balanceBefore = address(this).balance - wethAmount;
router.swapExactETHForTokens{value: wethAmount}(
0,
getPath(WETH, token),
msg.sender,
block.timestamp
);
// Return any ETH dust to the caller
uint256 remaining = address(this).balance - balanceBefore;
if (remaining > 0) {
(bool ok, ) = msg.sender.call{value: remaining}("");
require(ok, "Dust refund failed");
}
}
By comparing the contract's ETH balance before and after the swap, the protocol identifies the leftover dust and returns it to the caller in the same transaction.
6. WETH Address Hardcoded on Wrong Chain
WETH is not a single canonical contract. Each network has its own WETH deployment, and the addresses are not interchangeable. Ethereum mainnet uses 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, Polygon uses 0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619, and Arbitrum uses 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1. A protocol that hardcodes the Ethereum WETH address and is deployed to Arbitrum will call a random contract or an empty address.
// VULNERABLE: hardcoded Ethereum mainnet WETH address
contract Vault {
// This address is wrong on every chain except Ethereum mainnet
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
function depositETH() external payable {
IWETH(WETH).deposit{value: msg.value}();
// On Arbitrum: calls a random EOA or contract, silently fails
}
}
The failure mode is silent on most chains. The call to deposit() hits a contract that is not WETH, does not revert (because external calls with value sent to a contract without a payable fallback will revert, but if the address happens to be a contract with a fallback, ETH is accepted and the protocol's accounting is now wrong).
// SAFE: WETH address as constructor parameter with interface verification
interface IWETH9 {
function deposit() external payable;
function withdraw(uint256) external;
function balanceOf(address) external view returns (uint256);
}
contract Vault {
IWETH9 public immutable weth;
constructor(address _weth) {
require(_weth != address(0), "Zero address");
// Verify the address implements WETH9 by calling a read-only method
uint256 bal = IWETH9(_weth).balanceOf(address(this));
// If this doesn't revert, the address responds to WETH9's interface
_ = bal;
weth = IWETH9(_weth);
}
}
Passing WETH as a constructor argument and validating the interface at deployment time makes the contract chain-agnostic and eliminates the entire class of wrong-chain address errors.
What ContractScan Detects
ContractScan analyzes WETH integration patterns statically and symbolically, flagging dual-path contracts before they are deployed.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| ETH/WETH accounting mismatch | Taint analysis — traces amount parameter vs msg.value in ETH/WETH branches |
Critical |
| msg.value reuse in loop | Control flow analysis — flags msg.value reads inside loop bodies with call instructions |
High |
| WETH unwrap reentrancy | Reentrancy graph — checks state writes relative to weth.withdraw and ETH transfer ordering |
Critical |
| WETH.balanceOf as canonical balance | Data flow analysis — detects balanceOf(address(this)) used in share price or redemption math |
High |
| ETH dust after WETH conversion | Balance delta analysis — identifies missing post-swap ETH balance reconciliation | Medium |
| Hardcoded WETH address | Constant address detector — flags known WETH addresses and cross-references the target chain | High |
Related Posts
- Withdrawal Pattern Security: Push vs Pull Payment Vulnerabilities
- ERC-20 Missing Return Value and safeTransfer Vulnerabilities
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.