Cracking Radmin Server 3 passwords

Written by Martin Perrier - 17/09/2021 - in Outils , Pentest , Reverse-engineering - Download
Reverse-engineering a hashing mechanism and optimizing password cracking

Radmin Server 3 is a remote administration software for Windows, used by companies to access their servers.

What would happen if you had access to one of those servers during a pentest, knowing others were using the same credentials? It turns out extracting the password from a local Radmin Server 3 user is not so simple. The full process, from reverse-engineering the hashing function to optimizing the password cracker, will be explained in this post.

 

Finding user data

The first step of this journey was to locate the Radmin user's data. To achieve this, a dynamic approach using the Process Monitor from Microsoft's Sysinternals was enough.

By filtering the processes using "rserver3.exe" as a name and adding a new user in the Radmin Server settings, activity related to the storage of the new user may appear.

Adding a Radmin user

 

Indeed, the program writes information related to the user into a registry key:

Process Monitor

 

By exporting the registry key, we are left with this blob of data:

[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Radmin\v3.0\Server\Parameters\Radmin Security\1]
"1"=hex:10,00,00,10,6a,00,6f,00,6e,00,61,00,74,00,68,00,61,00,6e,00,30,00,01,\
  00,98,47,fc,7e,0f,89,1d,fd,5d,02,f1,9d,58,7d,8f,77,ae,c0,b9,80,d4,30,4b,01,\
  13,b4,06,f2,3e,2c,ec,58,ca,fc,a0,4a,53,e3,6f,b6,8e,0c,3b,ff,92,cf,33,57,86,\
  b0,db,e6,0d,fe,41,78,ef,2f,cd,2a,4d,d0,99,47,ff,d8,df,96,fd,0f,9e,29,81,a3,\
  2d,a9,55,03,34,2e,ca,9f,08,06,2c,bd,d4,ac,2d,7c,df,81,0d,b4,db,96,db,70,10,\
  22,66,26,1c,d3,f8,bd,d5,6a,10,2f,c6,ce,ed,bb,a5,ea,e9,9e,61,27,bd,d9,52,f7,\
  a0,d1,8a,79,02,1c,88,1a,e6,3e,c4,b3,59,03,87,f5,48,59,8f,2c,b8,f9,0d,ea,36,\
  fc,4f,80,c5,47,3f,db,6b,0c,6b,db,0f,db,af,46,01,f5,60,dd,14,91,67,ea,12,5d,\
  b8,ad,34,fd,0f,d4,53,50,de,c7,2c,fb,3b,52,8b,a2,33,2d,60,91,ac,ea,89,df,d0,\
  6c,9c,4d,18,f6,97,24,5b,d2,ac,92,78,b9,2b,fe,7d,ba,fa,a0,c4,3b,40,a7,1f,19,\
  30,eb,c4,fd,24,c9,e5,a2,e5,a4,cc,f5,d7,f5,15,44,d7,0b,2b,ca,4a,f5,b8,d3,7b,\
  37,9f,d7,74,0a,68,2f,40,00,00,01,05,50,00,00,20,16,25,7c,87,78,bc,06,b3,6d,\
  35,8a,21,58,eb,26,89,f4,84,d0,a2,57,42,05,0c,5b,ad,ef,0a,f3,a6,d2,83,60,00,\
  01,00,1a,ea,28,ec,fa,04,94,09,64,39,6b,fd,b2,f3,e2,02,1c,76,19,82,d1,98,ba,\
  ab,e7,66,8f,df,66,1b,e1,b0,36,53,bd,2a,69,24,17,10,f1,cc,49,2c,f7,f4,7a,45,\
  3e,a6,c0,c7,df,b2,53,27,e2,c0,7b,a9,b5,c6,81,30,ee,ff,5b,15,c5,df,1d,87,f4,\
  a3,d9,67,5a,6f,f1,94,30,ea,b7,6f,ff,b1,68,55,b5,83,72,d8,d5,cf,bf,42,2a,67,\
  d3,04,e5,58,60,17,b8,9e,52,c9,17,66,64,eb,61,fe,d2,a4,d4,3c,a4,d9,fc,33,cd,\
  7e,8a,b0,15,76,4e,6a,38,94,af,ad,eb,f9,87,db,36,e1,b0,48,7b,83,59,86,02,b4,\
  9c,91,e2,6b,51,cc,d8,9c,71,9c,f9,64,4f,89,fa,e5,a6,9f,7b,4d,c7,3a,c5,e8,b7,\
  eb,f1,e0,2e,7c,49,73,59,20,7f,24,14,31,fc,25,7c,5c,99,56,99,a4,cc,b6,26,d0,\
  15,85,9a,70,27,aa,fd,f0,08,04,4e,c7,05,21,de,f1,d5,9c,43,dd,e0,64,47,77,dc,\
  51,bb,39,17,59,20,a0,f6,04,0d,91,67,f6,85,69,57,3b,70,39,0c,72,9a,d4,30,d6,\
  6e,77,9a,b8,1c,b1,a8,8a,20,00,00,04,ff,01,00,00

It is some kind of TLV (Type, Length, Value) format, and this Python function can extract its contents:

def parsekey(key):
    patstart = "=hex:"
    key = key[key.find(patstart)+len(patstart):]
    key = list(map(lambda x:int(x,16),key.replace(" ","").replace("\n","").replace("\\","").split(",")))
    
    content = {}
    i=0
    while i<len(key):
        dtyp = key[i+1]*0x100+key[i]
        dlen = key[i+2]*0x100+key[i+3]
        i+=4
        content[dtyp] = (bytes(key[i:i+dlen]))
        i+=dlen

    print("Username :",content[16].replace(b"\x00",b""))
    print("unk2 :",content[48].hex())
    print("unk3 :",content[64].hex())
    print("unk4 :",content[80].hex())
    print("unk5 :",content[96].hex())
    print("perms :",content[32].hex())

This is the output for the test user (jonathan:bonjour0)

Username : b'jonathan'
unk2 : 9847fc7e0f891dfd5d02f19d587d8f77aec0b980d4304b0113b406f23e2cec58cafca04a53e36fb68e0c3bff92cf335786b0dbe60dfe4178ef2fcd2a4dd09947ffd8df96fd0f9e2981a32da95503342eca9f08062cbdd4ac2d7cdf810db4db96db70102266261cd3f8bdd56a102fc6ceedbba5eae99e6127bdd952f7a0d18a79021c881ae63ec4b3590387f548598f2cb8f90dea36fc4f80c5473fdb6b0c6bdb0fdbaf4601f560dd149167ea125db8ad34fd0fd45350dec72cfb3b528ba2332d6091acea89dfd06c9c4d18f697245bd2ac9278b92bfe7dbafaa0c43b40a71f1930ebc4fd24c9e5a2e5a4ccf5d7f51544d70b2bca4af5b8d37b379fd7740a682f
unk3 : 05
unk4 : 16257c8778bc06b36d358a2158eb2689f484d0a25742050c5badef0af3a6d283
unk5 : 1aea28ecfa04940964396bfdb2f3e2021c761982d198baabe7668fdf661be1b03653bd2a69241710f1cc492cf7f47a453ea6c0c7dfb25327e2c07ba9b5c68130eeff5b15c5df1d87f4a3d9675a6ff19430eab76fffb16855b58372d8d5cfbf422a67d304e5586017b89e52c9176664eb61fed2a4d43ca4d9fc33cd7e8ab015764e6a3894afadebf987db36e1b0487b83598602b49c91e26b51ccd89c719cf9644f89fae5a69f7b4dc73ac5e8b7ebf1e02e7c497359207f241431fc257c5c995699a4ccb626d015859a7027aafdf008044ec70521def1d59c43dde0644777dc51bb39175920a0f6040d9167f68569573b70390c729ad430d66e779ab81cb1a88a
perms : ff010000

The first value is the username, and the last is related to user permissions. The others seem related to the password, but their format does not look familiar (yet).

 

Reverse-engineering the hashing function

The whole binary is statically compiled, there are no symbols, and very few external library calls. This makes locating the user-creation process harder, but there were still several ways to get to it, such as using the registry key write as a starting point and following the code back to the creation of its elements. However, an even faster approach would be to start from known cryptographic functions. I used this simple tool to locate some of those functions in the binary: https://www.aldeid.com/wiki/PEiD

PEID_usage

This tool was indeed able to locate some common cryptographic functions, such as hash functions, likely to be used during the generation of the password-related data previously uncovered.

PEiD - Detected functions

Using x64dbg, it seemed the program was using anti-debug countermeasures, as debugging the program during a login attempt was leading to a suspicious crash. But it turned out not to be the case, at least not on the code related to the creation or deletion of new users in the server configuration.

After placing breakpoints on the hash functions located by PEiD, the one corresponding to SHA1 triggered when creating a new user. Furthermore, after running past a few breaks, the username "jonathan" is passed as an argument, confirming a relation between the use of this hash function and user creation:

x64dbg SHA1 breakpoint

Inspecting the call stack gives us the addresses of functions leading up to this point:

x64dbg call stack

After reversing the functions mentioned in the call stack and renaming their contents, the location highlighted above seems particularly interesting:

First hash pseudocode

Here is the Python translation of what this function does:

shahash = hashlib.sha1(salt+hashlib.sha1(username+b":"+password).digest()).digest()

salt being a 32-byte value generated in the previous function, equal to the unk4 value parsed from the registry key, and username and password being represented in UTF16. This is only the first part of the hashing process, and the purpose of the other unk values from the registry key is still unknown at this point.

One last step involves those values, in the hash_postprocess function, but this part was not so easy to understand using static analysis. It takes as input the hash calculated above, and creates a new Bignum value. A simple dynamic approach was taken, feeding the function with small values by changing the input manually in the debugger, and looking at the output:

 Input  Output
 0  1
 1  5
 2  25
 3  125
 4  625

It seems like Output == 5Input. 5 corresponds to the unk3 element of the registry key. But this does not hold true when Input is big, and in those cases the Output value seems to always be the same order of magnitude as unk2, but smaller than unk2.

Another interesting information is that unk2 seems constant for any username or password, so it is not derived from the hash. Using this information, we can lay out this hypothesis: Output == unk3Input mod unk2. This was experimentally verified with several big values.

The following Python code is equivalent to this last function:

final_hash = pow(unk3,int(shahash.hex(),16),unk2)

The final_hash value corresponds to unk5 in the registry key. With this last bit of information, we have all the necessary elements to carry out a brute-force attack on the registry key:

import hashlib
import sys
import argparse

parser = argparse.ArgumentParser(description='Dump (and bruteforce) Radmin Server 3 creds')
parser.add_argument('regkey', metavar='regkey.txt')
parser.add_argument('--wordlist', help='wordlist for bruteforce', default=None)

args = parser.parse_args()

wordlist = args.wordlist
key = open(args.regkey).read()

def to_utf16(st):
    newar = []
    for l in st:
        newar.append(l)
        newar.append(0)
    return bytes(newar)


def parsekey(key):
    patstart = "=hex:"
    key = key[key.find(patstart)+len(patstart):]
    key = list(map(lambda x:int(x,16),key.replace(" ","").replace("\n","").replace("\\","").split(",")))
    
    content = {}
    i=0
    while i<len(key):
        dtyp = key[i+1]*0x100+key[i]
        dlen = key[i+2]*0x100+key[i+3]
        i+=4
        content[dtyp] = (bytes(key[i:i+dlen]))
        i+=dlen
    
    username = content[16]
    modulus = content[48]
    g = content[64]
    salt = content[80]
    hashh = content[96]

    print("Username :",username.replace(b"\x00",b""))
    print("Modulus :",modulus.hex())
    print("Generator :",g.hex())
    print("Salt :",salt.hex())
    print("Verifier :",hashh.hex())

    return username,modulus,g,salt,hashh

username,modulus,g,salt,hashh = parsekey(key)
modulus = int(modulus.hex(),16)
g = int(g.hex(),16)
hashh = int(hashh.hex(),16)

if wordlist:
    n = 0
    for line in open(wordlist,"rb").readlines():
        n+=1
        if(n%1000)==0:print(n)
        passw = to_utf16(line.strip())
        concat = username+b":"+passw
        shahash = hashlib.sha1(salt+hashlib.sha1(concat).digest()).digest()
        if pow(g,int(shahash.hex(),16),modulus) == hashh:
            print("PASSWORD : ",passw.decode("utf8"))
            exit()
$ python3 hash_dumper.py regkey.txt --wordlist ./test.txt

Username : jonathan
Modulus : 9847fc7e0f891dfd5d02f19d587d8f77aec0b980d4304b0113b406f23e2cec58cafca04a53e36fb68e0c3bff92cf335786b0dbe60dfe4178ef2fcd2a4dd09947ffd8df96fd0f9e2981a32da95503342eca9f08062cbdd4ac2d7cdf810db4db96db70102266261cd3f8bdd56a102fc6ceedbba5eae99e6127bdd952f7a0d18a79021c881ae63ec4b3590387f548598f2cb8f90dea36fc4f80c5473fdb6b0c6bdb0fdbaf4601f560dd149167ea125db8ad34fd0fd45350dec72cfb3b528ba2332d6091acea89dfd06c9c4d18f697245bd2ac9278b92bfe7dbafaa0c43b40a71f1930ebc4fd24c9e5a2e5a4ccf5d7f51544d70b2bca4af5b8d37b379fd7740a682f
Generator : 05
Salt : 16257c8778bc06b36d358a2158eb2689f484d0a25742050c5badef0af3a6d283
Verifier : 1aea28ecfa04940964396bfdb2f3e2021c761982d198baabe7668fdf661be1b03653bd2a69241710f1cc492cf7f47a453ea6c0c7dfb25327e2c07ba9b5c68130eeff5b15c5df1d87f4a3d9675a6ff19430eab76fffb16855b58372d8d5cfbf422a67d304e5586017b89e52c9176664eb61fed2a4d43ca4d9fc33cd7e8ab015764e6a3894afadebf987db36e1b0487b83598602b49c91e26b51ccd89c719cf9644f89fae5a69f7b4dc73ac5e8b7ebf1e02e7c497359207f241431fc257c5c995699a4ccb626d015859a7027aafdf008044ec70521def1d59c43dde0644777dc51bb39175920a0f6040d9167f68569573b70390c729ad430d66e779ab81cb1a88a
PASSWORD :  bonjour0

The password "bonjour0" is found from the supplied wordlist, meaning the implementation is correct! Of course, additional tests were made to confirm it.

It turns out that this algorithm is not custom-made, but is an implementation of the "Secure Remote Password" (SRP) protocol, using SHA1 as hash function. The number 5 is named generator, the first calculated hash is named exponent, and the final hash is named verifier. Knowing those values is not enough to connect, or even to perform passive or active Man-in-the-Middle attacks without knowledge of the password.

The final exponentiation also makes the password hard to crack if the modulus and generator are correctly chosen, as it becomes a very slow operation relative to the first part of the hashing process, and is not easily reversible.

 

Optimizing the password cracker

The Python implementation being very slow, a first iteration of a C implementation was made by Thibault Guittet.

Despite a huge speedup, there were still ways to optimize the algorithm. The first and most obvious one being an implementation of multi-threaded cracking.

The next step was implementing our own modular exponentiation, instead of using BN_mod_exp at every iteration.

As the generator does not change for each try, caching the consecutive squares of the generator reduces the exponentiation operation to an average of 80 modular multiplications.

Let's explain how using 16-bits exponent instead of a 160-bits exponent, for clarity. All following operations are implicitly mod N.

When caching the consecutive squares of a generator G, using our current optimization, we effectively cache:

  • G0b0000000000000001
  • G0b0000000000000010
  • G0b0000000000000100
  • ....
  • G0b1000000000000000

After that, if we want, for example, G0b0010000000001001, we can compute

G0b0010000000000000 * G0b0000000000000001 *  G0b0000000000001000

For each bit of the base-2 representation of the exponent, a 1 will represent a modular multiplication with one of the cached values, and a 0 will represent no operation.

The average number of multiplications for a random exponent of 16 bits would then be 7 (with 8 values multiplied together).

As the exponent on the "real" algorithm corresponds to a SHA1 output, there will be on average 80 "1" bits on a total of 160 bits, so the average number of modular multiplications would be 79.

In practice, this first optimization reduced by 30% the total time for 100 000 different hash computations, when compared to the same code using BN_mod_exp instead of the custom exponentiation.

This is an improvement, but not a huge one. Fortunately, it is possible to push this optimization further, and this can be visualized by using a different base to represent the exponent of the 16-bit example from before.

In the precedent example, we cached the results from all possible exponent values with one non-zero digit in base 2. Now, we will cache the results from all possible exponent values with one non-zero digit in base 16.

  • G0x0001
  • G0x0002
  • ...
  • G0x000f
  • G0x0010
  • G0x0020
  • ...
  • G0xe000
  • G0xf000

This represents more values than before! In fact, there are 16*4=64 different cached values, instead of just 16. But now, if we want to compute GE with E being a random 16-bit value, we only need 3 modular multiplications! Here is the operation with E=0x4d6f as an example:

G0x4d6f = G0x4000 * G0x0d00 * G0x0060 * G0x000f

This technique can be used with base 2 or base 16, but would also be valid for any base. In this small example, we could even use the base 65536, effectively caching all possible values and reducing the number of necessary multiplications to zero.

Of course, for 160-bit exponents, we cannot cache all the values, but using 2 as a base for the cache, as we did in the initial approach, is also far from optimal. The base 216=65536 was empirically chosen, requiring to cache 65536*(160/16)=655360 different values. This process lasts a few seconds, but needs to be performed only once for a given generator, and could be stored on the disk to avoid recomputing it in the future, even if this feature is not implemented.

Here are the hashrates (in hashes per second) for each implementation, measured on a Lenovo T490 laptop :

 Method  Hashrate (H/s)
 Single-thread Python  800
 Single-thread C with BN_mod_exp  8000
 Multithread C with BN_mod_exp   26000
 Single-thread C with custom exp  57000
 Multithread C with custom exp  200000

 

The C implementation is available at https://github.com/synacktiv/Radmin3-Password-Cracker, and the base used by the cache can be adjusted by modifying CHUNK_LEN.