Missing Writable Check
Detects account modifications performed without verifying the is_writable flag, allowing read-only accounts to be silently corrupted or causing runtime errors.
Missing Writable Check
Overview
Remediation Guide: How to Fix Missing Writable Check
The missing writable check detector identifies Solana program functions that write to account data or transfer lamports from an account without first verifying that the account’s is_writable flag is set to true. The Solana runtime enforces account mutability through this flag: each account passed to an instruction carries an is_writable boolean. Programs that modify accounts without checking this flag may silently corrupt state, panic when borrowing mutably, or expose runtime inconsistencies that can be exploited.
The detector uses CFG-based dataflow analysis to track CheckWritable statements across basic blocks. For each StoreAccountData or TransferLamports statement, it checks whether the target account has been validated as writable in the current CFG path. Confidence is reduced for Anchor programs (which handle this via #[account(mut)]) and PDA accounts (where the runtime enforces writability).
Why This Is an Issue
An attacker can construct a transaction that passes a read-only account in a position where the program expects a mutable one. Depending on the runtime version and context, the result ranges from a program panic (denial of service) to silent corruption of the expected state. In either case, the program’s invariants are broken: either the write silently fails and state becomes inconsistent, or the program crashes mid-execution and leaves a partially-updated state.
Programs that attempt to close accounts, update balances, or write metadata without verifying writability are vulnerable to griefing attacks where a malicious caller passes a non-writable account to block the operation or corrupt the calling logic.
How to Resolve
Check is_writable before any write operation. In Anchor, use #[account(mut)] on every account that is written to — Anchor validates the flag during account deserialization.
// Before: Vulnerable — writes without checking is_writable
pub fn update_balance(accounts: &[AccountInfo], new_balance: u64) -> ProgramResult {
let user_account = &accounts[0];
// VULNERABLE: attacker passes a read-only account — behavior is undefined
let mut account_data = user_account.data.borrow_mut();
account_data[0..8].copy_from_slice(&new_balance.to_le_bytes());
Ok(())
}
// After: Fixed — validate is_writable before any modification
pub fn update_balance(accounts: &[AccountInfo], new_balance: u64) -> ProgramResult {
let user_account = &accounts[0];
if !user_account.is_writable {
return Err(ProgramError::InvalidAccountData);
}
let mut account_data = user_account.data.borrow_mut();
account_data[0..8].copy_from_slice(&new_balance.to_le_bytes());
Ok(())
}
Examples
Vulnerable Code
use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult};
pub fn update_balance(accounts: &[AccountInfo], new_balance: u64) -> ProgramResult {
let user_account = &accounts[0];
// VULNERABLE: writing without checking is_writable
// If attacker passes a non-writable account, runtime behavior is undefined:
// - May panic when borrow_mut() is called
// - May silently fail, leaving state inconsistent
let mut account_data = user_account.data.borrow_mut();
account_data[0..8].copy_from_slice(&new_balance.to_le_bytes());
Ok(())
}
Fixed Code
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program_error::ProgramError,
};
// Native program: explicit is_writable check
pub fn update_balance(accounts: &[AccountInfo], new_balance: u64) -> ProgramResult {
let user_account = &accounts[0];
// FIXED: verify writable flag before any modification
if !user_account.is_writable {
return Err(ProgramError::InvalidAccountData);
}
let mut account_data = user_account.data.borrow_mut();
account_data[0..8].copy_from_slice(&new_balance.to_le_bytes());
Ok(())
}
use anchor_lang::prelude::*;
#[account]
pub struct UserAccount {
pub balance: u64,
}
// Anchor: #[account(mut)] validates is_writable during deserialization
#[derive(Accounts)]
pub struct UpdateBalance<'info> {
#[account(mut)] // Anchor checks is_writable automatically here
pub user_account: Account<'info, UserAccount>,
pub authority: Signer<'info>,
}
pub fn update_balance(ctx: Context<UpdateBalance>, new_balance: u64) -> Result<()> {
ctx.accounts.user_account.balance = new_balance;
Ok(())
}
Sample Sigvex Output
{
"detector_id": "missing-writable-check",
"severity": "critical",
"confidence": 0.85,
"description": "StoreAccountData statement writes to account variable var_0 at statement index 2 in function update_balance. No CheckWritable statement found for var_0 in any predecessor block.",
"location": {
"function": "update_balance",
"offset": 0
}
}
Detection Methodology
The detector uses the same CFG-based dataflow infrastructure as missing-signer-check:
- Computes per-block entry validation state from predecessor blocks (
SharedDataflowState). - Tracks
CheckWritablestatements as write-permission markers. - For each
StoreAccountDataorTransferLamportsstatement, checks whether the target account has been marked as writable-validated in the current CFG path. - Applies confidence modifiers: Anchor program with
#[account(mut)]pattern detected → confidence × 0.30; PDA-derived accounts → confidence 0.25.
Limitations
False positives:
- PDA-derived accounts are flagged with Low severity and 0.25 confidence. The Solana runtime enforces that only the owning program can write to program-derived addresses, so findings for PDAs rarely represent real vulnerabilities.
- Anchor programs: Anchor’s
#[account(mut)]constraint validatesis_writableautomatically during account deserialization. Confidence is reduced to 30% for Anchor contexts to reflect this.
False negatives:
- Programs that delegate writability checking to a helper function called before the main handler may produce false negatives if the check is in a predecessor block not captured by the CFG analysis scope.
Related Detectors
- Missing Signer Check — detects missing signer validation, typically co-present with missing writable checks
- Lamport Drain — detects unauthorized lamport transfers, which also require writable accounts
- Duplicate Mutable Accounts — detects aliased mutable accounts passed at multiple positions