Skip to content

[Bug]: Timing Attack Vulnerability in Token Comparisons #6021

@coygeek

Description

@coygeek

CVSS Assessment

Metric Value
Score 7.4 / 10.0
Severity High
Vector CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

CVSS v3.1 Calculator

Summary

Multiple token validation endpoints use standard string comparison (!== or ===) instead of constant-time comparison. This allows attackers to determine tokens character-by-character via timing side-channel attacks.

Affected Code

Instance 1: Hook Token Validation

File: src/gateway/server-http.ts:82

const { token, fromQuery } = extractHookToken(req, url);
if (!token || token !== hooksConfig.token) {  // VULNERABLE: timing leak
  res.statusCode = 401;
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
  res.end("Unauthorized");
  return true;
}

Instance 2: Node Pairing Token Verification

File: src/infra/node-pairing.ts:277

export async function verifyNodeToken(
  nodeId: string,
  token: string,
  baseDir?: string,
): Promise<{ ok: boolean; node?: NodePairingPairedNode }> {
  const state = await loadState(baseDir);
  const normalized = normalizeNodeId(nodeId);
  const node = state.pairedByNodeId[normalized];
  if (!node) {
    return { ok: false };
  }
  return node.token === token ? { ok: true, node } : { ok: false };  // VULNERABLE: timing leak
}

Instance 3: Device Token Verification

File: src/infra/device-pairing.ts:434

export async function verifyDeviceToken(params: {
  deviceId: string;
  token: string;
  role: string;
  scopes: string[];
  baseDir?: string;
}): Promise<{ ok: boolean; reason?: string }> {
  return await withLock(async () => {
    const state = await loadState(params.baseDir);
    const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
    // ... validation checks ...
    if (entry.token !== params.token) {  // VULNERABLE: timing leak
      return { ok: false, reason: "token-mismatch" };
    }
    // ...
  });
}

Contrast with secure implementations in the codebase:

src/gateway/auth.ts:35-40:

import { timingSafeEqual } from "crypto";

function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) {
    return false;
  }
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

src/line/signature.ts:12-17:

// Use constant-time comparison to prevent timing attacks.
if (hashBuffer.length !== signatureBuffer.length) {
  return false;
}
return crypto.timingSafeEqual(hashBuffer, signatureBuffer);

Note: The safeEqual function in auth.ts is currently private (not exported) and is only used internally for gateway token/password validation.

Attack Surface

How is this reached?

  • Network (HTTP/WebSocket endpoint, API call)

Authentication required?

  • None (unauthenticated/public access)

Entry points:

  1. Hook endpoint at the gateway HTTP server (server-http.ts)
  2. Node pairing verification during device connection (node-pairing.ts)
  3. Device token verification during device authentication (device-pairing.ts)

Attacker makes many requests with different token guesses, measuring response times.

Exploit Conditions

Complexity:

  • High (requires race condition, specific config, or timing)

User interaction:

  • None (automatic, no victim action needed)

Prerequisites:

  • Network access to the vulnerable endpoint
  • Ability to make many requests with precise timing measurements
  • Low network jitter (local network or controlled environment)

Impact Assessment

Scope:

  • Unchanged (impact limited to vulnerable component)

What can an attacker do?

Impact Type Level Description
Confidentiality High Extract hook, node pairing, or device tokens via timing oracle
Integrity High With hook token, attacker can invoke hooks and modify gateway state; with node/device token, attacker can impersonate paired devices
Availability None No direct availability impact

Steps to Reproduce

  1. Target the hook endpoint: POST /hooks
  2. Use a timing attack tool (e.g., time-based blind techniques):
    import requests
    import time
    
    def measure_response_time(token_guess):
        start = time.perf_counter_ns()
        requests.post("http://target/hooks", headers={"X-Hook-Token": token_guess})
        return time.perf_counter_ns() - start
    
    # Compare timing for different first characters
    for c in "abcdefghijklmnopqrstuvwxyz0123456789":
        avg_time = sum(measure_response_time(c + "X" * 31) for _ in range(1000)) / 1000
        print(f"{c}: {avg_time}ns")
  3. Character with longer average response time is correct (comparison went further before failing)
  4. Repeat for each character position to extract full token

Recommended Fix

Export the safeEqual() function from auth.ts and use it in all three vulnerable locations:

Step 1: Export the function in src/gateway/auth.ts:

export function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) {
    return false;
  }
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

Step 2: Fix src/gateway/server-http.ts:

import { safeEqual } from "./auth.js";

if (!token || !safeEqual(token, hooksConfig.token)) {
  res.statusCode = 401;
  // ...
}

Step 3: Fix src/infra/node-pairing.ts:

import { timingSafeEqual } from "node:crypto";

function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) {
    return false;
  }
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

// In verifyNodeToken():
return safeEqual(node.token, token) ? { ok: true, node } : { ok: false };

Step 4: Fix src/infra/device-pairing.ts:

import { timingSafeEqual } from "node:crypto";

function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) {
    return false;
  }
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

// In verifyDeviceToken():
if (!safeEqual(entry.token, params.token)) {
  return { ok: false, reason: "token-mismatch" };
}

Alternatively, create a shared utility module for safeEqual to avoid duplication across the three files.

References

  • CWE: CWE-208 - Observable Timing Discrepancy

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstaleMarked as stale due to inactivity

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions