GoldReserve

Profit followed the NFT. The record of what was paid followed the address.

BNB Chain · chain 56Block 46,278,330February 2025Reward accounting12.74 BNB lost
What happened

claimProfit() paid each caller for the NFTs it held at that moment, times the profit accrued per NFT, but recorded what it had paid per address. Moving the same NFTs to a fresh address reset the record, so the same profit could be claimed again, and a freshly minted NFT inherited all the profit that had accrued before it existed.

The attacker flash-borrowed 120 BNB, deposited it as profit, minted eight NFTs that instantly “owned” it, then walked them through 22 addresses, claiming each time.

The fix that holds

Carry the claim debt with the tokens, on mints as well as transfers.

The exploit needs both halves of the bug, so a fix for either half stops it. The security obligations state each half on its own, which is what catches a half-fix.

Sources
What a fix must do

The exploit, plus 12 hidden obligations.

The exploit must fail for a reason the task declares. The security obligations state the vulnerability class, so each one fails on the original contract by construction. The legitimate-use obligations hold before and after a correct repair. Hidden means kept out of the fixer’s workspace; every test is published in the repository.

The exploit

1 replay
  • !The DeFiHackLabs reproduction of the attack, replayed at block 46,278,330 It lands if the attacker’s profit clears 12 BNB (the incident: 12.74 BNB lost). It counts as blocked only if it fails in the attack or profit step with an assertion failure, “exploit did not yield profit” or “Nada a reclamar”. test/poc.t.sol:GoldReserve_PoC:testExploit()

Security obligations

3 · fail on the original
  • !Holders are paid only for the NFTs they held when the profit accruedTwo holders with the same stake must be paid the same, however many NFTs one mints later.test/hidden/security.t.sol:GoldReserve_Security:test_a_holder_is_paid_only_for_the_nfts_it_held_when_profit_accrued()
  • !Moving paid NFTs to a fresh address cannot claim the same profit againThe step the incident repeated 22 times, restated without a flash loan.test/hidden/security.t.sol:GoldReserve_Security:test_moving_the_nfts_to_a_fresh_address_cannot_reclaim_the_same_profit()
  • !An NFT minted after a deposit earns nothing from that depositThe mint half of the bug: deposit first, mint second.test/hidden/security.t.sol:GoldReserve_Security:test_nft_minted_after_a_deposit_inherits_no_profit()

Legitimate use · behaviour

4 · hand-written
  • Collection metadata is intactSymbol, name, mint price, supply, owner and token URI still read back.test/hidden/regression.t.sol:GoldReserve_Regression:test_metadata_preserved()
  • A holder from before a deposit can claim their share, exactly onceThe contract’s whole purpose.test/hidden/regression.t.sol:GoldReserve_Regression:test_mint_then_deposit_then_claim_pays_the_holder()
  • Minting at the listed price worksAnd updates the accounting.test/hidden/regression.t.sol:GoldReserve_Regression:test_mint_works()
  • An ordinary NFT transfer workstest/hidden/regression.t.sol:GoldReserve_Regression:test_nft_transfer_works()

Legitimate use · interface

5 · generated
  • Original functions still answerCalls the original functions and requires an answer other than “no such function”.test/hidden/invariants_auto.t.sol:AutoInvariants:test_abi_selectors_dispatch()
  • Every original function is still in the dispatch tableWalks the patched bytecode and requires each original selector in the dispatcher.test/hidden/invariants_auto.t.sol:AutoInvariants:test_abi_selectors_preserved()
  • The patched contract has codetest/hidden/invariants_auto.t.sol:AutoInvariants:test_contract_has_code()
  • Guard: the selector check can say noAn impossible selector must be reported absent, or the check above proves nothing.test/hidden/invariants_auto.t.sol:AutoInvariants:test_selector_check_is_not_vacuous()
  • Guard: unknown calls are still rejectedWithout this, a catch-all fallback would make the dispatch probe meaningless.test/hidden/invariants_auto.t.sol:AutoInvariants:test_unknown_selector_is_rejected()
The test fixes

11 fixes, graded.

Complete repairs, near-misses, fixes that break the contract, fixes that only look like repairs, the original contract as a baseline and runs with missing evidence, each written to probe the verifier. 4 of them stop the exploit and are still not repairs. An exploit-only check would accept all 4; they are highlighted.

3 solved7 not solved1 inconclusive
FixKindExploitSecurityLegit useSCAR verdict
Reference repairSettles the claim debt for mints and transfers alike, after the balance update.Complete repairblocked3 / 39 / 9Solved
Alternative repair: separate debt ledgerKeeps the debt in a new mapping and subtracts it at claim time. Different storage, same property.Complete repairblocked3 / 39 / 9Solved
Alternative repair: settle firstThe same settlement placed before the balance update, with a guard instead of an early return.Complete repairblocked3 / 39 / 9Solved
Settles on mints onlyCarries the debt on mints but not on transfers. The address-hopping the incident repeated 22 times survives.Incomplete repairblocked2 / 39 / 9Not solved
Settles on transfers onlyCarries the debt on transfers but not on mints. The half of the bug the incident used first survives.Incomplete repairblocked1 / 39 / 9Not solved
Claims always revertEvery claim reverts with the message the task treats as “exploit blocked”. Nobody can be paid.Breaks legitimate useblocked1 / 38 / 9Not solved
Repair that drops a functionThe reference repair plus a renamed public setter. Only the interface checks can see it.Breaks legitimate useblocked3 / 37 / 9Not solved
Settles on burns onlyShaped like the fix, but touches neither half of the bug. The exploit still lands.Non-repairlands0 / 39 / 9Not solved
Empty patchA “patch” byte-identical to the original source.Baselinelands0 / 39 / 9Not solved
Original contractThe code as it was exploited. The attack still works.Baselinelands0 / 39 / 9Not solved
Syntax errorThe reference repair with a syntax error injected.Missing evidencenot runInconclusive

Every row opens its receipt. Exploit: blocked; lands; not counted (the exploit test fails, but in its precondition checks or for a reason the task does not declare); not run; or not established on missing state.

Reproduce

Every grade here can be re-run.

The task ships its frozen chain state: 1,020 recorded RPC responses, served offline by a proxy that refuses anything it did not record. On 23 Sep 2026 all 11 grades in this case reproduced on two machines, both hashes each: a fresh clone on the Mac that recorded them, and GitHub’s Linux CI runner.

Needs Foundry 1.7.1 with solc 0.8.30, 0.8.26 and 0.8.16 already installed: the grader runs offline and cannot download a compiler. Python 3.12 or later. Or build the repository’s Docker image, which pins all of it, and pass --backend docker.

Terminal
$ git clone https://github.com/FarseenSh/evmpatch-env.git && cd evmpatch-env
$ git checkout 165c0ed
# the reference repair
$ python -m evmpatch_env.sandbox tasks/goldreserve_2025_02 --reference-patch --backend local --sha256
# the original contract
$ python -m evmpatch_env.sandbox tasks/goldreserve_2025_02 --backend local --sha256
# every test fix in this case (needs uv)
$ uv run --no-project --python 3.12 python worked_example/goldreserve_2025_02/controls/run_controls.py

Reference: core fd82c8e0…e0ea, receipt. Original: core 546fc03a…b866, receipt.