Footer
General•November 09, 2026

The Secret Network Research Path

Cosmos Rolled Back. SGX Kept the Write.

By Rahul Saxena (@saxenism)

Severity: High — topology-dependent terminal halt

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

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 used an SGX-resident machine allowlist while executing registration messages. In the affected release, a successful machine-replacement call changed that in-memory allowlist before the Cosmos SDK knew whether the surrounding transaction would commit.

That ordering made a failed transaction capable of leaving hidden, consensus-relevant state behind.

The trigger was one signed transaction with two messages. The first, RaAuthenticate, succeeded and changed the enclave allowlist. The second, a MsgSend of 1uscrt to a module account that Secret hard-coded as a blocked recipient, failed. The SDK correctly discarded its branched multistore, so the 72-byte replacement record did not commit. The enclave's process-local HashMap, however, retained the replacement.

Every node initially agreed: the transaction failed, its result was the same everywhere, and the application root remained consistent. The inconsistency was latent. It surfaced only after one SGX executor restarted and reconstructed its allowlist from compiled IDs and committed records. Because the failed transaction had committed no replacement record, the restarted executor returned to the old allowlist while executors that stayed up retained the new one.

A replay of the replacement then produced different ABCI results on the two populations.

No transaction was broadcast to Secret Network during this research. The work used synthetic canaries and controlled seeds; it did not access user or third-party contract plaintext.

From failed transaction to divergent state

The sequence has five steps:

  1. Poison. The first message (RaAuthenticate) executes and changes the enclave allowlist from old machine ID O to actual machine ID A. The second message (MsgSend) then fails on Secret's blocked module-account check. The Cosmos cache is discarded; the enclave mutation remains.
  2. Wait. Committed state is still S0 = {O present, A absent}, while every enclave that executed the failed transaction holds S1 = {O absent, A present}.
  3. Restart. A cleanly restarted executor reconstructs S0 from committed state. Executors that did not restart remain at S1.
  4. Replay. The replacement is submitted again as an ordinary next transaction. S0 accepts it; S1 rejects it because O is no longer present.
  5. Diverge. The two populations return different ABCI codes, producing different TxResults hashes and application roots.

The failed transaction still commits ante effects, including the sequence-number advance, so the replay uses the account's next sequence number. It is not a replay in the mempool-rejection sense; it is a new signed transaction carrying the same replacement operation.

Why the transaction boundary failed

The affected allowlist was a Rust HashMap behind a mutex in enclave memory. It had no transactional branch and no abort callback. The replacement path called Data::update during message execution.

Verbatim upstream source (cosmwasm/enclaves/execute/src/registration/attestation.rs:189-195, Secret3dev/SecretNetwork@2b8e1878):

if self.m_to_o.contains_key(machine) {
    error!("machine {} already exists", hex::encode(machine));
    return false;
}

self.m_to_o.remove(machine_pop);
self.m_to_o.insert(*machine, *owner);

The registration ECALL performed that mutation before returning the replacement data to the host.

Verbatim upstream source (cosmwasm/enclaves/execute/src/registration/onchain.rs:183-197, Secret3dev/SecretNetwork@2b8e1878; the // handle changes... comment is upstream):

if let Some(machine_id_hash) = verified_quote.machine_id_hash {
    // handle changes to the SGX allow-list
    let mut allow_list = crate::registration::attestation::PPID_WHITELIST
        .lock()
        .unwrap();

    let owner: &allow_list::Owner =
        &verified_quote.body.report_data.d[32..].try_into().unwrap();

    if !allow_list.update(&machine_id_hash, owner, machine_pop) {
        return NodeAuthResult::InvalidCert;
    }
    slice::from_raw_parts_mut(p_machine_info, allow_list::OWNER_LEN)
        .copy_from_slice(owner);

Only after the ECALL succeeded did the keeper stage the durable replacement record in the Cosmos multistore. The SDK ran message handling against a cache and wrote that cache only when no message returned an error.

Upstream excerpt (baseapp/baseapp.go:954-991, scrtlabs/cosmos-sdk@v0.50.14-secret.12). Code lines are verbatim; // ... marks omitted upstream lines, and every other comment is upstream:

runMsgCtx, msCache := app.cacheTxContext(ctx, txBytes)

// Attempt to execute all messages and only update state if all messages pass
// and we're in DeliverTx. Note, runMsgs will never return a reference to a
// Result if any single message fails or does not have a registered Handler.
msgsV2, err := tx.GetMsgsV2()
if err == nil {
	result, err = app.runMsgs(runMsgCtx, msgs, msgsV2, mode)
}

// ...

if err == nil {
	if mode == execModeFinalize {
		// When block gas exceeds, it'll panic and won't commit the cached store.
		consumeBlockGas()

		msCache.Write()
	}

	// ...
}

Cosmos could discard its cache when the second message, MsgSend, failed. It could not undo the ECALL that the first message, RaAuthenticate, had already made.

The late failure did not require an injected fault. A positive 1uscrt send reached the bank handler and failed because Secret marked the destination module account as blocked. Both messages used the same ordinary account as signer, so the envelope required one signature. The default proposal path admitted the transaction bytes, and both handlers ran in order during FinalizeBlock.

Exploitation still required genuine SGX hardware running the official enclave and an attestation accepted by the vulnerable replacement path. The finding did not forge Intel attestation or substitute an arbitrary enclave measurement.

Why a restart exposed it

Secret rebuilt the allowlist once per process from committed replacement records. MaybeSetEnclaveColdData iterated those records and submitted each one to the enclave.

Upstream excerpt (x/registration/internal/keeper/keeper.go:274-329, Secret3dev/SecretNetwork@2b8e1878). Code lines are verbatim; // ... marks omitted upstream lines (store setup, query construction, and proof-error handling):

func (k *Keeper) MaybeSetEnclaveColdData(ctx sdk.Context) error {
	if k.coldDataSet {
		return nil
	}

	k.coldDataSet = true

	// ...

	for ; it.Valid(); it.Next() {
		// ...

		resp, err := k.queryer.Query(ctx, req)

		// ...

		api.SubmitMachineSwap(index, value, merkle_proof_serialized)
	}

	return nil
}

The poison transaction had no committed replacement record, so a fresh process could not reconstruct its enclave-side mutation. The restarted process therefore matched committed state; the process that stayed up did not.

This matters because SGX execution determines validator results. A validator using a local enclave can acquire the distinction directly across a selective restart. A replay-mode validator instead inherits the answer of its configured SGX source. The independently changing unit is therefore an enclave result domain, not necessarily one validator process.

Selective validator restarts are normal operating behavior. The inconsistency was created by a public failed transaction; restart merely revealed it.

Reproduction and evidence boundary

The evidence was built in layers:

  • A production DCAP receipt showed that the exact released enclave loaded on Azure SGX and produced the required official-measurement attestation.
  • A hardware enclave harness exercised the unchanged vulnerable Data::update.
  • A two-process harness crossed the enclave-lifetime boundary and exercised the real cold-data ECALL.
  • An exact-SDK witness measured failed multi-message rollback, persistence of external state, committed-database reload, replay divergence, TxResults hashes, application roots, and a fixed control.
  • A signed-envelope bridge established message order, one signer and signature, route registration, default proposal admission, and certificate-size capacity.

No single experiment ran the full sequence from production DCAP bytes through the production registration and bank handlers, Cosmos rollback, process restart, cold reconstruction, replay, and next-header rejection. The evidence is compositional: experiments establish the measured rollback and divergence boundaries, while pinned source establishes the production handler ordering and header behavior between them. Full receipts, identities, limitations, and archival command transcripts are in the technical appendix.

The exact-SDK witness measured the following replay results:

  • Restarted side: code 0, TxResults hash 52e98ed8…, application root 7c78c9f7….
  • Non-restarted side: code 4, "old machine absent: unauthorized", TxResults hash 71b41a55…, application root 71f0c404….

Those are measured rollback-and-replay results from the exact-SDK witness. They are not a measurement of validator topology or a live network halt.

The fixed control moved allowlist changes behind the commit-derived path. Both sides then reconstructed the same state, accepted the replay, and produced equal result hashes and application roots.

From measured divergence to halt reachability

The measured result ends with divergent ABCI codes, TxResults hashes, and application roots.

The next step is source-derived: Secret's Comet fork places AppHash and LastResultsHash in the next header and validates a proposed header against local state. Once two result domains compute different values, each rejects a next header carrying the other's values.

Whether that disagreement becomes a terminal network halt is topology-dependent. It depends on how validator power is distributed across independently changing SGX result domains, including local enclaves and replay-source pools.

For the historical validator snapshot used in the model, total power was T = 359,249,635 and quorum was Q = 239,499,757. Neither cohort could commit when the power obtaining the restarted S0 result fell within:

119,749,879 <= R <= 239,499,756

This interval and the peeling schedule in the appendix are arithmetic over that captured set, not a measured mainnet halt. They do not establish current topology, restart order, active-set evolution, source independence, or repair timing. If all stake-bearing processes shared one SGX result source, their power would remain together. Those deployment facts were not public, which is why the finding is rated High rather than Critical.

Fix

The v1.27 line removed the uncommitted enclave write from registration. The registration ECALL now validates a proposed update through read-only Data::validate_update; persistence and enclave mutation occur through the committed Merkle path.

Verbatim upstream source (cosmwasm/enclaves/execute/src/registration/onchain.rs:193-207, Secret3dev/SecretNetwork@v1.27.2; the three comment lines are upstream):

if let Some(machine_id_hash) = verified_quote.machine_id_hash {
    // Do not mutate PPID_WHITELIST on this ecall (failed Cosmos tx
    // must not keep SGX RAM). Validate only; host persists swap info; committed
    // Merkle path is ecall_submit_machine_swap.
    let owner: &allow_list::Owner =
        &verified_quote.body.report_data.d[32..].try_into().unwrap();

    {
        let allow_list = crate::registration::attestation::PPID_WHITELIST
            .lock()
            .unwrap();
        if !allow_list.validate_update(&machine_id_hash, owner, machine_pop) {
            return NodeAuthResult::InvalidCert;
        }
    }

The immutable allow_list binding and validate_update(&self, ...) prevent this ECALL from modifying the map. A replacement reaches the enclave allowlist only through the committed-record path, so a failed Cosmos transaction can no longer leave this hidden mutation behind.

The same release also keys the allowlist membership check to the quote's PPID-derived machine ID rather than the caller-supplied replacement ID (registration/attestation.rs:894-912, Secret3dev/SecretNetwork@v1.27.2, whose upstream comment reads // WL key is the quote PPID, not ReplaceMachineId.), removing the vulnerable reachability condition used in this finding. This describes the public v1.27.2 source tag; Bluethroat did not independently rebuild or test the signed enclave running on secret-4.

Timeline

  • 2026-09-05 — Production SGX receipt and September deployment snapshot captured on Azure.
  • 2026-09-06 — Two-process restart and cold-data receipt captured; second September deployment snapshot recorded at height 27,020,273; finding reported to Secret Network maintainers under coordinated disclosure.
  • 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 and retained the enclave).
  • 2026-10-22 — Maintainers cleared the finding for publication.

Takeaway

A transaction boundary is only atomic if it contains every consensus-relevant write. Secret's SDK cache behaved correctly: the second message, MsgSend, failed, and the cache did not commit. The bug was that the first message had already changed state outside that cache.

For TEE-integrated chains, the useful audit question is whether a running node can hold consensus-relevant state that a clean restart would not reproduce. Here, the enclave allowlist could. The cleanly restarted node returned to committed truth, and that correct reconstruction exposed the split.

References