31. Unvalidated Struct Fields
Overview
Unvalidated struct fields occur when smart contracts accept user-provided data to populate struct fields without proper validation, leading to invalid state, security bypasses, or protocol corruption. In Sui Move, structs often represent critical protocol state, and failing to validate inputs during construction or modification can have severe consequences.
Risk Level
High — Can lead to invalid state, security bypasses, or financial losses.
OWASP / CWE Mapping
| OWASP Top 10 | MITRE CWE |
|---|---|
| A04 (Insecure Design) | CWE-20 (Improper Input Validation) |
The Problem
Common Validation Failures
| Issue | Risk | Description |
|---|---|---|
| No range validation | High | Values outside acceptable bounds |
| No format validation | Medium | Invalid strings or identifiers |
| No relationship validation | High | Fields that must be consistent |
| No business rule validation | High | Values that violate protocol logic |
| No sanitization | Medium | Malicious or unexpected input |
Vulnerable Example
module vulnerable::lending {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
public struct LendingPool has key {
id: UID,
/// VULNERABLE: No validation on interest rate
interest_rate_bps: u64, // Could be 0 or 1000000
/// VULNERABLE: No validation on ratios
collateral_ratio_bps: u64,
liquidation_ratio_bps: u64,
/// VULNERABLE: No validation on addresses
treasury: address,
oracle: address,
}
/// VULNERABLE: Accepts any values without validation
public entry fun create_pool(
interest_rate_bps: u64,
collateral_ratio_bps: u64,
liquidation_ratio_bps: u64,
treasury: address,
oracle: address,
ctx: &mut TxContext
) {
let pool = LendingPool {
id: object::new(ctx),
interest_rate_bps, // Could be 100% or 0%
collateral_ratio_bps, // Could be less than liquidation!
liquidation_ratio_bps,
treasury, // Could be @0x0
oracle, // Could be attacker's address
};
transfer::share_object(pool);
}
/// VULNERABLE: Update without validation
public entry fun update_rates(
pool: &mut LendingPool,
new_interest_rate: u64,
new_collateral_ratio: u64,
_ctx: &mut TxContext
) {
// No validation - could set absurd rates
pool.interest_rate_bps = new_interest_rate;
pool.collateral_ratio_bps = new_collateral_ratio;
}
}
module vulnerable::nft {
use sui::object::{Self, UID};
use std::string::{Self, String};
public struct NFT has key, store {
id: UID,
/// VULNERABLE: No length limits
name: String,
description: String,
/// VULNERABLE: No URL validation
image_url: String,
/// VULNERABLE: No bounds on attributes
rarity: u64,
power: u64,
}
/// VULNERABLE: Accepts any string content
public fun create_nft(
name: String,
description: String,
image_url: String,
rarity: u64,
power: u64,
ctx: &mut TxContext
): NFT {
// No validation at all!
NFT {
id: object::new(ctx),
name, // Could be empty or 10MB
description, // Could contain malicious content
image_url, // Could be javascript: or data: URI
rarity, // Could be any number
power, // Could break game balance
}
}
}
module vulnerable::auction {
use sui::object::{Self, UID};
use sui::clock::Clock;
public struct Auction has key {
id: UID,
/// VULNERABLE: Time relationships not validated
start_time: u64,
end_time: u64,
/// VULNERABLE: Price relationships not validated
starting_price: u64,
reserve_price: u64,
minimum_increment: u64,
}
/// VULNERABLE: Invalid time/price configurations allowed
public fun create_auction(
start_time: u64,
end_time: u64,
starting_price: u64,
reserve_price: u64,
minimum_increment: u64,
ctx: &mut TxContext
): Auction {
// No validation of relationships!
Auction {
id: object::new(ctx),
start_time, // Could be in the past
end_time, // Could be before start_time!
starting_price, // Could be higher than reserve
reserve_price,
minimum_increment, // Could be 0
}
}
}
module vulnerable::user {
public struct UserProfile has key {
id: UID,
owner: address,
/// VULNERABLE: No validation on username
username: vector<u8>,
/// VULNERABLE: Tier can be set to anything
tier: u8,
/// VULNERABLE: Points can be manipulated
points: u64,
}
/// VULNERABLE: User can set their own tier
public entry fun create_profile(
username: vector<u8>,
tier: u8, // User chooses their tier!
points: u64, // User chooses their points!
ctx: &mut TxContext
) {
let profile = UserProfile {
id: object::new(ctx),
owner: tx_context::sender(ctx),
username,
tier,
points,
};
transfer::transfer(profile, tx_context::sender(ctx));
}
}Attack Scenarios
module attack::exploit_unvalidated {
use vulnerable::lending;
use vulnerable::user;
/// Set up a lending pool that always liquidates
public entry fun create_trap_pool(ctx: &mut TxContext) {
lending::create_pool(
10000, // 100% interest rate
5000, // 50% collateral ratio
9999, // 99.99% liquidation ratio (> collateral!)
@attacker, // Treasury to attacker
@attacker, // Fake oracle
ctx
);
}
/// Create an admin-tier user profile
public entry fun become_admin(ctx: &mut TxContext) {
user::create_profile(
b"hacker",
255, // Max tier = admin?
1000000000, // Billion points
ctx
);
}
}Secure Example
module secure::lending {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
const E_INVALID_INTEREST_RATE: u64 = 1;
const E_INVALID_COLLATERAL_RATIO: u64 = 2;
const E_INVALID_LIQUIDATION_RATIO: u64 = 3;
const E_RATIOS_INCONSISTENT: u64 = 4;
const E_INVALID_ADDRESS: u64 = 5;
const E_NOT_ADMIN: u64 = 6;
/// Bounds for validation
const MIN_INTEREST_RATE_BPS: u64 = 0;
const MAX_INTEREST_RATE_BPS: u64 = 5000; // Max 50% APR
const MIN_COLLATERAL_RATIO_BPS: u64 = 10000; // Min 100%
const MAX_COLLATERAL_RATIO_BPS: u64 = 30000; // Max 300%
const MIN_LIQUIDATION_RATIO_BPS: u64 = 10000; // Min 100%
const LIQUIDATION_BUFFER_BPS: u64 = 500; // 5% buffer required
public struct AdminCap has key {
id: UID,
pool_id: ID,
}
public struct LendingPool has key {
id: UID,
interest_rate_bps: u64,
collateral_ratio_bps: u64,
liquidation_ratio_bps: u64,
treasury: address,
oracle: address,
}
/// SECURE: Validates all inputs before creating pool
public entry fun create_pool(
interest_rate_bps: u64,
collateral_ratio_bps: u64,
liquidation_ratio_bps: u64,
treasury: address,
oracle: address,
ctx: &mut TxContext
) {
// Validate interest rate
assert!(
interest_rate_bps >= MIN_INTEREST_RATE_BPS &&
interest_rate_bps <= MAX_INTEREST_RATE_BPS,
E_INVALID_INTEREST_RATE
);
// Validate collateral ratio
assert!(
collateral_ratio_bps >= MIN_COLLATERAL_RATIO_BPS &&
collateral_ratio_bps <= MAX_COLLATERAL_RATIO_BPS,
E_INVALID_COLLATERAL_RATIO
);
// Validate liquidation ratio
assert!(
liquidation_ratio_bps >= MIN_LIQUIDATION_RATIO_BPS,
E_INVALID_LIQUIDATION_RATIO
);
// SECURE: Validate relationship between ratios
// Collateral must be higher than liquidation + buffer
assert!(
collateral_ratio_bps >= liquidation_ratio_bps + LIQUIDATION_BUFFER_BPS,
E_RATIOS_INCONSISTENT
);
// Validate addresses
assert!(treasury != @0x0, E_INVALID_ADDRESS);
assert!(oracle != @0x0, E_INVALID_ADDRESS);
let pool = LendingPool {
id: object::new(ctx),
interest_rate_bps,
collateral_ratio_bps,
liquidation_ratio_bps,
treasury,
oracle,
};
let pool_id = object::id(&pool);
// Create admin cap for authorized updates
let admin_cap = AdminCap {
id: object::new(ctx),
pool_id,
};
transfer::share_object(pool);
transfer::transfer(admin_cap, tx_context::sender(ctx));
}
/// SECURE: Validated updates with admin authorization
public entry fun update_rates(
admin_cap: &AdminCap,
pool: &mut LendingPool,
new_interest_rate: u64,
new_collateral_ratio: u64,
_ctx: &mut TxContext
) {
// Verify admin
assert!(admin_cap.pool_id == object::id(pool), E_NOT_ADMIN);
// Validate new values
assert!(
new_interest_rate <= MAX_INTEREST_RATE_BPS,
E_INVALID_INTEREST_RATE
);
assert!(
new_collateral_ratio >= MIN_COLLATERAL_RATIO_BPS &&
new_collateral_ratio >= pool.liquidation_ratio_bps + LIQUIDATION_BUFFER_BPS,
E_INVALID_COLLATERAL_RATIO
);
pool.interest_rate_bps = new_interest_rate;
pool.collateral_ratio_bps = new_collateral_ratio;
}
}
module secure::nft {
use sui::object::{Self, UID};
use std::string::{Self, String};
use std::vector;
const E_NAME_TOO_SHORT: u64 = 1;
const E_NAME_TOO_LONG: u64 = 2;
const E_DESCRIPTION_TOO_LONG: u64 = 3;
const E_INVALID_URL: u64 = 4;
const E_INVALID_RARITY: u64 = 5;
const E_INVALID_POWER: u64 = 6;
const E_INVALID_CHARACTERS: u64 = 7;
const MIN_NAME_LENGTH: u64 = 3;
const MAX_NAME_LENGTH: u64 = 64;
const MAX_DESCRIPTION_LENGTH: u64 = 1024;
const MAX_URL_LENGTH: u64 = 256;
const MAX_RARITY: u64 = 5;
const MAX_POWER: u64 = 100;
public struct NFT has key, store {
id: UID,
name: String,
description: String,
image_url: String,
rarity: u64,
power: u64,
}
/// SECURE: Validates all NFT fields
public fun create_nft(
name: String,
description: String,
image_url: String,
rarity: u64,
power: u64,
ctx: &mut TxContext
): NFT {
// Validate name
let name_len = string::length(&name);
assert!(name_len >= MIN_NAME_LENGTH, E_NAME_TOO_SHORT);
assert!(name_len <= MAX_NAME_LENGTH, E_NAME_TOO_LONG);
assert!(is_valid_name(&name), E_INVALID_CHARACTERS);
// Validate description
assert!(string::length(&description) <= MAX_DESCRIPTION_LENGTH, E_DESCRIPTION_TOO_LONG);
// Validate URL
assert!(string::length(&image_url) <= MAX_URL_LENGTH, E_INVALID_URL);
assert!(is_valid_url(&image_url), E_INVALID_URL);
// Validate numeric fields
assert!(rarity <= MAX_RARITY, E_INVALID_RARITY);
assert!(power <= MAX_POWER, E_INVALID_POWER);
NFT {
id: object::new(ctx),
name,
description,
image_url,
rarity,
power,
}
}
/// Validate name contains only allowed characters
fun is_valid_name(name: &String): bool {
let bytes = string::bytes(name);
let len = vector::length(bytes);
let mut i = 0;
while (i < len) {
let byte = *vector::borrow(bytes, i);
// Allow alphanumeric, space, hyphen, underscore
let valid = (byte >= 48 && byte <= 57) || // 0-9
(byte >= 65 && byte <= 90) || // A-Z
(byte >= 97 && byte <= 122) || // a-z
byte == 32 || byte == 45 || byte == 95; // space, -, _
if (!valid) {
return false
};
i = i + 1;
};
true
}
/// Validate URL format (basic check)
fun is_valid_url(url: &String): bool {
let bytes = string::bytes(url);
let len = vector::length(bytes);
// Must have minimum length for https://x
if (len < 10) {
return false
};
// Must start with https://
let https_prefix = b"https://";
let mut i = 0;
while (i < 8) {
if (*vector::borrow(bytes, i) != *vector::borrow(&https_prefix, i)) {
return false
};
i = i + 1;
};
// Block dangerous schemes
// (Already handled by requiring https://)
true
}
}
module secure::auction {
use sui::object::{Self, UID};
use sui::clock::{Self, Clock};
const E_INVALID_START_TIME: u64 = 1;
const E_INVALID_END_TIME: u64 = 2;
const E_DURATION_TOO_SHORT: u64 = 3;
const E_DURATION_TOO_LONG: u64 = 4;
const E_INVALID_STARTING_PRICE: u64 = 5;
const E_RESERVE_BELOW_START: u64 = 6;
const E_INCREMENT_TOO_SMALL: u64 = 7;
const MIN_DURATION_MS: u64 = 3600_000; // 1 hour
const MAX_DURATION_MS: u64 = 604800_000; // 1 week
const MIN_INCREMENT_BPS: u64 = 100; // 1% minimum increment
public struct Auction has key {
id: UID,
start_time: u64,
end_time: u64,
starting_price: u64,
reserve_price: u64,
minimum_increment: u64,
}
/// SECURE: Validates time and price relationships
public fun create_auction(
start_time: u64,
end_time: u64,
starting_price: u64,
reserve_price: u64,
minimum_increment: u64,
clock: &Clock,
ctx: &mut TxContext
): Auction {
let now = clock::timestamp_ms(clock);
// Validate start time is in the future
assert!(start_time > now, E_INVALID_START_TIME);
// Validate end time is after start time
assert!(end_time > start_time, E_INVALID_END_TIME);
// Validate duration bounds
let duration = end_time - start_time;
assert!(duration >= MIN_DURATION_MS, E_DURATION_TOO_SHORT);
assert!(duration <= MAX_DURATION_MS, E_DURATION_TOO_LONG);
// Validate prices
assert!(starting_price > 0, E_INVALID_STARTING_PRICE);
assert!(reserve_price >= starting_price, E_RESERVE_BELOW_START);
// Validate minimum increment
let min_increment = (starting_price * MIN_INCREMENT_BPS) / 10000;
assert!(minimum_increment >= min_increment, E_INCREMENT_TOO_SMALL);
Auction {
id: object::new(ctx),
start_time,
end_time,
starting_price,
reserve_price,
minimum_increment,
}
}
}
module secure::user {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
const E_USERNAME_TOO_SHORT: u64 = 1;
const E_USERNAME_TOO_LONG: u64 = 2;
const E_INVALID_USERNAME: u64 = 3;
const MIN_USERNAME_LENGTH: u64 = 3;
const MAX_USERNAME_LENGTH: u64 = 20;
const STARTING_TIER: u8 = 0;
const STARTING_POINTS: u64 = 0;
public struct UserProfile has key {
id: UID,
owner: address,
username: vector<u8>,
tier: u8,
points: u64,
}
/// SECURE: System-controlled tier and points
public entry fun create_profile(
username: vector<u8>,
ctx: &mut TxContext
) {
// Validate username
let len = vector::length(&username);
assert!(len >= MIN_USERNAME_LENGTH, E_USERNAME_TOO_SHORT);
assert!(len <= MAX_USERNAME_LENGTH, E_USERNAME_TOO_LONG);
assert!(is_valid_username(&username), E_INVALID_USERNAME);
let profile = UserProfile {
id: object::new(ctx),
owner: tx_context::sender(ctx),
username,
tier: STARTING_TIER, // SECURE: System-set, not user input
points: STARTING_POINTS, // SECURE: System-set, not user input
};
transfer::transfer(profile, tx_context::sender(ctx));
}
/// SECURE: Only authorized upgrades
public entry fun upgrade_tier(
admin_cap: &AdminCap,
profile: &mut UserProfile,
new_tier: u8,
) {
// Only admin can change tier
profile.tier = new_tier;
}
fun is_valid_username(username: &vector<u8>): bool {
let len = vector::length(username);
let mut i = 0;
while (i < len) {
let byte = *vector::borrow(username, i);
// Alphanumeric and underscore only
let valid = (byte >= 48 && byte <= 57) || // 0-9
(byte >= 65 && byte <= 90) || // A-Z
(byte >= 97 && byte <= 122) || // a-z
byte == 95; // _
if (!valid) {
return false
};
i = i + 1;
};
true
}
}Validation Patterns
Pattern 1: Range Validation
const MIN_VALUE: u64 = 1;
const MAX_VALUE: u64 = 1000;
fun validate_range(value: u64) {
assert!(value >= MIN_VALUE && value <= MAX_VALUE, E_OUT_OF_RANGE);
}Pattern 2: Relationship Validation
fun validate_time_range(start: u64, end: u64, now: u64) {
assert!(start > now, E_START_IN_PAST);
assert!(end > start, E_END_BEFORE_START);
assert!(end - start <= MAX_DURATION, E_DURATION_TOO_LONG);
}Pattern 3: Builder Pattern
public struct ConfigBuilder {
interest_rate: Option<u64>,
collateral_ratio: Option<u64>,
}
public fun new_builder(): ConfigBuilder {
ConfigBuilder {
interest_rate: option::none(),
collateral_ratio: option::none(),
}
}
public fun with_interest_rate(builder: ConfigBuilder, rate: u64): ConfigBuilder {
assert!(rate <= MAX_RATE, E_INVALID_RATE);
ConfigBuilder {
interest_rate: option::some(rate),
..builder
}
}
public fun build(builder: ConfigBuilder): Config {
assert!(option::is_some(&builder.interest_rate), E_MISSING_FIELD);
assert!(option::is_some(&builder.collateral_ratio), E_MISSING_FIELD);
Config {
interest_rate: option::extract(&mut builder.interest_rate),
collateral_ratio: option::extract(&mut builder.collateral_ratio),
}
}Pattern 4: Whitelist Validation
const ALLOWED_TIERS: vector<u8> = vector[0, 1, 2, 3];
fun validate_tier(tier: u8) {
let mut valid = false;
let len = vector::length(&ALLOWED_TIERS);
let mut i = 0;
while (i < len) {
if (*vector::borrow(&ALLOWED_TIERS, i) == tier) {
valid = true;
break
};
i = i + 1;
};
assert!(valid, E_INVALID_TIER);
}Recommended Mitigations
1. Define Clear Bounds
const MIN_VALUE: u64 = X;
const MAX_VALUE: u64 = Y;2. Validate All Inputs
public fun create(value: u64): Object {
validate_value(value); // Always validate
Object { value }
}3. Validate Relationships
assert!(end_time > start_time, E_INVALID_TIME_RANGE);
assert!(collateral > liquidation, E_INVALID_RATIO);4. System-Controlled Sensitive Fields
// User provides: username
// System controls: tier, points, permissions5. Sanitize String Inputs
assert!(is_valid_format(input), E_INVALID_FORMAT);
assert!(length <= MAX_LENGTH, E_TOO_LONG);Testing Checklist
- Test boundary values (min, max, min-1, max+1)
- Test zero values where inappropriate
- Test maximum length strings
- Test invalid characters in strings
- Test inconsistent relationship values
- Test that sensitive fields cannot be user-set
- Test validation error messages are informative
- Fuzz test with random inputs