Revest Finance ERC-1155 callback fnftId race / config-overwrite vault drain (2022-03-27)

  • Name: Revest Finance ERC-1155 callback fnftId race / config-overwrite vault drain
  • Date: 2022-03-27
  • Protocol: Revest Finance
  • Chain: Ethereum
  • Category: reentrancy / ERC-1155 callback reentrancy / identifier-allocation race / FNFT config overwrite / vault drain
  • Loss: roughly $2M across BLOCKS, ECO, RENA, LYXe, and smaller token positions; Revest said another roughly $2M of RVST losses were prevented by emergency transfer freezing
  • Summary: Revest let ERC-1155 mint callbacks run before FNFTHandler finalized the next fnftId. The attacker first minted one zero-value FNFT series with small supply, then minted a second zero-value series with huge supply to their own attack contract. During the second mint’s onERC1155Received() callback, they re-entered depositAdditionalToFNFT() against the first series with quantity < supply, which made Revest ask getNextId() for a brand-new split series. Because fnftsCreated had not yet been incremented, getNextId() returned the still-in-flight id of the huge second series. TokenVault.handleMultipleDeposits() then overwrote that live series config from depositAmount = 0 to depositAmount = 1 token, instantly making millions of already-minted worthless FNFTs redeemable for real vault assets. The exploit was not just ERC-1155 has callbacks; it was new-id uniqueness and series configuration were assumed before the callback window had actually closed.
  • Trigger / entry point:
    • Revest’s mintAddressLock() path minted ERC-1155 FNFTs through FNFTHandler.mint() and delivered them to arbitrary recipients, including contracts that could implement onERC1155Received().
    • FNFTHandler incremented fnftsCreated only after _mint(...), so the callback window opened while getNextId() still pointed at the id being minted.
    • depositAdditionalToFNFT() could create a fresh split series when the requested quantity was smaller than the source series supply.
    • TokenVault.handleMultipleDeposits() trusted that newFNFTId referred to an actually unused series and wrote the updated depositAmount into that id’s config without checking whether that series already existed.
  • Root cause:
    • Revest violated a classic callback-safety invariant: unique identifier allocation and dependent configuration writes were not finalized before an external callback could re-enter the system.
    • The bug was amplified by treating newFNFTId as a safe blank slot purely because it came from getNextId(), even though nested execution could reach that helper while the original mint for the same id was still live.
    • Economically, this turned a tiny real deposit into mass issuance of redeemable vault claims: once the giant in-flight series inherited depositAmount = 1, every pre-minted FNFT in that series became a withdrawal right against real assets.
  • Exploit path:
    1. The attacker deployed contract 0xb480ac726528d1c195cd3bb32f19c92e8d928519 and used it as the recipient for newly minted FNFTs so it could re-enter through onERC1155Received().
    2. They first called mintAddressLock() to create a small zero-value series, for example fnftId = 1036, with supply 2 and depositAmount = 0 in the target ERC-20.
    3. They immediately called mintAddressLock() again to create a huge zero-value series, for example fnftId = 1037, with supply 7,700,000 for ECO or hundreds of thousands for other target tokens, again with depositAmount = 0.
    4. During the second mint, ERC-1155 delivery to the attack contract triggered onERC1155Received() before fnftsCreated was incremented.
    5. Inside that callback, the attacker called depositAdditionalToFNFT(1036, 1e18, 1), targeting only part of the earlier two-token series.
    6. Because quantity = 1 was smaller than the source supply of 2, Revest decided it needed a new split series and called getNextId() to obtain a supposedly unused id.
    7. Since the outer mint had not yet advanced fnftsCreated, getNextId() returned 1037 again: the live id of the giant series already being minted to the attacker.
    8. TokenVault.handleMultipleDeposits() then wrote depositAmount = oldDepositAmount + amount = 1e18 into series 1037, effectively overwriting the giant zero-value series configuration with a real per-token claim.
    9. Once control returned and minting finished, the attacker held millions of 1037 FNFTs, each now redeemable for one whole underlying token despite only one token having been deposited through the split path.
    10. Because the FNFTs were address-locked to the attacker, they could immediately call withdrawFNFT() and drain the vault’s holdings of RENA, BLOCKS, ECO, LYXe, and smaller token balances.
    11. Revest froze RVST transfers before the attacker could execute the same play against the protocol’s own token pool, preventing an additional roughly $2M of loss.
  • What the attacker needed to be true:
    • ERC-1155 mint recipients had to be able to run arbitrary callback code during _mint().
    • fnftsCreated had to remain stale until after the callback, so getNextId() could be raced.
    • depositAdditionalToFNFT() had to support partial-supply splits that create a new series id mid-execution.
    • TokenVault had to accept newFNFTId as trustworthy and overwrite configuration without proving the slot was actually unused.
    • The targeted vaults had to contain liquid ERC-20 balances that the now-repriced FNFTs could redeem.
  • Technical anatomy worth remembering:
    • This is a good reminder that next id is itself security-critical state. If a protocol assumes id uniqueness while an external callback can still ask for getNextId(), the id allocator becomes a custody surface.
    • The exploit did not require re-entering the exact same function. It only required a callback into another code path that also trusted the not-yet-finalized id namespace.
    • The most important overwritten field was depositAmount, because that number is the economic truth of one FNFT series: once raised from 0 to 1 token, millions of pre-existing series members became valid withdrawal claims.
    • BlockSec later showed the incident hid an even simpler adjacent zero-day in TokenVault.handleMultipleDeposits(): when creating a split series, the function also rewrote the old series’ depositAmount, which could have enabled similar inflation without the ERC-1155 callback race if not mitigated quickly.
  • Representative transactions and addresses:
    • Revest entry contract: 0x2320A28f52334d62622cc2EaFa15DE55F9987eD9
    • LockManager: 0x226124E83868812D3Dae87eB3C5F28047E1070B7
    • TokenVault: 0xA81bd16Aa6F6B25e66965A2f842e9C806c0AA11F
    • FNFTHandler: 0xe952bda8c06481506e4731C4f54CeD2d4ab81659
    • Attack contract: 0xb480ac726528d1c195cd3bb32f19c92e8d928519
    • First RENA theft transaction: 0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428
    • BLOCKS theft / dump transaction cited by Revest: 0x8e2259d6344782c61303d067418fd5c56ce52ed2db4dfab8449a60df385cbcca
    • ECO theft transaction cited by Revest: 0x19b10c6d38f0b911fdc0e722d681a70a56699d70559eefef3d4d6fe88276c813
    • Emergency RVST transfer-freeze transaction: 0x18fd1a2b17109c7f2bb7244d99bcfb71d131fd9d522fdc41b8a9fb3cdded9c98
    • Blocked follow-up RVST-drain attempt: 0x7bbc01ad66ba9b07fdc82afe4e0c99ffcf317680635d040427afed06b25c8c0f
  • Response / mitigation:
    • Revest soft-locked FNFT creation and withdrawal on affected deployments and froze RVST transfers quickly enough to stop the attacker from repeating the exploit against the protocol’s own token.
    • The direct code fix was to break the classic pattern in FNFTHandler.mint(): update fnftsCreated before _mint(...) and add guards against repeated minting of the same FNFT id.
    • Because BlockSec found an additional critical logic bug in TokenVault.handleMultipleDeposits(), Revest also deployed a reduced-surface Revest entry contract that disabled more complex flows while mitigations were put in place.
    • The larger lesson is that callback-bearing token standards and next-id helpers must be treated as one security object; if either can observe the other half-finished, issuance becomes forgeable.

Pattern tags

Why the exploit worked

  • FNFTHandler.mint() authenticated the wrong object boundary. It treated getNextId() as if it already referred to a future, unused series even though ERC-1155 delivery had not finished and fnftsCreated still pointed at the in-flight id.
  • depositAdditionalToFNFT() then gave the attacker a perfect bridge from that half-finished namespace into money-good vault state. A partial-supply split asked for a supposedly fresh id mid-callback, so one tiny real deposit could be rebound onto a giant zero-value series that had already been minted.
  • TokenVault.handleMultipleDeposits() completed the economic rewrite by trusting newFNFTId as a blank configuration slot rather than proving the destination series was actually unused. Once it overwrote depositAmount, millions of already-issued receipts changed from worthless placeholders into valid withdrawal rights.
  • Withdrawal logic honored the rewritten series config as canonical reserve truth. The exploit therefore paid because receipt issuance, receipt meaning, and vault redemption all disagreed about when a series became final.
  • BlockSec’s follow-on zero-day matters because it shows the bug class was broader than one callback edge: handleMultipleDeposits() also blurred old-series and new-series accounting authority. The callback race was the live exploit path, but the deeper failure was that issuance-splitting and vault-config mutation were not one tightly owned state machine.

Three issuance promotions this incident collapsed

  1. A not-yet-finalized next id was allowed to behave like an actually fresh issuance slot.
    • FNFTHandler.mint() delivered ERC-1155 tokens before advancing fnftsCreated, so the callback window exposed an id that was still only the series currently being minted.
    • That should have remained an unfinished allocator state, not something sibling flows could already consume as the next blank series.
  2. That half-finished allocator state was over-promoted into canonical destination-series truth for a split flow.
    • During onERC1155Received(), depositAdditionalToFNFT() asked getNextId() for a supposedly unused split destination and received the same in-flight id.
    • The real authorization break was not merely callbacks exist; it was the protocol treated an id still being minted as the canonical fresh destination whose economics were now safe to define or overwrite.
  3. That rewritten destination-series story still inherited live vault redemption authority.
    • TokenVault.handleMultipleDeposits() trusted the returned newFNFTId as a blank config slot and rewrote depositAmount, turning millions of already-minted zero-value receipts into valid claims on real vault balances.
    • The final authority jump was from this looks like a new split series to these existing FNFTs may now redeem real escrowed assets, which is why an allocator race became an immediate vault drain rather than a harmless metadata collision.

Detection and audit pivots

  • Search every ERC-1155 / ERC-721 mint or safe-delivery path for id chosen, callback fires, counter increments later. Treat id allocators, supply maps, and per-series config writes as callback-sensitive solvency state, not as harmless bookkeeping.
  • For any getNextId() or new series helper, prove the returned id cannot already have supply, config, or mint-in-progress state. Came from the allocator is not enough; reviewers should demand an explicit unused slot invariant.
  • Review split, migration, deposit-additional, and receipt-upgrade paths together with minting. Revest paid out because a sibling function could create or rewrite a supposedly new series while another series with that id was still being delivered.
  • Add adversarial tests where contract recipients re-enter from onERC1155Received() / onERC721Received() into every path that can create ids, rewrite receipt economics, or move vault balances.
  • Property-test conservation of redeemable value: after any mint, split, or additional-deposit flow, the total withdrawable underlying across all affected series should equal actual deposited assets, especially when zero-value series and partial-supply splits are allowed.
  • Audit vault write helpers like handleMultipleDeposits() for old series versus new series ownership confusion. If one helper can update both the source and destination series economics, reviewers should assume a namespace or accounting collision is monetizable until proven otherwise.

Failure-prevention checklist

  • Reserve or advance fnftId before any ERC-1155 _mint() can trigger onERC1155Received(). The namespace visible during a callback must already exclude the in-flight series.
  • Treat id allocated, series supply minted, and series economics configured as one atomic issuance object. If those steps cannot be made atomic, sibling paths should fail closed until the object is fully finalized.
  • Require explicit blank-slot proofs before writing new-series config: zero supply, zero config, no mint in progress, and no prior claim history for the destination id.
  • Keep receipt delivered and receipt meaning established in separate phases. A callback-visible FNFT should never still be awaiting the write that determines what each token can withdraw.
  • Constrain split and deposit-additional helpers so they cannot rewrite both source-series and destination-series economics without proving exact value conservation across both sides.
  • Put reentrancy protection on the whole issuance / split / config authority boundary, not only on the one user-facing mint path that first looked dangerous.
  • Fuzz zero-value-series flows, partial-supply splits, and contract-recipient mint callbacks together. Revest only paid because all three features shared one state machine.
  • Add invariant tests that total redeemable underlying across old series plus new series never exceeds assets actually escrowed in TokenVault, even during nested execution.

Audit prompts

  • Which exact mint path exposes getNextId()-derived ids to a receiver callback before fnftsCreated or equivalent namespace state advances?
  • Can any callback-reachable sibling function create a split series, migrate a receipt, or rewrite depositAmount while that namespace is still half-finished?
  • What proves a supposedly fresh destination id is actually blank rather than already minted, already configured, or still being minted in the outer frame?
  • Does the code ever let zero-value series be mass-minted before a later helper writes the per-token claim amount that redemption will honor?
  • If a helper can update both old-series and new-series config, what invariant proves it cannot accidentally duplicate economic meaning across both?
  • Do contract-recipient tests cover mint -> callback -> depositAdditionalToFNFT -> split -> withdraw style sequences, or only simple mint and withdraw happy paths?
  • Is the reentrancy guard placed on the real shared authority surface, or can a callback simply hop into a different function that trusts the same allocator and vault state?
  • What single property test proves that a tiny real deposit can never be rebound onto a large pre-minted receipt supply?

Lessons that should already be standard

  • Next id is not bookkeeping. In receipt systems, namespace allocation is direct custody and issuance authority.
  • ERC-1155 mint callbacks belong in the same threat model as withdrawals and external transfers. Issuance code is callback-exposed code.
  • A receipt should not be callback-visible before its economic meaning is final. Half-configured claim tokens are already an exploit surface.
  • Fresh id from allocator is not a proof of nonexistence if the allocator itself can be observed mid-transition.
  • Split, migration, and deposit additional helpers are solvency code. They do not become safer just because they are secondary or convenience paths.
  • If one helper can still rewrite old-series and new-series economics after minting has started, the protocol has not actually decided what object owns claim truth yet.
  • Conserving redeemable value across receipt families should be a first-class invariant, not an informal expectation.

Why this incident matters

  • Revest is a sharp corpus exemplar for reentrancy beyond balance accounting: the callback did not merely race a balance update, it raced the protocol’s series-identity allocator and then rewrote the economic meaning of millions of already-issued claims.
  • It is also a strong ERC-1155 lesson. Protocols often think about callbacks only at withdrawal or transfer boundaries, but issuance code that mints to contracts is just as exposed.
  • The note is worth keeping alongside other overissuance incidents because the payout came from turning worthless receipt tokens into vault-valid withdrawal rights, not from manipulating a price feed or traditional borrow path.

Internal linkages

Control surface