security(solana): bind withdraw_execute TokenMapping to submit-time src_token #181
Labels
No labels
agent:implement
agent:ready
backend
bug
cannot-reproduce
confirmed
desktop
docs
documentation
duplicate
enhancement
feature
frontend
good first issue
help wanted
high-risk
in-review
invalid
mobile
needs-triage
P0-critical
P1-high
P2-medium
P3-low
qa
QA
question
ready
report
responsive
security
security-escalate
smart-contract
solana
tablet
test-pass
ux
wallet-issue
wallet:keplr
wallet:metamask
wallet:station
wallet:walletconnect
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-bridge-monorepo#181
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Solana
withdraw_submitpinsTokenMappingby PDA seeds(src_chain, src_token)and copies onlylocal_mintontoPendingWithdraw.token.PendingWithdrawdoes not storesrc_token(or the mapping pubkey).withdraw_executere-derives the mapping from(pending_withdraw.src_chain, token_mapping.dest_token)and only requiresmint == pw.token == token_mapping.local_mint. The V2 hash still validates because it hashes the destination mint, not the remotesrc_token.If two mappings share the same
local_minton onesrc_chain(different remotedest_tokenand/orTokenMode), execute can run through the alternate mapping (MintBurnvsLockUnlock) withoutHashMismatch.This is not #176 (1-of-1 operator approve). This is not #180 (Terra pending
src_decimalsafter approve). This is not closed #104 (token_mappinguninitialized at execute). Those share withdraw / mapping words. The bug is execute-time mapping substitution plus no uniqueness oflocal_mintpersrc_chain.Internal review id: SOL-H2 (high). Still in source as of 2026-09-12.
Bundle (same ticket, do not split):
src_token(or the mapping identity) onPendingWithdraw.withdraw_executemust seedTokenMappingfrom that stored identity, not from the passed account’sdest_tokenfield.register_tokenmust reject a second mapping for the same(src_chain / dest_chain, local_mint).TokenMode) reverts.Founder-required Solana program. No community autoland. Do not add
ready.Impact (today vs hypothetical)
Funds at risk in source whenever two
TokenMappingPDAs exist for the same remote chain and the same SPLlocal_mint.register_tokenis admin-only and keys PDAs by(dest_chain, dest_token)only — it never uniqueness-checkslocal_mint. A later second register (confused ops, or a hostile admin) is enough; no user can create the second mapping.Once those two PDAs exist, execute is not admin-gated: the recipient supplies accounts. They can pass the mapping whose
modeisMintBurninstead of the submit-timeLockUnlock(unbackedmint_to) or the reverse (unlock from the bridge ATA for a mint/burn route).compute_transfer_hashdoes not include remotesrc_token(INV-H2: destination token word). Hash recompute (INV-W2) therefore does not detect the swap.Not a permissionless drain on a deployment whose live mappings already have unique
(chain, local_mint). Rate limits (INV-W4) bound size; they do not bind mode. Sticky until the program is upgraded (pending account space / seed change). In-flight pending PDAs stay on the old layout until they execute or cancel.Hypothetical-only if every live program has never had two mappings to one mint and admin will never register a second. Source still allows both.
Do not publish a mainnet submit-then-swap-mapping-then-execute sequence.
SOL-H2: submit binds
src_token; execute does notwithdraw_submit(packages/contracts-solana/programs/cl8y-bridge/src/instructions/withdraw_submit.rs):token_mappingwith[b"token", params.src_chain, params.src_token].token_mapping.local_mint == params.dest_token(TokenMappingMismatch).pw.token = params.dest_token, copiestm.src_decimals/tm.decimals, does not writesrc_token.PendingWithdraw(state/pending_withdraw.rs) fields:transfer_hash,src_chain,src_account,dest_account,token(local mint),amount,nonce, decimals, operator gas, flags, bump. No remote token id. No mapping pubkey.withdraw_execute(instructions/withdraw_execute.rs):token_mappingwith[b"token", pending_withdraw.src_chain, token_mapping.dest_token]. The third seed is taken from the passed mapping account, so any valid mapping PDA for thatsrc_chainsatisfies seeds.mint.key() == token_mapping.local_mintandpw.token == mint.key(). Same mint, different remotedest_token, still passes.token_mapping.mode(LockUnlock→transfer_checkedfrom bridge ATA;MintBurn→mint_to). Payout size uses storedpw.src_decimals/pw.dest_decimals, not the execute-time mapping decimals — so the hole is mode / vault vs mint, not a second decimal rewrite (that is #180 on Terra).register_tokeninits[b"token", dest_chain, dest_token]with no index PDA onlocal_mint. Two remote tokens can point at one SPL mint with differentTokenMode. MintBurn still requires bridge mint authority at register; that does not stop a second mapping onto a mint the bridge already controls.Audit tests cover honest LockUnlock vs MintBurn deposits (
security_audit.test.ts§20) and PDA confusion for wrong chain / wrong hash / wrong mint. They do not submit against mapping A and execute against mapping B with the samelocal_mint. INV-W2 / INV-D1 docs say execute binds mint to the pending record; they do not say execute binds the same mapping PDA used at submit.Why the new implementation is needed
src_tokenword (INV-H1 / EVM / Terra parity). Freeze the mapping identity on the pending account instead.local_mintper remote chain is the second lock: even a patched execute should not allow two live PDAs that make substitution possible after a future seed mistake.Constraints / guardrails
compute_transfer_hashlayout. Do not putsrc_tokeninto the 224-byte digest. Do not break EVM / Terra hash goldens.(src_chain, nonce)replay, rate limits,pw.token == mint, orWrongRecipient.[TokenMapping::SEED, pw.src_chain, pw.src_token](or equivalent stored mapping identity), nottoken_mapping.dest_tokenfrom the remaining account. Also requiretoken_mapping.dest_token == pw.src_tokenandtoken_mapping.local_mint == pw.token.register_token: reject if a mapping for(dest_chain, local_mint)already exists (init an index PDA, or scan is not acceptable on-chain). Same mint on a different dest chain remains allowed. Do not remove admin-only auth.PendingWithdrawlayout: adding[u8; 32](and bump/space) is in scope. New submits must allocate the newINIT_SPACE. In-flight old-size PDAs: fail closed on execute (cannot bindsrc_token) or a one-shot migrate that copies identity from the original mapping without letting the caller name a different mapping. Document the choice. Do not silently keep the circulardest_tokenseed for old rows.withdraw_execute_nativedoes not take a mapping (native sentinel +LockUnlockonly). Out of scope except: do not introduce a native substitution path; uniqueness should still forbid a second native mapping for the same chain if native usesPubkey::default().ready. No public mainnet substitution recipe.Relevant files
packages/contracts-solana/programs/cl8y-bridge/src/instructions/withdraw_submit.rssrc_token; does not persist itpackages/contracts-solana/programs/cl8y-bridge/src/state/pending_withdraw.rssrc_token/ mapping identity fieldpackages/contracts-solana/programs/cl8y-bridge/src/instructions/withdraw_execute.rstoken_mapping.dest_token; CPI usesmodepackages/contracts-solana/programs/cl8y-bridge/src/instructions/register_token.rslocal_mintuniqueness per chainpackages/contracts-solana/programs/cl8y-bridge/src/state/token_registry.rsTokenMapping/TokenModepackages/contracts-solana/programs/cl8y-bridge/src/hash.rspackages/contracts-solana/tests/security_audit.test.tspackages/contracts-solana/tests/spl_security.test.tsdocs/SOLANA_BRIDGE_INVARIANTS.mdRecommended direction
src_token: [u8; 32]toPendingWithdraw.withdraw_submitcopiesparams.src_token(and keep decimal snapshot from that mapping).WithdrawExecutemapping seeds to[TokenMapping::SEED, pending_withdraw.src_chain.as_ref(), pending_withdraw.src_token.as_ref()]. Keepmint == local_mint == pw.token.local_mintuniqueness PDA (e.g. seeds[b"mint-map", dest_chain, local_mint]) created inregister_token. Second register for that pair reverts (TokenMappingMismatchor a dedicated error).dest_tokenandTokenMode; submit via A; execute with B must fail; execute with A still unlocks/mints as registered. Duplicateregister_tokenreverts. Honest single-mapping flow stays green.Acceptance criteria
(src_chain, src_token_A),withdraw_executewith mapping(src_chain, src_token_B)that shareslocal_mintreverts (seeds / mismatch). Hash still matches. No mint_to / unlock via B.TokenModeafter approve + delay.register_tokenfor an existing(dest_chain, local_mint)with a differentdest_tokenreverts. Distinct mints on the same chain still register.cargo testin the Solana program and the documented Anchor suite still pass for existing honest paths.(src_chain, src_token);local_mintis unique per remote chain.Test plan (functional paths)
token == A.local_mint; storedsrc_token == A.dest_tokenregister_tokenB after A, samelocal_mint, same dest chainregister_tokensame mint, other dest chainsrc_tokenthat does not match mapping seedsTokenMappingMismatch/ seeds (unchanged)Test plan (attack, hack, and abuse)
Non-exploitative. Local validator / Anchor tests only. Do not use these as a mainnet recipe.
LockUnlock, B=MintBurn; submit A, execute Bsrc_chainwith the same mintregister_tokenafter A already liveVerification criteria
security_audit.test.tsand/orspl_security.test.ts).withdraw_executemapping seeds includepending_withdraw.src_token(or the documented stored identity).register_tokencreates/checks alocal_mintuniqueness PDA.docs/SOLANA_BRIDGE_INVARIANTS.mdINV-W2 / INV-D1 mention mapping identity freeze. SPL audit class 4 (wrong mint / mapping) cites the new reject-alternate-mapping test.Out of scope
src_decimalsadmin rewrite.TokenRegistryuniqueness (separate stack).src_token.First-pass model recommendation
Recommendation: grok-high
Rationale: Security class plus founder-required Solana program (
withdraw_executeCPI mode,PendingWithdrawaccount layout, mapping PDA seeds,register_tokenuniqueness). Composer is disallowed (High/security; contracts / keys). Not a local three-file tweak: submit persist, execute seeds, register index, in-flight layout, tests, invariants. A wrong remaining-account seed (token_mapping.dest_token) leaves substitution open. Verify with Anchor duplicate-mapping fixtures, not a live mainnet execute.