You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[High] asset_admin (not marketauth) seizes a delegated backing_bucket_authority and bypasses the #395 drain guard — the fifth leg of the UpdateAssetAuthority bypass #439
UpdateAssetAuthority lets a party who holds only asset_admin — not marketauth — take a delegated backing_bucket_authority from a capital provider that never consented. Having taken it, that party satisfies local_authorized in handle_withdraw_backing_bucket, which carries none of the conditions the admin path carries.
The consequence is specific: the drain guard shipped in #395 is bypassable. That guard exists precisely to stop the admin side reaching a delegated backing authority, and rotating the authority steps around it rather than defeating it.
This is the fifth leg of the same root cause as #414, #416, #417 and #437. Those closed the backing-bucket-as-registry-PDA case and the two insurance legs; an ordinary signable backing holder is still exposed.
The guard that gets bypassed
handle_withdraw_backing_bucket (src/v16_program.rs:10394) authorizes two ways (:10451-10455):
and then #395 hardens the admin arm to nothing whenever the role is delegated (:10458-10468):
// F-3 / D-STAKE-1 guard: when backing_bucket_authority is a bound (non-zero) authority// — which it always is for an activated asset, and which the stake program can set to// its PDA — the admin shutdown-drain path MUST NOT bypass it. Mirrors the guard in// handle_withdraw_insurance_asset.let admin_shutdown_authorized = if authorities.backing_bucket_authority != [0u8;32]{false}else{
admin_shutdown_authorized
};
So on any activated asset, marketauth cannot drain a delegated backing bucket at all — not even under shutdown_drain. That is a deliberate, shipped control, and the PoC below confirms it is active.
local_authorized has no such condition. It is satisfied by whoever currently holds the field. And who holds that field is decided by handle_update_asset_authority, where the admin_signed bypass (:12391-12397) skips the current holder's consent check for every kind except the two insurance legs and — since #437 — the oracle leg:
ASSET_AUTH_BACKING_BUCKET is absent. #424's custody guard (:12418) does not fill the gap — it fires only when current_value is the LP vault registry PDA and that registry is initialized, so an ordinary delegated holder is undefended.
#351 argued marketauth should not be able to shutdown-drain a delegated backing bucket, and was closed as working-as-intended. The substance nonetheless shipped in #395 (3c06dad), which is the guard quoted above.
This report does not ask for that decision to be revisited. It observes that the guard, as shipped, protects only one route. The party in this report is not marketauth, needs no shutdown_drain, and acts on a Live market with no timeout — because rotating the authority converts the request into the local_authorized branch that the guard never touches.
Why the trust boundary is real
backing_bucket_authority defaults to config.marketauth at InitMarket (:1983), so on a fresh market the admin and the holder are the same key and nothing here applies. It becomes a distinct party in exactly the situations the guard was written for: an external capital provider, or the stake program's PDA.
asset_admin is likewise not marketauth in general. It is rotatable in its own right, and for permissionless assets the activator holds it. So the seizing party can be someone with no claim on the market's funds at all.
Reproduction
tests/v16_wrapper.rs, using the existing in-process run_ix harness. Passes against main at a1a8168.
cargo test --test v16_wrapper f3_non_marketauth -- --nocapture
test f3_non_marketauth_asset_admin_seizes_backing_authority_and_bypasses_the_drain_gate ... ok
The test asserts, in order:
A capital provider is delegated backing_bucket_authority (provider co-signs) and funds domain 0 with 500.
Control — marketauth attempts WithdrawBackingBucket immediately and is refused. Two independent reasons apply here and we have not separated them: fix(wrapper): enforce insurance-withdrawal policy + backing-bucket drain guard (F-1/F-2/F-3) #395's guard forces admin_shutdown_authorized to false because the role is delegated, and shutdown_drain is in any case false on a healthy Live market. Either way the admin arm is closed at this moment, which is all the control needs to establish. The test asserts the refusal, so if that ever stops holding the test fails rather than silently proving nothing.
asset_admin is rotated to a third party; the test asserts that party is notmarketauth.
That party seizes backing_bucket_authority. The provider does not sign. This succeeds.
The attacker issues the identical WithdrawBackingBucket that marketauth was refused in step 2, and is authorized.
Observed on main:
provider funded backing domain 0 with 500
CONTROL marketauth withdraw (no shutdown) : Err("Custom(8)")
seizure by non-marketauth asset_admin : Ok
attacker withdraw (no shutdown) : Ok
v16_wrapper with this test added: 222 passed / 19 failed, against a clean-main baseline of 221 / 19 — the same 19 pre-existing failures, so no regression.
Scope of what the PoC proves
The test asserts the authorization decision, which is the defect. It deliberately does not assert a token balance: the SPL transfer is a stubbed CPI in the in-process harness (sol_invoke_signed() not available), so no tokens move in this environment. Step 5 shows the instruction is authorized where step 2 shows it is refused; demonstrating settled token movement end to end would need the LiteSVM harness with the built .so, which we have not done here.
Impact
A delegated backing provider's principal is reachable by the asset's admin without the provider's consent, on a live market, with no timeout and no shutdown condition — through a route the project has already decided should be closed for the marketauth path.
We rate this high: it is the same class as #416/#417 (both rated high and fixed), and it reaches a field that gates a token-moving instruction directly.
Suggested direction
Extend the same exclusion, with one carve-out so the LP vault recovery path survives:
The carve-out matters and is not optional. handle_create_lp_vault binds backing_bucket_authority = registry_pda (:14561), a PDA cannot sign, and handle_update_asset_authority enforces expect_signer(current) — so excluding the backing leg unconditionally would weld the field permanently once a vault is closed. That is the brick review of #415 caught, and we verified it recurs: under the unconditional form, backing_authority_rotation_is_allowed_when_no_lp_vault_registry_exists fails with Custom(8). Keeping the bypass for exactly the registry-PDA value leaves that value governed by #424's liveness guard, which already refuses the rotation while the vault is live.
This does not touch #395's guard or the marketauth withdrawal path, so the recovery behaviour that #351 defended is unaffected.
We have not opened a pull request. #437's fix is still in review as #438 and touches the same expression, so we would rather not stack a second change on it before you have looked at the first — and if you would prefer both legs closed in one commit, that is easily done instead. Happy to implement either.
/// `UpdateAssetAuthority` lets a party who is only `asset_admin` — not/// `marketauth` — take a delegated `backing_bucket_authority` from a capital/// provider that never consented.////// The seizure matters because of what it does to the withdrawal gate./// `handle_withdraw_backing_bucket` authorizes two ways:////// ```text/// local_authorized = backing_bucket_authority == signer // no gate/// admin_shutdown_authorized = shutdown_drain && marketauth == signer // timeout-gated/// ```////// Issue #351 was closed as working-as-intended on the basis that the admin's/// backing recovery is a *deliberate, timeout-gated* net. This test shows that/// gate can be stepped around: rotate the authority first, and the attacker/// becomes `local_authorized`, which carries no `shutdown_drain` condition.////// The control is the point — `marketauth` withdrawing at the same moment is/// refused, so the gate is demonstrably active and demonstrably bypassed.#[test]fnf3_non_marketauth_asset_admin_seizes_backing_authority_and_bypasses_the_drain_gate(){letmut admin = signer().writable();letmut market = market_account();letmut provider = signer().writable();letmut new_admin = signer().writable();letmut attacker = signer().writable();let mint = init_market(&mut admin,&mut market);// A capital provider is delegated asset 0's backing authority, and funds it.run_ix(Instruction::UpdateAssetAuthority{asset_index:0,kind:ASSET_AUTH_BACKING_BUCKET,new_pubkey: provider.key.to_bytes(),},&mut[&mut admin,&mut provider,&mut market],).expect("consented delegation must succeed");top_up_backing_bucket(&mut provider,&mut market,0,500,10_000);letmut vault = vault_token_account(&market, mint,500);letmut vault_auth = vault_authority_account(&market);letmut token_program = token_program_account();// CONTROL: the timeout gate is real. `marketauth` cannot withdraw right now.letmut admin_dest = user_token_account(admin.key, mint,0);let marketauth_now = run_ix(Instruction::WithdrawBackingBucket{domain:0,amount:100},&mut[&mut admin,&mut market,&mut admin_dest,&mut vault,&mut vault_auth,&mut token_program,],);assert!(
marketauth_now.is_err(),"control: marketauth must be refused without shutdown_drain — if this passes, \ the gate this test is about does not exist");// asset_admin is rotated to a party that is NOT marketauth. On a permissionless// asset the activator simply is this party from the start.run_ix(Instruction::UpdateAssetAuthority{asset_index:0,kind:ASSET_AUTH_ADMIN,new_pubkey: new_admin.key.to_bytes(),},&mut[&mut admin,&mut new_admin,&mut market],).expect("consented asset_admin rotation must succeed");let profile = state::read_asset_oracle_profile(&market.data,0).unwrap();assert_eq!(profile.asset_admin, new_admin.key.to_bytes());assert_ne!(
profile.asset_admin, admin.key.to_bytes(),"the seizing party must not be marketauth, or this proves nothing about #351");// The seizure. The provider does not sign; the market is Live; no timeout.run_ix(Instruction::UpdateAssetAuthority{asset_index:0,kind:ASSET_AUTH_BACKING_BUCKET,new_pubkey: attacker.key.to_bytes(),},&mut[&mut new_admin,&mut attacker,&mut market],).expect("SEIZURE: succeeds today with no consent from the provider");let after = state::read_asset_oracle_profile(&market.data,0).unwrap();assert_eq!(
after.backing_bucket_authority,
attacker.key.to_bytes(),"the provider's authority now belongs to the attacker");// And the attacker is `local_authorized`, which carries no shutdown_drain gate.letmut atk_dest = user_token_account(attacker.key, mint,0);let drained = run_ix(Instruction::WithdrawBackingBucket{domain:0,amount:100},&mut[&mut attacker,&mut market,&mut atk_dest,&mut vault,&mut vault_auth,&mut token_program,],);assert!(
drained.is_ok(),"the seized authority withdraws at the same moment marketauth was refused: {drained:?}");// NOTE ON SCOPE: this asserts the AUTHORIZATION decision, which is the defect.// The SPL transfer itself is a stubbed CPI in this in-process harness// (`sol_invoke_signed() not available`), so no tokens actually move here and// this test deliberately does not assert a balance.}
Summary
UpdateAssetAuthoritylets a party who holds onlyasset_admin— notmarketauth— take a delegatedbacking_bucket_authorityfrom a capital provider that never consented. Having taken it, that party satisfieslocal_authorizedinhandle_withdraw_backing_bucket, which carries none of the conditions the admin path carries.The consequence is specific: the drain guard shipped in #395 is bypassable. That guard exists precisely to stop the admin side reaching a delegated backing authority, and rotating the authority steps around it rather than defeating it.
This is the fifth leg of the same root cause as #414, #416, #417 and #437. Those closed the backing-bucket-as-registry-PDA case and the two insurance legs; an ordinary signable backing holder is still exposed.
The guard that gets bypassed
handle_withdraw_backing_bucket(src/v16_program.rs:10394) authorizes two ways (:10451-10455):and then #395 hardens the admin arm to nothing whenever the role is delegated (
:10458-10468):So on any activated asset,
marketauthcannot drain a delegated backing bucket at all — not even undershutdown_drain. That is a deliberate, shipped control, and the PoC below confirms it is active.local_authorizedhas no such condition. It is satisfied by whoever currently holds the field. And who holds that field is decided byhandle_update_asset_authority, where theadmin_signedbypass (:12391-12397) skips the current holder's consent check for every kind except the two insurance legs and — since #437 — the oracle leg:ASSET_AUTH_BACKING_BUCKETis absent. #424's custody guard (:12418) does not fill the gap — it fires only whencurrent_valueis the LP vault registry PDA and that registry is initialized, so an ordinary delegated holder is undefended.Why this is not a re-litigation of #351
#351 argued
marketauthshould not be able to shutdown-drain a delegated backing bucket, and was closed as working-as-intended. The substance nonetheless shipped in #395 (3c06dad), which is the guard quoted above.This report does not ask for that decision to be revisited. It observes that the guard, as shipped, protects only one route. The party in this report is not
marketauth, needs noshutdown_drain, and acts on a Live market with no timeout — because rotating the authority converts the request into thelocal_authorizedbranch that the guard never touches.Why the trust boundary is real
backing_bucket_authoritydefaults toconfig.marketauthatInitMarket(:1983), so on a fresh market the admin and the holder are the same key and nothing here applies. It becomes a distinct party in exactly the situations the guard was written for: an external capital provider, or the stake program's PDA.asset_adminis likewise notmarketauthin general. It is rotatable in its own right, and for permissionless assets the activator holds it. So the seizing party can be someone with no claim on the market's funds at all.Reproduction
tests/v16_wrapper.rs, using the existing in-processrun_ixharness. Passes againstmainata1a8168.The test asserts, in order:
backing_bucket_authority(provider co-signs) and funds domain 0 with 500.marketauthattemptsWithdrawBackingBucketimmediately and is refused. Two independent reasons apply here and we have not separated them: fix(wrapper): enforce insurance-withdrawal policy + backing-bucket drain guard (F-1/F-2/F-3) #395's guard forcesadmin_shutdown_authorizedtofalsebecause the role is delegated, andshutdown_drainis in any casefalseon a healthy Live market. Either way the admin arm is closed at this moment, which is all the control needs to establish. The test asserts the refusal, so if that ever stops holding the test fails rather than silently proving nothing.asset_adminis rotated to a third party; the test asserts that party is notmarketauth.backing_bucket_authority. The provider does not sign. This succeeds.WithdrawBackingBucketthatmarketauthwas refused in step 2, and is authorized.Observed on
main:v16_wrapperwith this test added: 222 passed / 19 failed, against a clean-mainbaseline of 221 / 19 — the same 19 pre-existing failures, so no regression.Scope of what the PoC proves
The test asserts the authorization decision, which is the defect. It deliberately does not assert a token balance: the SPL transfer is a stubbed CPI in the in-process harness (
sol_invoke_signed() not available), so no tokens move in this environment. Step 5 shows the instruction is authorized where step 2 shows it is refused; demonstrating settled token movement end to end would need the LiteSVM harness with the built.so, which we have not done here.Impact
A delegated backing provider's principal is reachable by the asset's admin without the provider's consent, on a live market, with no timeout and no shutdown condition — through a route the project has already decided should be closed for the
marketauthpath.We rate this high: it is the same class as #416/#417 (both rated high and fixed), and it reaches a field that gates a token-moving instruction directly.
Suggested direction
Extend the same exclusion, with one carve-out so the LP vault recovery path survives:
The carve-out matters and is not optional.
handle_create_lp_vaultbindsbacking_bucket_authority = registry_pda(:14561), a PDA cannot sign, andhandle_update_asset_authorityenforcesexpect_signer(current)— so excluding the backing leg unconditionally would weld the field permanently once a vault is closed. That is the brick review of #415 caught, and we verified it recurs: under the unconditional form,backing_authority_rotation_is_allowed_when_no_lp_vault_registry_existsfails withCustom(8). Keeping the bypass for exactly the registry-PDA value leaves that value governed by #424's liveness guard, which already refuses the rotation while the vault is live.This does not touch #395's guard or the
marketauthwithdrawal path, so the recovery behaviour that #351 defended is unaffected.We have not opened a pull request. #437's fix is still in review as #438 and touches the same expression, so we would rather not stack a second change on it before you have looked at the first — and if you would prefer both legs closed in one commit, that is easily done instead. Happy to implement either.
Environment
dcccrypto/percolator-progmainata1a8168dcccrypto/percolatormainat1bea669cargo test --test v16_wrapper, rustc 1.97.1PoC source
Appended to
tests/v16_wrapper.rs; reuses that file's existingsigner/market_account/init_market/run_ix/top_up_backing_bucket/vault_token_account/vault_authority_account/user_token_accounthelpers.