Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions contracts/fixtures/finding-codes/s029_timestamp_randomness.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! S029 — Timestamp used as randomness entropy.
//!
//! Block timestamps are not secret entropy. Validators can nudge
//! `env.ledger().timestamp()` within a window, making any randomness
//! derived solely from it manipulable.
#![no_std]
use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Vec};

#[contract]
pub struct TimestampRandomnessBuggy;

#[contract]
pub struct TimestampRandomnessSafe;

#[contractimpl]
impl TimestampRandomnessBuggy {
// ❌ BAD: timestamp used as seed — flagged because function name contains "seed".
pub fn seed_prng(env: Env) -> u64 {
let seed = env.ledger().timestamp();
seed % 1000
}

// ❌ BAD: timestamp used as randomness — function name contains "rand".
pub fn rand_roll(env: Env) -> u64 {
env.ledger().timestamp() % 6 + 1
}

// ❌ BAD: timestamp selects winner — function name contains "pick".
pub fn pick_winner(env: Env, participants: Vec<Address>) -> Address {
let idx = env.ledger().timestamp() % participants.len() as u64;
participants.get(idx as u32).unwrap()
}

// ❌ BAD: timestamp stored into a variable named "winner".
pub fn draw(env: Env, total: u64) -> u64 {
let winner = env.ledger().timestamp() % total;
winner
}
}

#[contractimpl]
impl TimestampRandomnessSafe {
// ✅ SAFE: timestamp used for expiry/time comparison only.
pub fn check_expiry(env: Env, deadline: u64) -> bool {
env.ledger().timestamp() > deadline
}

// ✅ SAFE: timestamp used for logging/record keeping, not as entropy.
pub fn record_time(env: Env) {
let ts = env.ledger().timestamp();
env.storage()
.persistent()
.set(&symbol_short!("LAST_TS"), &ts);
}
}
21 changes: 21 additions & 0 deletions contracts/unsafe-prng-example/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@ impl UnsafePrngExample {
pub fn set_value(env: Env, key: u32, value: u64) {
env.storage().persistent().set(&key, &value);
}

/// UNSAFE: Uses ledger timestamp as the sole source of randomness seed.
/// Flagged by the timestamp_randomness rule (S029).
pub fn pick_winner_by_timestamp(env: Env, participants: Vec<Address>) -> Address {
let seed = env.ledger().timestamp();
let idx = seed % participants.len() as u64;
participants.get(idx as u32).unwrap()
}

/// UNSAFE: Timestamp used directly to derive a rand value.
/// Flagged by the timestamp_randomness rule (S029).
pub fn rand_from_timestamp(env: Env) -> u64 {
let rand = env.ledger().timestamp() % 1000;
rand
}

/// SAFE: Timestamp used only for deadline/expiry checks.
/// This function will NOT be flagged.
pub fn is_expired(env: Env, deadline: u64) -> bool {
env.ledger().timestamp() > deadline
}
}

#[cfg(test)]
Expand Down
143 changes: 143 additions & 0 deletions docs/rules/timestamp-randomness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Timestamp Randomness Rule (S029)

## Overview

The `timestamp_randomness` rule detects use of `env.ledger().timestamp()` as a source of randomness entropy. Block timestamps are **not** secret and can be nudged by validators within a small window, making any randomness derived solely from them manipulable.

## Severity

**High** — exploitable on-chain; a validator or a well-timed transaction can influence the outcome.

## Detection Logic

The rule fires when **all** of the following hold:

1. `env.ledger().timestamp()` is called inside a function or expression.
2. The context is randomness-related, identified by **name**:
- Function name contains `rand`, `seed`, `pick`, or `winner` (case-insensitive), **OR**
- A variable binding on the left-hand side of an assignment contains one of those keywords.

Non-sensitive uses of `env.ledger().timestamp()` (deadline checks, expiry guards, audit logs) are **not** flagged.

## Examples

### ❌ Vulnerable — function named `pick_winner`

```rust
pub fn pick_winner(env: Env, participants: Vec<Address>) -> Address {
// UNSAFE: timestamp is predictable entropy
let idx = env.ledger().timestamp() % participants.len() as u64;
participants.get(idx as u32).unwrap()
}
```

### ❌ Vulnerable — variable named `seed`

```rust
pub fn initialize_game(env: Env) {
// UNSAFE: 'seed' bound to timestamp
let seed = env.ledger().timestamp();
env.storage().persistent().set(&symbol_short!("seed"), &seed);
}
```

### ❌ Vulnerable — variable named `rand`

```rust
pub fn roll_dice(env: Env) -> u64 {
let rand = env.ledger().timestamp() % 6 + 1;
rand
}
```

### ✅ Safe — timestamp used for expiry only

```rust
pub fn check_expiry(env: Env, deadline: u64) -> bool {
// SAFE: not randomness — pure time comparison
env.ledger().timestamp() > deadline
}
```

### ✅ Safe — timestamp for audit logging

```rust
pub fn record_action(env: Env) {
let ts = env.ledger().timestamp();
env.storage().persistent().set(&symbol_short!("LAST_TS"), &ts);
}
```

## Why Timestamps Are Dangerous as Randomness

Soroban ledger timestamps represent the UNIX timestamp of the ledger close. Validators have limited but real influence over this value:

- A validator can delay or advance a ledger close within protocol bounds.
- An attacker who can predict or influence the timestamp can reverse-engineer the outcome of any computation based solely on it.
- For lottery/NFT draw/reward distribution use cases this translates to direct financial manipulation.

## Mitigation

### 1. Use a VRF Oracle (Recommended for High-Stakes)

```rust
pub fn pick_winner(
env: Env,
participants: Vec<Address>,
vrf_proof: BytesN<64>,
) -> Address {
// Verify the VRF proof, then derive index from it
let idx = derive_index_from_vrf(&vrf_proof, participants.len() as u64);
participants.get(idx as u32).unwrap()
}
```

### 2. Combine Multiple Entropy Sources

```rust
pub fn pick_winner(env: Env, participants: Vec<Address>) -> Address {
let mut prng = env.prng();
// Combine timestamp with ledger sequence and contract address hash
let entropy = env.ledger().timestamp()
^ env.ledger().sequence() as u64
^ env.current_contract_address().to_string().len() as u64;
prng.reseed(entropy);
let idx = prng.gen_range(0..participants.len() as u64);
participants.get(idx as u32).unwrap()
}
```

### 3. Commit-Reveal Scheme

Have participants commit a secret hash off-chain; reveal it on-chain before the draw. The XOR of all revealed secrets becomes the seed. This eliminates any single party's ability to manipulate the outcome.

## Related Rules

- **S018 — Unsafe PRNG** (`unsafe_prng`): flags `env.prng()` used in state-critical code without reseeding. See [unsafe-prng.md](unsafe-prng.md).
- **S006 — Unsafe Pattern** (`UNSAFE_PATTERN`): generic unsafe runtime patterns.

## References

- [Soroban Ledger API](https://docs.rs/soroban-sdk/latest/soroban_sdk/ledger/struct.Ledger.html)
- [OWASP: Insufficient Randomness](https://owasp.org/www-community/vulnerabilities/Insecure_Randomness)
- [CWE-338: Use of Cryptographically Weak PRNG](https://cwe.mitre.org/data/definitions/338.html)
- [SWC-120: Weak Sources of Randomness](https://swcregistry.io/docs/SWC-120)

## Testing

```bash
# Run the rule unit tests
cargo test -p sanctifier-core timestamp_randomness

# Test against the fixture contract
cargo test -p sanctifier-core -- timestamp_randomness
```

## Configuration

The rule is enabled by default. To disable it selectively in `.sanctify.toml`:

```toml
[rules]
timestamp_randomness = "off"
```
12 changes: 12 additions & 0 deletions tooling/sanctifier-core/src/finding_codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ pub const TAINT_PROPAGATION: &str = "S026";
pub const STATIC_REENTRANCY: &str = "S027";
/// Usage of storage/deployment APIs that were removed or renamed in Soroban SDK v22.
pub const DEPRECATED_SDK_USAGE: &str = "S028";
/// Use of env.ledger().timestamp() as entropy for randomness.
pub const TIMESTAMP_RANDOMNESS: &str = "S029";

/// A single finding-code entry with machine-readable code, category, and
/// human-readable description.
Expand Down Expand Up @@ -365,6 +367,15 @@ pub fn all_finding_codes() -> Vec<FindingCode> {
remediation: "Migrate bump() to extend_ttl(), replace RawVal with Val, and use new deploy patterns from the Environment",
doc_url: "https://github.com/HyperSafeD/Sanctifier/blob/main/docs/error-codes.md",
},
FindingCode {
code: TIMESTAMP_RANDOMNESS,
category: "randomness",
description: "Block timestamp (env.ledger().timestamp()) used as entropy for randomness — validators can manipulate timestamps within bounds",
title: "Timestamp Used as Randomness",
severity: FindingSeverity::High,
remediation: "Never use env.ledger().timestamp() as a sole source of randomness. Use a VRF oracle or combine multiple unpredictable entropy sources",
doc_url: "https://github.com/HyperSafeD/Sanctifier/blob/main/docs/rules/unsafe-prng.md",
},
]
}

Expand Down Expand Up @@ -406,5 +417,6 @@ mod tests {
assert!(codes.iter().any(|c| c.code == TAINT_PROPAGATION));
assert!(codes.iter().any(|c| c.code == STATIC_REENTRANCY));
assert!(codes.iter().any(|c| c.code == DEPRECATED_SDK_USAGE));
assert!(codes.iter().any(|c| c.code == TIMESTAMP_RANDOMNESS));
}
}
3 changes: 3 additions & 0 deletions tooling/sanctifier-core/src/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pub mod taint_propagation;
pub mod static_reentrancy;
/// Soroban SDK v22 deprecated storage/deployment API patterns.
pub mod deprecated_sdk_usage;
/// Detect env.ledger().timestamp() used as entropy for randomness.
pub mod timestamp_randomness;
use serde::Serialize;
use std::any::Any;

Expand Down Expand Up @@ -218,6 +220,7 @@ impl RuleRegistry {
registry.register(taint_propagation::TaintPropagationRule::new());
registry.register(static_reentrancy::StaticReentrancyRule::new());
registry.register(deprecated_sdk_usage::DeprecatedSdkUsageRule::new());
registry.register(timestamp_randomness::TimestampRandomnessRule::new());
registry
}
}
Expand Down
Loading
Loading