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
- Construct a selectively incomplete candidate. The attacker takes a valid snapshot at a canonical height, retains the canonical multistore and
computeextension metadata (along with any non-target Wasm payloads), removes the target contract's Wasm payload, and recomputes the candidate's snapshot and chunk hashes. - 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.
- Pass restore verification. The store manager restores the canonical multistore and passes the shortened payload stream to the compute extension, which returns
nilatio.EOF. Comet verifies the restored app version, height, andLastBlockAppHashagainst the light-client-trusted values. - 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).
- Header rejection (source-derived, not observed). From the pinned Comet source, committing those divergent local commitments causes the next canonical header to fail
AppHashandLastResultsHashvalidation.
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
WasmSnapshotterthrough BaseApp'sOfferSnapshotandApplySnapshotChunkentry points. With one target Wasm payload removed and a decoy retained,OfferSnapshotand all chunk applications returnedACCEPT, the target Wasm remained absent while the decoy was present,Inforeported the trusted height andAppHash, 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=TRUEto admit local synthetic chain data), the same signed state-writing contract call returned code0(TxResultsHashd6c07def…9d9f24, workingAppHash6dfb1d57…eca4b5) on complete state and code3(Error opening Wasm file for reading: No such file or directory,TxResultsHash77cb6b14…04aaa3, workingAppHashe631cce8…5bd90d) on incomplete state. - Source-derived downstream behavior: Single-peer candidate admission, default proposal acceptance, local
LastResultsHash/AppHashpersistence onCommit, 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'sCommit, 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 tosecret-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-4tov1.27.2at height 27,286,266; it passed on 2026-09-22. - 2026-09-22 — Maintainers confirmed the remediation.
- 2026-09-23 —
v1.27.2released;secret-4upgraded at height 27,286,266, block time 12:29:45 UTC (v1.27.3followed 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
- Secret Network v1.26 source
- Wasm snapshot extension registration (
app/app.go:424-431) - Snapshot enumeration and affected restore behavior (
wasm_snapshotter.go:75-143) - Wasm file loading (
cache.rs:111-125) - Snapshot manager restore and chunk checks (
snapshots/manager.go:339-458) - Default proposal handler (
baseapp/abci_utils.go:369-431) - Comet state-sync verification (
statesync/syncer.go:490-521) - Comet next-header checks (
state/validation.go:66-83) - Fix in
v1.27.2(wasm_snapshotter.go:121-166)
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
| Component | Revision |
|---|---|
| Secret Network | github.com/Secret3dev/SecretNetwork at 2b8e18788e70d79245343ed53bc2cc42a345d2d4, tag v1.26.0-community-continuance |
| Secret store fork | github.com/scrtlabs/cosmos-sdk-store at 3dbf83228256cdfb6053507dba5acd9ec857b553, tag v1.1.1-secret.1 |
| Secret Cosmos SDK fork | github.com/scrtlabs/cosmos-sdk at f51dfde9658840bf59a9ed55037303b4bd52531a |
| Secret Comet fork | github.com/scrtlabs/tendermint at 27c3f3f1d30f47df45b40812e732aa7c56855ba2 |
| Execution mode covered | Default SGX mode (SECRET_NODE_MODE unset) |
| Remediation release | v1.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.goInspection of the pinned upstream code establishes:
- Snapshot creation enumerates committed
CodeInfoand 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: TestSelectiveComputePayloadOmissionSurvivesRealABCIRestoreThis 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:
| Result | Complete branch | Incomplete branch | Fresh-process incomplete branch |
|---|---|---|---|
Pre-block AppHash | e631cce8…5bd90d | e631cce8…5bd90d | e631cce8…5bd90d |
| Transaction result code | 0 | 3 | 3 |
TxResultsHash | d6c07def…9d9f24 | 77cb6b14…04aaa3 | 77cb6b14…04aaa3 |
Working post-block AppHash | 6dfb1d57…eca4b5 | e631cce8…5bd90d | e631cce8…5bd90d |
| Target Wasm present | yes | no | no |
The incomplete branches reported:
Error opening Wasm file for reading: No such file or directoryHarness conditions:
- Development-signed enclave.
- Minimal BaseApp rather than a full node.
- Synthetic local chain data.
SKIP_LIGHT_CLIENT_VALIDATION=TRUEin 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.

