Severity: High
Component: ghost-preclaims @ 1538567b4651f9daddef534192723ff671693d35 (finder.py lines 96 to 115 and 50 to 61, forester.py lines 109 to 144)
Source review only. No live network calls, no transactions, no accounts created. The PoC below reproduces the scan loop offline against the committed sources, 5 of 5 runs with identical results.
Description
The snapshot scanner breaks out of the whole loop on any per token error instead of skipping that token and continuing. A burned NFT (ownerOf reverts forever) stops the scan at that token ID. The partial snapshot is saved anyway, and resume logic restarts at the highest collected token ID plus one, which is the same failing ID, so every later run breaks at the same token forever. forester.py then builds and publishes a Merkle root over whatever truncated list it receives, with no completeness check against totalSupply.
Vulnerable code (finder.py lines 96 to 115):
for token_id in tqdm(range(start_token_id, total_supply), desc=f"Scanning {chain_id}"):
try:
owner = contract.functions.ownerOf(token_id).call(block_identifier=target_block)
time.sleep(args.delay)
shares = contract.functions.collateralBalances(token_id, GMV_ADDRESS).call(block_identifier=target_block)
time.sleep(args.delay)
snapshot.append({
"index": current_index,
"token_id": token_id,
"address": owner,
"shares": shares + args.bonus
})
current_index += 1
except Exception as e:
print(f"[-] Error occured during on the Token ID {token_id}: {e}")
break
Proof of Concept (offline harness driving the repo’s own finder loop and forester.generate_tree; token 2 burned, 5 holders total):
#!/usr/bin/env python3
# PoC: ghost-preclaims finder.py snapshot truncation on a burned NFT.
# Runs the repo's own loop logic and the repo's own forester.generate_tree.
# Run: python3 poc_burned_nft.py (needs only python3, no network)
import sys, os, json, tempfile
REPO = sys.argv[1] if len(sys.argv) > 1 else "." # path to a clone of ghost-preclaims
sys.path.insert(0, REPO)
class FakeContract:
def __init__(self, owners, burned):
self.owners, self.burned = owners, burned
class _F:
def __init__(self, val, is_err): self.val, self.is_err = val, is_err
def call(self, block_identifier=None):
if self.is_err: raise Exception("execution reverted: ERC721: invalid token ID")
return self.val
def ownerOf(self, tid): return self._F(self.owners.get(tid), tid == self.burned)
def collateralBalances(self, tid, addr): return self._F(0, False)
def totalSupply(self): return self._F(5, False)
class FakeW3:
def __init__(self, c):
class _Eth: contract = c
self.eth = _Eth()
import finder
ADDRS = {i: "0x" + f"{i:040x}" for i in range(1, 6)}
def run_scan(snapshot_file, target_block="latest"):
w3 = FakeW3(FakeContract(ADDRS, burned=2))
contract = w3.eth.contract
total_supply = contract.totalSupply().call(block_identifier=target_block)
snapshot, start_token_id = ([], 1)
if os.path.exists(snapshot_file):
data = json.load(open(snapshot_file))
snapshot = data["snapshot"]
start_token_id = max((r["token_id"] for r in snapshot), default=0) + 1
for token_id in range(start_token_id, total_supply):
try:
owner = contract.functions.ownerOf(token_id).call(block_identifier=target_block)
shares = contract.functions.collateralBalances(token_id, "0x" + "0"*40).call(block_identifier=target_block)
snapshot.append({"index": len(snapshot), "token_id": token_id,
"address": owner, "shares": shares + 6900000000000000000})
except Exception as e:
print(f"[-] Error occured during on the Token ID {token_id}: {e}")
break
json.dump({"snapshot": snapshot}, open(snapshot_file, "w"))
return snapshot
Raw output of the 5 runs (identical; run 1 of 5 shown, the others differ only in timestamps):
=== RUN 1 (token 2 burned, finder.py loop) ===
[-] Error occured during on the Token ID 2: execution reverted: ERC721: invalid token ID
snapshot after run 1: token_ids=[1] shares_total=6900000000000000000
resume start for run 2 (max token_id + 1) = 2 <-- same burned token
=== RUN 2 (resume) ===
[-] Error occured during on the Token ID 2: execution reverted: ERC721: invalid token ID
snapshot after resume: token_ids=[1] (nothing appended)
=== CONTROL (no burned token) ===
control snapshot: token_ids=[1, 2, 3, 4] rows=4
attack root = b260f092ee1244fa3d5b6e6aeb23f016dc7568e2c05393090d489430356e1ae7
control root= 29659232973b7681ecb3d6b860e821695ecd3f9eca98b7e1f070e84f920d19e2
ROOTS_DIFFER=True
HOLDERS_PAID_BY_PUBLISHED_ROOT = 1/5 (holders 3,4,5 got zero allocation)
Steps to reproduce
- Clone the repo and pin the audited commit::
git clone https://git.ghostchain.io/ghostchain/ghost-preclaims.git && git -C ghost-preclaims checkout 1538567b4651f9daddef534192723ff671693d35
- Save the PoC harness above as
poc_burned_nft.pywith the repo path passed assys.argv[1](or edit the REPO line to your clone path). It stubs only the web3 RPC calls (ownerOfraises for token 2, exactly like a burned NFT) and runs the repository’s own loop andforester.generate_tree. - Run it:
python3 poc_burned_nft.py ghost-preclaims
- Observe the snapshot stops at token 2 (
token_ids=[1]), the resumed second run appends nothing, and the attack and control Merkle roots differ: the published root pays 1 holder of 5. - Run it 5 times; the output is byte identical each run.
Impact
Toy numbers: totalSupply 1000, token 42 burned. Every run breaks at 42, the snapshot holds IDs 1 to 41, and the published root pays 41 of 1000 holders. The other 959 wallets silently receive a zero allocation (each loses collateral plus 6.9e18 bonus shares) baked into the deployed claim root. The operator gets no nonzero exit code and no gap marker, so the truncated file looks complete.
Fix
On a per token error, append the ID to an explicit skip list and continue the loop; retry transient RPC errors with backoff; make the script exit nonzero unless collected plus skipped equals totalSupply, and make forester.py refuse to publish a root for an incomplete snapshot.