@darkpills cybersecurity blog

Sharing knowledge and experiments on cyber security topics.

Breaking Java Random PRNG: UYBHYS 2022 challenge Writeup

A little challenge was introduced on twitter to win a ticket to Unlock Your Brain 2022 conference. This article is a write-up of the solution and explores the implementation of Java Random PRNG.

Disclaimer: I am not a cryptographic expert, just a security enthousiast.

The conference

The conference Unlock Your Brain Harden Your System aka “Unlock” or #UYBHYS is organized since 2015 by the Cantine numérique Brest and DIATEAM in Brest (France). During 2 days, participants will have the opportunity to attend to a day of conferences, 2 to 4 workshops and a 4 hours CTF, most of the time attack-defense style on Sea surf monsters theme. Last time, it was an OSINT CTF.

Unlock Your Brain Harden Your System

The challenge

Formind support this nice and friendly event since 2018 and offered to win a conference ticket on twitter by solving a challenge:

No more tickets for the conf @UYBHYS? 😭 No panic! The first to solve this little challenge win its ticket! 🤩 Send in DM the 3rd result of this secured password generator, knowing that the 2 first leaked: 7EHPFygG2gq2, 7M7y9YTHt800 your keyboards ready go! #cybersecurity

Twitter challenge text

package com.formind;

import java.util.Random;

class SecurePassword {

    // speed up the algorithm by not re-creating the PRNG
    public Random random = new Random();

    public String generatePassword() {

        StringBuilder password = new StringBuilder();
        while (true) {
            // generate a real random 32-bits signed int, ex: 447603982
            int i = this.random.nextInt();
            // convert integer to base36 (lower case + digits), ex: "7ehpfy"
            String passwordPart = Long.toString(i, 36);
            // we have signed integers, remove the "minus" sign
            passwordPart = passwordPart.replace("-", "");
            // append the result to the existing password
            password.append(passwordPart);
            // ANSSI says password length should be 9 minimum
            // stop if we got the minimum complexity
            if (password.length() > 10) {
                break;
            }
        }

        // now we have a lower case+digit password, ex: "7ehpfygg2gq2"
        // raise the complexity to have upper case letters
        StringBuilder result = new StringBuilder();
        for (int i = 0; i < password.length(); i++) {
            // if we have a letter from ascii code + 1 chance out of 2
            if (password.charAt(i) > 96 && this.random.nextInt() % 2 == 0) {
                // convert the lower case letter to upper case
                char x = (char) (password.charAt(i) - 32);
                result.append(x);
            } else {
                // just keep the initial character
                result.append(password.charAt(i));
            }
        }
        return result.toString();
    }

    public static void main(String[] args) {
        SecurePassword generator = new SecurePassword();

        System.out.println(generator.generatePassword());
        //7EHPFygG2gq2
        System.out.println(generator.generatePassword());
        //7M7y9YTHt800
        System.out.println(generator.generatePassword());
        // ????
    }
}

The solution

The first step is to analyse the source code and understand what it’s really doing. Note that the first version of the challenge did not contain the comments. However, it was decided to include them to make it as accessible as possible for everyone.

The java.util.Random PRNG

Password generator sounds like crypto challenge or equivalent. For a person used to CTF/code reviews, first line of the code will strike the reader:

import java.util.Random;

It is known that java.util.Random is not a cryptographically secured pseudo-random number generator (PRNG), especially for any operation that implies security, like password generation. The java source code of the class is pretty easy to read and this challenge is a good occasion to dive into this PRNG internal implementation (JDK7+):

java.util.Random internal implementation

  • The whole processed is based on an initial seed (S0)
  • New seeds (Sn) are sequencially computed from prevous seeds (Sn-1)
  • The inner state of the seed is 48-bits long
  • To compute the next seed, Java uses 2 constants: a multiplier and an addend
  • When a random value is required (byte, int, bool…), the next seed is computed and the random value derived from it
  • In the case of the nextInt(), the next random integer is just the current seed modulus 2^32: Sn mod 2^32 which is equivalent to Sn >>> (48 - 32)
  • So at every nextInt() call, we loose 16 bits of data between the last seed and the current seed: a seed of 48 bits modulus 2^32.

If you read the java.util.Random, you can summarize the operations with the following pseudo-code :

// constants
multiplier = 0x5DEECE66DL;
addend = 0xBL;
mask = (1L << 48) - 1; // 48-bits mask 

// compute initial seed
S0 = (seedInParam ^ multiplier) & mask; // seedInParam is: new Random(seedInParam)

// compute next seed
Sn = (Sn-1 * multiplier + addend) & mask;

So what we learnt:

  • For each instanciated Random, random values are computed from a sequence.
  • If you know the initial seed, you can reverse the generated random values.
  • Between each random int, we loose 16 bits of data between 2 seeds, so 65536 values.

So, if you can get 2 integer values, then you can bruteforce the seed with 65536 iterations.

Understanding the algorithm and the problem

Come back to the challenge. The first part of the password generation loop on nextInt() until the integers converted to base36 is at least a string of 10 characters:

while (true) {
    // generate a real random 32-bits signed int, ex: 447603982
    int i = this.random.nextInt();
    // convert integer to base36 (lower case + digits), ex: "7ehpfy"
    String passwordPart = Long.toString(i, 36);
    // we have signed integers, remove the "minus" sign
    passwordPart = passwordPart.replace("-", "");
    // append the result to the existing password
    password.append(passwordPart);
    // ANSSI says password length should be 9 minimum
    // stop if we got the minimum complexity
    if (password.length() > 10) {
        break;
    }
}

Integers converted to base36 will produce between 1 and 7 characters (log36(2^32) = 6.18). If you test the code several times, you will see that nextInt() converted to base36 produces most of the time a string of length 6 but also that there is a minus sign in front of the base36 since the int is signed.

Why so often 6 characters? Because between 36^6 and 36^5, you get most of the 2^32 / 2 values (/2 because it’s signed integer):

36^1 = 36
36^2 = 1296
36^3 = 46656
36^4 = 1679616
36^5 = 60466176
36^6 = 2176782336

So the probability it will be 6 characters is 98%:

(36^6 - 36^5) / (2^32  / 2) = 0,985

So we will make the assumption that we will have 2 integers of 6 characters in base36. We can still make other hypothesis later if this one does not verify. The loop will stop if the password length is more than 10. Then, we see that the minus sign is removed. We loose 1 bit of information for each integer, so 2 bits for them.

Then the rest of the code just randomly set the letters uppercase or lowercase randomly, based on the same PRNG:

StringBuilder result = new StringBuilder();
for (int i = 0; i < password.length(); i++) {
    // if we have a letter from ascii code + 1 chance out of 2
    if (password.charAt(i) > 96 && this.random.nextInt() % 2 == 0) {
        // convert the lower case letter to upper case
        char x = (char) (password.charAt(i) - 32);
        result.append(x);
    } else {
        // just keep the initial character
        result.append(password.charAt(i));
    }
}

This last part is not important. Since if we can reverse the seed, we will be able to know certainly what characters will be set upper or lower case afterwards.

With the first 2 passwords, we have the first 4 numbers. We will use the first one to reverse the seed and the 2 last to verify that the seed obtained is good.

Now we split the first password into 2 parts, lower it and convert it back to long:

long i1 = Long.valueOf("7ehpfy", 36);
long i2 = Long.valueOf("gg2gq2", 36);

Then, we need to bruteforce the upper 16-bits of the 48-bits seed since with have the lower 32-bits (remember Sn = (Sn-1 * multiplier + addend) & mask;):

long seed = 0L;
for (int i = 0; i < 65536; i++) {
    seed = i1 * 65536 + i;
    if (next(seed) == i2) {
        // a matching seed is found here!
        break;
    }
}

To compute the next state of the seed, we just copy the protected int next(int bits) function of Random:

public static int next(long seed) {
    int bits=32;
    long seed2 = (seed * 0x5DEECE66DL + 0xBL) & ((1L << 48) - 1);
    return (int)(seed2 >>> (48 - bits));
}

So, when a seed is found, we need to check with the 2nd password value and inject our seed in the SecurePassword class. The seed is not directly injected into the Random construstor but a computation is made to replicate the intiialScramble() function: (seed ^ 0x5DEECE66DL) & ((1L << 48) - 1). We make 10 calls to the nextInt() before injecting it because, this is the number of calls that will be made for upper and lower case computation in the generatePassword() method.

Random random = new Random((seed ^ 0x5DEECE66DL) & ((1L << 48) - 1));
random.nextInt();
for (int i=0;i<9;i++) random.nextInt();
SecurePassword generator = new SecurePassword();
generator.random = random;
System.out.println(generator.generatePassword());

And finally, remember that the minus sign is removed. So we need to bruteforce this minus sign for each integer i1 and i2:

for (int j = 0; j < 4; j++) {
    i1 = i1 * -1L;
    if (j == 2) i2 = i2 * -1L;
    for (int i = 0; i < 65536; i++) {
        seed = i1 * 65536 + i;
        if (next(seed) == i2) {
            break;
        }
    }

    // check if seed is good
    ...
}

So if we put all the parts together:

package com.formind;

import java.util.Random;

class Solution {

    public static int next(long seed) {
        int bits=32;
        long seed2 = (seed * 0x5DEECE66DL + 0xBL) & ((1L << 48) - 1);
        return (int)(seed2 >>> (48 - bits));
    }

    public static void main(String[] args) {

        long i1 = Long.valueOf("7ehpfy", 36);
        long i2 = Long.valueOf("gg2gq2", 36);
        long seed = 0L;
        for (int j = 0; j < 4; j++) {
            i1 = i1 * -1L;
            if (j == 2) i2 = i2 * -1L;
            for (int i = 0; i < 65536; i++) {
                seed = i1 * 65536 + i;
                if (next(seed) == i2) {
                    break;
                }
            }

            Random random = new Random((seed ^ 0x5DEECE66DL) & ((1L << 48) - 1));
            random.nextInt();
            for (int i=0;i<9;i++) random.nextInt();
            SecurePassword generator = new SecurePassword();
            generator.random = random;
            String nextPassword = generator.generatePassword();
            if (nextPassword.equals("7M7y9YTHt800")) {
                System.out.println("Matching seed found! The next password will be:");
                System.out.println(generator.generatePassword());
                break;
            }
        }
    }

}

And we get in the output the solution of the challenge:

Matching seed found! The next password will be:
CwBwk9F379wo

Side note about the auto-generated seed

You may wondered: when Random is instantiated with no parameters, like new Random(), where does the initial seed comes from?

It is computed from a static long called seedUniquifier, XORed with current nano time (10^-9 seconds): System.nanoTime(). This seedUniquifier has an initial value of 8682522807148012L and multiplied with 1181783497276652981L for each new instance of Random.

java.util.Random auto-generated seed implementation

This could be simplified with this pseudo-code:

// first instance of Random() with no argument is equivalent to:
new Random(8682522807148012L ^ System.nanoTime())

// second instance of Random()
new Random((8682522807148012L * 1181783497276652981L) ^ System.nanoTime())

// third intsance of Random()
new Random((8682522807148012L * 1181783497276652981L * 1181783497276652981L) ^ System.nanoTime())
....

With this simple example, you can see that the auto-generated seed of the class Random is not that random in fact… It’s just a sequence of long and nano time. For nano time, you still got around 10^9 values to guess if you know the creation datetime of the secret which can be achieved in 1 min on a nowadays workstation CPU.

But it’s not the end! The previous statement is valid with JDK7+, but you may encounter JDK6 in pentest and this gets even worst with the JDK6 Random implementation:

public Random() { this(++seedUniquifier + System.nanoTime()); }
private static volatile long seedUniquifier = 8682522807148012L;

Yes yes, you read that correcly: auto-generated seed in Java 6 is just an integer incremented added (not XORed) with nanotime:

// first instance of Random() with no argument is equivalent to:
new Random(8682522807148012L + System.nanoTime())

// second instance of Random()
new Random((8682522807148012L + 1L) + System.nanoTime())

// third intsance of Random()
new Random((8682522807148012L + 1L + 1L) ^ System.nanoTime())
....

It means that with this version, you do not have to bruteforce the seedUniquifier for each nano time. When bruteforcing each nano time, you actually take into account sequences of seedUniquifier by just adding a few more loop iterations.

There do not seem to be CVE on this issue, but it clearly shows the weakness of Random class for security related operations.

Lessons learnt

  • java.security.SecureRandom must be used for cryptographically secured PRNG (see javadoc) and similar functions is other languages.
  • Password complexity must meet security best practices like ANSSI and best solution is to use MFA.
  • 2 sequential random numbers of java.util.Random may be enough to reverse the seed of the instance
  • 1 random number of java.util.Random and the number generateion time may be enough to reverse the seed of the instance

Vincent MICHEL (@darkpills)

I work as a pentester in a cyber security company. This blog aims at sharing my thoughts and experiments on different topics if it can help otheres: web and internal penetration tests, vulnerability research, write-ups, exploit development, security best practices, tooling, and so on... I previously worked as a senior software developer and switched to this wonderfull land of security :)