Near Learn

Storage staking attacks

NEAR storage cost attacks: if anyone can make your contract store data, they lock your balance. Charge per byte, use NEP-145 deposits and cap input sizes.

Advanced7 min read3-question check

On NEAR, an account must hold a balance proportional to the bytes it stores — storage staking. That NEAR is locked, not spent: it cannot pay for gas or be transferred while the data exists. The cost is paid by the contract account, not by whoever caused the write.

So any method that lets a stranger add data to your state is a method that lets a stranger lock up your NEAR. Push enough bytes and the contract cannot cover its storage at all, and every write that grows state starts failing.

The bug#

Vulnerable — free, unbounded writes paid for by the contract
Rust
#[near]
impl Guestbook {
    // BUG: anyone can call this as often as they like, with a
    // message of any length. Each byte locks the contract's NEAR.
    pub fn add_message(&mut self, text: String) {
        self.messages.push(Message {
            author: env::predecessor_account_id(),
            text,
        });
    }
}

The fix#

Fixed — cap input size and make the caller pay for the bytes they add
Rust
use near_sdk::store::Vector;
use near_sdk::{env, near, require, AccountId, NearToken, PanicOnDefault, Promise};

const MAX_TEXT_LEN: usize = 280;

#[near(serializers = [borsh])]
pub struct Message {
    author: AccountId,
    text: String,
}

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Guestbook {
    messages: Vector<Message>,
}

#[near]
impl Guestbook {
    #[payable]
    pub fn add_message(&mut self, text: String) {
        require!(text.len() <= MAX_TEXT_LEN, "message too long");
        let author = env::predecessor_account_id();

        let before = env::storage_usage();
        self.messages.push(Message { author: author.clone(), text });
        // store:: collections write lazily; flush so the bytes are counted now
        self.messages.flush();
        let bytes = env::storage_usage().saturating_sub(before);

        let cost = env::storage_byte_cost().saturating_mul(bytes as u128);
        let deposit = env::attached_deposit();
        require!(deposit >= cost, "attach enough NEAR to cover storage");

        let refund = deposit.saturating_sub(cost);
        if refund > NearToken::from_yoctonear(0) {
            Promise::new(author).transfer(refund);
        }
        // if this panics, the push above is reverted along with everything else
    }
}
  • List every method that can grow state. Who pays for each byte?
  • Bound every user-supplied String and Vec with require!.
  • Refund excess deposit, and refund stake when data is removed.
  • Keep a NEAR buffer on the contract account for its own storage, and monitor it.

Check yourself

3 questions · progress saved in this browser

  1. 1.A public, non-payable method lets anyone append a 10 KB string to a vector. What is the main risk?
  2. 2.What does NEP-145 standardize?
  3. 3.In near-sdk 5, you measure env::storage_usage() before and after pushing to a store::Vector, and the difference is 0. Why?