Trivial C# Random Exploitation

Exploiting random number generators requires math, right? Thanks to C#’s Random, that is not necessarily the case! I ran into an HTTP 2.0 web service issuing password reset tokens from a custom encoding of (new Random()).Next(min, max) output. This led to a critical account takeover. Exploitation did not require scripting, math or libraries. Just several clicks in Burp. While I had source code, I will show a method of discovering and exploiting this vulnerability in a black-box or bug-bounty style engagement.

The exploit uses no math, but I do like math. So, there is a bonus section on how to optimize and invert Random.

The Vulnerability

I can’t share the client code, but it was something like this:

var num = new Random().Next(min, max);
var token = make_password_reset_token(num);
save_reset_token_to_db(user, token);
return issue_password_reset(user.email, token);

This represents a typical password reset. The token is created using Random(), and there is no seed. This gets encoded to an alphanumeric token. The token is sent to the user in email. The user can then log in with their email and token.

This may be trivially exploitable.

How the C# PRNG Works

Somehow documentation linked me to the following reference implementation. This is not the real implementation, but it’s good enough. Don’t get into the weeds here, the Random(int Seed) is only displayed for the sake of context.

Git link

      public Random()
        : this(Environment.TickCount) {
      }

      public Random(int Seed) {
        int ii;
        int mj, mk;

        //Initialize our Seed array.
        //This algorithm comes from Numerical Recipes in C (2nd Ed.)
        int subtraction = (Seed == Int32.MinValue) ? Int32.MaxValue : Math.Abs(Seed);
        mj = MSEED - subtraction;
        SeedArray[55]=mj; // [2]
        mk=1;
        for (int i=1; i<55; i++) {  //Apparently the range [1..55] is special (Knuth) and so we're wasting the 0'th position.
          ii = (21*i)%55;
          SeedArray[ii]=mk;
          mk = mj - mk;
          if (mk<0) mk+=MBIG;
          mj=SeedArray[ii];
        }
        for (int k=1; k<5; k++) {
          for (int i=1; i<56; i++) {
        SeedArray[i] -= SeedArray[1+(i+30)%55];
        if (SeedArray[i]<0) SeedArray[i]+=MBIG;
          }
        }
        inext=0;
        inextp = 21;
        Seed = 1;
      }

This whole system hinges on the 32 bit Seed. This builds the internal state (SeedArray[55]) with some ugly math. If Random is initialized without an argument, the Environment.TickCount is used as Seed. All output of a PRNG is determined by its seed. In this case, it’s the TickCount

  • essentially just time. So you can think of this whole algorithm as emailing you the time, just with a very odd encoding.

In some sense, you can even submit a time to encode. You do this, not with a URL parameter but by waiting. Wait for the right time and you get the encoding you want. What time, or event, should we wait for?

The Exploit

The documentation says it best.

In .NET Framework, the default seed value is derived from the system clock, which has finite resolution. As a result, different Random objects that are created in close succession by a call to the parameterless constructor have identical default seed values and, therefore, produce identical sets of random numbers.

If we submit two requests in the same 1ms window, we get the same Seed, same seed same output, same reset token sent to two email addresses. One email we own of course, the other belongs to an admin.

How do we hit the 1ms window? We use the single packet attack.

Will it really work though?

Blackbox Methodology

You don’t want to go spamming admins with reset emails before you even verify the vulnerability. So make two accounts on the website that you control. While you can do the attack with one account, it’s prone to false positives. You’re sending two account resets in rapid succession. The second request may write a different reset token to the DB before the email service reads the first, resulting in a false positive.

Use Burp’s repeater groups to perform the single packet attack to reset both accounts. Check your email for duplicate tokens. If you fail, go on testing other stuff until the lockout window dies. Then just hit send again, likely you don’t need to worry about keeping a session token alive.

Note: Burp displays round trip time in the lower-right corner of Repeater.

Route Trip Time of request 4 in Race group

Keep an eye on that number. Each request has its own time. For me, it took about 10 requests before I got a duplicate token. That only occurred when the difference in round trip times was 1ms or less.

When launching the actual exploit, the only way to check if your token matches the victim account’s, is to log in. Login requests tend to be rate limited and guarded. So first verify with testing accounts. Use that to obtain a delta time window that works. Then, when launching the actual exploit, only attempt to log in when the delta time is within your testing bounds.

Ah… I guess subtracting two times counts as math. Exploiting PRNG’s always require math.

Wrapping Up

This attack is not completely novel. I have seen similar attacks used in CTF’s. It’s a nice lesson on time though. We control time by waiting, or not waiting. If a secret token is just an encoded time, you can duplicate them by duplicating time.

If you look into the .NET runtime enough, you can convince yourself this attack won’t work. Random has more then one implementation, the one my client should of used does not seed by time. I can even prove this with dotnetfiddle. This is like the security version of “it works on my computer”. This is why we test “secure” code and why we fuzz with random input. So try this exploit next time you see a security token.

This applies to more then just C#’s Random. Consider python’s uuid? The documentation warns of potential collisions due to lack of “synchronization” depending on “underlying platform”, unless safeUUID is used. I wonder if the attack will work there? Only one way to find out.

The fix for weak PRNG vulnerabilities is always to check the documentation. In this case you have to click the “Supplemental API remarks for Random.” in the “Remarks” section to get to the security info where it says:

To generate a cryptographically secure random number, such as one that’s suitable for creating a random password, use one of the static methods in the System.Security.Cryptography.RandomNumberGenerator class`.

So C# use RandomNumberGenerator instead of Random.

Bonus: Cracking C#’s Old Random Algorithm

Ahead is some math. It’s not too bad, but figured I would warn you. This is the “hard” way to exploit this finding. I wrote a library that can predict the output of Random::Next. It can also invert it, to go back to the seed. Or you can find the first output from the seventh output. None of this requires brute force, just a single modular equation. The code can be found here.

I intended this to be a fun weekend math project. Things got messed up when I found collisions due to an int underflow.

Seeding Is All Just Math

Let’s look at the seed algorithm, but try to generalize what you see. The SeedArray[55] is obviously the internal state of the PRNG. This is built up with “math”. If you look closely, almost every time SeedArray[i] is assigned, it’s with a subtraction. Right afterward you always see a check, did the subtraction results in a negative number? If so, add MBIG. In other words, all the subtraction is done mod MBIG.

The MBIG value is Int32.MaxValue, aka 0x7fffffff, aka 2^31 - 1. This is a Mersenne prime. Doing math, mod’ing a prime results in what math people call a Galois field. We only say that because Évariste Galois was so cool. A Galois field is just a nice way of saying “we can do all the normal algebra tricks we learned since middle school, even though this isn’t normal math”.

So, lets say SeedArray[i] is some a*Seed + b mod MBIG. It gets changed in a loop though by subtracting some other c*Seed + d mod MBIG. We don’t need that loop - algebra says to just (a+c)*Seed + (b+d) Mod MBIG. By churning through the loop doing algebra you can get every element of SeedArray in the form of a*Seed + b mod MBIG

Every time the PRNG is sampled, Random::InternalSample is called. That is just another subtraction. The result is both returned and used to set some element of SeedArray. It’s just some equation again. It’s still in the Galois field, it’s still just algebra and you can invert all of these equations. Given one output of Random::Next we can invert the corresponding equation and get the original Seed.

But, we can do more too!

csharp_rand.py library

The library I made builds SeedArray from these equations. It will output in terms of these equations. Let’s get the equation that represents the first output of Random for any Seed:

>>> from csharp_rand import csharp_rand
>>> cs = csharp_rand()
>>> first = cs.sample_equation(0)
>>> print(first)
rand = seed * 1121899819 + 1559595546 mod 2147483647

This represents the first output of Random for any seed. Use .resolve(42) to get the output of new Random(42).Next().

>>> first.resolve(42)
1434747710

Or invert and resolve 1434747710 to find out what seed will produce 1434747710 for the first output of Random.

>>> first.invert().resolve(1434747710)
42

This agrees with (dotnetfiddle).

See the readme for more complicated examples.

An int Underflow in Random

Having just finished my library, I excitedly showed it to the first person who would listen to me. Of course it failed. There must be a bug and of course I blamed the original implementation. But since account takeover bugs don’t care about my feelings, I fixed the code… mostly…

In short, the original implementation has an int underflow which throws the math equations off for certain seed values. Only certain SeedArray elements are affected. For example, the following shows the first output of Random does not need any adjustment, but 13th output does.

>>> print(cs.sample_equation(0))
rand = seed * 1121899819 + 1559595546 mod 2147483647
>>> print(cs.sample_equation(12))
rand = seed * 1476289907 + 1358625013 mod 2147483647 underflow adjustment: -2

So the 13th output will be seed * 1476289907 + 1358625013, unless the seed causes an underflow, then it will be off by -2. The code attempts to decide if the overflow occurs itself. This works great until you invert things.

Consider, what seed value will produce 908112456 as the 13th output of Random::Next?

>>> cs.sample_equation(12).invert().resolve2(908112456)
(619861032, 161844078)

Both seeds, 619861032 and 161844078, will produce 908112456 on the 13th output (poc). Seed 619861032 does it the proper way, from the non-adjusted equation. Seed 619861032 gets there from the underflow. This “collision” means there are exactly 2 seeds that produce the same output. This means 908112456 is 2x more likely to occur on the 13th output then the first. It also means there is no seed that will produce 908112458 on the 13th output of Random. A quick brute force produced some 80K+ other “collision” just like this one.

Bonus Conclusion

Sometimes the smart way is dumb. What started as a fun math thing ended up feeling like death by a thousand cuts. It’s better to version match and language match your exploit and get it going fast. If it takes a long time, start optimizing while it’s still running. But before you optimize, TEST! Test everything! Otherwise you will run a brute force for hours and get nothing. Why? well maybe Random(Environment.TickCount) is not Random() because explicitly seeding results in a different algorithm! Ugh…. I am going to go audit some more endpoints…