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.