The First Deposit
Briefing
The vault prices shares from its token balance. Anyone can transfer tokens to it directly, without calling `deposit`.
Assume an 18-decimal asset and a victim depositing 10 tokens shortly after launch.
// Quantix Security Research Network — training challenge.// Intentionally vulnerable. Written for education; do not deploy.// SPDX-License-Identifier: MITpragma solidity 0.8.24; interface IERC20 { function balanceOf(address) external view returns (uint256); function transfer(address, uint256) external returns (bool); function transferFrom(address, address, uint256) external returns (bool);} contract TrainingVault { IERC20 public immutable asset; uint256 public totalShares; mapping(address => uint256) public sharesOf; constructor(IERC20 _asset) { asset = _asset; } function totalAssets() public view returns (uint256) { return asset.balanceOf(address(this)); } function deposit(uint256 assets) external returns (uint256 shares) { uint256 supply = totalShares; shares = supply == 0 ? assets : (assets * supply) / totalAssets(); require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed"); totalShares = supply + shares; sharesOf[msg.sender] += shares; } function redeem(uint256 shares) external returns (uint256 assets) { assets = (shares * totalAssets()) / totalShares; sharesOf[msg.sender] -= shares; totalShares -= shares; require(asset.transfer(msg.sender, assets), "transfer failed"); }}Hints
Reveal one at a time. Each moves a step closer to the answer.
- H1
Hidden
- H2
Hidden
- H3
Hidden
Solution
Try writing your own explanation and fix before opening it.
The attacker deposits 1 wei and receives 1 share. They then transfer, say, 10 tokens directly to the vault. `totalAssets()` is now 10e18 + 1 while `totalShares` is 1.
The victim deposits 10e18. Shares minted = 10e18 × 1 / (10e18 + 1), which rounds down to 0. The victim’s tokens are added to the vault but they own nothing; the attacker’s single share now redeems the whole balance, about 20 tokens.
Defences: add virtual shares and assets (a decimals offset) to the conversion so donations cannot dominate the exchange rate, revert when a deposit would mint zero shares, and consider seeding the vault with an initial deposit whose shares are burned. Tracking assets internally instead of reading the raw balance also removes the donation lever.
+ uint256 private constant OFFSET = 1e6; // virtual shares per virtual asset+ function deposit(uint256 assets) external returns (uint256 shares) {- uint256 supply = totalShares;- shares = supply == 0 ? assets : (assets * supply) / totalAssets();+ uint256 supply = totalShares;+ shares = (assets * (supply + OFFSET)) / (totalAssets() + 1);+ require(shares > 0, "zero shares"); require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed"); totalShares = supply + shares; sharesOf[msg.sender] += shares; } function redeem(uint256 shares) external returns (uint256 assets) {- assets = (shares * totalAssets()) / totalShares;+ assets = (shares * (totalAssets() + 1)) / (totalShares + OFFSET);