On January 31, 2026, Step Finance lost 261,854 SOL (~$27.3 million) — not to a smart contract bug, but to compromised executive devices and stolen private keys. The attacker gained control of the program upgrade authority, deployed a malicious version, and drained the treasury in minutes.
Step Finance, SolanaFloor, and Remora Markets all shut down permanently in March. No smart contract audit would have prevented this. The vulnerability was operational: a single point of failure in program authority management.
This is a pattern-level problem. Here are five guardrails that make upgrade authority compromise survivable.
The Upgrade Authority Problem
Every upgradeable Solana program has an upgrade_authority — a single pubkey that can deploy new bytecode at any time. By default, this is the deployer's wallet. If that key is compromised, the attacker owns the program.
┌──────────────────────────────────────────┐
│ DEFAULT SOLANA UPGRADE │
│ │
│ Developer Wallet (hot key) │
│ │ │
│ ▼ │
│ solana program deploy program.so │
│ │ │
│ ▼ │
│ Program instantly updated │
│ No delay. No approval. No alert. │
└──────────────────────────────────────────┘
This is the Step Finance scenario. One compromised laptop → full program control → treasury drained.
Guardrail 1: Multisig Upgrade Authority
The minimum viable defense: transfer upgrade authority to a multisig.
Squads Protocol is the standard on Solana. Set up an M-of-N multisig where no single compromised key can trigger an upgrade:
# Create a Squads multisig (3-of-5)
solana program set-upgrade-authority <PROGRAM_ID> \
--new-upgrade-authority <SQUADS_VAULT_ADDRESS> \
--upgrade-authority <CURRENT_KEYPAIR>
Critical configuration choices:
- Threshold: ≥ 3-of-5 — survives 2 compromised keys
- Key storage: Hardware wallets (Ledger) — resistant to malware
- Geographic distribution: ≥ 2 jurisdictions — survives physical seizure
- Recovery plan: Documented, tested — prevents lockout
What it prevents: A single compromised device can no longer upgrade the program.
What it doesn't prevent: Social engineering all signers, or a malicious insider. That's where guardrail 2 comes in.
Guardrail 2: Time-Locked Upgrades
Even with multisig, upgrades should never be instant. A time lock gives the community and monitoring systems time to detect and respond.
use anchor_lang::prelude::*;
#[account]
pub struct UpgradeProposal {
pub program_id: Pubkey,
pub buffer_address: Pubkey,
pub proposer: Pubkey,
pub proposed_at: i64,
pub execution_after: i64,
pub executed: bool,
pub cancelled: bool,
}
pub const TIME_LOCK_SECONDS: i64 = 172_800; // 48 hours
pub fn propose_upgrade(ctx: Context<ProposeUpgrade>, buffer: Pubkey) -> Result<()> {
let clock = Clock::get()?;
let proposal = &mut ctx.accounts.proposal;
proposal.program_id = ctx.accounts.target_program.key();
proposal.buffer_address = buffer;
proposal.proposer = ctx.accounts.proposer.key();
proposal.proposed_at = clock.unix_timestamp;
proposal.execution_after = clock.unix_timestamp + TIME_LOCK_SECONDS;
proposal.executed = false;
proposal.cancelled = false;
emit!(UpgradeProposed {
program_id: proposal.program_id,
buffer: buffer,
executable_after: proposal.execution_after,
});
Ok(())
}
pub fn execute_upgrade(ctx: Context<ExecuteUpgrade>) -> Result<()> {
let clock = Clock::get()?;
let proposal = &ctx.accounts.proposal;
require!(!proposal.executed, ErrorCode::AlreadyExecuted);
require!(!proposal.cancelled, ErrorCode::Cancelled);
require!(
clock.unix_timestamp >= proposal.execution_after,
ErrorCode::TimeLockActive
);
// Execute via BPF Loader CPI
Ok(())
}
pub fn cancel_upgrade(ctx: Context<CancelUpgrade>) -> Result<()> {
let proposal = &mut ctx.accounts.proposal;
require!(!proposal.executed, ErrorCode::AlreadyExecuted);
proposal.cancelled = true;
emit!(UpgradeCancelled { program_id: proposal.program_id });
Ok(())
}
Key design decisions:
- 48-hour minimum lock for production programs holding >$1M TVL
- Cancel is easier than execute — any single signer can cancel
- On-chain events for every proposal, cancellation, and execution
Guardrail 3: Bytecode Verification Before Execution
A time lock is useless if nobody checks what's being deployed:
# Build reproducibly
anchor build --verifiable
# Hash the output
sha256sum target/verifiable/program.so
# Community verifies the proposed buffer:
solana program dump <BUFFER_ADDRESS> /tmp/proposed.so
sha256sum /tmp/proposed.so
# Must match the published hash
If the proposed buffer's hash doesn't match the published source code's verifiable build, cancel immediately.
Guardrail 4: On-Chain Upgrade Monitor
Deploy an automated monitor that alerts on any upgrade-related activity:
import { Connection, PublicKey } from "@solana/web3.js";
const BPF_LOADER = new PublicKey(
"BPFLoaderUpgradeab1e11111111111111111111111"
);
async function monitorUpgrades(connection: Connection) {
connection.onLogs(BPF_LOADER, (logs) => {
const isUpgrade = logs.logs.some(l => l.includes("Upgrade"));
const isSetAuth = logs.logs.some(l => l.includes("SetAuthority"));
if (isUpgrade || isSetAuth) {
// Fire alerts to Telegram, Discord, PagerDuty
sendAlert(`🚨 Program upgrade detected: ${logs.signature}`);
}
});
}
If Step Finance had this, the team would have known about the malicious upgrade within seconds — not minutes after the treasury was drained.
Guardrail 5: Conditional Immutability
For mature programs, implement defense asymmetry:
pub fn freeze_program(ctx: Context<FreezeProgram>) -> Result<()> {
// Any single multisig member can freeze (instant)
let gov = &mut ctx.accounts.governance;
gov.is_frozen = true;
gov.freeze_started_at = Clock::get()?.unix_timestamp;
Ok(())
}
pub fn unfreeze_program(ctx: Context<UnfreezeProgram>) -> Result<()> {
let gov = &ctx.accounts.governance;
let clock = Clock::get()?;
// Emergency council only (5-of-7), 7-day delay
require!(
clock.unix_timestamp >= gov.freeze_started_at + 604_800,
ErrorCode::UnfreezeDelayActive
);
Ok(())
}
Even if 4 of 5 normal multisig members are compromised, a single honest member freezes everything. Unfreezing requires a separate, higher-threshold council and a full week.
What Would Have Saved Step Finance
- Multisig: Attacker needs 3+ keys, not 1
- Time lock: 48h window to detect and cancel
- Bytecode verification: Community spots unknown bytecode
- Upgrade monitor: Alert within seconds
- Conditional immutability: Any team member freezes instantly
With all five guardrails, $27.3 million stays in the treasury.
Implementation Priority
- Today: Transfer upgrade authority to a Squads multisig (30 min)
- This week: Set up upgrade monitor with alerts (2 hours)
- This sprint: Implement time-locked upgrades (1-2 days)
- This quarter: Add bytecode verification to CI/CD
- Post-stabilization: Evaluate conditional immutability
The cost of all five guardrails is measured in hours. The cost of not having them is measured in millions.
Key Takeaways
- Upgrade authority is root access. Treat it like cold storage for treasury funds.
- Multisig is table stakes, not the finish line. Without time locks and monitoring, coordinated attacks still succeed silently.
- Defense asymmetry saves you. Make freezing easy and upgrading hard.
- Monitor the BPF Loader. It's the single chokepoint for all Solana program upgrades.
- $27.3M was lost to operational security, not code. Security is a stack — code, operations, and governance all need coverage.
This is part of the DeFi Security Best Practices series. The Step Finance incident is a wake-up call for every Solana team running upgradeable programs.
Top comments (0)