CVE-2025-59390

By daeda1us 02:25 PM - November 28th 2025

Apache Druid CVE-2025-59390

CVSS 9.8

Daeda1us and 1nfocalypse

What is Apache Druid? Why is it important

Apache Druid is a high performance, real-time analytics database that delivers
sub-second queries on streaming and batch data at scale and under load. It is
used by giants in their respective areas.

Technology & Cloud: Cisco, Confluent, Salesforce, Shopify, Slack, Unity
E-Commerce & Retail: Alibaba, eBay, PayPal, Pinterest, Target, Walmart
Media & Entertainment: Hulu, Snapchat, Twitch, Twitter, Yahoo
Travel & Services: Airbnb, Lyft, Reddit, Verizon, BT

You can view the many more companies using this software here:
https://druid.apache.org/druid-powered

So what did we find?

Originally, i was auditing the code for druid when i stumbled across the use of
ThreadLocalRandom.current().nextLong() to create a signiture, having taken a course on
insecure randomness before it piqued my interest but what is a PRNG and what is
insecure randomness?

Insecure randomness occurs when software relies on a random number generator
(RNG) that is predictable or has insufficient entropy for its security purpose.

What is a PRNG and an LCG?
An LCG updates its internal state using a simple linear equation modulo a fixed
number. This structure makes it mathematically straightforward to reverse
engineer the seed and parameters if an attacker can obtain a few outputs. Once
the state is known, all past and future outputs can be calculated without
interacting with the system further.

Onto CVE-2025-59390

When signature.secret (the cookie signing secret) is not explicitly configured, Apache
Druid’s Kerberos authenticator falls back to generating a secret with
ThreadLocalRandom.current().nextLong().

Vulnerable code snippet

if (signatureSecret == null) {
    signatureSecret = Long.toString(ThreadLocalRandom.current().nextLong());
    log.warn("'signature.secret' configuration not set, using a random value as secret");
}

As we mentioned before, ThreadLocalRandom is not cryptographically secure; its
outputs are produced by a linear congruential generator (LCG) and can be
recovered or predicted. So this begs, the question how do we exploit this classic
case of CWE-338?

How this can be exploited

What an attacker needs: one or more valid signed tokens that the server
issued. So, understanding this, we only need to capture a single users
Kerberos Auth token and then we can crack the seed.

Once this seed is cracked, we can then forge Kerberos authentication tokens
for any user including the administrator, meaning full privilege escalation.

Cracking The Seed

Unfortunately, the aforementioned PRNG had never been reversed, so this would
have to be done for the first time (probably). We will now go into the deeper
technicalities of the POC.

The general concept of JDK 8+'s ThreadLocalRandom's nextInt

  • 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 number.

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.

Proof-of-Concept (seed recovery)

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;
}

In Laymen’s terms :

  1. The server has a secret 64-bit number (the real seed).
  2. Every time it needs a “random” value, it adds a fixed magic number (GAMMA) to the seed, runs the result through the mix32 blender, and only shows you the top 32 bits of the scrambled output. The bottom 32 bits get thrown in the rubbish bin.

Because the bottom 32 bits are missing, there are 4.3 billion possible full 64-bit numbers that could have produced the 32-bit output you see.

Here’s the trick the POC uses:

  • We take the 32 bits we can see and glue them to the left side of a 64-bit number (that’s the “high half”).

  • Then we just loop through all 4,294,967,296 possible values for the missing “low half” (0 to 4.3 billion).

  • For every guess, we reverse the mix32 blender step-by-step (undo the multiplications with magic inverse numbers and undo the XORs).

  • After reversing, we add GAMMA again and run it forward through mix32 to see if we get the second 32-bit number we saw in the cookie.

  • If it matches a second (or third) output, we’ve found the exact original seed – no false positives.

A decent laptop finishes the 4.3 billion guesses in a few seconds to a couple of minutes. Once we have the seed, we can generate the server’s secret signing key and forge any users cookies a perform account takeover on any user with Kerberos auth.

We run into a number of issues for ThreadLocalRandom these being

  • Counter-based state, not LCG → It advances by adding a constant ( seed += GAMMA ) instead of multiplying/adding with known values like java.util.Random
  • Non-linear mixing → Each output is passed through mix32 / mix64 functions with XORs, shifts, and multiplications (derived from MurmurHash3), making the relationship non-invertible in practice
  • 128-bit internal state → Much larger than the 48-bit state of java.util.Random , so brute force is infeasible.

This script means even though they're generating 64 bit secrets, only the least
significant 48 bits actually matter since the operations take place modulo 2^48.
So, in a sense it leaves the final 2^16 possibilities, Trivially brute-forcable, (only 65,536 options). This effectively lets you recover the full 64-bit seed.

Also the generator does satisfy Hull-Dobell
The Hull-Dobell rules just make sure your LCG counts through every possible number before repeating.

Kill Chain

  • Initial access: Attacker obtains access to a low-privileged account capable of Kerberos authentication (phishing, compromised host, or other).
  • Token capture: While the account authenticates, the attacker captures a valid signed Kerberos authentication artifact (cookie/token).
  • Seed recovery (PoC): Using the captured artifact(s) and an offline PoC, the attacker recovers the PRNG seed that was used to generate the signature.secret.
  • Token forgery: With the recovered seed, the attacker derives the signing secret and forges Kerberos tokens for arbitrary usernames.
  • Impersonation & escalation: The attacker uses forged tokens to access services as targeted users including administrators achieving full privilege escalation.

Credits & acknowledgements

Discoverers: Luke ‘Daeda1us’ Smith, 1nfocalypse
Upstream fix: PR addressing the issue (see references).

References

  • Apache Druid — Kerberos extension docs (configuration and cookie signing behavior). druid.apache.org
  • Java ThreadLocalRandom documentation — “not cryptographically secure” guidance. Oracle Docs
  • OWASP — Insecure randomness / guidance on secure RNG usage. OWASP
  • NVD / CVE — example of predictable session IDs caused by use of Random (Jetty). NVD
  • MITRE CWE-338 — Use of cryptographically weak PRNGs. cwe.mitre.org
  • https://alvaro-videla.com/2016/10/inside-java-s-threadlocalrandom.html
  • https://github.com/1nfocalypse/Cracking-Chaos (You should read his DEFCON slides)
  • Fix - https://github.com/apache/druid/pull/18368/files
    NVD - https://nvd.nist.gov/vuln/detail/CVE-2025-59390