Welcome back to part 2.2 of this series! If you have not yet checked out part 1 or part 2.1, please do so first as they highlight important reconnaissance steps as well as the first half of the disassembly analysis! Let's recall the current functionality we've encountered based on the developed source code:
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <openssl/aes.h>
#include <openssl/pem.h>
#include <openssl/rsa.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
static RSA *grsa_struct = NULL;
static unsigned char iv[] = {0x98, 0xC9, 0xD8, 0xF0, 0x13, 0x3D, 0x06, 0x95,
0xE2, 0xA7, 0x09, 0xC8, 0xB6, 0x96, 0x82, 0xD4};
static unsigned char aes_in[] = {0xC8, 0xD3, 0x2F, 0x40, 0x9C, 0xAC,
0xB3, 0x47, 0xC8, 0xD2, 0x6F, 0xDC,
0xB9, 0x09, 0x0B, 0x3C};
static unsigned char aes_key[] = {0x35, 0x87, 0x90, 0x03, 0x45, 0x19,
0xF8, 0xC8, 0x23, 0x5D, 0xB6, 0x49,
0x28, 0x39, 0xA7, 0x3F};
unsigned char out[] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46};
int check_cert(char *pem, void *n) {
OPENSSL_add_all_algorithms_noconf();
FILE *pem_fd = fopen(pem, "r");
if (pem_fd != NULL) {
RSA *lrsa_struct[2];
*lrsa_struct = RSA_new();
if (!PEM_read_RSAPublicKey(pem_fd, lrsa_struct, NULL, n)) {
RSA_free(*lrsa_struct);
puts("Read RSA private key failed, maybe the password is incorrect.");
} else {
grsa_struct = *lrsa_struct;
}
fclose(pem_fd);
}
if (grsa_struct != NULL) {
return 0;
} else {
return -1;
}
}
int aes_cbc_encrypt(size_t length, unsigned char *key) {
AES_KEY dec_key;
AES_set_decrypt_key(aes_key, sizeof(aes_key) * 8, &dec_key);
AES_cbc_encrypt(aes_in, key, length, &dec_key, iv, AES_DECRYPT);
return 0;
}
int call_aes_cbc_encrypt(unsigned char *key) {
aes_cbc_encrypt(0x10, key);
return 0;
}
int actual_decryption(char *sourceFile, char *tmpDecPath, unsigned char *key) {
int ret_val = -1;
size_t st_blocks = -1;
struct stat statStruct;
int fd = -1;
int fd2 = -1;
void *ROM = 0;
int *RWMEM;
off_t seek_off;
unsigned char buf_68[68];
int st;
memset(&buf_68, 0, 0x40);
memset(&statStruct, 0, 0x90);
st = stat(sourceFile, &statStruct);
if (st == 0) {
fd = open(sourceFile, O_RDONLY);
st_blocks = statStruct.st_blocks;
if (((-1 < fd) &&
(ROM = mmap(0, statStruct.st_blocks, 1, MAP_SHARED, fd, 0),
ROM != 0)) &&
(fd2 = open(tmpDecPath, O_RDWR | O_NOCTTY, 0x180), -1 < fd2)) {
seek_off = lseek(fd2, statStruct.st_blocks - 1, 0);
if (seek_off == statStruct.st_blocks - 1) {
write(fd2, 0, 1);
close(fd2);
fd2 = open(tmpDecPath, O_RDWR | O_NOCTTY, 0x180);
RWMEM = mmap(0, statStruct.st_blocks, PROT_EXEC | PROT_WRITE,
MAP_SHARED, fd2, 0);
if (RWMEM != NULL) {
ret_val = 0;
}
}
}
}
puts("EOF part 2.1!\n");
return ret_val;
}
int decrypt_firmware(int argc, char **argv) {
int ret;
unsigned char key[] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46};
char *ppem = "/tmp/public.pem";
int loopCtr = 0;
if (argc < 2) {
printf("%s <sourceFile>\r\n", argv[0]);
ret = -1;
} else {
if (2 < argc) {
ppem = (char *)argv[2];
}
int cc = check_cert(ppem, (void *)0);
if (cc == 0) {
call_aes_cbc_encrypt((unsigned char *)&key);
printf("key: ");
while (loopCtr < 0x10) {
printf("%02X", *(key + loopCtr) & 0xff);
loopCtr += 1;
}
puts("\r");
ret = actual_decryption((char *)argv[1], "/tmp/.firmware.orig",
(unsigned char *)&key);
if (ret == 0) {
unlink(argv[1]);
rename("/tmp/.firmware.orig", argv[1]);
}
RSA_free(grsa_struct);
} else {
ret = -1;
}
}
return ret;
}
int encrypt_firmware(int argc, char **argv) { return 0; }
int main(int argc, char **argv) {
int ret;
char *str_f = strstr(*argv, "decrypt");
if (str_f != NULL) {
ret = decrypt_firmware(argc, argv);
} else {
ret = encrypt_firmware(argc, argv);
}
return ret;
}
We left of right after the setup stuff:
The last thing we analyzed was re-mapping the file located at /tmp/.firmware.orig
back into memory with some minor adjustments to file permissions. Again if the mapping fails we'll take a non-wanted path, so we will ignore that in the analysis again. So in case of success the still mapped sourceFile
is being prepared as a function argument to a function called check_magic
:
check_magic
check_magic
is pretty straightforward. It takes a mapped memory (at least read-only permissions) segment as an argument and calls int memcmp(mapped_sourceFile, "SHRS", 4)
to check whether the first 4 bytes in the binary correspond to "SHRS" (0x53485253).
According to the return value of memcmp
, which is stored in $v0
, sltiu $v0, 1
sets the return value of this function as follows:
- If
$v0
== 0 (== full match) →$v0 = 1
- Else
$v0 = 0
The disassembly graph earlier shows that if the return value of check_magic
is != 0 the branch to magic_succ
is taken (bnez $v0, magic_succ
). This little function serves as a sanity check and first confirmation that an encrypted image starts with a valid header. We already saw this string earlier in the hex dump in part 1. So, this part at least clears up the confusion about this seemingly non-random string. It was indeed non-random after all! The snippet below again confirms the presence of "SHRS" in an encrypted image file.
So, once we pass this check and are back in the actual_decryption
function, control flow continues here:
The first two chunks that lead up two a call to uint32_t htonl(uint32_t hostlong)
are best explained with an example. Once again, we're using the header of an encrypted firmware sample:
$v0:
↓
00000000: 5348 5253 00d1 d9a6 00d1 d9b0 67c6 6973 SHRS........g.is
00000010: 51ff 4aec 29cd baab f2fb e346 fda7 4d06 Q.J.)......F..M.
[...]
lwl $v1 11($v0):
↓
00000000: 5348 5253 00d1 d9a6 00d1 d9b0 67c6 6973 SHRS........g.is
00000010: 51ff 4aec 29cd baab f2fb e346 fda7 4d06 Q.J.)......F..M.
[...]
--> $v1 = b0d9
move $a0, $v1
--> $a0 = b0d9 XXXX
lwr $a0, 8($v0):
↓
00000000: 5348 5253 00d1 d9a6 00d1 d9b0 67c6 6973 SHRS........g.is
00000010: 51ff 4aec 29cd baab f2fb e346 fda7 4d06 Q.J.)......F..M.
[...]
--> $a0 = b0d9 d100
nop
move $v0, $a0
--> $v0 = b0d9 d100
move $a0, $v0
--> $a0 = b0d9 d100
htonl(b0d9 d100)
In MIPS, we need this pair of lwl, lwr
instructions for unaligned memory access. Basically, you're providing the lwl
instruction with the address of the most significant byte of an unaligned memory location. It will automatically put the corresponding bytes into the upper bytes of the destination register ($v1
/$a0
respectively). lwr
works analogously, with the only difference being that the picked bytes are put in the lower bytes of the destination register. The results are combined by merging both byte values (so these instructions can be used in either order!).
# datalen_2
int.from_bytes(b'\xb0\xd9\xd1\x00', byteorder='little', signed=False)
13752752
# Alternatively, we can use the socket package
from socket import htonl
htonl(0xb0d9d100)
13752752
# datalen_1
int.from_bytes(b'\xa6\xd9\xd1\x00', byteorder='little', signed=False)
13752742
Both return values are saved on the stack (0x128+datalen_2($sp)
/ 0x128+datalen_1($sp)
). The question remains what are they used for and why are there two almost identical values (\xb0\xd9\xd1\x00
vs. \xa6\xd9\xd1\x00
). Neither of those two seem to be the full length of the encrypted firmware binary:
> wc -c DIR_882_FW120B06.BIN
13759047 DIR_882_FW120B06.BIN
> wc -c DIR_882_FW120B06.BIN | cut -d' ' -f1 | xargs printf '0x%x\n'
0xd1f247
Their purpose will become clear soon, as one of these values is used right away! After this fun memory access magic, a function call to calcSha512Digest
is being prepared with 3 arguments: calcSha512Digest(mapped_sourceFile + 0x6dc, datalen_2, buf)
.
calcSha512Digest
As the name already gives away, this function calculates a SHA512 message digest. Based on the function arguments, this invocation will use the mapped encrypted firmware at offset 0x6dc
, with length datalen_2
, which we just calculated earlier in the htonl
part! When taking this offset into account could datalen_2 = total size - offset
?
> # total size - offset - datalen_2
> pcalc 0xd1f247 - 0x6dc - 0xd1d9b0
4532 0x11b4 0y1000110110100'
# Clearly not..
The provided buffer variable is used to store the hash result memory. The function itself just uses the SHA512 library functions. If you look a bit more closely you'll notice that this holy trinity of SHA512_Init
, SHA512_Update
, and SHA512_Final
all belong to OpenSSL and are taken from libcrypto
which we found in the strings output in part 1!
Right after exiting calcSha512Digest
the basic block continues with setting up another memcmp
to compare the just calculated hash digest to an expected value:
This value is hard-coded in the encrypted binary (which is still mapped as RO into memory at this point) at offset 0x9C
. The third supplied argument to the int memcmp(const void *s1, const void *s2, size_t n)
call is again the size and as expected, has to be 0x40 bytes as SHA512 returns a 512 bit digest (== 64 bytes == 0x40 bytes). We can easily extract said value from our encrypted firmware:
> pcalc 0x40
64 0x40 0y1000000
> pcalc 0x9c
156 0x9c 0y10011100
> hd DIR_882_FW120B06.BIN -s 0x9c -n 64
0000009c 68 bf e5 30 a0 49 b9 e8 5d a0 bb 81 71 87 05 cd |h..0.I..]...q...|
000000ac 70 25 18 f2 8f af d6 21 35 05 31 7e fd af 60 56 |p%.....!5.1~..`V|
000000bc d8 ed e7 71 6c 39 d1 68 0d a7 13 f4 04 41 87 58 |...ql9.h.....A.X|
000000cc e9 97 36 73 99 78 8b 01 10 ee 12 d6 b6 3b 69 ec |..6s.x.......;i.|
000000dc
> hd DIR_882_FW120B06.BIN -s 0x9c -n 64 -e '156/1 "%x" "\n"' | cut -d$'\n' -f2
68bfe530a049b9e85da0bb8171875cd702518f28fafd621355317efdaf6056d8ede7716c39d168da713f44418758e997367399788b110ee12d6b63b69ec
If there is a mismatch, control flow is, as usual, redirected to an early exit. However, in case of success control is redirected to the part I renamed as hash1_succ
(memcmp returns (in $v0
) 0 on an exact match, which is followed by a branching beqz $v0, hash1_succ
instruction).
The first part in hash1_succ
sets up another call to aes_cbc_encrypt
with five arguments:
- arg1 (
$a0
): mapped source File +0x6dc
- arg2 (
$a1
): datalen_2 - arg3 (
$a2
): decryption key (still the one from the debugprintf
to stdout earlier) - arg4 (
$a3
): IVEC (same as before) - arg5 (on stack): mapped file at /tmp/ location
We already covered earlier what this function does. However, instead of calculating the decryption key with size 0x40 a large chunk of memory from the mapped and encrypted firmware is being used with the decryption key. This can only mean one thing: This code block is responsible for decrypting the whole thing!
So, after aes_cbc_encrypt
returns we basically have access to the fully decrypted firmware already. Directly afterwards, there is another call to calcSha512Digest
:
This time with our suspected freshly decrypted firmware, datalen_1
, and a buffer as the arguments. The result of the SHA512 operation is compared against an expected value at encrypted_firmware + 0x5c
. Once again, we're talking about a 64 byte value here:
Analogous to other memcmp operations earlier, another beqz $v0, hash2_succ
redirects control flow based on the result. We've seen this multiple times by now, so we're directly taking a look at the wanted branching result.
I'll be pretty quick about this one. We have yet another function call where a SHA512 message digest is calculated. This time, the only difference being that the digest is calculated over the entire decrypted firmware image concatenated with the decryption key. And as always, the result is compared against a hard-coded value in the encrypted firmware at offset 0x1c
:
> hd DIR_882_FW120B06.BIN -s 0x1c -n 64 -e '92/1 "%x" "\n"' | cut -d$'\n' -f2
fda74d6a466e6adbfc49d13f3f7d112986b2a351de9085b783f74d3a2a255ab813cfb2a177ab29946066ebc2589882748e3541ee2514442e8d68e466e2c
Before quickly moving onto the next part denoted as hash3_succ
let's recap what datalen_1
and datalen_2
are used for!
Analysis of the datalen variables:
Just earlier, we saw the two variables used almost interchangeably. The invested reader might have already figured it out. But the one call to aes_cbc_encrypt
earlier gave away that datalen_2
at offset 0x8 - 0x12 corresponds op the length of the decrypted firmware binary. At the end of this series we will be able to decrypt these firmware samples just fine so here is a little foreshadowing with a decrypted sample:
# datalen_2
> hd DIR_882_FW120B06.BIN -n 4 -s 0x8
00000008 00 d1 d9 b0 |....|
0000000c
> wc -c decrypted_DIR_882_FW120B06.BIN | cut -d' ' -f1 | xargs printf '%x\n'
d1d9b0
But what about datalen_1
at offset 0x4 - 0x8?
# datalen_1
> hd DIR_882_FW120B06.BIN -s 0x4 -n 4
00000004 00 d1 d9 a6 |....|
00000008
# datalen_1
int.from_bytes(b'\xb0\xd9\xd1\x00', byteorder='little')
13752742
It's almost identical in size compared to datalen_2
with only a difference of 10 bytes. However, as it is smaller, it cannot be the size of the decrypted payload. We also already ruled out that it is the size of the encrypted firmware image before. So, what is it?
Let's backtrack! Right after, a call to aes_cbc_encrypt
the datalen_1
is used to calculate two SHA512 message digests over the decrypted firmware image, once without the decryption key put in the cipher and once with it in there. That does not seem to be any helpful... So, what about the missing 10 bytes (difference between these two length variables) that are disregarded in the hash calculation?
When seeking to datalen_1
and getting the hex dump until EOF, we can see that it is just NULL bytes! So, these 10 bytes cannot be some kind of check sum or any other relevant metadata. If you look very close at the addresses, you can see that the decrypted firmware ends at 0x1d9b0. What's so special about this address? Turns out it's not the address that is special but the properties of it:
Now, why is a 16 byte alignment needed in the first place? The answer is in the disassembly, where datalen_2
is used with the call to aes_cbc_encrypt
! As the RFC for AES_CBC_ENCRYPT for IPSec nicely states:
2.4. Block Size and Padding
The AES uses a block size of sixteen octets (128 bits).
Padding is required by the AES to maintain a 16-octet (128-bit)
blocksize. Padding MUST be added, [...], such that
the data to be encrypted ([...]) has a length that is a multiple of 16 octets.
Because of the algorithm specific padding requirement, no additional
padding is required to ensure that the ciphertext terminates on a 4-
octet boundary [...]. Additional padding MAY be included, as
specified in [ESP], as long as the 16-octet blocksize is maintained.
We can conclude that datalen_2
is based on datalen_1
and the difference between them will always be a dynamically calculated value between 1 and 15 to keep the firmware 16-byte aligned. So summarize this further:
- datalen_1 → size of decrypted payload
- datalen_2 → size of decrypted payload with 16 byte alignment
Offset analysis: The curious case of 0x6dc
Another thing that may have caused confusion is why the first SHA512 digest calculation as well as the decryption started at this particular offset of 0x6dc? When you look at the first 2k bytes of an encrypted firmware image, they look rather arbitrary:
However, by now we already figured out most of what the first 0xdb
bytes hold. Nevertheless, there is a clear-cut at offset 0xdb
to 0x2db
which is exactly 512 NULL bytes. This looks like the security header may end at 0xdb
. With the assumption based on one of the calls to aes_cbc_encrypt
that our data payload only starts at 0x6dc
what are these 1024 seemingly random bytes that as of right now hold neither relevant metadata nor parts of the encrypted payload?
> pcalc 0x6db - 0x2db
1024 0x400 0y10000000000
To figure that out we need to dive back right into the control flow over at hash3_succ
!
Here a new function call to sha512_checker
is being prepared which is invoked a second time (with different arguments) once the first call succeeds and with that returns 1. You can see that both function calls basically have the following signature func(mapped_enc_fw_base_address + security_header_offset, 64, mapped_enc_fw_base_address + yet_unidentified_offset, 512)
as their arguments. This function will answer all the remaining questions regarding the first yet unknown bytes. So, what does it do?
sha512_checker
This function is used for two additional integrity checks with calls to int RSA_verify(int type, const unsigned char *m, unsigned int m_len, unsigned char *sigbuf, unsigned int siglen, RSA *rsa)
.
This library call verifies that the signature sigbuf
of size siglen
matches a given message digest m
of size m_len
. type
denotes the message digest algorithm that was used to generate the signature. Lastly, as expected, RSA_verify
returns 1
on success. rsa
is the signer's public key, which in this case defaults to the /etc_ro/public.pem
(if not overwritten by argv[2]
). When following along until here the function parameters should all make sense by now.
type
- As seen in the disassembly graph, the type is hard-coded to0x2a2
, which corresponds to SHA512.m
- SHA512 hash digest that are 64 bytes in size. In our case, these correspond to the digest of the size of the encrypted and decrypted firmware.m_len
- size ofm
, which is hard-coded to 64 bytes (which only makes sense).sigbuf
- hard-coded 512 byte signatures at offsets0x2dc
and0x4dc
in the encrypted firmware.siglen
- fixed size of 512 bytes because we're dealing with signatures of these sizes (as the distance between the two offsets from sigbuf show as well).RSA_cert
- Is the struct in memory loaded with the values from reading the public key that is by default located in/etc_ro/public.pem
.
> pcalc 0x2a2
674 0x2a2 0y1010100010
> echo '#include <openssl/obj_mac.h>' | gcc -E - -dM | grep 674
#define NID_SHA512 674
Ultimately, we do not have to include these two checks in a custom decryption PoC, since we're already past the point of the actual decryption. Furthermore, we made sure the SHA512 digests match the expected values. These additional checks are most likely put there to verify the origin of the encrypted firmware image.
This basically concludes the complete decryption routine. The only thing I left out are some minor bad paths we are not interested in any way. Following this is only code that sets the return value of this function (0 on success, -1 otherwise) and the function tear down, which takes care of all the un-mapping/closing from all the IO related operations:
decrypt_firmware tear down
So, successfully completing the actual_decryption
routine returns 0 to the decrypt_firmware
function. At this point, this function only cleans up as well by unlinking (removing) the original sourceFile (encrypted firmware image) and renaming the decrypted one from /tmp/.firmware.orig → sourceFile (with sourceFile obviously being the name of the supplied encrypted firmware sample).
The last step is returning to main
and with that preparing, the process tear down. So, that's it! That was the complete firmware decryption scheme D-Link is using for their recent router firmware. Re-implementing everything we've seen so far results in the following C-code:
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <openssl/aes.h>
#include <openssl/ossl_typ.h>
#include <openssl/pem.h>
#include <openssl/rsa.h>
#include <openssl/sha.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
RSA *grsa_struct;
static unsigned char iv[] = {0x98, 0xC9, 0xD8, 0xF0, 0x13, 0x3D, 0x06, 0x95,
0xE2, 0xA7, 0x09, 0xC8, 0xB6, 0x96, 0x82, 0xD4};
static unsigned char aes_in[] = {0xC8, 0xD3, 0x2F, 0x40, 0x9C, 0xAC,
0xB3, 0x47, 0xC8, 0xD2, 0x6F, 0xDC,
0xB9, 0x09, 0x0B, 0x3C};
static unsigned char aes_key[] = {0x35, 0x87, 0x90, 0x03, 0x45, 0x19,
0xF8, 0xC8, 0x23, 0x5D, 0xB6, 0x49,
0x28, 0x39, 0xA7, 0x3F};
unsigned char out[] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46};
const static char *null = "0";
int check_cert(char *pem, void *n) {
RSA *lrsa_struct[2];
OPENSSL_add_all_algorithms_noconf();
FILE *pem_fd = fopen(pem, "r");
if (pem_fd != NULL) {
lrsa_struct[0] = RSA_new();
if (PEM_read_RSAPublicKey(pem_fd, lrsa_struct, NULL, n) == NULL) {
RSA_free(lrsa_struct[0]);
puts("Read RSA private key failed, maybe the password is incorrect.");
} else {
grsa_struct = lrsa_struct[0];
}
fclose(pem_fd);
}
if (grsa_struct == NULL) {
return -1;
} else {
return 0;
}
}
int aes_cbc_encrypt(unsigned char *in, unsigned int length,
unsigned char *user_key, unsigned char *ivec,
unsigned char *out) {
AES_KEY dec_key;
unsigned char iv[16];
memcpy(iv, ivec, 16);
AES_set_decrypt_key(user_key, 0x80, &dec_key);
AES_cbc_encrypt(in, out, (unsigned int)length, &dec_key, iv, AES_DECRYPT);
return 0;
}
int call_aes_cbc_encrypt(unsigned char *key) {
aes_cbc_encrypt(aes_in, 0x10, aes_key, iv, key);
return 0;
}
int check_magic(void *mapped_mem) {
return (unsigned int)(memcmp(mapped_mem, "SHRS", 4) == 0);
}
int calc_sha512_digest(void *mapped_mem, u_int32_t len, unsigned char *buf) {
SHA512_CTX sctx;
SHA512_Init(&sctx);
SHA512_Update(&sctx, mapped_mem, len);
SHA512_Final(buf, &sctx);
return 0;
}
int calc_sha512_digest_key(void *mapped_mem, u_int32_t len, void *key,
unsigned char *buf) {
SHA512_CTX sctx;
SHA512_Init(&sctx);
SHA512_Update(&sctx, mapped_mem, len);
SHA512_Update(&sctx, key, 0x10);
SHA512_Final(buf, &sctx);
return 0;
}
int check_sha512_digest(const unsigned char *m, unsigned int m_length,
unsigned char *sigbuf, unsigned int siglen) {
return RSA_verify((int)0x2a2, m, m_length, sigbuf, siglen, grsa_struct);
}
int actual_decryption(char *sourceFile, char *tmpDecPath, unsigned char *key) {
int ret_val = -1;
size_t st_blocks = -1;
struct stat stat_struct;
int _fd;
int fd = -1;
void *ROM = (void *)0x0;
unsigned char *RWMEM = (unsigned char *)0x0;
off_t seek_off;
unsigned char hash_md_buf[68] = {0};
unsigned int mcb;
uint32_t datalen_1;
uint32_t datalen_2;
memset(&hash_md_buf, 0, 0x40);
memset(&stat_struct, 0, 0x90);
_fd = stat(sourceFile, &stat_struct);
if (_fd == 0) {
fd = open(sourceFile, O_RDONLY);
st_blocks = stat_struct.st_size;
if (((-1 < fd) &&
(ROM = mmap(NULL, st_blocks, PROT_READ, MAP_SHARED, fd, 0),
ROM != 0)) &&
(_fd = open(tmpDecPath, O_RDWR | O_NOCTTY | O_CREAT, S_IRUSR | S_IWUSR),
-1 < _fd)) {
seek_off = lseek(_fd, st_blocks - 1, 0);
if (seek_off == st_blocks - 1) {
write(_fd, null, 1);
close(_fd);
_fd = open(tmpDecPath, O_RDWR | O_NOCTTY, S_IRUSR | S_IWUSR);
RWMEM =
mmap(NULL, st_blocks, PROT_READ | PROT_WRITE, MAP_SHARED, _fd, 0);
if (RWMEM != 0) {
mcb = check_magic(ROM);
if (mcb == 0) {
puts("No image matic found\r");
} else {
datalen_1 = htonl(*(uint32_t *)(ROM + 8));
datalen_2 = htonl(*(uint32_t *)(ROM + 4));
calc_sha512_digest((ROM + 0x6dc), datalen_1, hash_md_buf);
_fd = memcmp(hash_md_buf, (ROM + 0x9c), 0x40);
if (_fd == 0) {
aes_cbc_encrypt((ROM + 0x6dc), datalen_1, key,
(unsigned char *)(ROM + 0xc), RWMEM);
calc_sha512_digest(RWMEM, datalen_2, hash_md_buf);
_fd = memcmp(hash_md_buf, (ROM + 0x5c), 0x40);
if (_fd == 0) {
calc_sha512_digest_key(RWMEM, datalen_2, (void *)key,
hash_md_buf);
_fd = memcmp(hash_md_buf, (ROM + 0x1c), 0x40);
if (_fd == 0) {
_fd = check_sha512_digest((ROM + 0x5c), 0x40, (ROM + 0x2dc),
0x200);
if (_fd == 1) {
_fd = check_sha512_digest((ROM + 0x9c), 0x40, (ROM + 0x4dc),
0x200);
if (_fd == 1) {
ret_val = 0;
puts("We in!");
} else {
ret_val = -1;
}
} else {
ret_val = -1;
}
} else {
puts("check sha512 vendor failed\r");
}
} else {
printf("check sha512 before failed %d %d\r\n", datalen_2,
datalen_1);
int ctr = 0;
while (ctr < 0x40) {
printf("%02X", *(hash_md_buf + ctr));
ctr += 1;
}
puts("\r");
ctr = 0;
while (ctr < 0x40) {
printf("%02X",
*(unsigned int *)(unsigned char *)(ROM + ctr + 0x5c));
ctr += 1;
}
puts("\r");
}
} else {
puts("check sha512 post failed\r");
}
}
}
}
}
}
if (ROM != (void *)0x0) {
munmap(ROM, stat_struct.st_blocks);
}
if (RWMEM != (unsigned char *)0x0) {
munmap(RWMEM, st_blocks);
}
if (-1 < (int)fd) {
close(fd);
}
return ret_val;
}
int decrypt_firmware(int argc, char **argv) {
int ret;
unsigned char key[] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46};
char *ppem = "/tmp/public.pem";
int loopCtr = 0;
if (argc < 2) {
printf("%s <sourceFile>\r\n", argv[0]);
ret = -1;
} else {
if (2 < argc) {
ppem = (char *)argv[2];
}
int cc = check_cert(ppem, (void *)0);
if (cc == 0) {
call_aes_cbc_encrypt((unsigned char *)&key);
printf("key: ");
while (loopCtr < 0x10) {
printf("%02X", *(key + loopCtr) & 0xff);
loopCtr += 1;
}
puts("\r");
ret = actual_decryption((char *)argv[1], "/tmp/.firmware.orig",
(unsigned char *)&key);
if (ret == 0) {
unlink(argv[1]);
rename("/tmp/.firmware.orig", argv[1]);
}
RSA_free(grsa_struct);
} else {
ret = -1;
}
}
return ret;
}
int encrypt_firmware(int argc, char **argv) {
puts("TODO\n");
return -1;
}
int main(int argc, char **argv) {
int ret;
char *str_f = strstr(*argv, "decrypt");
if (str_f != NULL) {
ret = decrypt_firmware(argc, argv);
} else {
ret = encrypt_firmware(argc, argv);
}
return ret;
}
Summary
When combining what we found out during analysis, we can conclude that the encrypted firmware has a 1756 byte security header as shown below:
It contains the following fields in this exact order:
- Magic bytes
- Length of decrypted firmware - padding
- Length of decrypted firmware in bytes
- Initialization vector value for AES_128_CBC to decrypt data
- SHA512 64 byte message digest of decrypted firmware + key
- SHA512 64 byte message digest of decrypted firmware
- SHA512 64 byte message digest of encrypted firmware
- 512 unused NULL bytes (0xdc to 0x2dc) # no colored box for this one
- 512 byte Signature 1
- 512 byte Signature 2
With this in mind, we can clearly see that the header continues until offset 0x6dc
before the actual encrypted data payload starts. This seems consistent across all images I looked at. Hence, the total size of the security header is 1756 bytes, with 220 bytes for the initial 7 bullet points containing various meta checks, the unused 512 NULL byte area and the two 512 byte verification signatures at the end. The NULL byte area could potentially be reserved space for a future verification check or an additional signature.
Finally, I noticed that the /etc_ro/public.pem
differ across devices:
Testing against other encrypted D-Link images
My custom script (linked at the bottom) was evaluated against additional newer firmware samples of different routers, and the decryption always worked flawlessly. This shows that the security header information as well as the imgdecrypt binary itself did not change. The following firmware samples were tested for validation:
Device | Router release | Sample name | Md5sum encrypted | FW release |
---|---|---|---|---|
DIR-882 | Q2 2017 | 2_DIR-882_RevA_Firmware122b04.bin | a59e7104916dc1770ad987a13c757075 | 07/10/19 |
DIR-882 | Q2 2017 | DIR882A1_FW130B10_Beta_for_security_issues_Stackoverflow_20191219.bin | 339f98563ea9c0b2829a3b40887dabbd | 02/20/20 |
DIR-1960 | Q2 2019 | DIR-1960_RevA_Firmware103B03.bin | f2aff7a08e44d77787c7243f60c1334c | 10/30/19 |
DIR-2660 | Q4 2019 | DIR-2660_RevA_Firmware110B01.bin | ba72e99a3cea77482bab9ea757d33dfc | 11/25/19 |
DIR-3060 | Q4 2019 | DIR-3060_RevA_Firmware111B01.bin | 86e3f7baebf4178920c767611ec2ba50 | 10/22/19 |
Unpacking D-Link DIR3060 - A quick analysis
So let's test our final script against a random DIR3060 firmware that I downloaded:
> ./dlink-dec.py DIR_3060/DIR-3060_RevA_Firmware111B01.bin DIR_3060/decrypted_DIR-3060_RevA_Firmware111B01 dec
[*] Calculating decryption key...
[+] OK!
[*] Checking magic bytes...
[+] OK!
[*] Verifying SHA512 message digest of encrypted payload...
[+] OK!
[*] Verifying SHA512 message digests of decrypted payload...
[+] OK!
[+] OK!
[+] Successfully decrypted DIR-3060_RevA_Firmware111B01.bin!
> file DIR_3060/decrypted_DIR-3060_RevA_Firmware111B01.bin
decrypted_DIR-3060_RevA_Firmware111B01.bin: u-boot legacy uImage, Linux Kernel Image, Linux/MIPS, OS Kernel Image (lzma), 18080478 bytes, Mon Jan 6 08:45:07 2020, Load Address: 0x81001000, Entry Point: 0x81643D00, Header CRC: 0xCF5AE78D, Data CRC: 0x70C7567F
> binwalk DIR_3060/decrypted_DIR-3060_RevA_Firmware111B01.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 uImage header, header size: 64 bytes, header CRC: 0x342C3662, created: 2019-09-16 06:46:37, image size: 18030334 bytes, Data Address: 0x81001000, Entry Point: 0x816357A0, data CRC: 0x35F99EE4, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux Kernel Image"
160 0xA0 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: 23452096 bytes
Looks promising! The script finished without any errors, and file as well as binwalk report proper file sections that are typical for a firmware image. How about unpacking?
We can just use binwalk or whip out FACT the Firmware Analysis and Comparison Tool that ships with the FACT extractor, which includes additional file signatures and carvers for extraction!
> ./extract.py decrypted_DIR-3060_RevA_Firmware111B01.bin -o tmp
> ./extract.py extraced_ubootLZMA4/8834EC -o dir3060_file_system
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
[2020-03-20 11:00:20][unpackBase][INFO]: Plug-ins available:
['TPWRN702N', 'adobe', 'ambarella', 'ambarella_romfs', 'arj',
'avm_sqfs_fake', 'dahua', 'deb', 'dji_drones', 'dlm', 'dsk',
'dsk_extended', 'generic_carver', 'generic_fs','intel_hex', 'jffs2',
'nop', 'patool', 'pjl', 'postscript', 'raw', 'ros', 'sevenz', 'sfx',
'sit', 'squash_fs', 'srec', 'tek', 'tpltool', 'ubi_fs', 'ubi_image',
'uboot', 'uefi', 'untrx', 'update_stream', 'xtek', 'yaffs', 'zlib']
[2020-03-20 11:00:29][unpack][INFO]: 1903 files extracted
[2020-03-20 12:00:30][extract][WARNING]: Now taking ownership of the files. You may need to enter your password.
{
"plugin_used": "PaTool",
"plugin_version": "0.5.2",
"output": "137449 blocks\npatool: Extracting /tmp/extractor/input
/8834EC ...\npatool: running '/bin/cpio' --extract --make-directories
--preserve-modification-time --no-absolute-filenames --force-local
--nonmatching \"*\\.\\.*\"
< '/tmp/extractor/input/8834EC'\npatool: with shell='True', cwd='/tmp/fact_unpack_o4kgxvd5'\npatool:
... /tmp/extractor/input/8834EC extracted to `/tmp/fact_unpack_o4kgxvd5'.\n",
"analysis_date": 1584702021.1047733,
"number_of_unpacked_files": 1887,
"number_of_unpacked_directories": 97,
"summary": [
"no data lost"
],
"entropy": 0.5405103347033559,
"size_packed": 61216856,
"size_unpacked": 225761852
}
> cd dir3060_file_system && ls
bin etc etc_ro init lib rom sbin share usr
> cp $(which qemu-mipsel-static) .
> sudo chroot . ./qemu-mipsel-static bin/imgdecrypt Hello_0x00sec
key:C05FBF1936C99429CE2A0781F08D6AD8
We've done it! Not only that, but we successfully unpacked the encrypted firmware image and can directly see a known constant in the form of the etc_ro folder, which contains the certificate we saw right at the beginning!
Just for fun we can snoop around the file system locally now without having to have a device at hand which is handy for static analysis:
> cat /etc/shadow
root:$1$ZVpxbK71$2Fgpdj.x9SBOCz5oyULHd/:17349:0:99999:7:::
daemon:*:0:0:99999:7:::
ftp:*:0:0:99999:7:::
network:*:0:0:99999:7:::
nobody:*:0:0:99999:7:::
> cat etc/shadow | cut -d$'\n' -f1 | cut -d":" -f2 > hash.txt
> cat hash.txt
$1$ZVpxbK71$2Fgpdj.x9SBOCz5oyULHd/
> hashcat -m 500 -a 0 -o cracked.txt hash.txt rockyou.txt -O
> cat cracked.txt
$1$ZVpxbK71$2Fgpdj.x9SBOCz5oyULHd/:root
So, we seemingly got a root:root login on the newest flagship router model from last year and no privilege separation whatsoever with everything running as root as usual.
Note: An invested reader pointed out that on his D-Link devices he looked at, the login mechanism does not read from /etc/shadow at all. But instead, a whole new /etc/passwd is generated on boot and the root user is not even uid(0). The real root account is "admin" and the password is actually read from the "Config" partition of the flash chip. However, it is still possible to obtain it from the official firmware update file via some further static analysis.
Finally, instead of enumerating the extracted file system locally we could have also dumped the decrypted firmware file into FACT instead of only using the FACT extractor to get tons of more interesting metadata about this firmware:
As we can see, the encrypted firmware sample in the first screenshot is not extracted at all. The file type only matches the newly added signature for the D-Link "SHRS" magic byte sequence. However, after applying the firmware decryption we can see a fully unrolled Linux file tree as well as a very verbose file-type with lots of meta-information in addition to the correctly cracked password :).
PS: Ignore the first lengthy password tag in the decrypted firmware, that's a bug in the output parser from the password cracker :D.
Conclusion:
In this blog post, I showcased how we're able to circumvent a firmware encryption by finding the path of the least resistance and I also guess the path of minimal purchase costs. Our research shows that our results from analyzing the D-Link DIR882 directly translate up to the flagship model D-Link DIR3060 among others. This shows that the encryption mechanism and more importantly the structure of the security header never changed over the course of the past 2-3 years! Moreover, the decryption key stayed the same as well.
IMO, this was a rather fun challenge and utilizing a dynamically resolved decryption key that is based on three hard-coded constants in addition to that public certificate mechanism is not such a horrible solution for securing firmware updates. I could only crack this due to the physical access to the device. That said, I never much poked around the other potential vulnerable components like the OTA firmware update mechanism or the web GUI, as this would be beyond the scope of this article.
The final decryption script, the re-constructed C code and everything else can be found on my GitHub. The full script also includes a basic encryption routine that mimics the original encryption.
This first blog post series was already rather lengthy, even though I omitted the whole hooking up to the serial console and dumping the binary of interest. Nevertheless, I hope you enjoyed reading through it. I'm more than happy to receive any feedback, so please hit me up on Twitter.
PS: Show FACT some love:
PPS: As of now the custom unpacker is part of FACT by default so, there is no need to manually unpack these images anymore :)!
PPPS: Sorry for the often wonky naming of basic blocks and variables! Did that irritate you and do you prefer a blank IDA database next time, or was it not that bad?
PPPPS: If you feel like dynamically debugging the original MIPS binary, it is certainly possible from within GDB:
And with that, it's time to say EOF.