Daeda1us and 1nfocalypse
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
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.
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?
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.
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.
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;
}
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.
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
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.
Discoverers: Luke ‘Daeda1us’ Smith, 1nfocalypse
Upstream fix: PR addressing the issue (see references).