6 min read

Breaking Blockchain Consensus with a JSONPath Wildcard

Breaking Blockchain Consensus with a JSONPath Wildcard
Chain Halt via JSONPath Wildcard

A walkthrough of a High-severity non-determinism bug I found in the SEDA Protocol audit contest.


Background: What is SEDA?

SEDA is a decentralized oracle network built on the Cosmos SDK. It aggregates off-chain data and delivers it on-chain through a consensus-driven process. At a high level, the flow is:

  1. A user submits a Data Request specifying what data should be fetched.
  2. Overlay nodes fetch the data and commit a hash of their result.
  3. Nodes later reveal the underlying data.
  4. The SEDA chain's tally module processes the reveals, applies any configured consensus filter, and derives the final agreed result.

The bug described here comes from that consensus filter.


The Consensus Filter

Each Data Request can optionally include a consensus filter. The relevant filter type here is FilterJson, which applies a JSONPath expression to each revealed JSON object before consensus is evaluated.

This is useful when the reveal contains more data than the requester actually cares about. For example, a reveal might look like:

{
  "price": 3421.50,
  "timestamp": 1735000000,
  "source": "exchange-a",
  "metadata": { ... }
}

If only price matters, the requester can use a JSONPath expression such as $.price so the tally module extracts just that field before comparing results across nodes.

The issue is that the filter accepts arbitrary JSONPath expressions.


Looking at JSONPath Semantics

Once I saw that FilterJson accepted arbitrary JSONPath input, the next step was to check what the underlying query language actually guarantees. JSONPath is not limited to simple key access. It supports features such as:

  • Dot notation$.price
  • Array indexing$.items[0]
  • Recursive descent$..price
  • Filter expressions$.items[?(@.price > 100)]
  • Wildcard selectors$.* or $.items[*]

The wildcard selector is where the problem starts.


What the RFC Says

JSONPath was standardized in RFC 9535. The important detail appears in the section describing wildcard selector behavior:

"The order in which the children of an object appear in the resultant nodelist is not stipulated, since JSON objects are unordered."

In other words, when a wildcard selector is applied to a JSON object, the RFC does not require a deterministic ordering for the results. Different implementations are allowed to return the object's children in different orders.

For arrays, the ordering is defined by array position. For objects, it is not.

That design is reasonable for a general-purpose query language, but it is unsafe in a consensus system where every node must derive identical bytes from the same input.


The Vulnerable Code Path

The tally module processes each reveal by applying the configured JSONPath filter.

obj, err := parser.Parse(revealBytes)
if err != nil {
  errors[i] = true
  continue
}
expr, err := jp.ParseString(dataPath)
if err != nil {
  errors[i] = true
  continue
}
elems := expr.GetNodes(obj)
if len(elems) < 1 {
  errors[i] = true
  continue
}
data := elems[0].String()

The filtered result is then serialized and stored as part of the consensus-critical result.

The relevant flow is:

Reveal JSON
     |
     v
Apply JSONPath expression (FilterJson)
     |
     v
Serialize result -> store in DataResult
     |
     v
Nodes compare DataResults to reach consensus

For a simple expression like $.price, this is deterministic. Every node reads the same field and serializes the same value.

The problem appears when a wildcard is applied to an object. Suppose the reveal is:

{
  "price": 3421.50,
  "volume": 1200000,
  "change": -0.42
}

One node might produce:

[3421.50, 1200000, -0.42]

Another node might produce:

[1200000, -0.42, 3421.50]

Both outputs are valid under RFC 9535. But they are different byte sequences. If those bytes are hashed or stored in consensus-critical state, validators can diverge.


Attack Scenario

An attacker does not need to compromise any node or break any cryptography. They only need to submit a Data Request with a FilterJson expression that uses a wildcard over an object, for example:

$.*

Or:

$.data.*

Or any equivalent expression where the wildcard eventually operates on an object node.

If the revealed object has multiple fields, different validators may serialize the filtered result differently. A larger object increases the number of possible output orderings and makes disagreement more likely. An attacker can increase the chance of divergence by ensuring the oracle program returns a sufficiently large object.

The consequence is a chain halt.

After executing a block, each Cosmos SDK node computes an AppHash, which is the Merkle root of application state. CometBFT includes that hash in the next block header. When block N+1 is proposed, validators check that the header's AppHash matches the one they computed after executing block N.

If validators stored different DataResult values because the JSONPath result was serialized in different orders, they compute different AppHash values. They will then reject headers that do not match their local state. Once the validator set is split across incompatible state roots, the network cannot obtain the 2/3+ precommits needed to finalize the next block.

This is a liveness failure rather than a safety failure. The chain does not finalize conflicting histories; it stops finalizing blocks altogether until operators coordinate a recovery. In practice, that usually means identifying the divergence point, rolling affected validators back to a pre-divergence state, deploying a patched binary, and restarting through an emergency upgrade or coordinated recovery procedure.

One detail that makes this slightly harder to spot operationally is that the divergence does not become visible in the same block where the malicious request is processed. It appears in the following block, when validators compare AppHash values.

The state update path in the EndBlock is what turns this into a consensus failure:

_, tallyResults[i] = k.FilterAndTally(ctx, req, params, gasMeter)
dataResults[i].Result = tallyResults[i].Result
dataResults[i].ExitCode = tallyResults[i].ExitCode
dataResults[i].Consensus = tallyResults[i].Consensus

processedReqs[req.ID] = k.DistributionsFromGasMeter(ctx, req.ID, req.Height, gasMeter, params.BurnRatio)

dataResults[i].GasUsed = gasMeter.TotalGasUsed()
dataResults[i].Id, err = dataResults[i].TryHash()

for i := range dataResults {
  err := k.batchingKeeper.SetDataResultForBatching(ctx, dataResults[i])
}

Once different validators derive different tallyResults[i] values, they also store different DataResult values and compute different state roots.


Why This Is Easy to Miss

This bug is easy to miss because the application code can look completely reasonable. The code path is likely just:

  1. Evaluate the JSONPath expression.
  2. Take the result.
  3. Serialize it.
  4. Store it for consensus.

Nothing about that sequence looks obviously dangerous in isolation.

The issue comes from an assumption mismatch between the application and the dependency. The application assumes the query result is deterministic. The JSONPath specification does not guarantee that for object wildcards.

That kind of issue is harder to notice in code review because the bug does not come from a typical coding mistake such as missing validation, arithmetic errors, or access control failures. It comes from using a dependency in a consensus-critical context without restricting the parts of the dependency that are non-deterministic.


Fix Options

The protocol should ensure that FilterJson only permits deterministic behavior. There are a few ways to do that:

  1. Reject unsafe expressions at validation time
    Disallow JSONPath expressions that can apply wildcard selectors to object nodes, such as .*[*], or any equivalent construct.
  2. Canonicalize query results before serialization
    Sort or otherwise canonicalize the result of every JSONPath query before it is serialized. This is more defensive because it does not rely on an exact classification of which JSONPath features are safe.
  3. Restrict FilterJson to a safe subset of JSONPath
    Only allow deterministic operations such as direct field access and array indexing with concrete indices.

Of these, canonicalization is the most generally robust approach because it reduces the chance of similar non-determinism bugs from other JSONPath features or implementation details. Expression restriction can also work, but it requires confidence that the allowed subset is truly deterministic under all supported inputs.


Takeaways

1. Dependencies are part of the attack surface.

The root cause here is not an obvious error in SEDA's code. It is the interaction between consensus-critical state handling and a dependency whose specification permits non-deterministic output. In audits, dependency behavior matters as much as local application logic when the dependency feeds into hashing, state transitions, or validator agreement.

2. In consensus systems, non-determinism is a correctness issue.

In a single-node application, non-deterministic ordering may just be inconvenient. In a blockchain or other replicated state machine, it can halt the protocol. Whenever the same input is processed by multiple machines, it is worth checking whether the implementation guarantees identical outputs at the byte level.

3. User-supplied expressions in powerful query languages need careful review.

JSONPath, XPath, SQL, and regex all have richer semantics than they first appear to. If users can submit arbitrary expressions and those expressions influence consensus-critical processing, every language feature becomes part of the security review.


This finding was submitted to the SEDA Protocol audit contest on Sherlock and judged as a unique High severity issue.