Footer
General•November 09, 2026

The Secret Network Research Path

The Missing Blob

Secret Network Accepted Selectively Incomplete Wasm Snapshot Restores

By Rahul Saxena (@saxenism)

Severity: Medium — requires victim-initiated state sync

Affected component: Secret3dev/SecretNetwork at commit 2b8e18788e70d79245343ed53bc2cc42a345d2d4 (v1.26.0-community-continuance), Secret store fork v1.1.1-secret.1 (3dbf83228256cdfb6053507dba5acd9ec857b553), Secret Cosmos SDK fork v0.50.14-secret.12 (f51dfde9658840bf59a9ed55037303b4bd52531a), and Secret Comet fork v0.38.23-secret.1 (27c3f3f1d30f47df45b40812e732aa7c56855ba2).

Fix: v1.27.2, released 2026-09-23 and activated on secret-4 at height 27,286,266 (2026-09-23 12:29:45 UTC) under governance proposal 377; v1.27.3 later retained the enclave.

Secret Network commits contract metadata, including CodeInfo and its code hash, inside the Cosmos multistore, but stores the executable Wasm bytes outside that commitment as hash-named files under .compute/wasm/wasm. During state sync, those files travel in a snapshot extension.

In the audited v1.26 stack, the compute snapshotter wrote whatever payloads arrived and treated the end of the payload stream as success without reconciling the received files against restored CodeInfo records. A snapshot provider could therefore serve the canonical multistore, selectively omit one contract's Wasm payload, recompute the candidate's snapshot and chunk hashes, and pass the restore checks. The restored node reported the trusted height and AppHash, and the missing file remained absent across restart.

In a separate SGX hardware harness, applying the same contract call to complete state and to selectively incomplete state diverged: complete state executed the contract and returned code 0, while incomplete state failed to open the missing Wasm file, returned code 3, and derived different TxResultsHash and working post-block AppHash values. That the restored node would then reject the canonical next header follows from the pinned Comet source; it was not observed.

Attack sequence

  1. Construct a selectively incomplete candidate. The attacker takes a valid snapshot at a canonical height, retains the canonical multistore and compute extension metadata (along with any non-target Wasm payloads), removes the target contract's Wasm payload, and recomputes the candidate's snapshot and chunk hashes.
  2. Have the candidate selected during state sync. A node entering state sync with no pre-existing copy of the target Wasm file connects to the provider and selects the candidate. The attacker cannot force a healthy node to reset or enter state sync.
  3. Pass restore verification. The store manager restores the canonical multistore and passes the shortened payload stream to the compute extension, which returns nil at io.EOF. Comet verifies the restored app version, height, and LastBlockAppHash against the light-client-trusted values.
  4. Diverge when the omitted contract is called. The default proposal handler accepts a block calling the omitted contract without executing it (source-derived). During execution, complete state runs the contract while incomplete state returns a missing-file error and derives different result and working application commitments (measured in the separate SGX harness).
  5. Header rejection (source-derived, not observed). From the pinned Comet source, committing those divergent local commitments causes the next canonical header to fail AppHash and LastResultsHash validation.

Root cause

1. Executable state crossed the authenticated boundary

Secret registers .compute/wasm/wasm as a state-sync extension outside the committed multistore.

Verbatim upstream source (app/app.go:424-431, Secret3dev/SecretNetwork@2b8e1878):

if manager := app.BaseApp.SnapshotManager(); manager != nil {
	err := manager.RegisterExtensions(
		compute.NewWasmSnapshotter(app.BaseApp.CommitMultiStore(), app.AppKeepers.ComputeKeeper, filepath.Join(homePath, ".compute", "wasm", "wasm")),
	)
	if err != nil {
		panic(fmt.Errorf("failed to register snapshot extension: %s", err))
	}
}

Contract upload commits CodeInfo (including CodeHash) in the module KV store, while execution loads the hash-named file from .compute/wasm/wasm inside the SGX VM cache and errors if the file is absent.

2. Snapshot creation enumerated the set; restoration did not reconcile it

SnapshotExtension iterated committed CodeInfo entries and emitted each referenced Wasm file. RestoreExtension hashed and wrote each received payload, but returned nil at io.EOF without checking that every committed CodeInfo had been satisfied.

Verbatim upstream source (x/compute/internal/keeper/wasm_snapshotter.go:126-142, Secret3dev/SecretNetwork@2b8e1878):

for {
	wasmBytes, err := payloadReader()
	if err == io.EOF {
		return nil
	} else if err != nil {
		return errorsmod.Wrap(err, "invalid protobuf message")
	}

	codeHash := sha256.Sum256(wasmBytes)
	wasmFileName := hex.EncodeToString(codeHash[:])
	wasmFilePath := filepath.Join(ws.wasmDirectory, wasmFileName)

	err = os.WriteFile(wasmFilePath, wasmBytes, 0o600 /* -rw------- */)
	if err != nil {
		return errorsmod.Wrapf(err, "failed to write wasm file '%v' to disk", wasmFilePath)
	}
}

Per-payload hashing bound each filename to the bytes that arrived, and chunk hashes bound chunks to the candidate's own metadata; neither check detected an omitted payload.

3. State-sync verification covered the multistore root, and proposal admission did not execute contracts

Comet's verifyApp compared the restored app version, height, and LastBlockAppHash against the light-client-trusted values.

Upstream excerpt (statesync/syncer.go:490-521, scrtlabs/tendermint@27c3f3f1; logging omitted):

if !bytes.Equal(snapshot.trustedAppHash, resp.LastBlockAppHash) {
	return errVerifyFailed
}
if uint64(resp.LastBlockHeight) != snapshot.Height {
	return errVerifyFailed
}

Because selective omission left the multistore canonical, those checks passed. And because the pinned SDK used NoOpProcessProposal, proposal admission accepted transactions without executing contract calls, deferring the missing-file failure to FinalizeBlock.

Reproduction and evidence boundary

The finding rests on two separate component witnesses combined with source analysis, not a single continuous P2P-state-sync-to-next-header run:

  • Observed restoration acceptance: A local harness drove the exact affected store manager and WasmSnapshotter through BaseApp's OfferSnapshot and ApplySnapshotChunk entry points. With one target Wasm payload removed and a decoy retained, OfferSnapshot and all chunk applications returned ACCEPT, the target Wasm remained absent while the decoy was present, Info reported the trusted height and AppHash, and the omission persisted across restart.
  • Observed SGX execution divergence: Across three Azure SGX repetitions using the affected v1.26 keeper and VM source (with a development-signed enclave, minimal BaseApp, and SKIP_LIGHT_CLIENT_VALIDATION=TRUE to admit local synthetic chain data), the same signed state-writing contract call returned code 0 (TxResultsHash d6c07def…9d9f24, working AppHash 6dfb1d57…eca4b5) on complete state and code 3 (Error opening Wasm file for reading: No such file or directory, TxResultsHash 77cb6b14…04aaa3, working AppHash e631cce8…5bd90d) on incomplete state.
  • Source-derived downstream behavior: Single-peer candidate admission, default proposal acceptance, local LastResultsHash/AppHash persistence on Commit, and subsequent header rejection (state/validation.go:66-83) are established from the pinned source; the research did not run live P2P snapshot selection, observe the poisoned node's Commit, or observe rejection of a canonical next header. No chain halt was observed or claimed, no snapshot was served to a third-party node, and no transaction was broadcast to secret-4.

Full receipts, hashes, and source-inspection steps are in the technical appendix.

Fix

In v1.27.2, RestoreExtension invokes ensureAllCodeWasmPresent() at io.EOF. The check iterates restored CodeInfo records, resolves each expected hash-named Wasm file on disk, and fails the restore if any referenced file is missing or empty.

Verbatim upstream source (x/compute/internal/keeper/wasm_snapshotter.go:121-166, Secret3dev/SecretNetwork@v1.27.2; shown in two parts):

The end-of-stream branch of the restore loop (the rest of the loop is unchanged from the vulnerable version; // ... marks it as omitted):

for {
	wasmBytes, err := payloadReader()
	if err == io.EOF {
		return ws.ensureAllCodeWasmPresent()
	} else if err != nil {
		return errorsmod.Wrap(err, "invalid protobuf message")
	}

	// ...
}

And the new completeness check, whose doc comment is upstream:

// ensureAllCodeWasmPresent: restore fails until every CodeInfo wasm blob is on disk.
func (ws *WasmSnapshotter) ensureAllCodeWasmPresent() error {
	if ws.keeper == nil || ws.cms == nil {
		return fmt.Errorf("snapshot restore incomplete: wasm snapshotter missing keeper or store")
	}
	ctx := sdk.NewContext(ws.cms, tmproto.Header{}, false, log.NewNopLogger())
	var missing []string
	ws.keeper.IterateCodeInfos(ctx, func(id uint64, info types.CodeInfo) bool {
		path := filepath.Join(ws.wasmDirectory, hex.EncodeToString(info.CodeHash))
		st, err := os.Stat(path)
		if err != nil || st.Size() == 0 {
			missing = append(missing, fmt.Sprintf("%d", id))
		}
		return false
	})
	if len(missing) > 0 {
		return fmt.Errorf("snapshot restore incomplete: missing wasm blobs for code ids %s", strings.Join(missing, ","))
	}
	return nil
}

This closes the confirmed selective-payload omission path: the restored authenticated CodeInfo set defines the required executable files, and omitting a payload from the compute extension causes ApplySnapshotChunk to fail before restoration completes. The public changelog's 1.27.0 entry, carried forward in the v1.27.2 tag, records the change as “Snapshot restore fails until every CodeInfo wasm blob is on disk” and thanks Bluethroat Labs for responsible disclosure.

Timeline

  • 2026-09-09 — Finding reported to Secret Network maintainers under coordinated disclosure with source analysis, the restoration witness, and the hardware SGX execution witness.
  • 2026-09-21 — Governance proposal 377 submitted to upgrade secret-4 to v1.27.2 at height 27,286,266; it passed on 2026-09-22.
  • 2026-09-22 — Maintainers confirmed the remediation.
  • 2026-09-23 — v1.27.2 released; secret-4 upgraded at height 27,286,266, block time 12:29:45 UTC (v1.27.3 followed later).
  • 2026-10-15 — Maintainers cleared the selective-omission finding for publication.

Takeaway

A light-client-authenticated application root does not automatically authenticate files restored beside the multistore. Per-item hashes establish the integrity of items that arrive; restoration must separately prove that every executable referenced by committed state arrived before reporting the node as synchronized.

References

SNAPWASM-001 Technical Appendix

This appendix records provenance, source-inspection steps, and witness receipts for Secret Network Accepted Selectively Incomplete Wasm Snapshot Restores. It covers the confirmed selective-payload omission condition. Hashes containing … were truncated in the submitted report; full values are not available in this bundle.

Pinned revisions

ComponentRevision
Secret Networkgithub.com/Secret3dev/SecretNetwork at 2b8e18788e70d79245343ed53bc2cc42a345d2d4, tag v1.26.0-community-continuance
Secret store forkgithub.com/scrtlabs/cosmos-sdk-store at 3dbf83228256cdfb6053507dba5acd9ec857b553, tag v1.1.1-secret.1
Secret Cosmos SDK forkgithub.com/scrtlabs/cosmos-sdk at f51dfde9658840bf59a9ed55037303b4bd52531a
Secret Comet forkgithub.com/scrtlabs/tendermint at 27c3f3f1d30f47df45b40812e732aa7c56855ba2
Execution mode coveredDefault SGX mode (SECRET_NODE_MODE unset)
Remediation releasev1.27.2, commit 72c548f39602e87924f69289715ae8d7c8cfa7b4, released 2026-09-23

The September 2026 observations below are historical observations of these pinned affected revisions, not claims about current deployment state.

Public-source inspection

The following commands reproduce source inspection only. They do not reproduce the restoration or SGX witnesses.

git clone https://github.com/Secret3dev/SecretNetwork.git SecretNetwork-v1.26
git -C SecretNetwork-v1.26 checkout 2b8e18788e70d79245343ed53bc2cc42a345d2d4

git clone https://github.com/scrtlabs/cosmos-sdk-store.git cosmos-sdk-store
git -C cosmos-sdk-store checkout 3dbf83228256cdfb6053507dba5acd9ec857b553

git clone https://github.com/scrtlabs/cosmos-sdk.git cosmos-sdk-secret
git -C cosmos-sdk-secret checkout f51dfde9658840bf59a9ed55037303b4bd52531a

git clone https://github.com/scrtlabs/tendermint.git tendermint-secret
git -C tendermint-secret checkout 27c3f3f1d30f47df45b40812e732aa7c56855ba2

sed -n '75,143p' SecretNetwork-v1.26/x/compute/internal/keeper/wasm_snapshotter.go
sed -n '339,458p' cosmos-sdk-store/snapshots/manager.go
sed -n '215,236p' cosmos-sdk-secret/baseapp/baseapp.go
sed -n '369,431p' cosmos-sdk-secret/baseapp/abci_utils.go
sed -n '247,323p' tendermint-secret/statesync/syncer.go
sed -n '490,521p' tendermint-secret/statesync/syncer.go
sed -n '352,371p' tendermint-secret/state/execution.go
sed -n '661,729p' tendermint-secret/state/execution.go
sed -n '66,83p' tendermint-secret/state/validation.go
sed -n '347,352p' tendermint-secret/node/node.go

Inspection of the pinned upstream code establishes:

  • Snapshot creation enumerates committed CodeInfo and emits each referenced Wasm payload.
  • The affected restore implementation returns success when the received payload stream reaches EOF without reconciling it against all committed CodeInfo.
  • Snapshot chunk hashes bind data to the candidate's own metadata, not to the light-client-authenticated application root.
  • Comet's final state-sync check compares app version, height, and LastBlockAppHash.
  • The default proposal path accepts without executing contract calls.
  • Comet stores locally derived application and result commitments and validates the next header against them.

Any explanatory prose in this appendix is researcher analysis. The linked repository code and command-selected lines are upstream code; no researcher-authored comments are presented as upstream comments.

Restoration witness

A local harness drove the exact affected store manager and WasmSnapshotter through BaseApp's OfferSnapshot and ApplySnapshotChunk entry points. It preserved canonical committed state, removed one selected Wasm payload while retaining a decoy payload, and recomputed the snapshot data.

Recorded selective-omission receipt:

authentic_target_wasm_present=true poisoned_target_wasm_present=false decoy_present_both=true
baseapp_offer=ACCEPT baseapp_apply_all=ACCEPT comet_info_height_hash_match=true restart_hole_persists=true
poisoned_snapshot_readvertised=true poisoned_chunks_republished_byte_for_byte=true
--- PASS: TestSelectiveComputePayloadOmissionSurvivesRealABCIRestore

This establishes observed restoration acceptance for the selective construction. It does not establish candidate selection through a live P2P reactor or block execution.

The witness test file was added for this research and is not present in the public upstream repositories or this publication bundle. Therefore no command requiring that unavailable file is described as reproducible.

Hardware SGX execution witness

Three Azure SGX component repetitions used the affected v1.26 keeper and VM source. Each applied the same signed state-writing contract call to complete state and selectively incomplete state.

One recorded repetition:

ResultComplete branchIncomplete branchFresh-process incomplete branch
Pre-block AppHashe631cce8…5bd90de631cce8…5bd90de631cce8…5bd90d
Transaction result code033
TxResultsHashd6c07def…9d9f2477cb6b14…04aaa377cb6b14…04aaa3
Working post-block AppHash6dfb1d57…eca4b5e631cce8…5bd90de631cce8…5bd90d
Target Wasm presentyesnono

The incomplete branches reported:

Error opening Wasm file for reading: No such file or directory

Harness conditions:

  • Development-signed enclave.
  • Minimal BaseApp rather than a full node.
  • Synthetic local chain data.
  • SKIP_LIGHT_CLIENT_VALIDATION=TRUE in the go-test build path to admit that data.

In that test path, the flag bypasses current-block height/time and message/certificate-in-block admission predicates. It does not alter snapshot restoration, Wasm file lookup, contract semantics, result hashing, or application hashing.

The fresh-process branch reopened the same pre-trigger application state. It did not test restart after a divergent Commit.

Evidence composition

The research did not run one continuous sequence from malicious P2P snapshot selection through a poisoned node's real Commit and rejection of the next canonical header.

  • Restoration acceptance and persistence across restart were observed in the restoration witness.
  • SGX execution divergence was observed separately in the hardware witness.
  • Snapshot selection, proposal handling, commitment storage, next-header validation, and post-state-sync startup behavior were derived from pinned source.

The observed SGX values show execution divergence. The claim that a poisoned node would later reject a canonical header is source-derived.

Remediation verification

Public-source comparison between v1.26.0-community-continuance and v1.27.2 shows that RestoreExtension changed its end-of-stream behavior from unconditional success to a completeness check. The added function iterates restored CodeInfo, computes each expected hash-named path, and reports missing or empty Wasm files.

The public change is at wasm_snapshotter.go:121-166.

v1.27.2 was released on 2026-09-23. It was activated on secret-4 at height 27,286,266 under governance proposal 377.

Safety

All restoration and execution tests used local, researcher-controlled state and synthetic chain data. No snapshot was advertised to or served to a node outside the harness, and no transaction was broadcast to secret-4.