Decimal-Exponent Bomb in Gonka: Halting Consensus via Pre-Ante Rescaling and authz.MsgExec
How an unbounded int32 exponent in Gonka's custom Decimal wire type turns shopspring/decimal rescaling into a pre-ante CPU/RAM bomb—and how wrapping it in authz.MsgExec bypasses CheckTx to trigger a post-commit block of death.
On July 23, 2026, we disclosed a critical consensus-liveness vulnerability in the Gonka network (productscience/inference and its Cosmos SDK fork github.com/gonka-ai/cosmos-sdk v0.53.3-ps17-observability), earning a $20,000 bug bounty. Instead of using Cosmos SDK’s fixed-point LegacyDec, Gonka defines a custom protobuf message Decimal { int64 value; int32 exponent } and converts it directly into shopspring/decimal (v1.4.0) with no exponent bound. When MsgValidation.ValidateBasic() checks whether value_decimal lies in [0, 1] via GreaterThan(shopspring.NewFromInt(1)), shopspring/decimal aligns the exponents by materializing 10^|exponent| as a heap-allocated math/big.Int.
Because the active gonka-ai/cosmos-sdk fork runs validateBasicTxMsgs inside baseapp.runTx before the AnteHandler in every execution mode, that big.Int.Exp allocation is completely unmetered and runs prior to signature verification or fee deduction. What makes this bug particularly interesting is how an attacker can weaponize it across trust boundaries—moving from crashing a single RPC node in CheckTx, to gossiping valid in-range negative exponents across the mempool, to wrapping exponent = -2147483648 inside cosmos.authz.v1beta1.MsgExec so the transaction sails through CheckTx in microseconds and OOM-kills every validator post-commit in FinalizeBlock before the authz grant check is even reached.
The Primitive: Unbounded Wire Exponents in Pre-Ante ValidateBasic()
To represent fractional inference-validation scores on the wire, Gonka defines a two-field protobuf struct in decimal.proto and a helper method ToDecimal() that constructs a shopspring/decimal.Decimal (value * 10^exponent):
// productscience/inference proto/inference/inference/decimal.proto
message Decimal {
option (gogoproto.equal) = true;
int64 value = 1;
int32 exponent = 2;
}
// productscience/inference x/inference/types/decimal.go
func (d *Decimal) ToDecimal() decimal.Decimal {
return decimal.New(d.Value, d.Exponent)
}
When a node receives a MsgValidation message, MsgValidation.ValidateBasic() verifies that ValueDecimal falls inside [0, 1]:
// productscience/inference x/inference/types/message_validation.go (ValidateBasic)
if msg.ValueDecimal != nil {
decimalValue := msg.ValueDecimal.ToDecimal()
// value in [0,1]
if decimalValue.IsNegative() || decimalValue.GreaterThan(shopspring.NewFromInt(1)) {
return errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "value must be in [0,1]")
}
}
Locally, this looks harmless: decimal.New only stores big.NewInt(d.Value) and d.Exponent in a struct (O(1)), and IsNegative() only checks the sign of the significand.
The trap is decimalValue.GreaterThan(shopspring.NewFromInt(1)). In github.com/shopspring/decimal@v1.4.0, GreaterThan calls Cmp → RescalePair → rescale. Unlike fixed-point libraries, shopspring/decimal has no magnitude fast-path when two positive numbers have different exponents—it rescales the larger exponent down to the smaller exponent by computing 10^diff as a math/big.Int:
// github.com/shopspring/decimal@v1.4.0/decimal.go:499-503 (rescale)
expScale := new(big.Int).Exp(tenInt, big.NewInt(int64(diff)), nil)
if exp > d.exp {
value = value.Quo(value, expScale)
} else if exp < d.exp {
value = value.Mul(value, expScale)
}
Worse still, in the active gonka-ai/cosmos-sdk v0.53.3-ps17-observability fork, baseapp.runTx runs validateBasicTxMsgs at the very start of transaction processing—before installing a GasMeter and before executing the AnteHandler—in every mode (CheckTx, ReCheckTx, Simulate, and FinalizeBlock):
// gonka-ai/cosmos-sdk@v0.53.3-ps17-observability baseapp/baseapp.go:900-903 (runTx)
msgs := tx.GetMsgs()
if err := validateBasicTxMsgs(msgs); err != nil {
return sdk.GasInfo{}, nil, nil, err
}
This immediately gives a keyless remote attacker an unauthenticated crash primitive (Tier A): broadcasting a ~300-byte MsgValidation with ValueDecimal = { Value: 1, Exponent: -2147483648 } and a garbage signature forces rescale to compute 10^2147483648 during CheckTx before the signature is ever checked, pinning CPU and allocating multiple gigabytes of Karatsuba temporaries until the RPC or sentry node OOMs.
Pivot 1: Passing CheckTx with an In-Range Negative Exponent
Crashing a directly reachable RPC node in CheckTx does not halt the blockchain: if the node hangs or dies inside CheckTx, the transaction is never admitted to the CometBFT mempool and never gossips to sentried validators.
How do we get a poison MsgValidation past CheckTx so CometBFT gossips it across the entire network and an honest proposer packs it into a block?
Look closely at the math in ValidateBasic() when the exponent is negative:
- Suppose we pass
ValueDecimal = { Value: 1, Exponent: -E }withE = 10,000,000. - To compare
1 * 10^-10000000against1 * 10^0,RescalePairstill computesnew(big.Int).Exp(10, 10000000, nil)—burning ~1.37 seconds of CPU and 35.7 MB of heap allocations on a single transaction. - Once that rescale finishes, what is the mathematical value of
10^-10000000? It is0.000...001, which lies strictly inside[0, 1]. - Therefore,
GreaterThan(1)returnsfalse, andValidateBasic()returnsnil(valid)!
Two protocol features in Gonka turn this accepted CheckTx validation into a free, self-propagating consensus stall (Tier A′):
- Zero fees (
NetworkDutyFeeBypassDecorator):MsgValidationis classified as a fee-exempt network duty (x/inference/ante/ante_fee.go:116-126), so an ordinary participant with nonzeroValidationWeightin the current epoch pays zero gas fees to broadcast thousands of them (and live mainnet/testnet run withmax_gas = "-1"). - Bypassable duplicate filter (
revalidation = true): DuringCheckTx,ValidationEarlyRejectDecoratorchecks whether the participant already validatedmsg.InferenceId—but explicitly skips that check whenevermsg.Revalidation == true:
// productscience/inference x/inference/ante (ValidationEarlyRejectDecorator)
// Duplicate detection is only relevant for non-revalidation flows.
if !msg.Revalidation {
egv, found := d.inferenceKeeper.GetEpochGroupValidations(ctx, msg.Creator, inference.EpochId)
if found && slices.Contains(egv.ValidatedInferences, msg.InferenceId) {
return inferencetypes.ErrDuplicateValidation
}
}
Every MsgValidation with exponent = -10,000,000 and revalidation = true clears CheckTx (code 0), gossips peer-to-peer to every validator, and is packed by the next honest proposer (which runs NoOpMempool by default, decoding and packing transactions in PrepareProposal without re-running ValidateBasic()). When the block commits and enters FinalizeBlock, every validator simultaneously re-pays 10^10000000 in validateBasicTxMsgs plus a second GreaterThan rescale and .String() allocation inside the message handler (msg_server_validation.go:134-144). A few hundred ~300-byte transactions in a single block stall consensus network-wide and leave a permanent replay tarpit in the chain history.
Pivot 2: Deferring a Single-Tx “Block of Death” via authz.MsgExec
Tier A′ is a sustained network-wide stall, but it has two constraints: the attacker must hold an active participant key with nonzero ValidationWeight, and each transaction must use a moderate exponent (-10^7) so that CheckTx actually finishes and gossips it.
Can we do worse? Can an attacker with no participant key, no authz grant, and no malicious validator land a single exponent = -2147483648 transaction inside a committed block to guarantee an immediate, unrecoverable OOM crash across all validators?
To pull that off, we need CheckTx to skip MsgValidation.ValidateBasic() while FinalizeBlock still runs it. That exact validation asymmetry exists when MsgValidation is wrapped inside cosmos.authz.v1beta1.MsgExec (Tier A″):
1. Why the MsgExec Wrapper Survives CheckTx in Microseconds
First, in gonka-ai/cosmos-sdk v0.53.3-ps17-observability, *authz.MsgExec does not implement sdk.HasValidateBasic (x/authz/msgs.go exposes only GetMessages()). Consequently, validateBasicTxMsgs in CheckTx skips MsgExec without inspecting its inner messages.
Second, both of Gonka’s custom inference AnteHandler decorators explicitly unwrap one layer of authz.MsgExec:
// productscience/inference x/inference/ante/ante_fee.go (isNetworkDuty)
func isNetworkDuty(msg sdk.Msg, ik *inferencemodulekeeper.Keeper) bool {
if execMsg, ok := msg.(*authztypes.MsgExec); ok {
// ... unwraps one MsgExec level and checks isExemptMessageType(unwrapped)
When an attacker uses any ordinary signable account as the outer MsgExec.grantee and wraps an inner MsgValidation{ creator: <public_weighted_participant>, inference_id: <public_inference_id>, revalidation: true, value_decimal: { value: 1, exponent: -2147483648 } }:
NetworkDutyFeeBypassDecoratorunwrapsMsgExec, seesMsgValidation, and waives transaction fees.ValidationEarlyRejectDecoratorunwrapsMsgExecand verifies only that the innercreatorstring (a public on-chain address copied by the attacker) hasValidationWeightforinference_id, skipping duplicate detection becauserevalidation == true.- Neither decorator calls
ToDecimal(), and neither checks whether<public_weighted_participant>ever grantedauthzpermission to the attacker!
2. Why FinalizeBlock Detonates Before Checking the authz Grant
The wrapped transaction clears CheckTx in microseconds with only the attacker’s outer signature, gossips across the mempool, and is committed into a block by the next honest proposer. During FinalizeBlock, baseapp.runMsgs dispatches the committed MsgExec to authz.Keeper.Exec:
// gonka-ai/cosmos-sdk@v0.53.3-ps17-observability x/authz/keeper/msg_server.go:95-141
func (k Keeper) Exec(goCtx context.Context, msg *authz.MsgExec) (*authz.MsgExecResponse, error) {
// ...
msgs, err := msg.GetMessages()
// ...
if err := validateMsgs(msgs); err != nil { // <-- Line 116: runs inner ValidateBasic() FIRST
return nil, err
}
results, err := k.DispatchActions(ctx, grantee, msgs) // <-- Line 120: grant lookup is HERE
// ...
}
func validateMsgs(msgs []sdk.Msg) error {
for i, msg := range msgs {
m, ok := msg.(sdk.HasValidateBasic)
if !ok { continue }
if err := m.ValidateBasic(); err != nil { // <-- *MsgValidation detonates 10^2147483648
return errorsmod.Wrapf(err, "msg %d", i)
}
}
return nil
}
Look at the ordering in authz.Keeper.Exec: validateMsgs(msgs) runs at line 116, while k.DispatchActions(ctx, grantee, msgs)—which queries the authz store and returns authz.ErrNoAuthorizationFound (x/authz/keeper/keeper.go:121-130)—is not called until line 120.
┌──────────────────────────────────────────────────────────────────────────────────┐
│ TIER A'' (authz.MsgExec) DEFERRED DETONATION ACROSS STAGES │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ Tx: authz.MsgExec { │
│ grantee: <any_attacker_account>, // outer tx signer (no grant) │
│ msgs: [ MsgValidation { │
│ creator: <public_participant_addr>, // copied from chain state │
│ inference_id: <public_inference_id>, // copied from chain state │
│ revalidation: true, // skips duplicate filter │
│ value_decimal: { value: 1, exponent: -2147483648 } │
│ } ] │
│ } │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ 1. CheckTx: Survives in <1 ms │ │
│ │ • validateBasicTxMsgs : MsgExec is NOT HasValidateBasic -> skipped │ │
│ │ • Ante decorators : unwrap MsgExec -> 0 fee, valid public creator/id │ │
│ │ • Result : code 0 -> CometBFT mempool gossip -> block commit │ │
│ └─────────────────────────────────────┬──────────────────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ 2. FinalizeBlock: Post-Commit Detonation in authz.Keeper.Exec │ │
│ │ • msg_server.go:116 : validateMsgs(msgs) calls inner ValidateBasic() │ │
│ │ • decimal.go:499 : computes new(big.Int).Exp(10, 2147483648, nil) │ │
│ │ • msg_server.go:120 : DispatchActions (grant check) is NEVER REACHED │ │
│ │ • Result : every validator OOMs on committed block (crash loop)│ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
Because CometBFT persists the committed block to disk before FinalizeBlock runs, every validator OOM-crashes before ErrNoAuthorizationFound can be returned—and crashes again at the exact same block height every time it restarts.
Empirical Cost Curve and Verification
To verify both the shopspring/decimal v1.4.0 rescale curve and the authz.Keeper.Exec execution ordering without disrupting live networks, we ran standalone micro-benchmarks and a production test suite (//go:build poc) against the pinned gonka-ai/cosmos-sdk v0.53.3-ps17-observability fork:
Exponent (-E) |
Wall-Clock Time | Heap Allocations | ValidateBasic() Return |
|---|---|---|---|
-100,000 |
1.06 ms |
0.31 MB |
nil (in [0, 1]) |
-1,000,000 |
28.0 ms |
2.8 MB |
nil (in [0, 1]) |
-5,000,000 |
350.9 ms |
17.4 MB |
nil (in [0, 1]) |
-10,000,000 |
1.06 s |
26.3 MB |
nil (in [0, 1]) |
-50,000,000 |
18.3 s |
155 MB |
nil (in [0, 1]) |
-100,000,000 |
65.0 s |
331 MB |
nil (in [0, 1]) |
-200,000,000 |
133.0 s |
405 MB |
nil (in [0, 1]) |
-400,000,000 |
> 9.8 min (killed) |
~0.5+ GB |
Hangs / OOM |
Running the test suite directly against the production MsgValidation.ValidateBasic() and authz.Keeper.Exec implementations on Linux confirms both the cost curve and the pre-grant detonation:
--- PASS: TestPoC_Sink_CostCurve (real MsgValidation.ValidateBasic)
exp=-1000000 time=33.9ms alloc=3.6 MB err=<nil>
exp=-3000000 time=179.1ms alloc=10.2 MB err=<nil>
exp=-10000000 time=1.373s alloc=35.7 MB err=<nil>
--- PASS: TestPoC_Sink_UnboundedHang (real MsgValidation.ValidateBasic)
ValidateBasic did NOT return within 20s for exponent=-200000000
--- PASS: TestPoC_Authz_MsgExec_SkipsCheckTxValidation
MsgExec is NOT HasValidateBasic -> skipped by validateBasicTxMsgs in CheckTx
--- PASS: TestPoC_Authz_Exec_DetonatesBeforeGrant (real authz.Keeper.Exec)
Exec stuck in validateMsgs (msg_server.go:116) after 15s — inner
ValidateBasic detonated BEFORE DispatchActions/grant (:120); no grant/funds/key
ok github.com/productscience/inference/x/inference/types 36.675s
Remediation and Engineering Takeaways
No operational configuration change (firewalling RPCs, revoking authz grants, or lowering max_bytes / max_gas) stops a self-propagating Tier A′ or Tier A″ payload once it enters P2P gossip. Fixing the vulnerability requires enforcing a strict exponent bound at the Decimal chokepoint before any conversion to shopspring/decimal:
// Recommended guard in productscience/inference x/inference/types/decimal.go
const (
maxDecExp = 18
minDecExp = -18
)
func (d *Decimal) Validate() error {
if d == nil {
return nil
}
if d.Exponent > maxDecExp || d.Exponent < minDecExp {
return fmt.Errorf("decimal exponent %d out of range [%d,%d]", d.Exponent, minDecExp, maxDecExp)
}
return nil
}
Calling d.Validate() at the top of MsgValidation.ValidateBasic() and hardening ToDecimal() / ToLegacyDec() closes MsgValidation across all execution modes—including the authz.MsgExec wrapper path—as well as sibling Decimal sinks in MsgUpdateParams.ValidateBasic() (validateStabilityZoneBound) and MsgRegisterModel.ValidationThreshold.
Three broader engineering lessons stand out from this finding:
- Never expose an unbounded exponent to an arbitrary-precision decimal library: Unlike fixed-scale decimals (
LegacyDec),shopspring/decimalconstructs(value, exp)inO(1)time but materializes10^|exp_1 - exp_2|in memory on the first comparison or.String()call. Any wire format carrying an exponent must clamp it to a tight range ([-18, 18]) before conversion. - Pre-ante
ValidateBasic()and Gorecover()are not protected by the CosmosGasMeter: Pure Gobig.Intarithmetic consumes zero SDK gas, and a Go runtime OOM (fatal error: runtime: out of memory) cannot be caught bybaseapp.runTx’srecover(). - Wrapper messages like
authz.MsgExecmust not defer expensive inner validation pastCheckTxor prior to authorization checks: When customAnteHandlerdecorators unwrapMsgExecduringCheckTxto grant fee exemptions, skipping innerValidateBasic()untilauthz.Keeper.Exec—and runningvalidateMsgsbefore checking whether a grant exists—turns any inner validation bug into an unauthenticated post-commit block of death.