Reproduced Exploit

FairSide FSDVesting — a fully vested beneficiary never receives the conviction NFT

claimVestedTokens pays whatever calculateVestingClaim returns, then releases fairSideConviction only when amount == tokenClaim. amount is the original vest. tokenClaim is only the tokens paid by this call. A beneficiary who already claimed 400 of a 1000 FSD vest, and who is fully vested in this blo…

Nov 2021•Ethereum•logic•5 min read

Loss

Conviction NFT 1 stays in FSDVesting. The beneficiary is paid all 1000 FSD and holds 0 NFTs. A later claim re…

Chain

Ethereum

Category

logic

Date

Nov 2021

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: 42326-h-03-beneficiary-cant-get-fairsideconviction-nft-unless-they. Standalone Foundry PoC and full write-up: 42326-h-03-beneficiary-cant-get-fairsideconviction-nft-unless-they_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/wrong-condition · vuln/locked-funds

Key info#

FieldValue
LossConviction NFT 1 stays in FSDVesting. The beneficiary is paid all 1000 FSD and holds 0 NFTs. A later claim reverts
Vulnerable contractFSDVesting.claimVestedTokens
Attacker EOA0x1111111111111111111111111111111111111111
Attack contractExploit (the beneficiary)
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2021-11
Compilersolc 0.8.27, cancun, via IR
Bug classNFT release compares the original vest to this claim only

TL;DR#

claimVestedTokens pays whatever calculateVestingClaim returns, then releases fairSideConviction only when amount == tokenClaim. amount is the original vest. tokenClaim is only the tokens paid by this call. A beneficiary who already claimed 400 of a 1000 FSD vest, and who is fully vested in this block, is paid the remaining 600 FSD. 1000 == 600 is false, so the NFT stays in the contract. The next claim reverts because nothing is left to pay. The fixed check is amount == totalClaimed, and that path mints the NFT to the beneficiary.

Background#

Code4rena reviewed FairSide in November 2021. WatchPug reported H-03 against FSDVesting.claimVestedTokens. The schedule is meant to hand the beneficiary a fairSideConviction NFT once every vested FSD has been claimed. The implementation tests the size of the current claim instead of the total claimed. The only successful case is a single claim, made after the vest has fully elapsed, for the entire original amount. Any earlier partial claim makes that equality impossible. The sponsor confirmed the behavior and disagreed with the severity.

The Playground replay uses one block whose timestamp is already past the vest end. The schedule is stored with start = 0 and duration = 1, so block.timestamp >= start + duration is true at timestamp 1. Nothing in run() warps time.

The vulnerable code#

SOLIDITY
function claimVestedTokens() external override onlyBeneficiary {
    uint256 tokenClaim = calculateVestingClaim();
    require(
        tokenClaim > 0,
        "FSDVesting::claimVestedTokens: Zero claimable tokens"
    );

    totalClaimed = totalClaimed.add(tokenClaim);
    lastClaimAt = block.timestamp;

    fsd.safeTransfer(msg.sender, tokenClaim);

    emit TokensClaimed(msg.sender, tokenClaim, block.timestamp);

    // @> VULN: amount is the original vest, tokenClaim is only this claim, so a prior claim never releases the NFT
    if (amount == tokenClaim) {
        uint256 tokenId = fsd.tokenizeConviction(0);
        fairSideConviction.transferFrom(address(this), msg.sender, tokenId);
    }
}

The fix compares the original vest to the total claimed, which already includes this call:

DIFF
-    if (amount == tokenClaim) {
+    if (amount == totalClaimed) {
         uint256 tokenId = fsd.tokenizeConviction(0);
         fairSideConviction.transferFrom(address(this), msg.sender, tokenId);
     }

Root cause#

tokenClaim is the increment paid by the current call. totalClaimed is updated to totalClaimed + tokenClaim before the NFT check, but the check does not read totalClaimed. After any earlier claim, tokenClaim is strictly less than amount even when this call finishes the vest. The mint and the transferFrom sit inside that branch, so they never run. Once totalClaimed equals amount, a later call computes a zero claim and reverts on tokenClaim > 0. The NFT cannot be released by any later transaction.

Preconditions#

  • The vest has already ended in this block. calculateVestingClaim takes the fully vested branch and returns amount - totalClaimed.
  • totalClaimed is already 400 FSD from an earlier claim. This call's tokenClaim is 600 FSD.
  • Conviction NFT 1 sits on the vesting contract. The vulnerable branch is the only code that transfers it.
  • The caller is the beneficiary.

Attack walkthrough#

  1. The exploit escrows conviction NFT 1 onto FSDVesting. The contract's NFT balance becomes 1.
  2. calculateVestingClaim sees block.timestamp >= start + duration and returns 600 FSD, the unpaid remainder of the 1000 FSD vest.
  3. claimVestedTokens adds 600 FSD to totalClaimed (now 1000 FSD) and transfers 600 FSD to the beneficiary. The beneficiary's FSD balance is 1000 FSD.
  4. amount == tokenClaim is 1000 == 600. tokenizeConviction and transferFrom do not run. NFT 1 stays on the vesting contract. The beneficiary's NFT balance stays 0.
  5. A second claimVestedTokens computes 0 claimable FSD and reverts. The NFT is still on the contract.

Diagrams#

flowchart TD A["Vest has already ended in this block"] --> B["Conviction NFT 1 is escrowed on FSDVesting"] B --> C["Beneficiary claims the remaining 600 FSD"] C --> D["Check compares 1000 FSD to this 600 FSD claim"] D --> E["NFT transfer is skipped"] E --> F["Later claim reverts and NFT 1 stays in the contract"]

Impact#

The beneficiary is paid the full 1000 FSD and still does not hold the conviction NFT. NFT 1 remains owned by FSDVesting (balance 1). The follow-up claim reverts, so there is no later call that can take the missed branch. The control contract, with the same 400 FSD already claimed and the same fully vested remainder, calls tokenizeConviction and transferFrom. The beneficiary ends holding NFT 1 and the vesting contract's NFT balance is 0. The FSD payout is the same 1000 FSD either way. The difference is the NFT.

PoC#

BASH
cd 42326-h-03-beneficiary-cant-get-fairsideconviction-nft-unless-they_exp
forge test -vv

test_exploit_fully_vested_claim_leaves_nft asserts the vesting contract still owns NFT 1, the beneficiary owns 0 NFTs, the beneficiary holds 1000 FSD, totalClaimed is 1000 FSD, and the follow-up claim reverted. test_control_mints_nft_to_beneficiary asserts the fixed check mints NFT 1 to the beneficiary and leaves the vesting contract with 0 NFTs, with the same 1000 FSD paid.

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.