Mình đã học dịch ngược, hệ thống nhúng và phần mềm điều khiển hệ thống nhúng khá lâu rồi. Dạo gần đây mình lại nghĩ “Sao mình không kết hợp 2 kĩ năng này để làm 1 cái gì đấy hay?”. Thế nên mình thử sức bản thân bằng cách phá mã của 1 phần mềm điều khiển đã được mã hóa của 1 jack cắm kết nối WiFi. Mình đã ghi lại quá trình phá mã và cách suy nghĩ của mình trong bài blog này.

Về thiết bị

Gần đây mình cũng đọc được 1 lỗ hổng trong thiết bị jack cắm Moxa NPort W2150A Serial-To-Wifi, lỗ hổng này sử dụng buffer overflow dạng stack. Xong mình nghĩ là mình cũng muốn thử làm 1 dự án liên quan đến bên bảo mật cho thiết bị này. Bởi vì thiết bị này có phần mềm đã được mã hóa, mình quyết định sẽ cố phá mã để có thể lấy được mã nguồn của phần mềm điều khiển.

Mình muốn bắt đầu với 1 phiên bản cũ hơn của phần mềm điều khiển của thiết bị này. Mình tìm được phiên bản v2.2, được xuất bản vào khoảng 2019. MÌnh sẽ sử dụng phiên bản này.

Phân tích phần mềm

Sau khi đọc về note xuất bản và doc cho phiên bản 2.2 và các phiên bản cũ hơn, mình tìm thấy đc 1 phần khá thú vị trong note xuất bản của phiên bản 1.11:

Note xuất bản phiên bản 1.11

Phiên bản 1.11 là phiên bản tiên quyết cho phiên bản 2.2. Tức là mình cần phiên bản 1.11 để có thể tải phiên bản 2.2. Mình nghĩ là phần mã hóa cho phần mềm điều khiển được thêm vào trong phiên bản 2.2. Thế nên mình đã tải phiên bản v1.11 và bắt đầu phân tích phiên bản này.

Đầu tiên, mình dùng binwalk. công cụ này cho phép mình “bước” qua cả file nhị phân và tìm các định dạng file và các định dạng nén trong phần mềm. Câu lệnh này cũng có nhiều công cụ phân tích nhị phân khác nhau.

binwalk moxa-nport-w2150a-w2250a-series-firmware-v2.2.rom

Khi mình chạy câu lệnh binwalk trên, mình được kết quả là 1 file MySQL. Đây là 1 báo động giả bởi vì mình không nghĩ là 1 jack cắm kết nối WiFi sẽ cần sử dụng database.

Tiếp theo thì mình thử binwalk phiên bản 1.11:

binwalk moxa-nport-w2150a-w2250a-series-firmware-1.11.rom
NPort firmware phiên bản 1.11 binwalk

Output này xác nhận là phiên bản 1.11 không bị mã hóa. Trong output này có 2 điều khá thú vị: 2 hệ thống file squashfs đã được nén bằng gzip. squashfs là cả 1 hệ thống file của Linux.

Mình sẽ thử giải nén phần mềm này:

binwalk -e moxa-nport-w2150a-w2250a-series-firmware-1.11.rom

Câu lệnh này sẽ giải nén phiên bản 1.11 vào tệp _moxa-nport-w2150a-w2250a-series-firmware-1.11.rom.extracted:

NPort firmware phiên bản 1.11 giải nén

Trong folder đó có các folder con squashfs-root, các folder này có hệ thống file Linux của phần mềm này. Trước khi mình truy cập folder này thì mình cần phân quyền đúng cho folder đó:

sudo chmod -R 770 squashfs-root*

Giờ mình có thể truy cập các folder squashfs-root:

NPort firmware phiên bản 1.11 hệ thống file đã được giải nén

Sau khi nhìn qua các folder trong phần mềm này, mình tìm được 1 file có tên là libupgradeFirmware.so trong folder lib của folder squashfs-root-1. Bởi vì phiên bản 2.2 cần có phiên bản 1.11, mình đoán là file libupgradeFirmware.so sẽ có thông tin về các phần mềm này đã được mã hóa. Mình sẽ phân tích và dịch ngược file nhị phân này:

Dịch ngược file libupgradeFirmware.so

Mình sẽ dùng Ghidra để dịch ngược.

Trước hết thì mình sẽ dùng câu lệnh strings để xem có những hàm gì trong file này:

NPort firmware strings

Chúng ta có thể thấy được 1 vài hàm có chữ “AES” trong đó, tức là firmware này sẽ dùng thuật toán mã hóa khối AES. Mình chạy lệnh grep để tìm các hàm có chứ “AES”:

NPort firmware strings grep aes

Firmware này dùng AES ở chế độ ECB (Electronic Code Block). Bởi vì chế độ ECB tạo text mã hóa (ciphertext) giống nhau với text thường (plaintext) giống nhau, hacker có thể suy ra khóa bí mật và phá mã dữ liệu đã được mã hóa bằng chế độ ECB. Đây là 1 lỗ hổng lớn mà mình có thể tấn công.

Mình cũng thấy từ output của strings có 1 vài hàm có chữ fw trong đó. Mình đoán là đó là viết tắt cho từ “firmware”. Mình sẽ chạy grep với chữ fw để xem có những hàm nào:

NPort firmware Ghidra fw_decrypt function

Hàm fw_decrypt chắc là 1 hàm dùng để giải mã firmware, hàm này chắc là 1 hàm khá quan trọng.

Mình sẽ thử mở hàm này lên trong Ghidra:

undefined8 fw_decrypt(void *param_1,uint *param_2,undefined4 param_3)
{
  undefined4 uVar1;
  uint *puVar2;
  byte *pbVar3;
  uint decrypt_size;
  uint uVar4;
  void *__src;
  uint *local_24;
  undefined4 uStack_20;
  
  decrypt_size = *param_2;
  if (param_1 == (void *)0x0) {
    uVar1 = 0xffffffff;
  }
  else if (*(char *)((int)param_1 + 0xe) == '\x01') {
    if ((((decrypt_size < 0x29) || (decrypt_size < (*(byte *)((int)param_1 + 0xd) + 10) * 4)) ||
        (decrypt_size < *(uint *)((int)param_1 + 8))) || ((decrypt_size - 0x28 & 0xf) != 0)) {
      uVar1 = 0xfffffffe;
    }
    else {
      pbVar3 = &passwd.3309;
      while (pbVar3 + 4 != ubuf) {
        *pbVar3 = *pbVar3 ^ 0xa7;
        pbVar3[1] = pbVar3[1] ^ 0x8b;
        pbVar3[2] = pbVar3[2] ^ 0x2d;
        pbVar3[3] = pbVar3[3] ^ 5;
        pbVar3 = pbVar3 + 4;
      }
      local_24 = param_2;
      uStack_20 = param_3;
      ecb128Decrypt((uchar *)param_1,(uchar *)param_1,decrypt_size,&passwd.3309);
      uVar4 = *(uint *)((int)param_1 + 8);
      if (((0x28 < uVar4) && ((*(byte *)((int)param_1 + 0xd) + 10) * 4 < uVar4)) &&
         (*(char *)((int)param_1 + 0xe) == '\0')) {
        __src = (void *)((int)param_1 + (uint)*(byte *)((int)param_1 + 0xd) * 4 + 0x24);
        memcpy(&local_24,__src,4);
        puVar2 = (uint *)cal_crc32((int)__src + 4,uVar4 + (*(byte *)((int)param_1 + 0xd) + 10) * -4,
                                   0);
        if (puVar2 == local_24) {
          if ((int)decrypt_size < (int)uVar4) {
            uVar1 = 0xfffffffb;
          }
          else {
            *param_2 = uVar4;
            uVar1 = 0;
          }
          goto LAB_0001191c;
        }
      }
      uVar1 = 0xfffffffc;
    }
  }
  else {
    uVar1 = 0;
  }
LAB_0001191c:
  return CONCAT44(param_1,uVar1);
}

Sau khi đọc qua code của fw_decrypt 1 lượt thì mình thấy là fw_decrypt có gọi 1 hàm tên là ecb128Decrypt. Đây chắc là hàm giải mã AES 128 ở chế độ ECB. Hàm này trực tiếp gọi các hàm AES trong thư viện OpenSSL. Mình có thể dùng công cụ của OpenSSL để giải mã phần mềm firmware này. Nhưng mình sẽ cần khóa dùng cho việc mã hóa phần mềm này để có thể giải mã nó.

Dịch ngược hàm ecb128Decrypt

Mình sẽ bắt đầu dịch ngược hàm ecb128Decrypt này:

NPort firmware ecb128Decrypt hàm dịch ngược

Trong lúc phân tích mình sẽ đặt tên lại và đặt kiểu dữ liệu lại cho các biến trong phần mềm firmware này.

Mình sẽ bắt đầu với hàm AES. Hàm AES_set_decrypt_key sẽ có input là khóa người dùng và mở rộng nó ra thành 1 khóa AES. Hàm AES_set_decrypt_key này sẽ sử dụng biến auStack_30. Trong tài liệu chính thức của OpenSSL, input đầu tiên của hàm này là khóa người dùng. Thế nên mình có thể đặt tênauStack_30 thành user_key. AStack_124 là khóa AES thế nên mình sẽ đặt tên nó thành aes_key. Khóa AES này sẽ được sử dụng cho việc giải mã phần mềm.

Hàm này cũng cần lấy 1 tham số là kích cỡ khóa. Trong chương trình này thì kích cỡ khóa là 0x80 (là 128 trong số nguyên). Tức là firmware này sử dụng khóa AES với kích cỡ khóa là 128-bit. Mình sẽ đổi kiểu dữ liệu của 0x80 thành số nguyên.

Tiếp theo, trên hàn strncpy, dòng này đang copy 16 byte từ param_4 sang auStack_30 (khóa người dùng user_key). Tức là param_4 là 1 khóa giải mã bởi vì user_key sẽ được sử dụng trong hàm giải mã AES. Mình sẽ đặt tên cho biến param_4 thành decrypt_key.

Tiếp theo, ở dòng có biến in and out, các biến này có chứa dữ liệu của param_1param_2 và các dữ liệu này được cộng với sai số là 0x10 (16 trong số nguyên). 2 biến này cũng được cho vào hàm AES_ecb_encrypt.

Hàm AES_ecb_encrypt sẽ cần 1 buffer đầu vào, 1 buffer đầu ra, 1 khóa AES và chế độ mã hóa. Bởi vì hàm AES_ecb_encrypt có chế độ mã hóa 0 ở trường hợp này, hàm AES_ecb_encrypt sẽ được đặt vào chế độ giải mã. Thế hàm này sẽ giải mã dữ liệu trong buffer đầu vào dùng khóa AES và cho dữ liệu đã được giải mã vào buffer đầu ra.

Mình suy ra là biến param_1param_2 là buffer đầu vào và buffer đầu ra. Mình sẽ đặt tên param_1 thành decrypt_inparam_2 thành decrypt_out và đổi kiễu dữ liệu thành uchar* bởi vì biến inout đều là uchar*.

Tiếp theo, biến iVar1 biến đếm được dùng cho vòng lặp, vòng lặp này chỉ dừng khi biến đếm bằng param_3 + -0x28. Thế nên param_3 sẽ là kích cỡ của buffer đầu vào. Mình sẽ đặt tên param_3 thành decrypt_size. decrypt_size cần được cộng với sai số là -0x28, điều này có nghĩa là file này sẽ có 1 vài byte đệm ở đầu file. Lúc mình dùng hexdump, mình tìm được 1 vài byte 00 ở đầu file, đó chắc là lý do tại sao decrypt_size cần được cộng với sai số -0x28 (40 byte)

Đây là hàm đã được đặt tên lại:

NPort firmware ecb128Decrypt hàm đã được đặt tên lại

Thế là mình đã hiểu được cách hàm ecb128Decrypt hoạt động: Nó lấy 1 buffer đầu vào (decrypt_in), giải mã nó với khóa (decrypt_key), và cho kết quả vào đầu ra (decrypt_out).

Dịch ngược hàm fw_decrypt

Tiếp theo, mình sẽ phân tích hàm fw_decrypt và dịch ngược nó lại.

Note: Code của hàmfw_decrypt khá là dài thế nên mình sẽ copy nó vào 1 block code thay vì chụp ảnh

undefined8 fw_decrypt(void *param_1,uint *param_2,undefined4 param_3)
{
  undefined4 uVar1;
  uint *puVar2;
  byte *pbVar3;
  uint decrypt_size;
  uint uVar4;
  void *__src;
  uint *local_24;
  undefined4 uStack_20;
  
  decrypt_size = *param_2;
  if (param_1 == (void *)0x0) {
    uVar1 = 0xffffffff;
  }
  else if (*(char *)((int)param_1 + 0xe) == '\x01') {
    if ((((decrypt_size < 0x29) || (decrypt_size < (*(byte *)((int)param_1 + 0xd) + 10) * 4)) ||
        (decrypt_size < *(uint *)((int)param_1 + 8))) || ((decrypt_size - 0x28 & 0xf) != 0)) {
      uVar1 = 0xfffffffe;
    }
    else {
      pbVar3 = &passwd.3309;
      while (pbVar3 + 4 != ubuf) {
        *pbVar3 = *pbVar3 ^ 0xa7;
        pbVar3[1] = pbVar3[1] ^ 0x8b;
        pbVar3[2] = pbVar3[2] ^ 0x2d;
        pbVar3[3] = pbVar3[3] ^ 5;
        pbVar3 = pbVar3 + 4;
      }
      local_24 = param_2;
      uStack_20 = param_3;
      ecb128Decrypt((uchar *)param_1,(uchar *)param_1,decrypt_size,&passwd.3309);
      uVar4 = *(uint *)((int)param_1 + 8);
      if (((0x28 < uVar4) && ((*(byte *)((int)param_1 + 0xd) + 10) * 4 < uVar4)) &&
         (*(char *)((int)param_1 + 0xe) == '\0')) {
        __src = (void *)((int)param_1 + (uint)*(byte *)((int)param_1 + 0xd) * 4 + 0x24);
        memcpy(&local_24,__src,4);
        puVar2 = (uint *)cal_crc32((int)__src + 4,uVar4 + (*(byte *)((int)param_1 + 0xd) + 10) * -4,
                                   0);
        if (puVar2 == local_24) {
          if ((int)decrypt_size < (int)uVar4) {
            uVar1 = 0xfffffffb;
          }
          else {
            *param_2 = uVar4;
            uVar1 = 0;
          }
          goto LAB_0001191c;
        }
      }
      uVar1 = 0xfffffffc;
    }
  }
  else {
    uVar1 = 0;
  }
LAB_0001191c:
  return CONCAT44(param_1,uVar1);
}

Ở dòng này:

ecb128Decrypt((uchar *)param_1,(uchar *)param_1,decrypt_size,&passwd.3309);

Bởi vì mình biết cách hàm ecb128Decrypt hoạt động, mình có thể thấy là tham số decrypt_indecrypt_out có cùng 1 biến: param_1. Điều này có nghĩa là biến param_1 được giải mã vào chính nó. Mình sẽ đặt tên param_1 thành fw_buffer và đặt lại kiểu dữ liệu thành uchar*.

Hàm ecb128Decrypt cũng lấy 1 tham số gọi là decrypt_size, tham số này có dữ liệu từ param_2 (ở dòng decrypt_size = *param_2;). Mình sẽ đặt tên của param_2 thành fw_buffer_size.

ecb128Decrypt(fw_buffer,fw_buffer,decrypt_size,&passwd.3309);

Ở dòng điều kiện “if”, nó sẽ check xem param_1 (fw_buffer) có giá trị null hay không. Nếu nó có giá trị là null thì biến uVar2 sẽ được gán giá trị 0xffffffff. Biến uVar2 là biến đầu ra của hàm fw_decrypt thế nên mình sẽ đặt tên nó thành return_value.

Đặt return_value thành 0xffffffff sẽ overflow biến này return_value thành 1 giá trị âm. Mình có thể check xem số này là số nào bằng cách đổi kiểu dữ liệu của return_value từ uint sang int:

if (fw_buffer == (uchar *)0x0) {
    return_value = -1;
}

Nó sẽ đặt return_value thành -1, có nghĩa là fail trong C. Mình có thể suy ra kiểu dữ liệu của hàm fw_decrypt từ biến uVar2.

int fw_decrypt(uchar *fw_buffer,uint *fw_buffer_size,undefined4 param_3)

Hàm này đã trở nên dễ đọc hơn nhiều

Ở trong câu lệnh if ở trong câu lệnh else if, nó sẽ check lỗi và kích cỡ hàm không hợp lệ và trả về giá trị âm nếu không hợp lệ. Câu lệnh else sau đó nhìn nhìn khá thú vị, mình sẽ xem nó làm những gì:

else {
  pbVar3 = &passwd.3309;
  while (pbVar3 + 4 != ubuf) {
    *pbVar3 = *pbVar3 ^ 0xa7;
    pbVar3[1] = pbVar3[1] ^ 0x8b;
    pbVar3[2] = pbVar3[2] ^ 0x2d;
    pbVar3[3] = pbVar3[3] ^ 5;
    pbVar3 = pbVar3 + 4;
  }
  local_24 = fw_buffer_size;
  uStack_20 = param_3;
  ecb128Decrypt(fw_buffer,fw_buffer,decrypt_size,&passwd.3309);
  uVar4 = *(uint *)(fw_buffer + 8);
  if (((0x28 < uVar4) && (bVar1 = fw_buffer[0xd], (bVar1 + 10) * 4 < uVar4)) &&
     (fw_buffer[0xe] == '\0')) {
    memcpy(&local_24,fw_buffer + (uint)bVar1 * 4 + 0x24,4);
    puVar2 = (uint *)cal_crc32((int)(fw_buffer + (uint)bVar1 * 4 + 0x24 + 4),
                               uVar4 + (fw_buffer[0xd] + 10) * -4,0);
    if (puVar2 == local_24) {
      if ((int)uVar4 <= (int)decrypt_size) {
        *fw_buffer_size = uVar4;
        return 0;
      }
      return -5;
    }
  }
  return_value = -4;
}

Mình sẽ đi qua từng đoạn và phân tích nó:

pbVar3 = &passwd.3309;

passwd.3309 nhìn giống như 1 biến chứa mật khẩu. Biến này cũng được dùng cho tham số decrypt_key của hàm ecb128Decrypt. Biến pbVar3 sẽ giữ giá trị của passwd.3309 thế nên mình sẽ đặt tên pbVar3 thành password.

Tiếp theo là vòng lặp while, biến password sẽ được biến đổi qua 1 vài thao tác XOR:

while (pbVar3 + 4 != ubuf) {
  *pbVar3 = *pbVar3 ^ 0xa7;
  pbVar3[1] = pbVar3[1] ^ 0x8b;
  pbVar3[2] = pbVar3[2] ^ 0x2d;
  pbVar3[3] = pbVar3[3] ^ 5;
  pbVar3 = pbVar3 + 4;
}

Đây có thể là 1 phương thức “làm mờ” hoặc mã hóa. Mình sẽ thử mô phỏng vòng lặp này bằng cách viết lại vòng lặp này trong Python.

Đầu tiên thì mình sẽ cần lấy dữ liệu mà passwd.3309 đang trỏ đến, mình có thể làm thế bằng cách nhìn trong cửa số Bytes của Ghidra:

NPort firmware fw_decrypt passwd3309 byte

Mình sẽ copy các byte được bôi đen vào 1 mảng trong Python:

passwd = [0x95, 0xb3, 0x15, 0x32, 0xe4, 0xe4, 0x43, 0x6b, 0x90, 0xbe, 0x1b, 0x31, 0xa7, 0x8b, 0x2d, 0x05]

Mình sẽ làm lại vòng lặp while với các thao tác XOR như trong chương trình dịch ngược:

i = 0
while (i < len(passwd)):
	passwd[i] ^= 0xa7
	passwd[i + 1] ^= 0x8b
	passwd[i + 2] ^= 0x2d
	passwd[i + 3] ^= 5
	
	i += 4

Sau đó mình có thể in ra mật khẩu:

print("".join(chr(byte) for byte in passwd))

Đây là chương trình Python full:

passwd = [0x95, 0xb3, 0x15, 0x32, 0xe4, 0xe4, 0x43, 0x6b, 0x90, 0xbe, 0x1b, 0x31, 0xa7, 0x8b, 0x2d, 0x05]

i = 0
while (i < len(passwd)):
	passwd[i] ^= 0xa7
	passwd[i + 1] ^= 0x8b
	passwd[i + 2] ^= 0x2d
	passwd[i + 3] ^= 5
	
	i += 4

print("".join(chr(byte) for byte in passwd))

Khi mình chạy chương trình Python này, nó sẽ in ra cái này:

NPort firmware fw_decrypt vòng lặp while trong python

Thế là mình được khóa giải mã AES cho chương trình này là “2887Conn7564”. Mình có thể sử dụng khóa này để giải mã firmware. Trước hết thì mình cần chuyển khóa này thành mã hex:

print("".join(hex(byte)[2:] for byte in passwd))

Dòng này sẽ cho ra kết quả là 32383837436f6e6e373536340000

Giải mã phần mềm

Làm thế nào để mình có thể giải mã firmware này với khóa mình vừa lấy được?

Mình có thể dùng OpenSSL. Câu lệnh openssl có hỗ trợ mã hóa AES 128-bit ở chế độ ECB thế nên mình sẽ sử dụng nó.

Trước khi mình giải mã, khi mà mình dịch ngược hàm ecb128Decrypt và dùng câu lệnh hexdump, mình biết được là firmware đã bị mã hóa này có khoảng 0x28 byte đệm (40 byte trong số nguyên)

Thế nên mình phải loại bỏ các byte đệm đó, nếu không thì nó sẽ cố giải mã các byte đệm đó, tạo ra dữ liệu xấu. Mình sẽ dùng câu lệnh dd cho việc này:

$ dd if=moxa-nport-w2150a-w2250a-series-firmware-v2.2.rom of=firmware-offseted.encrypted bs=1 skip=40
8874768+0 records in
8874768+0 records out
8874768 bytes transferred in 54.281718 secs (163495 bytes/sec)

Note: bs có nghĩa là kích cỡ khối, skip có nghĩa là số byte để skip

Bây giờ mình có thể dùng openssl để giải mã file firmware-offseted.encrypted:

openssl aes-128-ecb -d -K "32383837436f6e6e373536340000" -in firmware-offseted.encrypted -out firmware.decrypted

Câu lệnh này sẽ cho ra file firmware.decrypted. Nếu như mình chạy câu lệnh binwalk lên file đã được giải mã này thì nó sẽ ra:

NPort firmware đã được giải mã binwalk

Mình sẽ giải nén file này ra _firmware.decrypted.extracted:

binwalk -e firmware.decrypted
cd _firmware.decrypted.extracted

Cho các folder squashfs-root quyền thực thi:

chmod +x -R squashfs-root*

Và thế là mình có quyền truy cập vào firmware phiên bản 2.2 của thiết bị này:

NPort firmware hệ thống file đã được giải mã

Kết luận

Đó là cách mình dịch ngược và giải mã 1 firmware đã được mã hóa. Mình học được nhiều về cách phân tích firmware để tìm ra lỗ hổng và cách tấn công các lỗ hổng đó.