Skip to content

fix: F-2026-18146 | [Dual Defense] Unbounded Universal Payload Size and Unbounded gRPC Response in Universal Validator - #349

Merged
0xNilesh merged 3 commits into
audit-fixesfrom
F-2026-18146
Aug 26, 2026
Merged

fix: F-2026-18146 | [Dual Defense] Unbounded Universal Payload Size and Unbounded gRPC Response in Universal Validator#349
0xNilesh merged 3 commits into
audit-fixesfrom
F-2026-18146

Conversation

@0xNilesh

@0xNilesh 0xNilesh commented Aug 26, 2026

Copy link
Copy Markdown
Member

F-2026-18146 — Unbounded Universal Payload Size / Unbounded gRPC Response in the Universal Validator

Severity: Info. Two defences, one PR — a hard size limit on the chain side, and an explicit
receive bound on the validator side.

Why a flat size cap and not a gas price

MsgExecutePayload and MsgVoteInbound are fee exempt (app/txpolicy/gasless.go), so nothing
charges the submitter for the bytes it puts into a block, into consensus state, and into every node's
memory. On a fee-exempt path a price curve is not a defence — the size has to be bounded outright.

The number is flat rather than derived from the Solidity UniversalPayload struct because that struct
is variable length; a flat number is auditable and stable. 128 KiB, deliberately the same limit
F-2026-18140 (#346) applies to an ed25519 raw message, so there is one payload size limit to reason
about, not two. Different code paths, same number.

1 — Core: MaxUniversalPayloadBytes = 128 * 1024

x/uexecutor/types/constants.go. Two small helpers apply it:

  • (*UniversalPayload).ValidateSize() — the proto-serialized size of the payload
    (Size()), so every field is covered, not just data.
  • ValidatePayloadBlobSize(field, blob) — the hex blobs that carry a payload on the wire
    (raw_payload, verification_data).

Applied at each point a payload enters:

Entry Check
MsgExecutePayload.ValidateBasic payload size (via UniversalPayload.ValidateBasic) + verificationData
Keeper.ExecutePayload step 1 payload size + verificationData, before any EVM work
Inbound.ValidateBasic raw_payload, verification_data, embedded universal_payload
Keeper.VoteInbound same, at the top — before any state read or write
Inbound.ValidateForExecution payload size (via UniversalPayload.ValidateBasic), post-decode
Keeper.RevertStuckInbound inherits, it already calls Inbound.ValidateBasic

The keeper checks are not redundant belt-and-braces. A universal validator broadcasts its votes wrapped
in authz.MsgExec (universalClient/pushsigner/pushsigner.go wrapWithAuthZ). authz.MsgExec has no
ValidateBasic of its own, so baseapp's validateBasicTxMsgs does not reach the inner msg at CheckTx;
the inner ValidateBasic runs later, inside authz's Exec msg server, during delivery. Putting the cap
in the keeper as well makes it hold for every caller of the method regardless of which msg route, or
which SDK version's authz behaviour, carried the payload in.

Nothing in the tree comes close to 128 KiB — the largest hex literal anywhere is ~23 kB of characters.
No in-tree payload or fixture changes behaviour. One existing assertion is affected, see the
F-2026-18798 note below.

2 — UV: an explicit gRPC receive bound

universalClient/pushcore/pushCore.go built its dial options with transport credentials only —
no receive-size limit anywhere in universalClient/ (zero matches for MaxCallRecvMsgSize /
MaxRecvMsgSize before this change). The client was running on grpc-go's implicit 4 MiB default:
a bound nobody chose and that is tied to nothing this client asks for.

opts := []grpc.DialOption{
    grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxPushCoreRecvMsgSize)),
}

maxPushCoreRecvMsgSize = 8 MiB, sized off the largest poll rather than picked: GetAllPendingOutbounds
asks for pendingOutboundPageSize (1000) entries and gets the matching outbounds back, so 2 x 1000 rows
in one response. A row costs roughly a kilobyte today (the in-tree comment already says so), so a 4 KiB
per-row budget gives 4x headroom and lands on 8 MiB. Above the 4 MiB the client has been running on, so
nothing that works today starts failing; low enough that a hostile or broken endpoint cannot stream an
unbounded body into the validator. An oversized response now fails as
ResourceExhausted: received message larger than max (N vs. 8388608) against a bound this repo chose.

Tests

x/uexecutor/types/payload_size_test.go — a payload whose Size() is exactly 131072 is accepted;
131073 is rejected with universal payload too large: 131073 bytes exceeds the 131072 byte limit.
Same boundary for raw_payload, verification_data and the embedded universal_payload.

test/integration/uexecutor/payload_size_cap_test.go — over the real authz.MsgExec route, and
straight into the keeper. Asserts state before the error: an oversized vote writes no
PendingInbounds entry and creates no UniversalTx; a vote at the cap is recorded with its full
128 KiB raw_payload.

universalClient/pushcore/recv_size_test.go — a real gRPC server on a loopback port. A 5 MiB response
(above grpc-go's default, below ours) succeeds, which only holds because the dial option is set; a
response one byte past the bound fails as ResourceExhausted naming 8388608.

Mutation check

Reverted both caps (ValidateSize and ValidatePayloadBlobSize to return nil; the dial option
removed), kept the tests:

  • 7 core assertions flipped to An error is expected but got nil.
  • The integration state assertions flipped to Expected error with "collections: not found" in chain but got nil — i.e. without the cap the oversized inbound is written into PendingInbounds, which is
    the state-bloat vector this finding is about; and ExecutePayload walked on into a real
    getUEAForOrigin contract call carrying the oversized payload.
  • The UV test failed with received message larger than max (5242901 vs. 4194304) — the implicit
    default, demonstrating the exact gap.

Restored, all green.

Interaction with F-2026-18798 (already on audit-fixes)

18798 added an 80-character length cap on the uint256 decimal fields, with a regression that feeds a
3,000,000-digit value through UniversalPayload.ValidateBasic and asserts the per-field message.
A 3M-digit field is a >128 KiB payload, so the payload cap now fires first and shadows that one
assertion. Both rejections are O(1) on len() — no big.Int parse — which is the property that test
exists to hold, so its timing assertion is untouched and now accepts either rejection reason. The
per-field message itself stays pinned by TestValidateUint256String_Bounds and by
TestInboundAndOutbound_RejectHugeDecimalFast, neither of which is size capped.

Worth saying out loud for the Hacken reply: the 128 KiB cap clamps the worst case 18798 was priced
against.
Through a universal payload the largest reachable decimal field is now ~128 K digits, not
3 M, so 18798's quoted parse timings at 900 K+ digits are no longer observable on that path. They
remain reachable through Inbound.Amount and OutboundTx, which this cap does not cover.

Residual, deliberately not fixed here

MsgMigrateUEA.Signature is still unbounded. It rides the same fee-exempt list but carries a migration
payload, not a universal payload; folding it in would widen this PR past the finding. Worth a follow-up
decision.

Test run

go test -mod=readonly -p 1 -count=1 -tags="ledger test_ledger_mock test" ./x/... ./test/integration/...
14 packages ok, 1 failing: test/integration/uexecutor, on
TestRevertStuckInbound_PendingUnreachable_ThresholdMet_CreatesRevertOutbound and
TestRevertStuckInbound_PendingUnreachable_BelowThreshold_Accepted.

Both are pre-existing and unrelated. Checked out at 7473e484 with none of this branch's changes,
the same two fail identically. setupRevertStuckInbound never seeds the UniversalCore gas oracle, so
getOutboundTxGasAndFees on 0x…C0 reverts, the revert outbound lands ABORTED (4) instead of
PENDING (1) and is never indexed in PendingOutbounds. Nothing in this PR touches outbound creation
or gas fees.

go test -mod=readonly -p 1 -count=1 ./universalClient/...24 packages ok, 0 failing.

@0xNilesh
0xNilesh merged commit 9fd3a39 into audit-fixes Aug 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant