Breaking an embedded firmware encryption scheme

2025-08-06

最近需要回來熟悉一下 ghidra,而且還有 wannaCry 的債要還…先用這個比較簡單的

ref:
https://www.youtube.com/watch?v=4urMITJKQQs

我覺得寫得不錯,流程很簡單,就是抓舊版的韌體,透過 GHIDRA 從中找出 AES KEY 之後拿來解密新版本的韌體

首先抓 moxa v1.11 的韌體,這邊抓 v1.11的韌體根據是他的 release note:

裡面提到了可以直升 v2.0 以上的加密韌體,代表裡面要有 key 才能解

根據影片可以去

https://www.moxa.com/en/products/industrial-edge-connectivity/serial-device-servers/wireless-device-servers/nport-w2150a-w2250a-series#resources

下載韌體,但是現在網站上已經沒有 1.11 韌體的下載點了
透過 archive.org 有找到舊版的韌體

好玩的地方來了,我在 archive.org 上抓的 1.11 版韌體有加密(或是遺失 bytes),在官方 link 上抓得沒有(官方 link 還活著,只是從官網上移除入口而已)

上面是中間的小插曲,然後也同步把 2.2(加密) 版本抓下來了
照著影片方法取個別檔案的 entropy

這邊快速科普一下 entropy

1
2
3
4
5
6
7
在資訊理論中,entropy(熵) 描述的是資料的「不確定性」或「隨機性」。在 binwalk 的分析中:

熵的值範圍是從 0 到 8(以位元為單位,代表每 byte 的平均熵)。

0 表示資料完全沒有隨機性(例如全部是 0x00 或 0xFF)。

8 表示資料極度隨機,可能是加密或壓縮的內容。
1
2
3
4
5
熵值範圍	意義
0 ~ 3 很低熵,資料很重複,例如清空區域
3 ~ 6 中度熵,普通未壓縮資料
6 ~ 8 高熵,可能是壓縮或加密資料
= 8 完全隨機,幾乎一定是加密或壓縮過的內容

binwalk 的 Entropy x/y 軸分別代表 offset 與 entropy,但為什麼 Y 軸的 entropy 最大值不是 8 呢?
在 binwalk 的 source code 可以找到答案 (最後 return 的時候 / 8)

source: https://github.com/foreni-packages/binwalk/blob/master/src/binwalk/modules/entropy.py

所以在 binwalk 來說:

Y 值 實際 entropy 意義範例
1.0 8.0 bits 非常隨機(加密或壓縮資料)
0.75 6.0 bits 高 entropy(壓縮內容可能性高)
0.5 4.0 bits 中等 entropy(普通程式碼或資料)
0.25 2.0 bits 很低 entropy(可能是填充區)
0.0 0.0 bits 完全重複(0x00、0xFF 填充)

但這是舊版 (python) 的 binwalk 才是這樣呈現
新版 (Rust) 的最大值就是 8


ref: https://github.com/ReFirmLabs/binwalk/blob/master/src/entropy.rs

總之使用 binwalk -E 解完韌體後,會出現 squashfs-root 的資料夾,我們需要在裡面找韌體升級的相關檔案
但在這之前記得先 sudo chmod -R +rx squashfs-root 不然會有權限問題
然後嘗試 grep find upgrade 相關字串的檔案,因為我們的目標是升級韌體的那隻檔案

可以看到在 squashfs-root-0 有找到相關檔案
現在嘗試用 ghidra 解他看看,在 analysis 選項部份可以打勾

透過選擇工具列上的 Window->Functions 呼叫出 Function List 清單,搜尋關鍵字 (原本要搜尋 decrypt,但搜到 dec 的時候就有結果了)

然後選擇工具列上的 Window->Functions Graph 可以用圖的方式呈現 control flow


然後我們再往下追 ecb128Decrypt 的關聯性,可以看到有一個 AES_set_decrypt_key

AES_set_decrypt_key 裡面雖然沒東西(應該是解析失敗),但沒關係,我們追到這邊就取得 function 的 return type 跟 parameter type

所以我們回去 ecb128Decrypt 修改相關的 value type

首先把 AES_set_decrypt_key(auStack_30,0x80,&AStack_124);AES_set_decrypt_key(userKey,128,&aes_key); (中間的十六進位轉十進位透過滑鼠右鍵就能了)

往下看到 13 14 行
可以看到他把計算後的結果又過一個 uchar * 的轉換,然後在 function parameter 的型別卻是 void,這邊我們把它改的好看一下,直接 retype parameter 變成 uchar * (滑鼠右鍵 -> Retype Variable)

AES_ecb_encrypt 的 function spec 在下面
https://github.com/openssl/openssl/blob/master/crypto/aes/aes_ecb.c
整體改完的樣子長這樣:

接著到 fw_decrypt 也就是 call ecb128Decrypt 的 function

我的習慣是先照著已知型別的變數先改 type,也就是上一個我們看完的 function 參數的型別

首先 param_1 retype 成 uchar *,然後 uVar2 改 int,這樣他就會顯示 -1,-2 正常的呈現

然後 pbVar3 應該就是明文密碼了

最後的 return 很奇怪
return CONCAT44(param_1,uVar2)

這邊可以細解一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
根據 AAPCS (ARM Architecture Procedure Call Standard):

整數 / 指標

回傳值放在 R0

如果 64-bit (例如 long long),會用 R0:R1 (低位在 R0,高位在 R1)

浮點數(在支援 VFP/NEON 的情況下)

用 S0 / D0 浮點暫存器回傳

沒有硬體浮點的話,會退化成 R0 傳遞整數格式的浮點數

結構體 (struct)

如果結構體小到能塞進一個寄存器,就用 R0

如果比較大,會由 caller 提供一個記憶體位置,函式透過寫入那個位置「回傳」

總結

主要看 R0

如果是 64-bit 整數 → 看 R0:R1

如果是浮點數 → 看 S0 / D0

大結構 → 回傳值其實是寫到記憶體,不會只在寄存器裡

總之就是看 r0 (r0 相當於 eax,用來儲存參數用的)

uVar 放的在 r0

然後最後明明是 ldmia 的指令,為什麼 c 語言會有 return

這邊說一下 ldmia 是幹嘛的

以這為例子
LDMIA R1!, {R0, R2, R3}

假設
R1 = 0x1000,R0、R2、R3 = 0
且記憶體內容

1
2
3
4
5
[0x1000] = 0xAAAA

[0x1004] = 0xBBBB

[0x1008] = 0xCCCC

執行後:

1
2
3
4
5
6
7
R0 = 0xAAAA

R2 = 0xBBBB

R3 = 0xCCCC

R1 = 0x100C (因為有 !,所以會寫回更新後的位置)

讓我們來看看最後 return 指向的 ARM

ldmia sp!,{param_2,passwd,r3,r4,r5,r6,r7,r8,r10,pc}

可以看到他有動到最後的 pc register 所以會有跳轉的行為
再加上 uVar2 在 r0,所以可以得出他僅 return uVar2 的值,uVar2 為 int,因此改 function return type 為 int

最後的最後 就是解密啦
透過這段可以知道密碼被存在 passwd.3309 裡面

但要取多長呢?
ecb128Decrypt 可以知道 key 的長度是 0x10

因此就已 0x10 為長度取 passwd.3309 的 byte

複製出來後順著 c code 的呈現寫一個 python 取出 key

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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] = passwd[i] ^ 0xa7;
passwd[i+1] = passwd[i+1] ^ 0x8b;
passwd[i+2] = passwd[i+2] ^ 0x2d;
passwd[i+3] = passwd[i+3] ^ 5;
i = i + 4;

print(passwd)

for x in passwd:
print(hex(x),end='')
print('\n')

因為我們要餵進去 openssl 解密,所以給16進位key

然後不要 0x 所以我選擇用這種方式

1
2
3
4
5
astar@blog:/tmp$ python3 passwd.py | tr -d '0x'
[5, 56, 56, 55, 67, 111, 11, 11, 55, 53, 54, 52, , , , ]
32383837436f6e6e37353634

astar@blog:/tmp$

還有一個點,細看 decrypt 的過程,透過 decrypt_size-0x28 可以得知,要解密的開頭是往後數第 40 個 bytes 開始解密

這部分可以用 dd 解決

1
2
3
4
5
$ dd if=./moxa-nport-w2150a-w2250a-series-firmware-v2.2.rom of=moxa-nport-w2150a-w2250a-series-firmware-v2.2.rom.offset bs=1 skip=40

8874768+0 records in
8874768+0 records out
8874768 bytes (8.9 MB, 8.5 MiB) copied, 9.00683 s, 985 kB/s

然後再用 openssl 解密

openssl aes-128-ecb -d -K "32383837436f6e6e373536340000" -in ./moxa-nport-w2150a-w2250a-series-firmware-v2.2.rom.offset -out decrypt_firmware