25. Unbounded Vector Growth
Overview
Unbounded vector growth occurs when smart contracts allow vectors to grow without limits, leading to gas exhaustion attacks, denial of service, or excessive storage costs. In Sui Move, vectors stored in objects consume gas for both storage and iteration. Attackers can exploit unbounded vectors to make operations prohibitively expensive or cause transactions to fail.
Risk Level
High — Can lead to denial of service or protocol unavailability.
OWASP / CWE Mapping
| OWASP Top 10 | MITRE CWE |
|---|---|
| A05 (Security Misconfiguration) | CWE-770 (Allocation of Resources Without Limits or Throttling) |
The Problem
Common Unbounded Vector Issues
| Issue | Risk | Description |
|---|---|---|
| No size limit on push | Critical | Vector grows until gas exhaustion |
| Iteration over large vectors | High | O(n) operations become too expensive |
| Vector as primary storage | High | Should use Table or dynamic fields |
| No cleanup mechanism | Medium | Data accumulates forever |
| Copying large vectors | High | Unnecessary gas consumption |
Vulnerable Example
module vulnerable::registry {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use std::vector;
const E_ALREADY_REGISTERED: u64 = 1;
public struct Registry has key {
id: UID,
/// VULNERABLE: No limit on vector size
members: vector<address>,
/// VULNERABLE: Grows with every action
action_log: vector<ActionEntry>,
}
public struct ActionEntry has store, copy, drop {
actor: address,
action_type: u8,
timestamp: u64,
}
/// VULNERABLE: Vector grows without bound
public entry fun register(
registry: &mut Registry,
ctx: &mut TxContext
) {
let sender = tx_context::sender(ctx);
// O(n) check - gets slower as registry grows
let mut i = 0;
let len = vector::length(®istry.members);
while (i < len) {
assert!(*vector::borrow(®istry.members, i) != sender, E_ALREADY_REGISTERED);
i = i + 1;
};
// Unbounded growth
vector::push_back(&mut registry.members, sender);
}
/// VULNERABLE: Logs accumulate forever
public entry fun log_action(
registry: &mut Registry,
action_type: u8,
clock: &Clock,
ctx: &mut TxContext
) {
let entry = ActionEntry {
actor: tx_context::sender(ctx),
action_type,
timestamp: clock::timestamp_ms(clock),
};
// Never cleaned up - grows forever
vector::push_back(&mut registry.action_log, entry);
}
/// VULNERABLE: O(n) iteration becomes impossible
public fun get_total_members(registry: &Registry): u64 {
vector::length(®istry.members)
}
/// VULNERABLE: Will fail for large registries
public fun is_member(registry: &Registry, addr: address): bool {
let mut i = 0;
let len = vector::length(®istry.members);
while (i < len) {
if (*vector::borrow(®istry.members, i) == addr) {
return true
};
i = i + 1;
};
false
}
}
module vulnerable::marketplace {
use sui::object::{Self, UID};
use std::vector;
public struct Marketplace has key {
id: UID,
/// VULNERABLE: All listings in one vector
listings: vector<Listing>,
}
public struct Listing has store, drop {
seller: address,
price: u64,
item_id: ID,
active: bool,
}
/// VULNERABLE: Searching listings is O(n)
public fun find_listing(
market: &Marketplace,
item_id: ID
): Option<&Listing> {
let mut i = 0;
let len = vector::length(&market.listings);
while (i < len) {
let listing = vector::borrow(&market.listings, i);
if (listing.item_id == item_id) {
return option::some(listing)
};
i = i + 1;
};
option::none()
}
/// VULNERABLE: Removal leaves gaps or requires O(n) shift
public fun cancel_listing(
market: &mut Marketplace,
item_id: ID,
) {
let mut i = 0;
let len = vector::length(&market.listings);
while (i < len) {
let listing = vector::borrow(&market.listings, i);
if (listing.item_id == item_id) {
// swap_remove is O(1) but changes indices
// remove is O(n) due to shifting
vector::remove(&mut market.listings, i);
return
};
i = i + 1;
};
}
}
module vulnerable::voting {
use std::vector;
public struct Proposal has key {
id: UID,
/// VULNERABLE: All votes stored in vector
votes: vector<Vote>,
}
public struct Vote has store, drop {
voter: address,
choice: u8,
weight: u64,
}
/// VULNERABLE: Counting votes is O(n)
public fun count_votes(proposal: &Proposal): (u64, u64) {
let mut yes_votes = 0u64;
let mut no_votes = 0u64;
let mut i = 0;
let len = vector::length(&proposal.votes);
while (i < len) {
let vote = vector::borrow(&proposal.votes, i);
if (vote.choice == 1) {
yes_votes = yes_votes + vote.weight;
} else {
no_votes = no_votes + vote.weight;
};
i = i + 1;
};
(yes_votes, no_votes)
}
}Attack Scenario
module attack::dos_registry {
use vulnerable::registry::{Self, Registry};
use sui::tx_context::TxContext;
/// Attacker registers many addresses to bloat the registry
public entry fun bloat_registry(
registry: &mut Registry,
count: u64,
ctx: &mut TxContext
) {
// In practice, attacker would use multiple transactions
// and addresses to avoid duplicate checks
// After thousands of entries:
// - is_member() becomes too expensive
// - register() duplicate check times out
// - Legitimate users can't interact
}
}Secure Example
module secure::registry {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use sui::table::{Self, Table};
use sui::vec_set::{Self, VecSet};
const E_ALREADY_REGISTERED: u64 = 1;
const E_MAX_MEMBERS_REACHED: u64 = 2;
const E_NOT_MEMBER: u64 = 3;
const MAX_MEMBERS: u64 = 10_000;
public struct Registry has key {
id: UID,
/// SECURE: O(1) lookups with Table
members: Table<address, MemberInfo>,
member_count: u64,
}
public struct MemberInfo has store {
joined_at: u64,
active: bool,
}
/// SECURE: Bounded membership with O(1) operations
public entry fun register(
registry: &mut Registry,
clock: &Clock,
ctx: &mut TxContext
) {
let sender = tx_context::sender(ctx);
// O(1) existence check
assert!(!table::contains(®istry.members, sender), E_ALREADY_REGISTERED);
// Enforce maximum
assert!(registry.member_count < MAX_MEMBERS, E_MAX_MEMBERS_REACHED);
let info = MemberInfo {
joined_at: clock::timestamp_ms(clock),
active: true,
};
table::add(&mut registry.members, sender, info);
registry.member_count = registry.member_count + 1;
}
/// SECURE: O(1) membership check
public fun is_member(registry: &Registry, addr: address): bool {
if (table::contains(®istry.members, addr)) {
let info = table::borrow(®istry.members, addr);
info.active
} else {
false
}
}
/// SECURE: O(1) removal
public entry fun unregister(
registry: &mut Registry,
ctx: &mut TxContext
) {
let sender = tx_context::sender(ctx);
assert!(table::contains(®istry.members, sender), E_NOT_MEMBER);
let _info = table::remove(&mut registry.members, sender);
registry.member_count = registry.member_count - 1;
}
}
module secure::action_log {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::table::{Self, Table};
use sui::clock::{Self, Clock};
const MAX_LOG_ENTRIES: u64 = 1000;
public struct ActionLog has key {
id: UID,
/// SECURE: Circular buffer pattern
entries: Table<u64, ActionEntry>,
next_index: u64,
total_entries: u64,
}
public struct ActionEntry has store, drop {
actor: address,
action_type: u8,
timestamp: u64,
}
/// SECURE: Bounded log with circular overwrite
public entry fun log_action(
log: &mut ActionLog,
action_type: u8,
clock: &Clock,
ctx: &mut TxContext
) {
let entry = ActionEntry {
actor: tx_context::sender(ctx),
action_type,
timestamp: clock::timestamp_ms(clock),
};
// Calculate index in circular buffer
let index = log.next_index % MAX_LOG_ENTRIES;
// Remove old entry if exists
if (table::contains(&log.entries, index)) {
table::remove(&mut log.entries, index);
};
// Add new entry
table::add(&mut log.entries, index, entry);
log.next_index = log.next_index + 1;
if (log.total_entries < MAX_LOG_ENTRIES) {
log.total_entries = log.total_entries + 1;
};
}
}
module secure::marketplace {
use sui::object::{Self, UID, ID};
use sui::tx_context::{Self, TxContext};
use sui::table::{Self, Table};
use sui::dynamic_object_field as dof;
const E_LISTING_EXISTS: u64 = 1;
const E_LISTING_NOT_FOUND: u64 = 2;
const E_NOT_SELLER: u64 = 3;
public struct Marketplace has key {
id: UID,
/// SECURE: O(1) lookups by item ID
listings: Table<ID, ListingInfo>,
listing_count: u64,
}
public struct ListingInfo has store {
seller: address,
price: u64,
listed_at: u64,
}
/// Items stored as dynamic fields, not in vector
public entry fun list_item<T: key + store>(
market: &mut Marketplace,
item: T,
price: u64,
clock: &Clock,
ctx: &mut TxContext
) {
let item_id = object::id(&item);
assert!(!table::contains(&market.listings, item_id), E_LISTING_EXISTS);
let info = ListingInfo {
seller: tx_context::sender(ctx),
price,
listed_at: clock::timestamp_ms(clock),
};
// Store listing info in table (O(1))
table::add(&mut market.listings, item_id, info);
// Store item as dynamic field
dof::add(&mut market.id, item_id, item);
market.listing_count = market.listing_count + 1;
}
/// SECURE: O(1) lookup
public fun get_listing(
market: &Marketplace,
item_id: ID
): &ListingInfo {
assert!(table::contains(&market.listings, item_id), E_LISTING_NOT_FOUND);
table::borrow(&market.listings, item_id)
}
/// SECURE: O(1) removal
public entry fun cancel_listing<T: key + store>(
market: &mut Marketplace,
item_id: ID,
ctx: &mut TxContext
) {
assert!(table::contains(&market.listings, item_id), E_LISTING_NOT_FOUND);
let info = table::borrow(&market.listings, item_id);
assert!(info.seller == tx_context::sender(ctx), E_NOT_SELLER);
// Remove listing info
let _info = table::remove(&mut market.listings, item_id);
// Return item to seller
let item: T = dof::remove(&mut market.id, item_id);
transfer::public_transfer(item, tx_context::sender(ctx));
market.listing_count = market.listing_count - 1;
}
}
module secure::voting {
use sui::object::{Self, UID};
use sui::table::{Self, Table};
const E_ALREADY_VOTED: u64 = 1;
public struct Proposal has key {
id: UID,
/// SECURE: Track votes per address (O(1) check)
votes: Table<address, Vote>,
/// SECURE: Pre-aggregated totals
yes_votes: u64,
no_votes: u64,
total_weight: u64,
}
public struct Vote has store, drop {
choice: u8,
weight: u64,
}
/// SECURE: O(1) vote with pre-aggregation
public entry fun cast_vote(
proposal: &mut Proposal,
choice: u8,
weight: u64,
ctx: &mut TxContext
) {
let voter = tx_context::sender(ctx);
// O(1) duplicate check
assert!(!table::contains(&proposal.votes, voter), E_ALREADY_VOTED);
// Store vote
let vote = Vote { choice, weight };
table::add(&mut proposal.votes, voter, vote);
// Update aggregates (no need to iterate later)
if (choice == 1) {
proposal.yes_votes = proposal.yes_votes + weight;
} else {
proposal.no_votes = proposal.no_votes + weight;
};
proposal.total_weight = proposal.total_weight + weight;
}
/// SECURE: O(1) result retrieval
public fun get_results(proposal: &Proposal): (u64, u64, u64) {
(proposal.yes_votes, proposal.no_votes, proposal.total_weight)
}
}Vector Usage Guidelines
When to Use Vectors
// GOOD: Small, bounded collections
public struct Config has key {
admins: vector<address>, // Max 5 admins
tags: vector<vector<u8>>, // Max 10 tags
}
const MAX_ADMINS: u64 = 5;
public fun add_admin(config: &mut Config, admin: address) {
assert!(vector::length(&config.admins) < MAX_ADMINS, E_MAX_ADMINS);
vector::push_back(&mut config.admins, admin);
}When to Use Table/VecMap
// GOOD: Large or unbounded collections with key lookups
public struct UserRegistry has key {
users: Table<address, UserInfo>, // Unlimited users, O(1) lookup
}
// GOOD: When you need to iterate occasionally but lookup often
public struct TokenRegistry has key {
tokens: VecMap<ID, TokenInfo>, // Ordered, iterable, O(n) lookup
}When to Use Dynamic Fields
// GOOD: Heterogeneous or large object storage
public struct Wallet has key {
id: UID,
// Items stored as dynamic fields
// - No vector bloat
// - O(1) access by key
// - Items can be different types
}
public fun store_item<T: key + store>(wallet: &mut Wallet, item: T) {
let item_id = object::id(&item);
dof::add(&mut wallet.id, item_id, item);
}Recommended Mitigations
1. Set Maximum Sizes
const MAX_ENTRIES: u64 = 1000;
public fun add_entry(collection: &mut Collection, entry: Entry) {
assert!(vector::length(&collection.entries) < MAX_ENTRIES, E_MAX_SIZE);
vector::push_back(&mut collection.entries, entry);
}2. Use Tables for Large Collections
// Replace vector with Table for O(1) operations
members: Table<address, MemberInfo>, // Not vector<address>3. Pre-aggregate Data
// Don't iterate to count - maintain running totals
public struct Stats has key {
total_votes: u64, // Updated on each vote
total_value: u64, // Updated on each deposit
}4. Implement Cleanup Mechanisms
// Circular buffer for logs
let index = next_index % MAX_SIZE;
// Old entries automatically overwritten5. Use Pagination for Reads
public fun get_entries_paginated(
collection: &Collection,
offset: u64,
limit: u64
): vector<Entry> {
// Return only a subset
}Testing Checklist
- Verify all vectors have maximum size limits
- Test behavior at maximum capacity
- Confirm O(n) operations are avoided or bounded
- Test gas consumption with large data sets
- Verify cleanup mechanisms work correctly
- Check that Tables are used for key-based lookups
- Test pagination for large result sets