Each entry maps to a contract in vulnerable/ and its secure mirror in secure/.
Contract: vulnerable/missing_auth → vulnerable/missing_auth/src/secure.rs
Severity: Critical
Soroban's auth model requires every state-mutating function to call
address.require_auth() for the address whose resources are being modified.
Without this call the Soroban host places no restriction on who can invoke the
function — any account can submit a valid transaction.
Two functions are unprotected in the vulnerable contract:
transfer()— nofrom.require_auth(), anyone can drain any account.mint()— no admin check, anyone can inflate supply arbitrarily.
// ❌ transfer: no require_auth — anyone can drain `from`
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
let from_balance: i128 = env.storage().persistent().get(&DataKey::Balance(from.clone())).unwrap_or(0);
env.storage().persistent().set(&DataKey::Balance(from), &(from_balance - amount));
}
// ❌ mint: no admin check — anyone can mint arbitrary tokens
pub fn mint(env: Env, to: Address, amount: i128) {
let current: i128 = env.storage().persistent().get(&DataKey::Balance(to.clone())).unwrap_or(0);
env.storage().persistent().set(&DataKey::Balance(to), &(current + amount));
}// ✅ transfer: from must authorise every spend
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
from.require_auth();
// ...balance update unchanged...
}
// ✅ mint: only the stored admin may mint
pub fn mint(env: Env, to: Address, amount: i128) {
let admin: Address = env.storage().persistent().get(&DataKey::Admin).unwrap();
admin.require_auth();
// ...balance credit unchanged...
}Complete fund theft: any attacker can transfer the entire balance of any account. Unchecked mint allows unlimited supply inflation, devaluing all existing balances.
Contract: vulnerable/unchecked_math → secure/secure_vault
Severity: High
Rust's integer types wrap on overflow in --release builds unless
overflow-checks = true is set in the Cargo profile. Even with that flag,
relying on a panic is not the same as explicitly handling the error. The correct
approach is checked_mul / checked_add which return Option and force the
developer to handle the overflow case.
// ❌ Raw * — overflows silently without overflow-checks = true
let reward = staked * rate * elapsed;let reward = staked
.checked_mul(rate).expect("reward: overflow")
.checked_mul(elapsed).expect("reward: overflow");Reward calculation produces wildly incorrect values, enabling either free reward extraction or denial of rewards.
Contract: vulnerable/unprotected_admin → secure/protected_admin
Severity: Critical
Admin-only functions (set_admin, upgrade) that do not verify the caller is
the current admin. Because Soroban does not have implicit access control, any
account can call these functions and take over the contract.
pub fn set_admin(env: Env, new_admin: Address) {
// ❌ No require_auth on the current admin
env.storage().persistent().set(&DataKey::Admin, &new_admin);
}pub fn set_admin(env: Env, new_admin: Address) {
let current: Address = env.storage().persistent().get(&DataKey::Admin).unwrap();
current.require_auth(); // ✅ Only the current admin can rotate
env.storage().persistent().set(&DataKey::Admin, &new_admin);
}Full contract takeover: attacker becomes admin and can drain funds, upgrade to malicious WASM, or brick the contract.
Contract: vulnerable/unsafe_storage → secure/protected_admin
Severity: High
A public function that writes to persistent storage keyed by an Address
argument without verifying the caller owns that address. Any account can pass
any address and overwrite that account's data.
pub fn set_profile(env: Env, account: Address, display_name: String, kyc_level: u32) {
// ❌ No require_auth — anyone can write to any account's slot
env.storage().persistent().set(&DataKey::Profile(account), &Profile { display_name, kyc_level });
}pub fn set_profile(env: Env, account: Address, display_name: String, kyc_level: u32) {
account.require_auth(); // ✅ Only the account owner can update their profile
env.storage().persistent().set(&DataKey::Profile(account), &Profile { display_name, kyc_level });
}Data integrity violation: KYC levels, display names, or any stored metadata can be forged or wiped by any attacker.
Contract: vulnerable/self_transfer → vulnerable/self_transfer/src/secure.rs
Severity: High
When transfer(from, to, amount) is called with from == to, both balance
reads resolve to the same persistent storage slot. The function reads the
balance into two separate variables (from_balance and to_balance, both equal
to the original balance), subtracts amount on the first write, then overwrites
that same slot with to_balance + amount on the second write. The credit write
clobbers the debit write, so the account ends with original + amount instead of
original — inflating the balance by amount.
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
from.require_auth();
// ❌ No from != to check — self-transfer corrupts balance
let from_balance = get_balance(&env, &from);
let to_balance = get_balance(&env, &to); // same slot when from == to
set_balance(&env, &from, from_balance.checked_sub(amount).unwrap());
set_balance(&env, &to, to_balance.checked_add(amount).unwrap()); // overwrites subtraction
}pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
from.require_auth();
if from == to { return; } // ✅ FIX: no-op self-transfer, guard before any storage read
// ...
}A self-transfer is treated as a no-op (the common token-standard behaviour), so
the colliding debit and credit writes never happen. panic!("self-transfer not allowed") would be an equally valid choice. The guard sits before any storage
read so the corrupting double-write is unreachable.
Balance inflation: a user can repeatedly self-transfer to inflate their balance without limit.
Contract: vulnerable/missing_ttl → vulnerable/missing_ttl/src/secure.rs
Severity: Low
Soroban persistent storage entries are not permanent by default. Every entry has
a ledger TTL, and once that window passes the entry expires unless the contract
refreshes it with env.storage().persistent().extend_ttl(...).
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
from.require_auth();
// ❌ No extend_ttl — active balances are never renewed
env.storage().persistent().set(&from_key, &new_from);
env.storage().persistent().set(&to_key, &new_to);
}env.storage().persistent().set(&key, &amount);
env.storage().persistent().extend_ttl(&key, threshold, extend_to); // ✅ renew on writeLiveness failure: after the network's max TTL window, balances disappear and the contract starts reading them as missing. Funds can become permanently inaccessible.
Contract: vulnerable/zero_admin → secure/protected_admin
Severity: High
initialize(admin) stores the admin without validating that it is a real,
non-default address. Passing the zero/default address permanently bricks all
admin-gated functions because no one can ever satisfy require_auth for that
address.
pub fn initialize(env: Env, admin: Address) {
if env.storage().persistent().has(&DataKey::Admin) {
panic!("already initialized");
}
// ❌ Missing: assert admin != zero/default address
env.storage().persistent().set(&DataKey::Admin, &admin);
}pub fn initialize(env: Env, admin: Address) {
// ✅ Validate admin is a real account before storing
admin.require_auth();
env.storage().persistent().set(&DataKey::Admin, &admin);
}Contract bricked: if the zero address is stored as admin, no one can ever call admin-gated functions again.
Contract: vulnerable/zero_deposit → vulnerable/zero_deposit/src/secure.rs
Severity: Low
A vault contract where deposit accepts amount == 0 without error. This
writes a zero-balance entry to persistent storage, wasting ledger space and
confusing accounting logic that assumes stored entries always hold a positive
balance.
pub fn deposit(env: Env, user: Address, amount: i128) {
user.require_auth();
// ❌ No positive-amount guard — zero deposits create junk storage entries
let current: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0);
env.storage().persistent().set(&DataKey::Balance(user), &(current + amount));
}pub fn deposit(env: Env, user: Address, amount: i128) {
assert!(amount > 0, "deposit must be positive"); // ✅
// ...
}Ledger bloat and accounting confusion; ghost entries can be exploited by logic that gates access on the presence of a storage entry.
Contract: vulnerable/zero_stake → vulnerable/zero_stake/src/secure.rs
Severity: Medium
A staking contract where stake(staker, 0) succeeds and records a staked_at
timestamp. The staker occupies a storage slot and is treated as a valid staker
by any logic that checks is_staker, even though they contributed nothing.
Ghost entries can be exploited by future logic that gates access on staker
status (e.g. governance, airdrops).
pub fn stake(env: Env, staker: Address, amount: i128) {
staker.require_auth();
// ❌ Missing: assert!(amount > 0, "stake must be positive")
env.storage().persistent().set(&DataKey::Stake(staker.clone()), &StakeInfo { amount, staked_at });
}pub fn stake(env: Env, staker: Address, amount: i128) {
assert!(amount > 0, "stake must be positive"); // ✅
// ...
}Ghost staker entries; attackers gain staker status (governance votes, airdrop eligibility) without committing any capital.
Contract: vulnerable/double_claim → secure/secure_vault
Severity: Critical
A staking contract where claim_rewards computes the reward based on elapsed
ledgers since staked_at, but never resets staked_at after paying out. The
same elapsed window can be claimed over and over, draining the reward pool.
pub fn claim_rewards(env: Env, staker: Address) -> u64 {
staker.require_auth();
let staked_at = get_staked_at(&env, &staker);
let elapsed = env.ledger().sequence() - staked_at as u32;
let reward = get_stake(&env, &staker) * elapsed as u64 * get_rate(&env);
// ❌ staked_at is never updated — same window claimed repeatedly
reward
}// ✅ Reset the timestamp after every claim
env.storage().persistent().set(&DataKey::StakedAt(staker.clone()), &(env.ledger().sequence() as u64));Reward pool drained to zero: a staker can call claim_rewards in a tight loop
and extract unlimited rewards from a single stake.
Contract: vulnerable/reward_checkpoint_missing → vulnerable/reward_checkpoint_missing/src/secure.rs
Severity: High
A staking contract using the standard MasterChef-style global accumulator pattern
fails to snapshot the accumulator into the user's reward_debt when new stake is
added. The reward_debt is meant to record the accumulator value at deposit time,
so that pending rewards only accrue from that point forward. Without this checkpoint,
a late depositor inherits a debt of 0 and can immediately claim all accumulated
rewards that were earned before they joined.
pub fn stake(env: Env, user: Address, amount: u64) {
user.require_auth();
let acc = get_acc(&env);
let current_stake = get_stake(&env, &user);
// ❌ Missing: set_reward_debt(&env, &user, acc * (current_stake + amount));
// debt defaults to 0, so a late depositor can claim all historical rewards
env.storage().persistent().set(
&DataKey::Stake(user.clone()),
&(current_stake + amount)
);
}Pending rewards are computed as:
pending = (acc_reward_per_share × user_stake) - user_reward_debt
Without the checkpoint, debt = 0, so pending = acc × stake, which includes
all rewards earned before the user even joined.
pub fn stake(env: Env, user: Address, amount: u64) {
user.require_auth();
let new_total_stake = get_stake_secure(&env, &user).saturating_add(amount);
let acc = get_acc_secure(&env);
// Write the new balance
env.storage().persistent().set(
&SecureDataKey::Stake(user.clone()),
&new_total_stake
);
// ✅ FIX: Set reward_debt to the current accumulator × new stake.
// This captures the checkpoint so pending = 0 at deposit time.
let new_debt = acc.saturating_mul(new_total_stake) / 1_000_0000;
set_debt_secure(&env, &user, new_debt);
}Historical reward theft: a user who deposits after rewards have already accrued can immediately claim those pre-deposit rewards as if they had been staking all along. This allows late depositors to extract far more than they contributed.
Contract: vulnerable/div_by_zero → inline secure pattern
Severity: Medium
A staking contract that distributes rewards by dividing total_reward by
total_staked. When the pool is empty (total_staked == 0) the division
panics, giving any caller a reliable denial-of-service vector.
pub fn distribute(env: Env) -> u64 {
let total_staked: u64 = env.storage().persistent().get(&DataKey::TotalStaked).unwrap_or(0);
let total_reward: u64 = 1_000_000;
// ❌ Panics when total_staked == 0
total_reward / total_staked
}if total_staked == 0 {
return 0; // ✅ Guard before division
}
let per_unit = total_reward / total_staked;Denial of service: any caller can trigger a panic by invoking distribute
before anyone has staked.
Contract: vulnerable/missing_events → vulnerable/missing_events/src/secure.rs
Severity: Medium
State-mutating functions (mint, burn) that never call
env.events().publish(). Off-chain indexers and users cannot track these state
changes, leading to inconsistent views of the contract state and broken
monitoring.
pub fn mint(env: Env, to: Address, amount: i128) {
// ❌ No event emitted — off-chain indexers are blind to this mutation
let key = DataKey::Balance(to);
let current: i128 = env.storage().persistent().get(&key).unwrap_or(0);
env.storage().persistent().set(&key, &(current + amount));
}
pub fn burn(env: Env, from: Address, amount: i128) {
// ❌ No event emitted — off-chain indexers are blind to this mutation
let key = DataKey::Balance(from);
let current: i128 = env.storage().persistent().get(&key).unwrap_or(0);
env.storage().persistent().set(&key, &(current - amount));
}pub fn mint(env: Env, to: Address, amount: i128) {
// ... balance update ...
env.events().publish((symbol_short!("mint"),), (to, amount)); // ✅
}
pub fn burn(env: Env, from: Address, amount: i128) {
// ... balance update ...
env.events().publish((symbol_short!("burn"),), (from, amount)); // ✅
}Silent state changes break off-chain monitoring, auditing, and user-facing balance displays.
Contract: vulnerable/leaky_events → vulnerable/leaky_events/src/secure.rs
Severity: Low
A token contract that publishes post-transfer balances for both sender and recipient in every transfer event. Anyone monitoring the ledger can reconstruct every account's full transaction history and current balance from the event stream alone — no storage access required.
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
// ... balance update ...
// ❌ Emits exact post-transfer balances — leaks financial state of both parties
env.events().publish(
(symbol_short!("transfer"),),
(from, to, new_from_balance, new_to_balance),
);
}// ✅ Emit only the transfer amount — balances stay private
env.events().publish((symbol_short!("transfer"),), (from, to, amount));Privacy violation: full balance history of every account is publicly reconstructable from the event stream.
Contract: vulnerable/unprotected_delete → secure/protected_admin
Severity: High
A data registry contract where any caller can delete any account's persistent
storage entry via delete_entry() without owning that address. An attacker can
wipe any account's data with no authorization.
pub fn delete_entry(env: Env, account: Address) {
// ❌ No account.require_auth() — anyone can delete anyone's entry
env.storage().persistent().remove(&DataKey::Entry(account));
}pub fn delete_entry(env: Env, account: Address) {
account.require_auth(); // ✅ Only the account owner can delete their entry
env.storage().persistent().remove(&DataKey::Entry(account));
}Data destruction: an attacker can wipe KYC records, profile data, or any registry entry for any account.
Contract: vulnerable/unprotected_burn → secure/secure_burn
Severity: High
A token contract where burn() destroys tokens from any account without
requiring authorization from that account. Any caller can burn any account's
tokens, deflating supply and wiping balances.
pub fn burn(env: Env, account: Address, amount: i128) {
// ❌ No account.require_auth() — anyone can burn anyone's tokens
let balance: i128 = env.storage().persistent().get(&DataKey::Balance(account.clone())).unwrap_or(0);
env.storage().persistent().set(&DataKey::Balance(account), &(balance - amount));
}pub fn burn(env: Env, account: Address, amount: i128) {
account.require_auth(); // ✅ Only the token holder can burn their own tokens
// ...
}Targeted balance destruction: an attacker can zero out any account's token balance at will.
Contract: vulnerable/unprotected_fee_withdraw → secure/protected_fee_withdraw
Severity: Critical
A DEX-style contract that accumulates fees from swaps and exposes an unguarded
withdraw_fees() function. Any account can drain the contract's accumulated
fee balance to an arbitrary address.
pub fn withdraw_fees(env: Env, recipient: Address) {
// ❌ No admin.require_auth() — anyone can drain accumulated fees
let fees: i128 = env.storage().persistent().get(&DataKey::Fees).unwrap_or(0);
env.storage().persistent().set(&DataKey::Fees, &0i128);
env.events().publish((symbol_short!("withdraw"),), (recipient, fees));
}pub fn withdraw_fees(env: Env, recipient: Address) {
let admin: Address = env.storage().persistent().get(&DataKey::Admin).unwrap();
admin.require_auth(); // ✅ Only admin can withdraw fees
// ...
}Complete fee theft: any attacker can drain all accumulated protocol fees to an address they control.
Contract: vulnerable/reentrancy → vulnerable/reentrancy/src/secure.rs
Severity: High
A vault that notifies an external contract before reducing the user's balance.
An attacker-controlled notifier can call back into withdraw() while the
original user's balance is still intact, withdrawing more than they deposited.
pub fn withdraw(env: Env, user: Address, amount: i128, notify_id: Address) {
user.require_auth();
let balance = get_balance(&env, &user);
// ❌ External call BEFORE state update — reentrancy window
NotifyContractClient::new(&env, ¬ify_id).on_withdraw(&user, &amount);
// Balance update happens after the callback — too late
env.storage().persistent().set(&DataKey::Balance(user), &(balance - amount));
}pub fn withdraw(env: Env, user: Address, amount: i128, notify_id: Address) {
user.require_auth();
let balance = get_balance(&env, &user);
// ✅ Update state BEFORE external call
env.storage().persistent().set(&DataKey::Balance(user.clone()), &(balance - amount));
NotifyContractClient::new(&env, ¬ify_id).on_withdraw(&user, &amount);
}Double-spend: an attacker can withdraw more than their deposited balance by
re-entering withdraw during the callback.
Contract: vulnerable/reinit_attack → secure/protected_admin
Severity: Critical
A vault contract whose initialize() sets the admin and treasury balance but
performs no re-init guard. Any caller can invoke initialize() again after
deployment, replacing the admin with an attacker-controlled address and
effectively taking over the contract.
pub fn initialize(env: Env, admin: Address) {
// ❌ No check whether already initialized — can be called repeatedly
env.storage().persistent().set(&DataKey::Admin, &admin);
}pub fn initialize(env: Env, admin: Address) {
if env.storage().persistent().has(&DataKey::Admin) {
panic!("already initialized"); // ✅ One-time init guard
}
env.storage().persistent().set(&DataKey::Admin, &admin);
}Full contract takeover: an attacker calls initialize after deployment to
install themselves as admin.
Contract: vulnerable/string_admin → secure/protected_admin
Severity: Critical
A contract that stores the admin as a String and authenticates callers with a
plain == comparison. String comparison provides no cryptographic guarantee —
any caller who knows (or guesses) the stored string value can pass the check
without holding the corresponding private key.
pub fn set_config(env: Env, caller: String, value: u32) {
let admin: String = env.storage().persistent().get(&DataKey::Admin).unwrap();
// ❌ String equality — no cryptographic proof of key ownership
assert!(caller == admin, "not admin");
env.storage().persistent().set(&DataKey::Config, &value);
}pub fn set_config(env: Env, admin: Address, value: u32) {
let stored: Address = env.storage().persistent().get(&DataKey::Admin).unwrap();
assert!(admin == stored, "not admin");
admin.require_auth(); // ✅ Cryptographic proof via Stellar signature
env.storage().persistent().set(&DataKey::Config, &value);
}Admin bypass: anyone who can observe or guess the admin string can call privileged functions without holding the private key.
Contract: vulnerable/underflow_transfer → secure/secure_vault
Severity: High
A token contract where transfer() subtracts balances with raw - on i128.
If amount > from_balance the subtraction underflows, wrapping to a large
positive number and crediting the sender with a massive balance.
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
from.require_auth();
let from_balance: i128 = env.storage().persistent().get(&DataKey::Balance(from.clone())).unwrap_or(0);
// ❌ Raw subtraction — wraps to large positive on underflow
env.storage().persistent().set(&DataKey::Balance(from), &(from_balance - amount));
}let new_balance = from_balance.checked_sub(amount).expect("insufficient funds"); // ✅Balance inflation: a user with zero balance can transfer a large amount and end
up with a near-maximum i128 balance.
Contract: vulnerable/key_collision → vulnerable/key_collision/src/secure.rs
Severity: Medium
Using flat symbol_short! strings for all storage keys means any two keys that
share the same string value occupy the same storage slot. A user whose chosen
tag equals "admin" silently overwrites the global admin slot, and vice-versa.
pub fn set_admin(env: Env, admin: Address) {
// ❌ Plain symbol — collides with any user key also named "admin"
env.storage().persistent().set(&symbol_short!("admin"), &admin);
}
pub fn set_user_tag(env: Env, user: Address, tag: Symbol) {
// ❌ If tag == symbol_short!("admin"), this overwrites the admin slot
env.storage().persistent().set(&tag, &user);
}#[contracttype]
pub enum DataKey {
Admin,
UserTag(Address), // ✅ Enum variants are namespaced — no collision possible
}Storage corruption: a crafted user tag can overwrite the admin address or any other global config slot, leading to privilege escalation or data loss.
Contract: vulnerable/negative_transfer → secure/secure_transfer
Severity: High
A token contract where transfer() accepts negative amount values. A
negative amount reverses the transfer direction — the sender receives tokens
from the recipient instead of sending them, bypassing the recipient's auth.
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
from.require_auth();
// ❌ No positive-amount guard — negative amount steals from `to`
set_balance(&env, &from, get_balance(&env, &from) - amount);
set_balance(&env, &to, get_balance(&env, &to) + amount);
}pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
assert!(amount > 0, "amount must be positive"); // ✅
from.require_auth();
// ...
}Unauthorized fund extraction: from can call transfer(from, victim, -N) to
steal N tokens from victim using only their own auth.
Contract: vulnerable/timestamp_lock → vulnerable/timestamp_lock/src/secure.rs
Severity: Medium
A time-locked vault that uses env.ledger().timestamp() to enforce lock
periods. Validators can manipulate timestamps within a drift window (typically
5–15 seconds), allowing premature withdrawal of locked funds. Ledger sequences
are monotonically increasing and cannot be manipulated.
pub fn withdraw(env: Env, user: Address) {
user.require_auth();
let unlock_time: u64 = env.storage().persistent().get(&DataKey::UnlockTime(user.clone())).unwrap();
// ❌ Timestamp can be drifted by validators — lock can be bypassed
assert!(env.ledger().timestamp() >= unlock_time, "funds still locked");
// ...
}pub fn withdraw(env: Env, user: Address) {
user.require_auth();
let unlock_ledger: u32 = env.storage().persistent().get(&DataKey::UnlockLedger(user.clone())).unwrap();
// ✅ Ledger sequence is strictly monotonic — cannot be manipulated
assert!(env.ledger().sequence() >= unlock_ledger, "funds still locked");
// ...
}Premature withdrawal: an attacker can unlock funds before the intended lock period expires by exploiting validator timestamp drift.
Contract: vulnerable/no_slippage → vulnerable/no_slippage/src/secure.rs
Severity: High
An AMM-style swap contract that accepts no min_amount_out parameter. An
attacker can sandwich the victim's transaction: front-run to move the pool
price unfavourably, let the victim's swap execute at the worse rate, then
back-run to pocket the difference.
pub fn swap(env: Env, user: Address, amount_in: i128) -> i128 {
user.require_auth();
let reserve_a = get_reserve_a(&env);
let reserve_b = get_reserve_b(&env);
// ❌ No min_amount_out — any price impact is silently accepted
let amount_out = (amount_in * reserve_b) / (reserve_a + amount_in);
// ...
amount_out
}pub fn swap(env: Env, user: Address, amount_in: i128, min_amount_out: i128) -> i128 {
// ...
assert!(amount_out >= min_amount_out, "slippage exceeded"); // ✅
amount_out
}MEV sandwich attack: victim receives far fewer tokens than expected; attacker extracts the price impact as profit.
Contract: vulnerable/flash_loan_no_check → vulnerable/flash_loan_no_check/src/secure.rs
Severity: Critical
A flash loan contract that transfers funds to a borrower and invokes their callback, but never asserts that the borrowed amount was returned within the same transaction. This allows a borrower to permanently drain the lending pool without repaying.
pub fn flash_loan(env: Env, borrower_id: Address, amount: i128) {
// Transfer funds to borrower
BorrowerContractClient::new(&env, &borrower_id).on_flash_loan(&amount);
// ❌ No balance check after callback — repayment is never verified
}pub fn flash_loan(env: Env, borrower_id: Address, amount: i128) {
let balance_before = get_pool_balance(&env);
BorrowerContractClient::new(&env, &borrower_id).on_flash_loan(&amount);
// ✅ Assert full repayment after callback
assert!(get_pool_balance(&env) >= balance_before, "flash loan not repaid");
}Complete pool drain: a borrower can take a flash loan and never repay it, stealing the entire lending pool.
Contract: vulnerable/scanner_impersonation → vulnerable/scanner_impersonation/src/secure.rs
Severity: High
A scan result registry where submit_scan(scanner, ...) does not call
scanner.require_auth(). Any caller can pass an arbitrary scanner address
and submit findings attributed to that address, poisoning the registry with
fake results.
pub fn submit_scan(env: Env, scanner: Address, contract_address: Address, findings_hash: String) {
// ❌ No scanner.require_auth() — anyone can impersonate any scanner
let result = ScanResult { scanner, findings_hash, ... };
env.storage().persistent().set(&DataKey::Scan(contract_address), &result);
}pub fn submit_scan(env: Env, scanner: Address, contract_address: Address, findings_hash: String) {
scanner.require_auth(); // ✅ Only the real scanner can submit under their address
// ...
}Registry poisoning: an attacker can submit fake scan results attributed to a trusted scanner, undermining the integrity of the entire audit trail.
Contract: vulnerable/instant_oracle → vulnerable/instant_oracle/src/secure.rs
Severity: High
An oracle that allows set_price and get_price to be called in the same
ledger with no enforced delay. An attacker can borrow funds, call set_price
to an arbitrary value, exploit any contract that reads the oracle in the same
transaction, then repay — a classic flash-loan price manipulation.
pub fn get_price(env: Env) -> i128 {
// ❌ No staleness check — price set in the same ledger is immediately usable
env.storage().persistent().get(&DataKey::Price).unwrap()
}pub fn get_price(env: Env) -> i128 {
let updated_at: u32 = env.storage().persistent().get(&DataKey::UpdatedAt).unwrap();
// ✅ Require at least MIN_DELAY ledgers between update and consumption
assert!(env.ledger().sequence() > updated_at + MIN_DELAY, "price too fresh");
env.storage().persistent().get(&DataKey::Price).unwrap()
}Price manipulation: an attacker can set an arbitrary price and exploit it within the same transaction, enabling flash-loan attacks on any dependent protocol.
Contract: vulnerable/dust_griefing → secure/dust_griefing
Severity: Low
A vault contract where deposit() accepts any positive amount, including 1.
An attacker can create thousands of 1-unit deposits across many addresses,
bloating persistent storage and inflating TTL extension costs for everyone.
pub fn deposit(env: Env, user: Address, amount: i128) {
user.require_auth();
// ❌ No minimum deposit — dust amounts accepted unconditionally
let current = get_balance(&env, &user);
set_balance(&env, &user, current + amount);
}pub fn deposit(env: Env, user: Address, amount: i128) {
assert!(amount >= MIN_DEPOSIT, "deposit below minimum"); // ✅
// ...
}Storage bloat and increased ledger fees for all users; can be used to grief legitimate users by inflating their TTL renewal costs.
Contract: vulnerable/allowance_not_decremented → vulnerable/allowance_not_decremented/src/secure.rs
Severity: Critical
A token contract where transfer_from checks the spender's allowance but never
reduces it after use. This lets a spender drain the full owner balance with
repeated calls using a single approval.
pub fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) {
spender.require_auth();
let allowance = get_allowance(&env, &from, &spender);
assert!(allowance >= amount, "insufficient allowance");
// ❌ Allowance is never decremented — reusable forever
let from_balance = get_balance(&env, &from);
set_balance(&env, &from, from_balance - amount);
set_balance(&env, &to, get_balance(&env, &to) + amount);
}// ✅ Decrement allowance after every successful transfer_from
set_allowance(&env, &from, &spender, allowance - amount);Unlimited fund drain: a spender with a single approval can repeatedly call
transfer_from to drain the entire owner balance.
Contract: vulnerable/unprotected_emergency_withdraw → vulnerable/unprotected_emergency_withdraw/src/secure.rs
Severity: Critical
A time-locked vault with an emergency_withdraw function intended for admin
use only. Because it never calls admin.require_auth(), any user can invoke it
to bypass the time-lock and drain funds immediately.
pub fn emergency_withdraw(env: Env, user: Address) {
// ❌ No admin.require_auth() — any caller can release any user's locked funds
let balance = get_balance(&env, &user);
env.storage().persistent().set(&DataKey::Balance(user.clone()), &0i128);
env.events().publish((symbol_short!("emrg_wdw"),), (user, balance));
}pub fn emergency_withdraw(env: Env, user: Address) {
let admin: Address = env.storage().persistent().get(&DataKey::Admin).unwrap();
admin.require_auth(); // ✅ Only admin can trigger emergency withdrawal
// ...
}Time-lock bypass: any attacker can call emergency_withdraw to immediately
release any user's locked funds, defeating the purpose of the time-lock.
Contract: vulnerable/admin_rugpull → vulnerable/admin_rugpull/src/secure.rs
Severity: High
An escrow contract where the admin can call admin_withdraw(user, recipient)
using only their own auth. The user whose funds are being drained is never
consulted, giving the admin unilateral power to rug-pull any depositor.
pub fn admin_withdraw(env: Env, user: Address, recipient: Address) {
let admin: Address = env.storage().persistent().get(&DataKey::Admin).unwrap();
admin.require_auth();
// ❌ Only admin signs — user whose funds are taken has no say
let balance = get_balance(&env, &user);
set_balance(&env, &user, 0);
env.events().publish((symbol_short!("adm_wdw"),), (user, recipient, balance));
}pub fn admin_withdraw(env: Env, user: Address, recipient: Address) {
let admin: Address = env.storage().persistent().get(&DataKey::Admin).unwrap();
admin.require_auth();
user.require_auth(); // ✅ User must co-sign — admin cannot act unilaterally
// ...
}Rug-pull: the admin can drain any depositor's escrow balance to an arbitrary address without the depositor's consent.
Contract: vulnerable/sensitive_storage → vulnerable/sensitive_storage/src/secure.rs
Severity: High
A contract that stores a raw secret key or API credential in Soroban persistent storage. All ledger state is public on Stellar — any observer can read the value directly from ledger state without invoking the contract at all.
pub fn initialize(env: Env, admin: Address, secret_key: Bytes) {
admin.require_auth();
// ❌ Raw secret written to public ledger state — readable by anyone
env.storage().persistent().set(&DataKey::SecretKey, &secret_key);
}
pub fn get_secret(env: Env) -> Bytes {
env.storage().persistent().get(&DataKey::SecretKey)
.expect("secret key not set")
// ❌ No access control — any caller can extract the raw secret
}-
Public ledger state — Soroban persistent storage is fully transparent on Stellar. Any observer with access to the network can query the ledger and read the raw secret without calling any contract method.
-
No authentication on read — Even if
initializerequired auth, the stored secret can be read by anyone. Authentication only protects writes. -
Permanent exposure — Once written, the secret remains in ledger history indefinitely.
pub fn initialize(env: Env, admin: Address, secret_hash: Bytes) {
if env.storage().persistent().has(&DataKey::Admin) {
panic!("already initialized");
}
admin.require_auth();
// ✅ Store only a hash commitment — raw secret never touches the ledger
env.storage().persistent().set(&DataKey::Commitment, &secret_hash);
}
pub fn get_commitment(env: Env) -> Bytes {
env.storage().persistent().get(&DataKey::Commitment)
.expect("commitment not set")
// ✅ Returns only the commitment hash, not the raw secret
}Key improvements:
- Raw secrets are never written to ledger
- Only public commitments (SHA-256 hashes) are stored on-chain
- Raw secrets managed off-chain (HSM, secure vault, Vault, etc.)
- Re-initialization guard prevents admin replacement attacks
Credential exposure: Any observer can read the raw secret from ledger state, compromising any system that relies on that secret remaining private. This is especially critical for API keys, encryption keys, and private key material.
Contract: vulnerable/call_depth → inline secure pattern
Severity: Medium
Soroban enforces a maximum cross-contract call depth. A contract that recursively calls itself without tracking depth will panic at the host limit mid-execution, potentially leaving state partially updated. Attackers can craft inputs that trigger the depth limit at a critical point in execution.
pub fn process(env: Env, contract_id: Address, depth: u32) {
// ❌ No depth check — will hit Soroban call depth limit and panic
if depth > 0 {
CallDepthContractClient::new(&env, &contract_id)
.process(&contract_id, &(depth - 1));
}
// State update here may never be reached if depth limit is hit above
env.storage().persistent().set(&DataKey::Processed, &true);
}pub const MAX_DEPTH: u32 = 10;
pub fn process_safe(env: Env, contract_id: Address, depth: u32) {
assert!(depth <= MAX_DEPTH, "call depth exceeds safe threshold"); // ✅
if depth > 0 {
CallDepthContractClient::new(&env, &contract_id)
.process_safe(&contract_id, &(depth - 1));
}
env.storage().persistent().set(&DataKey::Processed, &true);
}Partial state update / denial of service: an attacker can craft a depth value that causes a panic mid-execution, leaving the contract in an inconsistent state or blocking legitimate callers.
Contract: vulnerable/ignored_return → secure mirror in vulnerable/ignored_return/src/secure.rs
When a Soroban contract invokes another contract and wraps the call in
let _ = ..., the return value and any non-panicking error are silently
discarded. The calling contract continues as if the operation succeeded,
leading to inconsistent state — e.g. crediting a user or marking an escrow
as released after a token transfer that actually failed.
pub fn release(env: Env) {
// ...
// ❌ Return value ignored — if token transfer fails, escrow still marks as released
let _ = token_interface::TokenClient::new(&env, &token_id)
.transfer(&env.current_contract_address(), &recipient, &amount);
// State updated unconditionally — funds may never have moved.
env.storage().persistent().set(&DataKey::Released, &true);
}pub fn release(env: Env) {
// ...
// ✅ Call transfer directly — no `let _ = ...`.
// A panicking token contract rolls back the entire transaction,
// so Released is never set to true unless the transfer succeeds.
token_interface::TokenClient::new(&env, &token_id)
.transfer(&env.current_contract_address(), &recipient, &amount);
env.storage().persistent().set(&DataKey::Released, &true);
}- Escrow permanently locked: the escrow is marked released but the recipient never receives funds. The funds are stuck with no way to reset the flag.
- More broadly, any state update that follows an ignored sub-call can be applied even when the underlying operation failed, breaking invariants.
- Severity: High
Contract: vulnerable/weak_randomness → secure mirror in vulnerable/weak_randomness/src/secure.rs
Contracts that derive randomness from env.ledger().sequence() or
env.ledger().timestamp() are fully predictable. Both values are public
information visible to every network participant before a transaction is
included. Validators can choose which ledger to include a transaction on;
sophisticated users can watch the mempool and submit at the exact moment
the sequence number maps to their address.
pub fn pick_winner(env: Env) -> Address {
let participants: Vec<Address> = /* ... */;
// ❌ Ledger sequence is known in advance — validators can time their entry
let idx = (env.ledger().sequence() as u32) % (participants.len() as u32);
participants.get(idx).unwrap()
}// Phase 1 — each participant commits hash(secret_nonce)
pub fn commit(env: Env, participant: Address, commitment: BytesN<32>) { /* ... */ }
// Phase 2 — each participant reveals their secret_nonce
pub fn reveal(env: Env, participant: Address, secret_nonce: u64) {
// ✅ Verify hash(revealed) == committed, then XOR into shared seed
}
// Phase 3 — derive winner from XOR seed once all have revealed
pub fn draw(env: Env) -> Address {
let seed: u64 = /* XOR of all revealed nonces */;
// ✅ No single party could bias the seed without seeing all others' secrets
let idx = (seed % participants.len() as u64) as u32;
participants.get(idx).unwrap()
}For production, a VRF oracle provides the strongest guarantee: provably unbiased randomness with an on-chain verifiable proof.
- Lottery / NFT mint manipulation: a validator or well-timed participant can guarantee they win every draw.
- Any randomness-dependent outcome (airdrops, game results, shuffles) is equally vulnerable.
- Severity: High
Contract: vulnerable/stale_pending_admin → vulnerable/stale_pending_admin/src/secure.rs
Severity: High
A two-step admin transfer contract where cancel_admin_transfer() emits an event
but never removes the PendingAdmin from persistent storage. The previously
proposed address can still call accept_admin() and take over ownership even
after cancellation.
pub fn cancel_admin_transfer(env: Env) {
let current: Address = env.storage().persistent().get(&DataKey::Admin).expect("not initialized");
current.require_auth();
// ❌ Missing: env.storage().persistent().remove(&DataKey::PendingAdmin);
env.events().publish((symbol_short!("cancel"),), (current,));
}pub fn cancel_admin_transfer(env: Env) {
let current: Address = env.storage().persistent().get(&SecureDataKey::Admin).expect("not initialized");
current.require_auth();
// ✅ Actually remove the pending admin — cancellation is real.
env.storage().persistent().remove(&SecureDataKey::PendingAdmin);
env.events().publish((symbol_short!("cancel"),), (current,));
}Additionally, the secure version uses a transfer nonce so that each proposal creates a unique acceptance window, preventing replay of stale proposals.
Privilege escalation: a cancelled pending admin can unexpectedly take over the contract, gaining access to all admin-gated functions.
Contract: vulnerable/accept_admin_missing_auth → vulnerable/accept_admin_missing_auth/src/secure.rs
Severity: Critical
A two-step admin transfer contract where accept_admin() reads the pending
admin from storage and promotes them — but never calls require_auth().
Any address can finalise the transfer without the pending admin's knowledge
or consent, enabling an attacker to seize admin privileges.
pub fn accept_admin(env: Env) {
let pending: Address = env.storage().persistent().get(&DataKey::PendingAdmin).expect("no pending admin");
// ❌ Missing: pending.require_auth();
env.storage().persistent().set(&DataKey::Admin, &pending);
env.storage().persistent().remove(&DataKey::PendingAdmin);
}pub fn accept_admin(env: Env) {
let pending: Address = env.storage().persistent().get(&SecureDataKey::PendingAdmin).expect("no pending admin");
// ✅ Require the pending admin to sign before promoting them.
pending.require_auth();
env.storage().persistent().set(&SecureDataKey::Admin, &pending);
env.storage().persistent().remove(&SecureDataKey::PendingAdmin);
}Privilege escalation: a random caller can finalise a pending admin transfer without the pending address's authorisation, gaining full admin control over the contract.
Contract: vulnerable/near_overflow_input → vulnerable/near_overflow_input/src/secure.rs
Severity: High
Functions that perform arithmetic operations (balance + amount, balance * rate) on user-controlled i128 inputs without validating that those inputs are within safe bounds. Passing values close to i128::MAX causes intermediate multiplications to overflow, triggering a panic in the runtime. This enables DoS attacks targeting specific user accounts.
pub fn deposit(env: Env, user: Address, amount: i128) {
user.require_auth();
// ❌ No upper bound check on amount
let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);
env.storage().persistent().set(&key, &(balance + amount));
}
pub fn apply_rate(env: Env, user: Address, rate: i128) {
let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);
// ❌ Unchecked multiplication: balance * rate can overflow
let new_balance = balance * rate;
env.storage().persistent().set(&key, &new_balance);
}const MAX_SAFE_AMOUNT: i128 = i128::MAX / 4;
pub fn deposit(env: Env, user: Address, amount: i128) {
user.require_auth();
// ✅ Validate amount is within safe bounds before use
if amount <= 0 || amount > MAX_SAFE_AMOUNT {
panic!("amount out of safe range");
}
let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);
env.storage().persistent().set(&key, &(balance + amount));
}
pub fn apply_rate(env: Env, user: Address, rate: i128) {
let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);
// ✅ Use checked_mul to safely detect overflow with clear error message
let new_balance = balance.checked_mul(rate).expect("overflow in apply_rate");
env.storage().persistent().set(&key, &new_balance);
}Denial of Service (DoS) on specific user accounts: an attacker can trigger panics by submitting transactions that cause arithmetic overflow, preventing legitimate users from interacting with the contract.
Contract: vulnerable/reward_debt_not_updated → vulnerable/reward_debt_not_updated/src/secure.rs
Severity: High
A staking contract using the MasterChef accumulator pattern. claim_rewards()
pays out the pending rewards (acc × stake − debt) but never advances
reward_debt afterward. Because the debt is never moved up to the current
accumulator, every subsequent claim_rewards call recomputes the same
pending amount and pays it out again — letting a staker drain the entire
reward pool with repeated calls.
This is in the same accumulator family as reward_checkpoint_missing but is a
distinct bug: that one fails to snapshot debt on deposit (a one-time theft of
historical rewards by a late joiner), whereas this one fails to update debt on
claim (unlimited drain of the same window).
pub fn claim_rewards(env: Env, user: Address) -> u64 {
user.require_auth();
let stake = get_stake(&env, &user);
let acc = get_acc(&env);
let entitled = acc.saturating_mul(stake) / 1_000_0000;
let debt = get_debt(&env, &user);
let pending = entitled.saturating_sub(debt);
// ❌ Missing: set_debt(&env, &user, entitled);
pending
}let pending = entitled.saturating_sub(debt);
// ✅ Advance debt so already-paid rewards can't be claimed again.
set_debt(&env, &user, entitled);
pendingUnlimited reward drain: a staker can call claim_rewards repeatedly and be paid
the same pending amount each time until the reward pool is empty.
| Check | Description |
|---|---|
require_auth on every mutating fn |
Every function that reads or writes resources belonging to an address must call address.require_auth() |
| Checked arithmetic | Use checked_add, checked_sub, checked_mul for all financial calculations |
| Admin gate on privileged fns | initialize, upgrade, set_admin, pause must verify the caller is the stored admin |
| Storage key ownership | Storage keys that include an Address must only be written after address.require_auth() |
| No re-initialization | Guard initialize with a check that the contract hasn't already been set up |
| TTL renewal for persistent entries | Long-lived state should call persistent().extend_ttl(...) on active reads/writes to avoid expiry |
| Positive-amount guards | Reject zero or negative amounts in deposit, stake, transfer, and burn |
| No raw secrets on-chain | Never store private keys, seeds, or API credentials in persistent storage; store only hash commitments |
| Slippage protection | AMM swaps must accept a min_amount_out parameter and assert the output meets it |
| Repayment check in flash loans | Assert pool balance is fully restored after the borrower callback returns |
| Call depth guard | Track recursion depth explicitly and reject calls that would exceed a safe threshold |
| Ledger sequence for time locks | Use env.ledger().sequence() instead of env.ledger().timestamp() for time-based locks |
| Typed storage keys | Use #[contracttype] enum variants instead of flat symbol_short! strings to prevent key collisions |
| Allowance decrement | transfer_from must decrement the spender's allowance after every successful transfer |
| Event privacy | Emit only the minimum data needed (e.g. transfer amount) — never emit post-transaction balances |