Reversing Zyxel VMG8823-B50B WPA algorithm generation for fun
After some issues with my old ISP, few months ago, I decided to switch to ********** which gave me a Zyxel VMG8823-B50B router and (finally) something that can be so called internet connection. Once powered on I quickly noticed that the default WPA key was a weak one:
- 9 uppercase chars;
- 1 digit.
While I was changing it with a stronger one I had the feeling that it was possible to generate the default key using some device’s data. Actually it is!
I had some spare time in week-end so I set up the environment and downloaded the factory firmware. Everything starts with:
binwalk -e V513ABEJ2C1.bin
Once extracted I dug through folder looking for an interesting binary filename. After few minutes i came across /lib/private/libzcfg_be.so.
It contains some useful exported functions such as:
- zcfgBeWlanGenDefaultKey;
- zcfgBeCommonGenKeyBySerialNum;
- zcfgBeCommonGenKeyBySerialNumMethod2.
The first is the longest one so I decided to give a look at other two. Unfortunately both use standard MD5 hash algorithm and my default WPA key contains some non-hex chars. Let’s give a look into zcfgBeWlanGenDefaultKey disassembly:
Since the function is quite wide and contains WEP code generation too I’ll jump some parts as I know the right flow 😜.
The function accepts 4 params. As you probably noticed I’ve renamed some vars and added comments which will help to figure it out what they need for. We’re interested in:
CMP R5, 2
LDREQ R1, custom_key_length
BEQ loc_9DDCC
This means that the output password length is user-defined (in my case is a 10 chars password).
Get and store the device serial number. Everything following this snippet is useless since it will generate the default WEP key. I’m going to jump over and reach the interesting code.
This part just cleans out arrays.
What I noticed so far is that the code is not meant to be optimized. The serial, for example, is copied into a second var but the original value is never used anymore and strlen function is called multiple times.
while (R7++ < strlen(serial)) {
*(serial_copy + R7) = *(serial + R7);
}
However, since we’re not here to discuss about optimization, I’ll not open this topic.
The serial_copy var is treated using the following algorithm:
for (int i = 0; i < strlen(serial_copy); i++} {
if (serial_copy[i] - 0x61 <= 0x19 && serial_copy[i] - 0x61 >= 0) {
serial_copy[i] -= 0x20;
}
}
I’d like to make a punctuation. From this point you’ll find vars with _md5_digest and _md5_string suffix:
- _md5_digest means that the var is pointing to a 16 bytes array;
- _md5_string means that the var is pointing to a 32 chars array (the md5 digest hex string representation).
Don’t be scared. Here you’re what happens here:
- A first MD5 hash is obtained from serial_copy string;
- The digest is formatted as hex string;
- The string PSK_ra0 is concatenated to the hex string;
- Another MD5 hash is computed for the new string.
When I saw this part I felt a bit confused. Then I said myself: “That’s useless!”. Another array (the one named sprint_ed_f_string) is cleaned out and two inverse functions are applied:
/* To make things easier... Assuming we've a random int value 7911 (0x1EE7), sprintf function will convert it into its decimal string representation which is "7911" and atoi will reconvert it into 7911 */sprintf(&buffer, "%d", serial_PSK_ra0_md5_digest[0] << 8 | serial_PSK_ra0_md5_digest[1]);
base_index = atoi(&buffer);
base_index contains an int value used in the following snippet:
I’ll keep register names to show what the code does
int R9[65] // zero_one_array;
int R1, R5, R6, R7;R1 = custom_key_length; // From above
R7 = base_index;for (R5 = 0, R6 = 1; R5 < R1; R5++, R7 *= 2) {
R9[R5] = sub_9FE48(sub_9FF1C(base_index, R7 * 2), R7)
}
I’ll not going deeper since sub_9FE48 and sub_9FF1C functions perform math operations that you’ll find in sources (bibbidi and bobbidi functions in magic.go file). At the end, zero_one_array (R9) will contain custom_key_length number of integer values (1 or 0). Our key length is 10 so it could be:
zero_one_array = {0, 1, 1, 0, 0, 0, 0, 1, 0, 0}
While writing I noticed I made a mistake. Probably, since zero_one_array and a var in which a pointer to a charset is stored were close and have the same type, IDA thought this var as part of zero_one_array. Don’t blame me, it was late 😜.
Anyway, the code saves a reference to an haystack charset
haystack = “WXY125690IOSVWZ3478ABCDEFGHJKLMNPQRTUXY”;
It’s time to use the zero_one_array:
char c;for (int i = 0; i < custom_key_length; i++) {
if (zero_one_array[i] == 1) {
c = sub_9FE20(serial_PSKra0_md5_digest[i], 26) + 65;
} else {
c = sub_9FE20(serial_PSKra0_md5_digest[i], 10) + 48;
} // Continue after next snippet
}
Again I’ll not describe sub_9FE20 (which contains the same code of sub_9FE48 function — probably it’s been defined as inline function or macro and that’s why there’s code redundancy).
We’re at arrival. The code will treat c var depending on 4th argument passed to the initial function (the one I called cocktail). There’re three different method. Since it was Saturday I gave them cocktails name:
- Mojito;
- Negroni;
- Cosmopolitan.
I had to reverse every method (you’ll find them into cocktail.go file) but I’ll show you the one used by my ISP (Cosmopolitan one).
If c is found in haystack charset its index is added to base_index (see above) and is passed as first argument to sub_9FF1C. The related char in charset array is appended to output buffer.
for (int j = 0; j < 12; j++) {
if (haystack[j + 3] == c) {
c = charset[sub_9FF1C(base_index + j, 0x18) + 0x36f];
} // Otherwise c is not changed
}
key[i] = c;
As always, few lines of code worth more than words. You’ll find the code at https://github.com/luc10/zykgen. In my case:
zykgen -c S***Y********
Outputs exactly my default WPA key.