Summary
Two distinct but co-located TSS preimage bugs in the Solana outbound execute path. Both let an attacker replay a valid TSS signature against a different effective execution. Same code surface (Go: pkg/contracts/solana/gateway_message.go; Anchor: programs/gateway/src/utils/validate_message_hash.rs), same migration cost (paused/drained/redeploy of the Solana gateway), so worth fixing in one coordinated release.
Bug 1 — Missing is_writable bit (HackenProof ZCNode-267)
The Go-side MsgExecute.Hash() / MsgExecuteSPL.Hash() and the Anchor-side validate_message_hash only hash account.key() for each remaining account. The is_writable bit is dropped on both sides. An attacker who observes a TSS-signed Solana outbound before its nonce is consumed can replay it with the same pubkeys but flipped readonly→writable, and the gateway will CPI into the destination program with the escalated privileges. is_signer is hardcoded to false by prepare_account_metas, so writability is the only escalation surface.
Pinned commit (Anchor): cc8b0e2 / PR #131.
Bug 2 — Length / count prefix ambiguity (HackenProof ZCNode-269)
Both sides concatenate ... || data || pubkey_1 || pubkey_2 || ... with no length prefix on data and no count prefix on remaining_accounts. Every pubkey is 32 bytes, so any 32-byte-aligned shift of the data / remaining_accounts boundary produces a byte-identical preimage. A single TSS signature is valid for many distinct (data, remaining_accounts) tuples.
Five constraints limit blast radius against the reference connected example program (PDA-constrained slot 0, hardcoded random_wallet target, UTF-8-gated data, atomic rollback on CPI error, is_signer = false), but the cryptographic primitive (TSS signature does not uniquely bind the account list) is real and the attack surface against user-deployed connected programs with looser slot constraints or wider input acceptance remains open.
Pinned commit (Anchor): 0852ac80 / PR #131.
Code links (shared surface)
zeta-node/pkg/contracts/solana/gateway_message.go:381-385 — MsgExecute.Hash() tail
zeta-node/pkg/contracts/solana/gateway_message.go:722-726 — MsgExecuteSPL.Hash() tail
zeta-node/pkg/contracts/solana/gateway_message.go:373 — to/destination_program hashed at fixed offset (bound)
protocol-contracts-solana/programs/gateway/src/utils/validate_message_hash.rs:30-38 — verifier loop
protocol-contracts-solana/programs/gateway/src/instructions/execute.rs:57-65 — handle_sol_common wiring
protocol-contracts-solana/programs/gateway/src/utils/prepare_account_metas.rs:8-31 — is_signer = false enforced (already correct)
Fix
Bundle both fixes into one Anchor + zetaclient release. Pause the Solana gateway, drain in-flight Execute/ExecuteSPL CCTXs under the old layout, deploy the Anchor upgrade with the new preimage layout, cut a zetaclient release with the matching Go change, roll the zetaclient fleet, then unpause.
New preimage layout (both Go and Anchor must match exactly):
- Length-prefix
data: emit data_len (u32 BE) || data instead of just data.
- For each remaining account, emit
pubkey(32B) || is_writable(1B) instead of just pubkey(32B). The is_signer byte is unnecessary since prepare_account_metas already forces it to false on the Anchor side.
Alternative: replace the ad-hoc concatenation with a Borsh-serialized typed struct on both sides, which length-prefixes Vec<u8> and Vec<Pubkey> automatically and eliminates this bug class for any future variable-length field added to the preimage. Longer-term hardening but bigger diff.
Severity
Each bug is independently High (cryptographic flaw in TSS-signed message construction, no permission required to weaponize beyond observing or constructing a Solana outbound, fix requires coordinated redeploy). One combined PR keeps the migration overhead bounded to a single pause/drain cycle.
References
Summary
Two distinct but co-located TSS preimage bugs in the Solana outbound execute path. Both let an attacker replay a valid TSS signature against a different effective execution. Same code surface (Go:
pkg/contracts/solana/gateway_message.go; Anchor:programs/gateway/src/utils/validate_message_hash.rs), same migration cost (paused/drained/redeploy of the Solana gateway), so worth fixing in one coordinated release.Bug 1 — Missing
is_writablebit (HackenProof ZCNode-267)The Go-side
MsgExecute.Hash()/MsgExecuteSPL.Hash()and the Anchor-sidevalidate_message_hashonly hashaccount.key()for each remaining account. Theis_writablebit is dropped on both sides. An attacker who observes a TSS-signed Solana outbound before its nonce is consumed can replay it with the same pubkeys but flipped readonly→writable, and the gateway will CPI into the destination program with the escalated privileges.is_signeris hardcoded tofalsebyprepare_account_metas, so writability is the only escalation surface.Pinned commit (Anchor):
cc8b0e2/ PR #131.Bug 2 — Length / count prefix ambiguity (HackenProof ZCNode-269)
Both sides concatenate
... || data || pubkey_1 || pubkey_2 || ...with no length prefix ondataand no count prefix onremaining_accounts. Every pubkey is 32 bytes, so any 32-byte-aligned shift of thedata/remaining_accountsboundary produces a byte-identical preimage. A single TSS signature is valid for many distinct(data, remaining_accounts)tuples.Five constraints limit blast radius against the reference
connectedexample program (PDA-constrained slot 0, hardcodedrandom_wallettarget, UTF-8-gateddata, atomic rollback on CPI error,is_signer = false), but the cryptographic primitive (TSS signature does not uniquely bind the account list) is real and the attack surface against user-deployed connected programs with looser slot constraints or wider input acceptance remains open.Pinned commit (Anchor):
0852ac80/ PR #131.Code links (shared surface)
zeta-node/pkg/contracts/solana/gateway_message.go:381-385—MsgExecute.Hash()tailzeta-node/pkg/contracts/solana/gateway_message.go:722-726—MsgExecuteSPL.Hash()tailzeta-node/pkg/contracts/solana/gateway_message.go:373—to/destination_program hashed at fixed offset (bound)protocol-contracts-solana/programs/gateway/src/utils/validate_message_hash.rs:30-38— verifier loopprotocol-contracts-solana/programs/gateway/src/instructions/execute.rs:57-65—handle_sol_commonwiringprotocol-contracts-solana/programs/gateway/src/utils/prepare_account_metas.rs:8-31—is_signer = falseenforced (already correct)Fix
Bundle both fixes into one Anchor + zetaclient release. Pause the Solana gateway, drain in-flight
Execute/ExecuteSPLCCTXs under the old layout, deploy the Anchor upgrade with the new preimage layout, cut a zetaclient release with the matching Go change, roll the zetaclient fleet, then unpause.New preimage layout (both Go and Anchor must match exactly):
data: emitdata_len (u32 BE) || datainstead of justdata.pubkey(32B) || is_writable(1B)instead of justpubkey(32B). Theis_signerbyte is unnecessary sinceprepare_account_metasalready forces it to false on the Anchor side.Alternative: replace the ad-hoc concatenation with a Borsh-serialized typed struct on both sides, which length-prefixes
Vec<u8>andVec<Pubkey>automatically and eliminates this bug class for any future variable-length field added to the preimage. Longer-term hardening but bigger diff.Severity
Each bug is independently High (cryptographic flaw in TSS-signed message construction, no permission required to weaponize beyond observing or constructing a Solana outbound, fix requires coordinated redeploy). One combined PR keeps the migration overhead bounded to a single pause/drain cycle.
References
hackeproof/analysis/blockchain/report_ZCNode-267.md,hackeproof/analysis/blockchain/report_ZCNode-269.md