Decrypting Firmware
Description
Bootloader Base Address : 0x21000000
Kernel Base Address : 0xc0008000
Solution
Only looked at this when the CTF ended 🥐
TL;DR
Search for article on reversing u-boot IoT firmware
Copy paste emulator code, modify it accordingly, then run to decrypt the kernel image
Use
vmlinux-to-elf
to convert kernel to an elf file to reverse itDiscover block_aes_decrypt and other key initializing functions, run similar emulator to emulate the functions to:
Get the aes key
Decrypt romfs using the aes key
Analysis
Getting Started: Mapping Out the Files
We are given 3 files:
dhboot.bin.img
- U-boot bootloaderkernel.img
- Encrypted kernel imageromfs-x.squashfs.img
- Encrypt SquashFS root filesystem image
Starting off without any knowledge on the boot up sequence of any firmware, we can guess that dhboot.bin.img
would start executing first during boot up.
With the help of a friend, I was told to search up "reversing uboot iot firmware" and refer to this site to decrypt the firmware:
The article states to use binwalk --entropy <file>
as to check whether the file has been encrypted or not. This is a check on how "random" the bytes are (less entropy mean several 0s in some areas or certain repeated patterns, for e.g.), encrypted files would have a high entropy due to their "randomness".

As seen above, both the kernel
and romfs
seems to have consistently high entropies throughout the file, highly indicating that they're encrypted. This is similar to what is shown in the article, where it indicates that dhboot
is likely to not be encrypted.
The rest of the details can be read up on the article itself, where they nicely explain on how they reversed the u-boot, to how they managed to decrypt the kernel.
However, I will properly provide some details on how to load the u-boot into ghidra for static analysis as this can be useful for future challenges (custom u-boot perhaps?).
Peeling Back U-Boot: Decompiling the Bootloader
Opening the file in Ghidra, we first select the Language
to be ARMv7 Little Endian
(following the article).

Click OK to select the language, then click on the Options...
button at the bottom right of the window, setting the Block Name
to anything, the Base Address
as 0x21000000
as per the challenge description, then the File Offset
would be where the code starts.
To determine where the code starts, we can do a rough guess on where the entropy starts in the file, by running hexdump -C ./dhboot.bin.img | head -n 50
and checking where the bunch of 00
s stop. (thank Jin Kai for this trick)

In the image above, we can guess that the code starts at the 0x840
offset of the file, so we set this in the File Offset
and try to decompile it.
You will receive a message on setting the Length
to some other value, so just set it according to the message.

Afterwhich, the binary should be decompiled properly!

Kernel Decryption: Extracting Offsets
Thankfully, there isn't a need for decompiling and reading code of the u-boot binary.
At the end of the article, a script is given to emulate the decryption function block_aes_decrypt
of the bootloader:
To decrypt the kernel that we have, we have to modify the code to work on our kernel image.
We have to modify
ARM_CODE32
to contain just the u-boot code, which as we found before is at the0x840
offset.FILE_TOBE_DEC
also have to be changed to only include the encrypted part of the kernel image, so we can skip past the 64 bytes header.ADDRESS
to start at0x21000000
Add a
SIZE
to calculate the size of the kernel image file - 0x40 bytes for header. Use thisSIZE
to be placed into theR2
register, and also to be read fromram_address
.
Lastly, we have to modify the start
and end
addresses of the emulator according to the block_aes_decrypt function call address. We can find this address by searching for the string "decrypt" in Ghidra, and cross referencing the article's screenshot of the decompiled code.

Jumping to the address of this function in IDA (0x2101305c), we can see a cleaner decompiled code (as compared to Ghidra). Using the strings as context clues, we can guess what each function represents, and so we find the block_aes_decrypt function based on the error message.

We can then use the address that call this function, following the article!

Kernel Decryption Script
from __future__ import print_function
from ctypes import sizeof
from unicorn import *
from unicorn.arm_const import *
from unicorn.unicorn_const import *
from capstone import *
import struct, binascii
#callback of the code hook
def hook_code(uc, addr, size, user_data):
mem = uc.mem_read(addr, size)
disas_single(bytes(mem),addr)
#disassembly each istruction and print the mnemonic name
def disas_single(data,addr):
for i in capmd.disasm(data,addr):
print("0x%x:\t%s\t%s" % (i.address, i.mnemonic, i.op_str))
break
#create a new instance of capstone
capmd = Cs(UC_ARCH_ARM, UC_MODE_ARM)
#code to be emulated
in_file = open("dhboot.bin.img", "rb") # opening for [r]eading as [b]inary
ARM_CODE32 = in_file.read()[0x840:]
in_file.close()
# file to be decrypted
in_file = open("kernel.img", "rb") # opening for [r]eading as [b]inary
FILE_TOBE_DEC = in_file.read()[0x40:]
in_file.close()
# U-Boot base address
# we have seen this in the previous article (DDR start at 8000_0000)
ADDRESS = 0x21000000
KERNEL_START = 0x40
KERNEL_SIZE = 0x2792f8 # total size of kernel image file
SIZE = KERNEL_SIZE - KERNEL_START
print("Emulate ARM code")
print("Shielder")
try:
# Initialize emulator in ARM-32bit mode
# with "ARM" ARM instruction set
mu = Uc(UC_ARCH_ARM, UC_MODE_ARM)
# map U-boot in memory for this emulation
# "// (1024 * 1024)" for memory allign pourses
i = len(ARM_CODE32) // (1024 * 1024)
mem_size = (1024 * 1024) + (i * (1024 * 1024))
mu.mem_map(ADDRESS, mem_size, perms=UC_PROT_ALL)
# write machine code to be emulated to memory
mu.mem_write(ADDRESS, ARM_CODE32)
# map STACK
stack_address = ADDRESS + mem_size
# 2MB
stack_size = (1024 * 1024) * 2
mu.mem_map(stack_address, stack_size, perms=UC_PROT_ALL)
# map the Kernel in RAM memory for this emulation
# remember that RAM starts at 8000_0000
# there we call RAM a sub-region of memory inside the RAM itself
ram_address = ADDRESS + mem_size + stack_size
ram_size = (1024 * 1024) * 8
mu.mem_map(ram_address, ram_size, perms=UC_PROT_ALL)
# write file to be decrypted to memory
mu.mem_write(ram_address, FILE_TOBE_DEC)
# initialize machine registries
mu.reg_write(UC_ARM_REG_SP, stack_address)
# first argument, memory pointer to the location of the file
mu.reg_write(UC_ARM_REG_R0, ram_address)
# second argument, memory pointer to the location on which write the file
mu.reg_write(UC_ARM_REG_R1, ram_address)
# third argument, block size to be read from memory pointed by r0
mu.reg_write(UC_ARM_REG_R2, SIZE)
# hook any instruction and disassembly them with capstone
# mu.hook_add(UC_HOOK_CODE, hook_code)
# emulate code in infinite time
# Address + start/end of the block_aes_decrypt function
# this trick save much headaches
mu.emu_start(0x210130CC, 0x210130D0)
# now print out some registers
print("Emulation done. Below is the CPU context")
r_r0 = mu.reg_read(UC_ARM_REG_R0)
r_r1 = mu.reg_read(UC_ARM_REG_R1)
r_r2 = mu.reg_read(UC_ARM_REG_R2)
r_pc = mu.reg_read(UC_ARM_REG_PC)
print(">>> r0 = 0x%x" %r_r0)
print(">>> r1 = 0x%x" %r_r1)
print(">>> r2 = 0x%x" %r_r2)
print(">>> pc = 0x%x" %r_pc)
# print("\nReading data from first 512byte of the RAM at: "+hex(ram_address))
print("==== BEGIN ====")
ram_data = mu.mem_read(ram_address, SIZE)
# print(str(binascii.hexlify(ram_data)))
print("==== END ====")
# from the reversed binary, we know which are the magic bytes
# at the beginning of the kernel
if b"27051956" == binascii.hexlify(bytearray(ram_data[:4])):
print("\nMagic Bytes match :)\n\n")
with open("kernel.bin", "wb") as f:
f.write(ram_data)
except UcError as e:
print("ERROR: %s" % e)

Dissecting Kernel ELF: Understanding the Decryption
We can convert the decrypted kernel image into an elf file using vmlinux-to-elf so that we can reverse the decryption process. As there is no write-ups or articles on how to reverse the decryption of the file system, we will have to reverse it ourselves 😥. We run vmlinux-to-elf kernel.bin kernel.elf --e-machine 40 --bit-size 32
(searching up ARM e machine gives us the number 40).
Loading the binary up in IDA Pro, we are given this message that indicates there are ARM and THUMB instructions that can be switched back and forth. The instruction can be changed from ARM to THUMB in IDA by using Alt-G
and changing the T
register value from 0
to 1
.

Looking through the functions, we can spot some function names containing "dahua" and back in the bootloader, we can find several strings (e.g. DH-DVR-LBX) that indicates that we are looking at a "Dahua IP Camera" Firmware.
Searching for "dahua ip camera reversing" on google, we can find a talk on Dahua IP Camera reversing made at OFFZONE 2022 https://2022.offzone.moscow/report/dahua-ip-camera-where-to-look-what-to-poke/. This includes a slide on the broad overview on how the kernel decrypts the romfs.

This talk is pretty old, so we can assume that the method of decryption is different for the firmware we have, but we can guess that some aspects of the decryption is similar, like how it uses AES CBC
.
We can see that the binary is thankfully not stripped so we can start off by searching for functions with "decrypt" in the name.

We only see 2 functions that uses AES, so we can look into those and find out where those functions are called from. Looking at SecUnit_AES_decrypt, we find the call SecUnit_EncryptFirmware ⇒ SecUnit_FirmwareAesCBCDecode ⇒ SecUnit_AES_decrypt, SecUnit_EncryptFirmware also calls SecUnit_AES_set_decrypt_key, which we can assume just sets the AES key for decryption.
However, we can't seem to find any references to SecUnit_EncryptFirmware by default. Remember that the binary uses both ARM and THUMB instructions? The reason the reference cannot be found is because the call was made by a THUMB instruction and IDA doesn't know that the function is in THUMB, so no reference. This can be resolved by spinning up another IDA instance and viewing it entirely in THUMB instructions.

After IDA finishes analyzing the binary, we can go the SecUnit_EncryptFirmware and check the references, and it seems like block_aes_decrypt calls it!

By doing this, we can find out that the function calls are mtdblock_readsect ⇒ block_aes_decrypt ⇒ SecUnit_EncryptFirmware ⇒ SecUnit_FirmwareAesCBCDecode. From reversing these functions, we discover a few things that can aid us in emulating the decryption:
AES CBC 512 is used
AES key is retrieved and used in the SecUnit_EncryptFirmware function
Function prototype SecUnit_EncryptFirmware(char *srcbuf, uint32_t size, char *dstbuf, uint32_t size2, uint8_t cryptoflags, int sector_skip, int* pCounter)
For SecUnit_EncryptFirmware, there are hardcoded parameters to decrypt a sector, which is
cryptoflags = 0x11
andsector_skip = 2
. These hardcoded parameters are passed in when block_aes_decrypt is called.
With these information, we can pretty much emulate the SecUnit_EncryptFirmware function and decrypt the romfs right? Well, not quite...
In order to find if the file decrypted properly, we have to find what to look out for in a properly decrypted squashfs image. In a normal unencrypted squashfs image, the data past the romfs header would still have a valid header starting with hsqs8
, which represents a squashfs filesystem. We can get an example romfs-x.squashfs.img
from https://snr.systems/site/data-files/Dahua/Firmware/NVR4216-4232-16P-4KS2/DH_NVR4XXX-4KS2_Eng_V3.216.0000000.0.R.180605/?utm_source=chatgpt.com as a reference.

However, if we run the emulator, we still get an unidentifiable file.

One thing to note is that in SecUnit_EncryptFirmware, there are mmioset function calls at 0xC0280C04, 0xC0280C14, and 0xC0280C24. The function mainly runs on ARM instructions, but mmioset runs on THUMB instructions.
If unicorn cannot switch from ARM to THUMB instructions, then we can circumvent this by patching these function calls out with NOPs 00 F0 20 E2. (it should run fine without patching)
So what can be the issue? At this point I was puzzled. I read over the functions and understood that the decryption is performed on every 3rd sector, because the sector_skip was set to 2. Running binwalk showed that some sectors were still identifiable as "xz compressed data", diffing the result to the original also showed that it tries to decrypt every 3rd sector.
This can only mean that one or some of my parameters to the AES decryption is incorrect. AES decryption only has 2 important values other than the source buffer and destination buffer. It's either the IV or the KEY that is wrong. Since I know the IV starts from 0 from a buffer on the stack in the mtdblock_readsect, then sure only the KEY is wrong.
The AES key can be seen taken from a global variable g_secUnitKey
and based on the cryptoflags passed into the function, there can be encryption and decryption and the use of 3 different AES key offset from g_secUnitKey
. However, our cryptoflags have to be fixed to 0x11 based on the arguments passed into block_aes_decrypt.
Deriving the AES Key: Tracing Key Initialization
Trying to see references to the AES key g_secUnitkey
, we see one especially noteworthy.

The function name seems to set the SecUnit key and we can see that this is called by prepare_namespace ⇒ block_aes_set_keyfactor. Now, this block_aes_set_keyfactor seems to be what we are finding because when we reverse it, we can determine how the key is initialized.
The block_aes_set_keyfactor first initializes an intermediary 16 byte buffer for the key on the stack.

It then calls firmware_get_key_constprop_0, which grabs data from several global variables like AES_KEY_N
and a reference to a string "ssc325".
It then uses these to hash the original key with SHA256, then perform some XOR and store the resulting value in keybuf
.

After this is done, block_aes_set_keyfactor calls SecUnit_SetKeyFactor with the keybuf
, the size of the key, and a keyid
which is a offset to the g_secUnitKey
, which is hardcoded as the value 1
. This aligns with the key offset that is used during decryption (0x11 &0xF as cryptoflags)!
The function calls SecUnit_FirmwareFactors which is a confusing function to look through. But note that if the function completes without any errors, then memcpy is performed from the keybuf
into the g_secUnitKey
global variable at the offset of keyid
.

This means that we can emulate both functions to get the correct AES key in order for the decryption to properly decrypt the romfs!
Emulating Kernel Decryption: RomFS Decryption
I had to emulate the functions individually because of how firmware_get_key_constprop_0 runs on THUMB instructions, while SecUnit_SetKeyFactor runs on ARM instructions.
We first emulate firmware_get_key_constprop_0 and retrieve the key in keybuf, then pass that key value to our other emulator that emulates SecUnit_SetKeyFactor. Emulating the function, we then read the value where the key was memcpy'ed to, then we pass this key into the correct address where SecUnit_EncryptFirmware would retrieve it from (an offset from g_secUnitKey
)!
Putting these all together, we can successfully decrypt the romfs, and then run unsquashfs to extract the files and find the flag ☺️


Romfs Decryption Scripts
from __future__ import print_function
from ctypes import sizeof
from unicorn import *
from unicorn.arm_const import *
from unicorn.unicorn_const import *
from capstone import *
import struct, binascii
from capstone.arm import *
capmd = Cs(CS_ARCH_ARM, CS_MODE_THUMB)
in_file = open("kernel.elf", "rb") # opening for [r]eading as [b]inary
ARM_CODE32 = in_file.read()[0x124:]
in_file.close()
ADDRESS = 0xc0008000
print("Emulate ARM code")
print("Shielder")
try:
mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB)
i = len(ARM_CODE32) // (1024 * 1024)
mem_size = (1024 * 1024) + (i * (1024 * 1024))
mu.mem_map(ADDRESS, mem_size, perms=UC_PROT_ALL)
mu.mem_write(ADDRESS, ARM_CODE32)
stack_address = ADDRESS + mem_size
stack_size = (1024 * 1024) * 2
mu.mem_map(stack_address, stack_size, perms=UC_PROT_ALL)
ram_address = ADDRESS + mem_size + stack_size
ram_size = (1024 * 1024) * 8 * 8
mu.mem_map(ram_address, ram_size, perms=UC_PROT_ALL)
mu.reg_write(UC_ARM_REG_SP, stack_address)
mu.reg_write(UC_ARM_REG_R0, ram_address)
mu.emu_start(0xC017C6AC | 1, 0xC017C720)
print("Emulation done. Below is the CPU context")
r_r0 = mu.reg_read(UC_ARM_REG_R0)
r_r1 = mu.reg_read(UC_ARM_REG_R1)
r_r2 = mu.reg_read(UC_ARM_REG_R2)
r_pc = mu.reg_read(UC_ARM_REG_PC)
print(">>> r0 = 0x%x" %r_r0)
print(">>> r1 = 0x%x" %r_r1)
print(">>> r2 = 0x%x" %r_r2)
print(">>> pc = 0x%x" %r_pc)
print("==== BEGIN ====")
ram_data = mu.mem_read(ram_address, 16)
print("==== END ====")
with open("key", "wb") as f:
f.write(ram_data)
print("DONE!")
except UcError as e:
print("ERROR: %s" % e)
Last updated
Was this helpful?