Join the discord

RoboForm 7 - password generation algorithm reversed

09 Nov, 2024 00:21
I've finally sat down on my ass to publish something, after watching this year's Joe Grand's talk at Defcon 32.
For those who haven't, he talked about a password generation software called RoboForm, that he and his partner in crime Bruno had to partially reverse engineer, in order to recreate a password generated, 10 years ago that today locks $3 million in Bitcoin.
That's the short story, the long one you can watch here https://www.youtube.com/watch?v=N2eKCAzM2kw and here https://www.youtube.com/watch?v=o5IySpAkThg
The reason why I hopped on the bandwagon with this one was the solution they chose, by essentially hooking RoboForm's runtime in order to generate a dictionary that they can then use to crack the wallet password.

That sounds like too much of a job, and as proven by their talk - a very slow one too.
But, I'm a simple man, so today I'll try to reverse enginner the key generation algorithm of RoboForm 7.9.1.1 (what I was able to find as close to Joe's case).

Finding a good entry


When it comes to finding a good entry point for the attack, RoboForm is written in some object oriented language (by the looks of it C++), so things are a little bit more complicated.
My plan was to set breakpoint to SetWindowTextW, and try backtracing until I find the place where the password is being generated.
The SetWindowTextW populating the password in the password field was here:
First step6DA3C1D0 | 6A FF                    | push FFFFFFFF                                                | < procedure start
6DA3C1D2 | 68 304DDC6D              | push <roboform.SEH_1057C1D0>                                 |
6DA3C1D7 | 64:A1 00000000           | mov eax,dword ptr fs:[0]                                     | eax:L"#R!3WN8n"
6DA3C1DD | 50                       | push eax                                                     | eax:L"#R!3WN8n"
6DA3C1DE | 83EC 10                  | sub esp,10                                                   |
6DA3C1E1 | 53                       | push ebx                                                     |
6DA3C1E2 | 55                       | push ebp                                                     |
6DA3C1E3 | 56                       | push esi                                                     |
6DA3C1E4 | 57                       | push edi                                                     |
6DA3C1E5 | A1 8484256E              | mov eax,dword ptr ds:[<___security_cookie>]                  | eax:L"#R!3WN8n"
6DA3C1EA | 33C4                     | xor eax,esp                                                  |
; snip
6DA3C2A7 | EB 11                    | jmp <roboform.loc_1057C2BA>                                  |
6DA3C2A9 | 8BCF                     | mov ecx,edi                                                  |
6DA3C2AB | E8 B06E0200              | call <roboform.sub_105A3160>                                 |
6DA3C2B0 | 8B00                     | mov eax,dword ptr ds:[eax]                                   | eax:L"#R!3WN8n"
6DA3C2B2 | 50                       | push eax                                                     | eax:L"#R!3WN8n"
6DA3C2B3 | 55                       | push ebp                                                     |
6DA3C2B4 | FF15 9CB9E16D            | call dword ptr ds:[<&SetWindowTextW>]                        | ; < here
The procedure starts at 6DA3C1D0, so I placed a breakpoint there and clicked the Generate New button again.
Since the freshly generated password is shown in a lot of places, I've used this to my advantage and noticed the new password was already present in memory.
This only means I'm still past the generation routine, but I can now take the return address from the stack and find the caller procedure here:
Second step6DA3F1B0 | 8B4424 04                | mov eax,dword ptr ss:[esp+4]                                 | [esp+4]:&L"YmF1#u1Q"
6DA3F1B4 | 56                       | push esi                                                     |
6DA3F1B5 | 50                       | push eax                                                     | eax:&L"YmF1#u1Q"
6DA3F1B6 | 8BF1                     | mov esi,ecx                                                  |
6DA3F1B8 | E8 13D0FFFF              | call <roboform.sub_1057C1D0>                                 | ; < here

Rince and repeat, breakpoint at 6DA3F1B0, generate new password, get the return address until this eventually led me here:
Third step6D977CC0 | 55                       | push ebp                                                     | ; < procedure entry
6D977CC1 | 8BEC                     | mov ebp,esp                                                  |
6D977CC3 | 6A FF                    | push FFFFFFFF                                                |
6D977CC5 | 68 80E2DA6D              | push <roboform.SEH_104B7CC0>                                 |
6D977CCA | 64:A1 00000000           | mov eax,dword ptr fs:[0]                                     |
6D977CD0 | 50                       | push eax                                                     |
6D977CD1 | 81EC 40010000            | sub esp,140                                                  |
6D977CD7 | A1 8484256E              | mov eax,dword ptr ds:[<___security_cookie>]                  |
6D977CDC | 33C5                     | xor eax,ebp                                                  |
6D977CDE | 50                       | push eax                                                     |
6D977CDF | 8D45 F4                  | lea eax,dword ptr ss:[ebp-C]                                 |
6D977CE2 | 64:A3 00000000           | mov dword ptr fs:[0],eax                                     |
6D977CE8 | 898D B4FEFFFF            | mov dword ptr ss:[ebp-14C],ecx                               |
6D977CEE | 8B8D B4FEFFFF            | mov ecx,dword ptr ss:[ebp-14C]                               |
6D977CF4 | E8 97300000              | call <roboform.sub_104BAD90>                                 |
6D977CF9 | 8B8D B4FEFFFF            | mov ecx,dword ptr ss:[ebp-14C]                               |
6D977CFF | E8 8C0C0000              | call <roboform.sub_104B8990>                                 |
6D977D04 | 8D45 F0                  | lea eax,dword ptr ss:[ebp-10]                                |
6D977D07 | 50                       | push eax                                                     |
6D977D08 | 8B8D B4FEFFFF            | mov ecx,dword ptr ss:[ebp-14C]                               |
6D977D0E | E8 ED000000              | call <roboform.sub_104B7E00>                                 | ; < here

After tracing over the last call at 6D977D0E, the newly generated password popped out in memory, so I was getting closer.
In the next iteration, I've traced into the call, and after some further tracing (over the calls inside) I got here:
Forth step6D978198 | 8955 B0                  | mov dword ptr ss:[ebp-50],edx                                |
6D97819B | 8B45 B8                  | mov eax,dword ptr ss:[ebp-48]                                |
6D97819E | 50                       | push eax                                                     |
6D97819F | E8 7C7C3300              | call <roboform.j___wtol>                                     | ; returns 1
6D9781A4 | 83C4 04                  | add esp,4                                                    |
6D9781A7 | 50                       | push eax                                                     |
6D9781A8 | 8B4D B0                  | mov ecx,dword ptr ss:[ebp-50]                                |
6D9781AB | 51                       | push ecx                                                     |
6D9781AC | E8 6F7C3300              | call <roboform.j___wtol>                                     | ; returns 8
6D9781B1 | 83C4 04                  | add esp,4                                                    |
6D9781B4 | 50                       | push eax                                                     |
6D9781B5 | 8B55 08                  | mov edx,dword ptr ss:[ebp+8]                                 |
6D9781B8 | 52                       | push edx                                                     |
6D9781B9 | 8B4D A4                  | mov ecx,dword ptr ss:[ebp-5C]                                |
6D9781BC | 81C1 340E0000            | add ecx,E34                                                  |
6D9781C2 | E8 69D01600              | call <roboform.@generate_password>                           | ; < here

The first wtol returns 1 and the second one returns 8, and if we take a look at the GUI, we can easily tell where these two values come from:

Of course the @generate_password is name that I later set there, but in the end, that was the procedure that was generating the passwords.



Password generation algorithm


From here things were getting easier.
After some fiddling in IDA, setting names and tracing some code in the debugger, I finally got the IDA decompiled code:
password generatorwchar_t **__thiscall generate_password(struc_1 *this, wchar_t **pszw_password, int num_chars, int min_digits) {
  v30 = 0;
  v27 = 0;
  time = _time64(0);
  time_lower = HIDWORD(time);
  srand(time - seed_mod);
  bitmask = this->bitmask;
  seed_mod -= 0xE3A78;
  if ( bitmask <= 0 || bitmask >= 0x40 )
    throw_error(".\\portable\\sib-password-gen.cpp", 0x2F, 0, L"m_nCharSequenceType>0 && m_nCharSequenceType<64", 1);
  if ( num_chars > 0x200 )
    num_chars = 0x200;
  szw_chr_upper = (wchar_t *)(Wstring::new() + 5);
  v30 = 8;
  szw_chr_lower = (wchar_t *)(Wstring::new() + 5);
  szw_chr_digits = (wchar_t *)(Wstring::new() + 5);
  v25 = (int)(Wstring::new() + 5);
  if ( check_flag(this, 0x20) ) {
    Wstring::set((int *)&szw_chr_upper, "ABCDEFGHJKLMNPQRSTUVWXYZ");
    Wstring::set((int *)&szw_chr_lower, "abcdefghijkmnopqrstuvwxyz");
    Wstring::set((int *)&szw_chr_digits, "23456789");
    Wstring::set(&v25, "23456789ABCDEF");
  } else {
    Wstring::set((int *)&szw_chr_upper, "ABCDEFGHIJKLMNOPQRSTUVWXYZ");
    Wstring::set((int *)&szw_chr_lower, "abcdefghijklmnopqrstuvwxyz");
    Wstring::set((int *)&szw_chr_digits, "0123456789");
    Wstring::set(&v25, "0123456789ABCDEF");
  }
  szw_charset = (wchar_t *)(Wstring::new() + 5);
  LOBYTE(v30) = 0xA;
  if ( check_flag(this, 1) )
    Wstring::concat(&szw_charset, szw_chr_upper, *((_DWORD *)szw_chr_upper - 5));
  if ( check_flag(this, 2) )
    Wstring::concat(&szw_charset, szw_chr_lower, *((_DWORD *)szw_chr_lower - 5));
  if ( check_flag(this, 4) )
    Wstring::concat(&szw_charset, szw_chr_digits, *((_DWORD *)szw_chr_digits - 5));
  if ( check_flag(this, 8) )
    Wstring::concat(&szw_charset, (wchar_t *)this->szw_charset_special, *(_DWORD *)(this->szw_charset_special - 20));
  if ( check_flag(this, 16) )
    sub_10001A20(&szw_charset, &v25);
  v8 = num_chars + 1;
  if ( num_chars + 1 > 0 ) {
    v9 = v8 & 1;
    v10 = v8 >> 1;
    memset(szw_password, 0, 4 * v10);
    v11 = &szw_password[2 * v10];
    for ( i = v9; i; --i )
      *v11++ = 0;
  }
  ctr_min_digits = min_digits;
  v22 = min_digits;
  if ( num_chars < min_digits ) {
    v22 = num_chars;
    ctr_min_digits = num_chars;
  }
  if ( ctr_min_digits < 0 ) {
    v22 = 0;
    ctr_min_digits = 0;
  }
  if ( ctr_min_digits > 0 ) {
    do {
      generate_character(this, szw_password, &num_chars, 4, &szw_chr_digits, num_chars);
      --ctr_min_digits;
    }
    while ( ctr_min_digits );
  }
  if ( *(_DWORD *)(this->szw_charset_special - 0x14) )
    generate_character(this, szw_password, &num_chars, 8, this, num_chars);
  generate_character(this, szw_password, &num_chars, 1, &szw_chr_upper, num_chars);
  generate_character(this, szw_password, &num_chars, 2, &szw_chr_lower, num_chars);
  if ( !v22 )
    generate_character(this, szw_password, &num_chars, 4, &szw_chr_digits, num_chars);
  for ( j = 0; j < num_chars; ++j ) {
    if ( !szw_password[j] )
      szw_password[j] = rand_char(&szw_charset);
  }
  v15 = Wstring::new();
  *pszw_password = (wchar_t *)(v15 + 5);
  LOBYTE(v30) = 11;
  password_length = wstrlen_0(szw_password);
  Wstring::copy(pszw_password, szw_password, password_length);
  v27 = 1;
  LOBYTE(v30) = 8;
  Wstring::free((volatile signed __int32 *)szw_charset - 5);
  LOBYTE(v30) = 6;
  Wstring::free((volatile signed __int32 *)(v25 - 20));
  LOBYTE(v30) = 4;
  Wstring::free((volatile signed __int32 *)szw_chr_digits - 5);
  LOBYTE(v30) = 2;
  Wstring::free((volatile signed __int32 *)szw_chr_lower - 5);
  v30 = 0;
  Wstring::free((volatile signed __int32 *)szw_chr_upper - 5);
  return pszw_password;
}

This may look far from perfect but it's enough for me to port it into a badly written python:
RoboForm password generatorclass roboform:
    seed = None
    seed_mod = None
    pass_length = None
    pass_length_ctr = None
    min_digits = None
    config = None
    password = None

    charset = {
        "upper": "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
        "upper_similar": "ABCDEFGHJKLMNPQRSTUVWXYZ",
        "lower": "abcdefghijklmnopqrstuvwxyz",
        "lower_similar": "abcdefghijkmnopqrstuvwxyz",
        "digit": "0123456789",
        "digit_similar": "23456789",
        "hex": "0123456789ABCDEF",
        "hex_similar": "23456789ABCDEF",
        "special": "!@#$%^&*"
    }
    charset_full = None

    def __init__(
        self, 
        pass_length=8, 
        chr_upper=True, 
        chr_lower=True, 
        chr_digits=True, 
        chr_specials="!@#$%^&*",
        chr_similar=False,
        min_digits=1,
    ):
        self.seed_mod = 0

        if pass_length > 0x200:
            pass_length = 0x200
        self.pass_length = pass_length

        self.config = 0
        if chr_upper:
            self.config |= 0x01
        if chr_lower:
            self.config |= 0x02
        if chr_digits:
            self.config |= 0x04
        if chr_specials:
            self.config |= 0x08
        if chr_similar:
            self.config |= 0x20

        self.charset_full = ""
        if self.config & 0x01:
            if self.config & 0x20:
                self.charset_full += self.charset["upper_similar"]
            else:
                self.charset_full += self.charset["upper"]

        if self.config & 0x02:
            if self.config & 0x20:
                self.charset_full += self.charset["lower_similar"]
            else:
                self.charset_full += self.charset["lower"]

        if self.config & 0x04:
            if self.config & 0x20:
                self.charset_full += self.charset["digit_similar"]
            else:
                self.charset_full += self.charset["digit"]

        if self.config & 0x08:
            self.charset["special"] = chr_specials
            self.charset_full += self.charset["special"]

        if self.config & 0x10:
            if self.config & 0x20:
                self.charset_full = self.charset["hex_similar"]
            else:
                self.charset_full = self.charset["hex"]

        self.min_digits = min_digits

    def __del__(self):
        pass

    def srand(self, seed):
        self.seed = seed

    def rand(self):
        self.seed = ((0x000343FD * self.seed) + 0x00269EC3) & 0xFFFFFFFF
        return (self.seed >> 0x10) & 0x7FFF

    def rand_range(self, range):
        return (self.rand() * range) // 0x8000

    def rand_char(self, charset):
        return ord(charset[self.rand_range(len(charset))])

    def generate_character(self, flag, charset):
        if self.pass_length_ctr == 0:
            return

        if not (self.config & flag):
            return

        # find empty character position
        while True:
            idx = self.rand_range(self.pass_length)
            if self.password[idx] == 0:
                break

        # generate random charatcer at position
        self.password[idx] = self.rand_char(charset)
        self.pass_length_ctr -= 1
    
    def generate(self, time):
        # init random seed
        self.srand(time - self.seed_mod)
        self.seed_mod -= 0x000E3A78

        # init password placeholder and length counter
        self.password = bytearray(self.pass_length)
        self.pass_length_ctr = self.pass_length

        # populate the minimum digits
        for i in range(self.min_digits):
            self.generate_character(0x04, self.charset["digit"])

        # put at least one special character, if they are present
        if len(self.charset["special"]):
            self.generate_character(0x08, self.charset["special"])

        # at least one uppercase character
        self.generate_character(0x01, self.charset["upper"])

        # at least one lowercase character
        self.generate_character(0x02, self.charset["lower"])

        # at least obe digit, if minimum digits is 0
        if self.min_digits == 0:
            self.generate_character(0x04, self.charset["digit"])        

        # populate the blank spots
        for i in range(self.pass_length):
            if self.password[i]:
                continue
            self.password[i] = self.rand_char(self.charset_full)

        return self.password.decode("utf-8")

rf = roboform(
    pass_length=20,
    chr_upper=True,
    chr_lower=True,
    chr_digits=True,
    chr_specials="",#"!@#$%^&*",
    chr_similar=False,
    min_digits=1,
)
password = rf.generate(1368634240)
print("%d" % 1368634240, password)

And it works! I promise.



But wait, there is more


The reason I claim it works is because I patiently stalked the videos in order to obtain test data, and I got it here:

Here we see the correct password "mAIQf0REsR3RRP43UHRx", with timestamp 1368634240 and that's my test stamp from my python code.
On the screenshot there is also previous and next generations for timestamp-N and timestamp+N, so I can verify that by running my implementation in a loop like that:
RoboForm password generatorfor timestamp in range(1368634240-10, 1368634240+10):
   rf = roboform(
        pass_length=20,
        chr_upper=True,
        chr_lower=True,
        chr_digits=True,
        chr_specials="",
        chr_similar=False,
        min_digits=1,
    )
    password = rf.generate(timestamp)
    print("%d" % timestamp, password)

and the result is:
generated passwords1368634230 p3GIGizgB69sost0r0Yr
1368634231 yl6izMhmggNwcL24tJrD
1368634232 1sGvizT2yCSDVwz7KhVm
1368634233 ynljhzhh8kKV3320yMEH
1368634234 CehHYh0NFDoH9464lOSg
1368634235 IcmMh03hiUnxdAU7kv9Y
1368634236 OWHAg0jDAKRmCkD0C5Ob
1368634237 KQmzg0PiqribkHv3Rs48
1368634238 aLIng0q5EWODQJK7dmLW
1368634239 gFWnbg0ljCvEtsO0M8oz
1368634240 mAIQf0REsR3RRP43UHRx
1368634241 s4nEIaf07kYys0U6nplt
1368634242 zPuJ2f0mFEVhZYV0BEMD
1368634243 3tore0VSku2V8bE3Wgho
1368634244 9oJfje08GaZKhfw6sOBH
1368634245 FioUe0olHFm59Fi0fEej
1368634246 LdKIe1UGvcyomNZ3p7BA
1368634247 RXps6d1AmTb9mNp66vae
1368634248 MXSKvd1qHwHgbwt9oG46
1368634249 Mpjd1WmxDQVFwXc3XZim

Therefore the code works exactly like the one shown on the video, and here's where the interesting part comes:
When you initially load the RoboForm password generation window, the password field should be empty, or filled with some placeholder value.
The developers knew that, so the obvious solution was to run the password generator on dialog init, and populate the password field with its value.
On initial run, the seed modifier, I called seed_mod is initially set to 0, so the first initial run of RoboForm will use only the timestamp as seed.
After initializing the srand, that seed_mod will be modified.
The way Joe's solution works, implies the guy with the Bitcoin used the first password that popped in RoboForm after he ran the application, without pressing the button.
If he pressed the button, the seed_mod would be something else, and the generated password list from above wouldn't match, even if the timestamp did.
Each button press would add another layer of complication to the password generator, so if we let's say, generated 1 milion passwords for timestamps stamp+0 to stamp+1000000, we then have to also generate 1 milion more for each button press.
In short, the Bitcoin owner got saved by his own laziness, by picking the first auto-generated password he saw, right after running RoboForm.
© nullsecurity.org 2011-2024 | contacts