Reproduced Exploit

VTVL — onlyDeposited locks the remaining milestone allocation

onlyDeposited lets a milestone be marked complete only when the contract's token balance is at least allocation * recipients.length. That product does not shrink when someone withdraws. With two recipients of 100 each, Alice completes and withdraws 100. The balance is then 100, the check still dema…

Apr 2023•Other•dos•4 min read

Chain

Other

Category

dos

Date

Apr 2023

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 47910-locked-tokens-in-milestone-contract-ottersec-none-vtvl-pdf. Standalone Foundry PoC and full write-up: 47910-locked-tokens-in-milestone-contract-ottersec-none-vtvl-pdf_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/dos/frozen-funds · vuln/dos/lockup · vuln/logic/wrong-condition

Reproduction: self-contained Foundry PoC, no fork and no cheatcodes in the exploit. Trace: output.txt.

Key info#

ImpactHIGH — after one recipient withdraws, 100 VTVL is stuck and the other recipient cannot complete or withdraw
ProtocolVTVL BaseMilestone
Vulnerable codeonlyDeposited: balance >= allocation * recipients.length
Bug classWithdrawals ignored by a constant balance check
FindingOtterSec — Robert Chen · #47910
Reportvtvl.io
SourceAuditVault
Compilersolc 0.8.27, cancun, via_ir

TL;DR#

onlyDeposited lets a milestone be marked complete only when the contract's token balance is at least allocation * recipients.length. That product does not shrink when someone withdraws. With two recipients of 100 each, Alice completes and withdraws 100. The balance is then 100, the check still demands 200, and Bob's setComplete reverts. His 100 stays in the contract. Counting totalWithdrawnAmount lets Bob complete and withdraw that 100.

The vulnerable code#

SOLIDITY
modifier onlyDeposited() {
    uint256 balance = tokenAddress.balanceOf(address(this));
    // @> VULN: live balance is compared to the original full allocation, which ignores withdrawals
    require(balance >= allocation * recipients.length, "NOT_DEPOSITED");
    // FIX: require(balance + totalWithdrawnAmount >= allocation * recipients.length, "NOT_DEPOSITED");
    _;
}

Root cause#

The threshold is a constant computed from the original recipient list. Withdrawals reduce balanceOf and do not reduce the threshold. Once any recipient is paid, the remaining balance is below allocation * recipients.length even though it still equals the unpaid allocations. setComplete is gated by that modifier, and withdraw requires complete, so the unpaid tokens cannot move.

Preconditions#

The contract holds exactly the sum of the allocations. At least one recipient completes and withdraws before another recipient is marked complete. No admin action and no time change are required.

Attack walkthrough#

  1. Deposit 200 VTVL for Alice and Bob, 100 each (100000000000000000000 per recipient).
  2. setComplete(Alice) passes: balance 200 >= 200.
  3. Alice withdraws 100. Balance is 100. totalWithdrawnAmount is 100.
  4. setComplete(Bob) hits onlyDeposited and reverts NOT_DEPOSITED because 100 < 200.
  5. The contract still holds 100 VTVL. Bob's balance is 0. Bob is not complete, so he cannot withdraw.

Control: setCompleteFixed requires balance + totalWithdrawnAmount >= allocation * recipients.length. After the same Alice withdrawal that sum is still 200. Bob completes and withdraws. The contract balance goes to 0 and Bob receives 100.

Diagrams#

flowchart TD A["Deposit 200 for two recipients of 100"] --> B["Alice completes while balance is 200"] B --> C["Alice withdraws 100 and the balance falls to 100"] C --> D["VULN: onlyDeposited still requires balance of at least 200"] D --> E["Bob setComplete reverts NOT_DEPOSITED"] E --> F["100 VTVL stays in the contract and Bob cannot withdraw"]

Impact#

Bob cannot withdraw his 100 VTVL. Those tokens sit in the milestone contract. Depositing another 100 to push the balance back to 200 would itself become stuck under the same check after Bob withdraws, which is the extra loss the report describes. The fixed check releases the original 100 without a top-up.

Remediation#

Do not compare the live balance to the original full allocation. Subtract tokens already withdrawn, or compare the balance minus tokens reserved for still-incomplete milestones with the single milestone's allocation, and track totalWithdrawnAmount as the later patch does.

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.