Introduction#

I and a couple of others wanted to attempt competing in the well-known Pwn2Own competition [1], and where better to start than in the SOHO smashup. In this category two devices have to be pwned; a router and a consumer device such as a NAS. The router is exposed only through its WAN interface, but the consumer interface is directly connected to the router and any of its services exposed to the LAN can be attacked if the router is pwned first.

The hardest part of this challenge seems the router, so this is where we started as well. We first checked which of the devices in scope have firmware available for download, attempted to unpack the downloaded firmware images, and ran EMBA [2] to get some preliminary analysis done. That’s when the real work started however, how do you find the full attack surface when only the WAN side of the router is exposed? Assuming the device uses a firewall that blocks traffic coming in from the WAN interface, this should fall in roughly three categories:

  1. UDP services listening on all interfaces: even if the firewall blocks traffic from the WAN interface going to this service, in some cases it’s possible to spoof the IP address on a single UDP packet, which would then be passed on to the service. Any vulnerability or exploit requiring communication back-and-forth is likely to fail with this attack vector.
  2. Administration service: Some routers expose a service by default that allows for example your ISP to monitor the status of the router. This is intended to help in debugging any problems you might have with your internet connection, but is an attack vector as well.
  3. Programs making calls to the outside world: Outgoing connections are generally not blocked by the firewall, and depending on the circumstances these can be intercepted and used as an attack vector. For example, a script checking for updates over an insecure connection could receive a custom firmware image with our backdoor in it.

Statically finding as many functionalities that fall in these categories is difficult, and buying an assortment of devices to test them all is expensive, so we would like to emulate the firmware we found online. In an ideal scenario we can find some vulnerabilities in this way and only need to test the exploit on one actual device. This blog post describes the process of emulating the Ubiquiti Dream Machine Pro firmware.

Unpacked firmware image#

The firmware for the UDM Pro can be downloaded from the site of Ubiquiti itself [3]. In the initial stage of this research version 4.1.13 was downloaded. This was unpacked using Binwalk:

binwalk -Me b012-UDMPRO-4.1.13-4bc426ae-2619-4f5f-8d19-7502798be61a.bin

Binwalk found the following signatures in the image:

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
DECIMAL                            HEXADECIMAL                        DESCRIPTION
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
336245                             0x52175                            PKCS DER hash, SHA256
528780                             0x8118C                            Device tree blob (DTB), version: 17, CPU ID: 0, total size: 25117 bytes
557452                             0x8818C                            Device tree blob (DTB), version: 17, CPU ID: 0, total size: 23817 bytes
586124                             0x8F18C                            Device tree blob (DTB), version: 17, CPU ID: 0, total size: 25396 bytes
614796                             0x9618C                            Device tree blob (DTB), version: 17, CPU ID: 0, total size: 26048 bytes
643468                             0x9D18C                            Device tree blob (DTB), version: 17, CPU ID: 0, total size: 26428 bytes
672140                             0xA418C                            Device tree blob (DTB), version: 17, CPU ID: 0, total size: 26345 bytes
1218172                            0x12967C                           CRC32 polynomial table, little endian
1228537                            0x12BEF9                           PKCS DER hash, SHA1
1228761                            0x12BFD9                           PKCS DER hash, SHA256
1229241                            0x12C1B9                           PKCS DER hash, SHA256
1229596                            0x12C31C                           U-Boot version string: 2015.07-alpine_db-2.21-HAL (Jul 30 2024 - 13:04:14 +0800)
1342204                            0x147AFC                           Device tree blob (DTB), version: 17, CPU ID: 0, total size: 1772 bytes
1470056                            0x166E68                           Device tree blob (DTB), version: 17, CPU ID: 0, total size: 14553682 bytes
16023802                           0xF480FA                           SquashFS file system, little endian, version: 4.0, compression: zstd, inode count: 46094, block size: 262144, image size: 761319517 bytes, created: 2024-12-24 15:05:15
777347386                          0x2E55613A                         ELF binary, 64-bit executable, ARM 64-bit for System-V (Unix), little endian
781166650                          0x2E8FA83A                         SHA256 hash constants, little endian

The interesting parts of the output are the fact it identified a Device Tree Blob (DTB), a U-Boot image, and a file system. In the output from the extraction the DTB and the file system have been unpacked. However, the U-Boot image was not extracted successfully, and notably a kernel image is missing. Given the other components found in the image file, a kernel image would have made sense. Hence, we also tried unpacking the image using Unblob:

unblob b012-UDMPRO-4.1.13-4bc426ae-2619-4f5f-8d19-7502798be61a.bin 

╭──────────────────────────────────────────────────────────────────────────────────────────────── unblob (25.5.26) ──────────────────────────────────────────────────────────────────────────────────────────────╮
│ Output path: [...]/ubiquiti/b012-UDMPRO-4.1.13-4bc426ae-2619-4f5f-8d19-7502798be61a.bin_extract │
│ Extracted files: 468│ Extracted directories: 57│ Extracted links: 49│ Extraction directory size: 799.47 MB                                                            │
╰────────────────────────────────────────────────────────────────────────────────────────────────── Summary ───────────────────────────────────────────────────────────────────────────────────────────────────╯
            Chunks distribution             
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┓
┃ Chunk type          ┃   Size    ┃ Ratio  ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━┩
│ SQUASHFS_V4_LE      │ 726.05 MB │ 90.91% │
│ ELF64               │ 26.21 MB  │ 3.28%  │
│ CPIO_PORTABLE_ASCII │ 19.28 MB  │ 2.41%  │
│ GZIP                │ 13.85 MB  │ 1.73%  │
│ UNKNOWN             │ 13.27 MB  │ 1.66%  │
│ ZSTD                │ 388.00 B  │ 0.00%  │
└─────────────────────┴───────────┴────────┘
Chunk identification ratio: 98.34%
               Encountered errors                
┏━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Severity         ┃ Name                       ┃
┡━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ Severity.WARNING │ ExtractCommandFailedReport │
└──────────────────┴────────────────────────────┘

That seems promising. Aside from the file system it also found a CPIO archive and a GZipped file. And indeed, when running file to further trying to identify the extracted GZip archive, it turns out to be our missing kernel:

file 1470272-7260772.gzip_extract/gzip.uncompressed
1470272-7260772.gzip_extract/gzip.uncompressed: Linux kernel ARM64 boot executable Image, little-endian, 4K pages

The CPIO archive is an initramfs, which can help tremendously when emulating the firmware:

file 7314056-16021055.gzip_extract/mkinitramfs-MAIN_heRQG1
7314056-16021055.gzip_extract/mkinitramfs-MAIN_heRQG1: ASCII cpio archive (SVR4 with no CRC)

With the artifacts retrieved using unblob we’re ready for a first attempt at emulating the firmware.

First emulation attempt#

In the initial search for information about the target we found a blog post [4] describing earlier attempts at emulating the UDM Pro. Repeating what they tried with the files we acquired in the first step gives the following command:

qemu-system-aarch64 -machine virt \
-cpu max \
-m 4G \
-kernel 1470272-7260772.gzip_extract/gzip.uncompressed \
-no-reboot -nographic \
-initrd 7314056-16021055.gzip_extract/mkinitramfs-MAIN_heRQG1

Just as described in the blog post, this provides no output whatsoever. As a next step they built a custom kernel which resolved this issue, but they did not provide any more information about which kernel they built.

debugging kernel#

Further googling yielded a repository based on the actual kernel used in the UDM Pro [5]. The repository mostly intends to use the built kernel on an actual device, rather then use it for emulation, so some changes were in order. I used the stock flavour branch to get closer to the kernel actually used on the UDM Pro at the time of testing. This did not initially work for me, due to a seemingly buggy macro EXPORT_SYMBOL_GPL in include/linux/export.h. After updating this to the newer version in the main branch the macro was working and a kernel could be built with make olddefconfig, export ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- LOCALVERSION= and make -j $(nproc). Just building a kernel does not really work for our purposes however, as we’re trying to use it in QEMU. This means it needs support for the virtio driver. We also want to make sure it can print its logs to a serial console in QEMU. I ended up with the following list, which probably ended up with some options which were not necessary but also did not cause any issues:

CONFIG_CMDLINE_FORCE=y
CONFIG_CONSOLE_LOGLEVEL_DEFAULT=8
CONFIG_CONSOLE_LOGLEVEL_QUIET=8
CONFIG_MESSAGE_LOGLEVEL_DEFAULT=7
CONFIG_DYNAMIC_DEBUG=y
CONFIG_EMBEDDED=y
CONFIG_DEBUG_KERNEL=y
CONFIG_PACKET_DIAG=y
CONFIG_SCSI_LOGGING=y
CONFIG_SCSI_VIRTIO=y
CONFIG_VT_HW_CONSOLE_BINDING=y
CONFIG_SERIAL_AMBA_PL011=y
CONFIG_SERIAL_AMBA_PL011_CONSOLE=y
CONFIG_VIRTIO_CONSOLE=y
CONFIG_HW_RANDOM_VIRTIO=y
CONFIG_HW_RANDOM=y
CONFIG_VIRTIO_MMIO=y
CONFIG_VIRTIO_NET=y
CONFIG_VIRTIO_PCI=y
CONFIG_VIRTIO_INPUT=y
CONFIG_VIRT_DRIVERS=y
CONFIG_VIRTIO_MEM=y
CONFIG_NLMON=y
CONFIG_PCIEPORT=y
CONFIG_AHCI=y
CONFIG_VIRTIO_MMIO_CMDLINE_DEVICES=y

These options could be added in .github/config/config.local.udm and are processed into one deduplicated file with the following command: ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- LOCALVERSION= make olddefconfig

The kernel can then be built as follows: make -j $(nproc)

After building the kernel with these options it can be found in arch/arm64/boot/Image.gz, which we run with QEMU as follows:

qemu-system-aarch64 \
-M virt \
-cpu cortex-a57 \
-m 4G \
-kernel Image.gz \
-no-reboot -nographic \
-initrd mkinitramfs-MAIN_heRQG1

This finally gives some output:

[    0.000000] Booting Linux on physical CPU 0x0000000000 [0x411fd070]
[    0.000000] Linux version 4.19.152-ui-alpine (root@2849382949fe) (gcc version 7.5.0 (Ubuntu/Linaro 7.5.0-3ubuntu1~18.04)) #1 SMP Wed Aug 20 09:37:00 UTC 2025
[    0.000000] Machine model: linux,dummy-virt
[    0.000000] efi: Getting EFI parameters from FDT:
[    0.000000] efi: UEFI not found.
[    0.000000] On node 0 totalpages: 1048576
[    0.000000]   DMA32 zone: 12288 pages used for memmap
[    0.000000]   DMA32 zone: 0 pages reserved
[    0.000000]   DMA32 zone: 786432 pages, LIFO batch:63
[    0.000000]   Normal zone: 4096 pages used for memmap
[    0.000000]   Normal zone: 262144 pages, LIFO batch:63
[    0.000000] psci: probing for conduit method from DT.
[    0.000000] psci: PSCIv1.1 detected in firmware.
[    0.000000] psci: Using standard PSCI v0.2 function IDs
[    0.000000] psci: Trusted OS migration not required
[    0.000000] psci: SMC Calling Convention v1.0
[    0.000000] random: get_random_bytes called from start_kernel+0xac/0x3f4 with crng_init=0
[    0.000000] percpu: Embedded 21 pages/cpu s45464 r8192 d32360 u86016
[    0.000000] pcpu-alloc: s45464 r8192 d32360 u86016 alloc=21*4096
[    0.000000] pcpu-alloc: [0] 0 
[    0.000000] Detected PIPT I-cache on CPU0
[    0.000000] CPU features: enabling workaround for ARM erratum 832075
[    0.000000] ARM_SMCCC_ARCH_WORKAROUND_1 missing from firmware
[    0.000000] CPU features: enabling workaround for EL2 vector hardening
[    0.000000] Built 1 zonelists, mobility grouping on.  Total pages: 1032192
[    0.000000] Kernel command line: 
[    0.000000] Dentry cache hash table entries: 524288 (order: 10, 4194304 bytes)
[    0.000000] Inode-cache hash table entries: 262144 (order: 9, 2097152 bytes)
[    0.000000] software IO TLB: mapped [mem 0xfbfff000-0xfffff000] (64MB)
[    0.000000] Memory: 4022080K/4194304K available (9084K kernel code, 884K rwdata, 2624K rodata, 576K init, 321K bss, 172224K reserved, 0K cma-reserved)
[    0.000000] SLUB: HWalign=64, Order=0-3, MinObjects=0, CPUs=1, Nodes=1
[    0.000000] rcu: Hierarchical RCU implementation.
[    0.000000] rcu:     RCU event tracing is enabled.
[    0.000000] rcu:     RCU restricting CPUs from NR_CPUS=4 to nr_cpu_ids=1.
[    0.000000] rcu: Adjusting geometry for rcu_fanout_leaf=16, nr_cpu_ids=1
[    0.000000] NR_IRQS: 64, nr_irqs: 64, preallocated irqs: 0
[    0.000000] GICv2m: range[mem 0x08020000-0x08020fff], SPI[80:143]
[    0.000000] arch_timer: cp15 timer(s) running at 62.50MHz (virt).
[    0.000000] clocksource: arch_sys_counter: mask: 0xffffffffffffff max_cycles: 0x1cd42e208c, max_idle_ns: 881590405314 ns
[    0.000232] sched_clock: 56 bits at 62MHz, resolution 16ns, wraps every 4398046511096ns
[    0.005982] Console: colour dummy device 80x25
[    0.007414] console [tty0] enabled
[    0.008612] Calibrating delay loop (skipped), value calculated using timer frequency.. 125.00 BogoMIPS (lpj=250000)
[    0.008769] pid_max: default: 32768 minimum: 301
[    0.010450] Mount-cache hash table entries: 8192 (order: 4, 65536 bytes)
[    0.010549] Mountpoint-cache hash table entries: 8192 (order: 4, 65536 bytes)
[    0.039304] /cpus/cpu-map: empty cluster
[    0.045874] ASID allocator initialised with 32768 entries
[    0.046722] rcu: Hierarchical SRCU implementation.
[    0.053472] EFI services will not be available.
[    0.054553] smp: Bringing up secondary CPUs ...
[    0.054645] smp: Brought up 1 node, 1 CPU
[    0.054685] SMP: Total of 1 processors activated.
[    0.054758] CPU features: detected: 32-bit EL0 Support
[    0.056096] CPU: All CPU(s) started at EL1
[    0.056355] alternatives: patching kernel code
[    0.073300] devtmpfs: initialized
[    0.083560] clocksource: jiffies: mask: 0xffffffff max_cycles: 0xffffffff, max_idle_ns: 7645041785100000 ns
[    0.083754] futex hash table entries: 256 (order: 2, 16384 bytes)
[    0.088269] DMI not present or invalid.
[    0.092711] NET: Registered protocol family 16
[    0.098546] cpuidle: using governor menu
[    0.102352] DMA: preallocated 256 KiB pool for atomic allocations
[    0.103024] Initializing Peripheral Bus System - PBS
[    0.103103] pbs entry was not found in device-tree
[    0.103149] Serial: AMBA PL011 UART driver
[    0.123407] 9000000.pl011: ttyAMA0 at MMIO 0x9000000 (irq = 39, base_baud = 0) is a PL011 rev1
[    0.134695] console [ttyAMA0] enabled
[    0.159191] HugeTLB registered 2.00 MiB page size, pre-allocated 0 pages
[    0.164950] vgaarb: loaded
[    0.166098] SCSI subsystem initialized
[    0.167094] libata version 3.00 loaded.
[    0.168456] usbcore: registered new interface driver usbfs
[    0.168866] usbcore: registered new interface driver hub
[    0.169222] usbcore: registered new device driver usb
[    0.169992] pps_core: LinuxPPS API ver. 1 registered
[    0.170165] pps_core: Software ver. 5.3.6 - Copyright 2005-2007 Rodolfo Giometti <giometti@linux.it>
[    0.170493] PTP clock support registered
[    0.171576] Advanced Linux Sound Architecture Driver Initialized.
[    0.181410] Bluetooth: Core ver 2.22
[    0.181714] NET: Registered protocol family 31
[    0.181877] Bluetooth: HCI device and connection manager initialized
[    0.182244] Bluetooth: HCI socket layer initialized
[    0.182483] Bluetooth: L2CAP socket layer initialized
[    0.182932] Bluetooth: SCO socket layer initialized
[    0.191356] clocksource: Switched to clocksource arch_sys_counter
[    0.217145] NET: Registered protocol family 2
[    0.224000] tcp_listen_portaddr_hash hash table entries: 2048 (order: 3, 32768 bytes)
[    0.224562] TCP established hash table entries: 32768 (order: 6, 262144 bytes)
[    0.225286] TCP bind hash table entries: 32768 (order: 7, 524288 bytes)
[    0.226074] TCP: Hash tables configured (established 32768 bind 32768)
[    0.228034] UDP hash table entries: 2048 (order: 4, 65536 bytes)
[    0.228545] UDP-Lite hash table entries: 2048 (order: 4, 65536 bytes)
[    0.230626] NET: Registered protocol family 1
[    0.232906] PCI: CLS 0 bytes, default 64
[    0.238063] Unpacking initramfs...
[    0.380690] Freeing initrd memory: 19736K
[    0.385535] Initialise system trusted keyrings
[    0.387399] workingset: timestamp_bits=46 max_order=20 bucket_order=0
[    0.399584] squashfs: version 4.0 (2009/01/31) Phillip Lougher
[    1.443831] Key type asymmetric registered
[    1.444121] Asymmetric key parser 'x509' registered
[    1.444502] Block layer SCSI generic (bsg) driver version 0.4 loaded (major 249)
[    1.444902] io scheduler noop registered
[    1.445079] io scheduler deadline registered
[    1.445552] io scheduler cfq registered (default)
[    1.445740] io scheduler mq-deadline registered
[    1.445877] io scheduler kyber registered
[    1.450725] pl061_gpio 9030000.pl061: PL061 GPIO chip @0x0000000009030000 registered
[    1.453589] al_dma: Annapurna Labs DMA Driver 0.01
[    1.462901] Serial: 8250/16550 driver, 4 ports, IRQ sharing enabled
[    1.466948] cacheinfo: Unable to detect cache hierarchy for CPU 0
[    1.487345] loop: module loaded
[    1.496525] libphy: Fixed MDIO Bus: probed
[    1.497030] tun: Universal TUN/TAP device driver, 1.6
[    1.498039] al_eth_drv: Initializing Peripheral Bus System (PBS) resources
[    1.498674] al_eth_drv: PBS entry was not found in device-tree
[    1.499473] ehci_hcd: USB 2.0 'Enhanced' Host Controller (EHCI) Driver
[    1.499915] ehci-pci: EHCI PCI platform driver
[    1.500352] ehci-platform: EHCI generic platform driver
[    1.500841] ohci_hcd: USB 1.1 'Open' Host Controller (OHCI) Driver
[    1.501206] ohci-pci: OHCI PCI platform driver
[    1.501727] uhci_hcd: USB Universal Host Controller Interface driver
[    1.502716] usbcore: registered new interface driver cdc_acm
[    1.502959] cdc_acm: USB Abstract Control Model driver for USB modems and ISDN adapters
[    1.503485] usbcore: registered new interface driver usb-storage
[    1.504206] usbcore: registered new interface driver cp210x
[    1.504786] usbserial: USB Serial support registered for cp210x
[    1.506462] i2c /dev entries driver
[    1.507662] Bluetooth: HCI UART driver ver 2.3
[    1.508069] Bluetooth: HCI UART protocol H4 registered
[    1.508270] Bluetooth: HCI UART protocol BCSP registered
[    1.510681] usbcore: registered new interface driver usbhid
[    1.510871] usbhid: USB HID core driver
[    1.512702] usbcore: registered new interface driver snd-usb-audio
[    1.519833] xt_time: kernel timezone is -0000
[    1.520619] IPVS: Registered protocols ()
[    1.521010] IPVS: Connection hash table configured (size=4096, memory=64Kbytes)
[    1.523391] IPVS: ipvs loaded.
[    1.524029] gre: GRE over IPv4 demultiplexor driver
[    1.524267] ip_gre: GRE over IPv4 tunneling driver
[    1.528886] IPv4 over IPsec tunneling driver
[    1.535455] NET: Registered protocol family 10
[    1.546849] Segment Routing with IPv6
[    1.549548] sit: IPv6, IPv4 and MPLS over IPv4 tunneling driver
[    1.552030] NET: Registered protocol family 17
[    1.552796] bridge: filtering via arp/ip/ip6tables is no longer available by default. Update your scripts to load br_netfilter if you need this.
[    1.553869] Bluetooth: RFCOMM socket layer initialized
[    1.554245] Bluetooth: RFCOMM ver 1.11
[    1.554460] Bluetooth: BNEP (Ethernet Emulation) ver 1.3
[    1.554708] Bluetooth: BNEP socket layer initialized
[    1.554929] Bluetooth: HIDP (Human Interface Emulation) ver 1.2
[    1.555189] Bluetooth: HIDP socket layer initialized
[    1.555377] 8021q: 802.1Q VLAN Support v1.8
[    1.559842] registered taskstats version 1
[    1.560071] Loading compiled-in X.509 certificates
[    1.589580] Key type encrypted registered
[    1.599947] input: gpio-keys as /devices/platform/gpio-keys/input/input0
[    1.602694] hctosys: unable to open rtc device (rtc0)
[    1.606102] cfg80211: Loading compiled-in X.509 certificates for regulatory database
[    1.738247] cfg80211: Loaded X.509 cert 'sforshee: 00b28ddf47aef9cea7'
[    1.738955] ALSA device list:
[    1.739072]   No soundcards found.
[    1.744353] platform regulatory.0: Direct firmware load for regulatory.db failed with error -2
[    1.744937] cfg80211: failed to load regulatory.db
[    1.748425] uart-pl011 9000000.pl011: no DMA platform data
[    1.768293] Freeing unused kernel memory: 576K
[    1.775520] Run /init as init process
Loading, please wait...
Starting version 247.3-7+deb11u6
^CBegin: Loading essential drivers ... [    3.431052] ui_hdd_pwrctl: loading out-of-tree module taints kernel.
[    3.434916] ui_hdd_pwrctl: Unknown symbol ui_hdd_pwrctl_blink_set (err -2)
[    3.437391] ui_hdd_pwrctl: Unknown symbol ui_hdd_pwrctl_blink_set (err -2)
modprobe: can't load module ui-hdd-pwrctl (extra/ui-hdd-pwrctl.ko): unknown symbol in module, or unknown parameter
[    3.451120] loop: exports duplicate symbol loop_register_transfer (owned by kernel)
[    3.453715] loop: exports duplicate symbol loop_register_transfer (owned by kernel)
modprobe: can't load module loop (kernel/drivers/block/loop.ko): invalid module format
[    3.463776] xxhash: exports duplicate symbol xxh32 (owned by kernel)
[    3.464506] xxhash: exports duplicate symbol xxh32 (owned by kernel)
modprobe: can't load module xxhash (kernel/lib/xxhash.ko): invalid module format
modprobe: module linear not found in modules.dep
modprobe: module multipath not found in modules.dep
modprobe: module raid0 not found in modules.dep
modprobe: module raid1 not found in modules.dep
modprobe: module raid456 not found in modules.dep
modprobe: module raid5 not found in modules.dep
modprobe: module raid6 not found in modules.dep
modprobe: module raid10 not found in modules.dep
modprobe: module efivars not found in modules.dep
done.
Begin: Running /scripts/init-premount ... done.
Begin: Mounting root file system ... 
Ubiquiti Debian OS initialization...
Begin: Waiting device /dev/mtdblock5 ... 30 ...
29 ...
[...]
1 ...
timed out waiting /dev/mtdblock5


BusyBox v1.30.1 (Debian 1:1.30.1-6+deb11u1) built-in shell (ash)
Enter 'help' for a list of built-in commands.

(initramfs)

Unpacking the image the right way#

While we now could interact with the emulated kernel, it doesn’t have any of the components from the firmware image we’re trying to emulate, only the kernel we just built. To be able to boot more of the firmware itself, we took a look at the initramfs. Unpacking this file yields a small file system used during startup, including some references to the mtdblock5 we are missing:

$ rg mtdblock5
board-define
4:CONFIGDEV=/dev/mtdblock5

scripts/product-override
7:CONFIGDEV="/dev/mtdblock5"

A script scripts/ubnt uses these values in commands which attempt to mount some partitions, which are in turn used in the init script in the root directory of the initramfs. The initramfs also contains various other scripts, among which /usr/sbin/fwupdate, which updates the device firmware with a new image. Since this accepts the same image as we’re trying to analyze it must contain more information about the image format. The scripts extracts various components from the firmware image using a function in /usr/sbin/ubnt-tools called fwextract. From the update script the following partitions can be found:

  • kernel
  • rootfs
  • preloader
  • atf
  • uboot

Looking for any of these terms in the firmware image itself shows that these are tagged with FILE, e.g. FILErootfs. We can easily check for other occurrences as follows:

$ rg -Uao 'FILE[a-z]+' b012-UDMPRO-4.1.13-4bc426ae-2619-4f5f-8d19-7502798be61a.bin
1:FILEuboot
6495:FILEkernel
45980:FILErootfs
282333:FILElg
577632:FILEo
2872083:FILEupdater

While lg and o might be false positives, updater looks promising.

We can run the ubnt-tools binary using QEMU’s user mode, from the initramfs root directory, e.g.: qemu-aarch64-static -cpu cortex-a57 -L $(pwd) usr/sbin/fwextract -t rootfs -o test b012-UDMPRO-4.1.13-4bc426ae-2619-4f5f-8d19-7502798be61a.bin Note that unpacking the firmware does not always preserve file permissions, and in our case ubnt-tools was not marked as executable, so a chmod +x usr/sbin/ubnt-tools might be required. The binary also seems to perform some checks to verify it’s not being emulated. I patched those out:

$ diff <(aarch64-none-linux-gnu-objdump -D fwextract) <(aarch64-none-linux-gnu-objdump -D ubnt-tools)
2c2
< fwextract:     file format elf64-littleaarch64
---
> ubnt-tools:     file format elf64-littleaarch64
19848c19848
<    14dcc: d503201f    nop
---
>    14dcc: b4000377    cbz x23, 14e38 <MD5_Final@@Base+0x39f4>
20154c20154
<    15294: d503201f    nop
---
>    15294: b4002fc1    cbz x1, 1588c <MD5_Final@@Base+0x4448>

Afterwards we can use it to extract the partitions in the intended way:

$ qemu-aarch64-static -cpu cortex-a57 -L $(pwd) usr/sbin/fwextract -kt rootfs -o rootfs.bin b012-UDMPRO-4.1.13-4bc426ae-2619-4f5f-8d19-7502798be61a.bin 
WARN: Firmware file: 'b012-UDMPRO-4.1.13-4bc426ae-2619-4f5f-8d19-7502798be61a.bin'
WARN: Creating partition data file: rootfs.bin

Repeating the command gives us the following files:

$ file rootfs.bin 
rootfs.bin: Squashfs filesystem, little endian, version 4.0, zstd compressed, 761319517 bytes, 46094 inodes, blocksize: 262144 bytes, created: Tue Dec 24 15:05:15 2024

$ file kernel.bin 
kernel.bin: Device Tree Blob version 17, size=14553682, boot CPU=0, string block size=157, DT structure block size=14551880

$ file uboot.bin 
uboot.bin: data

$ file updater.bin 
updater.bin: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, Go BuildID=MfgmSpD0MFJ2WLVTDXWD/_A2q3T6_MqUbEFrgCGar/MtIrxQouqjTvv_VaKK9A/k7mWl_hDkFnu_KF2rBxn, stripped

The kernel actually contains a FIT image:

$ mkimage -l kernel.bin 
Image contains unit addresses @, this will break signing
FIT description: AL324 UniFi Dream Machine Pro FIT image
Created:         Tue Dec 24 16:05:15 2024
 Image 0 (kernel@1)
  Description:  AL324 UDMPRO
  Created:      Tue Dec 24 16:05:15 2024
  Type:         Kernel Image
  Compression:  gzip compressed
  Data Size:    5790500 Bytes = 5654.79 KiB = 5.52 MiB
  Architecture: AArch64
  OS:           Linux
  Load Address: 0x04080000
  Entry Point:  0x04080000
  Hash algo:    sha1
  Hash value:   1a052671e28dd5497706a8784d61448dda291b59
  Sign algo:    sha1,rsa2048:udm_al324
  Sign value:   ccfb6519ce7a56b12e53432faca8d90c513391ca497b48ec9af3061a6bf628fac15669e6ff2848289f05c049aafe9ac7d1806bdff10ab024935024b37814d9de1f33c5d111629b2fa90b666d522943b8f464e80d696f29691e7b587a54d990edce795a45dffa253b71ff8c3a0d38b72797aed1e12ef4034fc08880839c6c0f4f8409384fbe07f95fd31b76cfe4afd7e55fb25d8ebe8112a45e0514a745eaf0ddad51cab51bac45da7c9212d1efb459a571db2103dc968fa18d3214f96d191ba8ada57a4645acd66f0504f77448052eef56620cd883085fad519c1c3a91360c3bd81ecbf5a7bc2c15cae4b657a6f8c264c7363716f1487f6303c0330dd1f41193
  Timestamp:    Tue Dec 24 16:05:19 2024
 Image 1 (fdt@1)
  Description:  al324-ubnt-udmpro-10g
  Created:      Tue Dec 24 16:05:15 2024
  Type:         Flat Device Tree
  Compression:  uncompressed
  Data Size:    25396 Bytes = 24.80 KiB = 0.02 MiB
  Architecture: AArch64
  Load Address: 0x04078000
  Hash algo:    sha1
  Hash value:   a7a2758200e6f2b9657e2f1104d4ddf3fc24d865
  Sign algo:    sha1,rsa2048:udm_al324
  Sign value:   5379277240e245d09223499348d20684819351393fe06006f50fd499f6b7b22ea1b87868bfa9271207a5eccf13424c84b2dc2f1723a6eba731616a4c190c922a93a8fdedc4f170ea8fcd63ea99cf4357a283bb6a4335afd21430cba3b7e4d939a0709f5bca85a2e58174ce40afd7605f672753b860dc94dd20757eae99721bb7ed9e5fa622b3fd88afa3351b865b4d6f10b3975ce56fee902081c6bb8ab7976dc4d0cbba90610e2ebf469e75095780b8e544b72d3b90a8ae03e4179f6fb23cae0661af0e16cb10dce1d90459b3a21b8c3e89c8e7e347e6840767aac469a0e54f50765a9ad83bf75bf7f60a59a5b1930b1b72121f348c4e548b945986f089e819
  Timestamp:    Tue Dec 24 16:05:20 2024
 Image 2 (fdt@2)
  Description:  al324-ubnt-udmpro-10g-v2
  Created:      Tue Dec 24 16:05:15 2024
  Type:         Flat Device Tree
  Compression:  uncompressed
  Data Size:    26048 Bytes = 25.44 KiB = 0.02 MiB
  Architecture: AArch64
  Load Address: 0x04078000
  Hash algo:    sha1
  Hash value:   911fb3ca61eb099a18229071742ca2d480a68668
  Sign algo:    sha1,rsa2048:udm_al324
  Sign value:   86d42e9dc0a5cacdbb2d3ebebbc1853a659b618da1bd023ad14545b15c24c3020de506b5d777214254ff83dd2379c9fd80fcdc46db1f6047eb8cde0369020b61cc4c7b7f029c41c684c3515bb135418ea82e5dca10baba53c0b29fdf13e21f2fef8f26946bb9038d7c6b6204a5c60a56edd76485f8f0768bdefd6c532a3bdc48792ad8998594eecf12597c4291946ff3957810af6a4c626efa81aef9de723bb31147595b0cce42b6e90673cdb8ee1f432e2621e7093ee18d29884d015ffb3cab192d63fb7bbb45ab700b9cd00eba07772c4ad12a37ecc31970aff520e4a81d4200930b8e448dcc83ec05508d0c2d21e755527447f5b32c346477abfde0af4925
  Timestamp:    Tue Dec 24 16:05:21 2024
 Image 3 (ramdisk@1)
  Description:  ramdisk
  Created:      Tue Dec 24 16:05:15 2024
  Type:         RAMDisk Image
  Compression:  gzip compressed
  Data Size:    8706999 Bytes = 8502.93 KiB = 8.30 MiB
  Architecture: AArch64
  OS:           Linux
  Load Address: unavailable
  Entry Point:  unavailable
  Hash algo:    sha1
  Hash value:   1164edb5fd12c7ceea16d6488c040967b3b72d39
  Sign algo:    sha1,rsa2048:udm_al324
  Sign value:   c9f59895137d9c3a9788959f11143e6332bbfeaceb711ae68cd5f4e82d45657f409f7cd068706be67b4f345fd791d5a09bae39c339643ed2228106cf3d0e0f956d66aaeb6672d036f2e49cff01f307f94b19755bd4e13af52c1bd3085f3644bb223c3f98cb7fe6ca6716af9016a9b1dc1b6e0cc14936d2c55790de0f23230747ff5a73184d6a6a1213db1709a0819d64eaf34913d0ec5492ec74164f0bbf5cdfaf8d682bc3fc5749455920dd53f7be9677c35c3b0c5764db2fbbe0d1e12561627bf57bbe82ae8f0929cf9864539c7292a1c5a7cc4b18b996170abb2077cdd3b10a5939a8047439ca734f90c8c01389ccd18f6b8f01a16b10403a6ac26a370be2
  Timestamp:    Tue Dec 24 16:05:22 2024
 Default Configuration: 'udmpro@1'
 Configuration 0 (udmpro@1)
  Description:  UDMPRO 10G v1 configuration
  Kernel:       kernel@1
  Init Ramdisk: ramdisk@1
  FDT:          fdt@1
  Hash algo:    sha1
  Hash value:   unavailable
 Configuration 1 (udmpro@2)
  Description:  UDMPRO 10G v2 configuration
  Kernel:       kernel@1
  Init Ramdisk: ramdisk@1
  FDT:          fdt@2
  Hash algo:    sha1
  Hash value:   unavailable

At an offset of 216 bytes we can find the magic bytes for GZIP, 1F08. We can extract the compressed kernel as follows [6]:

$ dd if=kernel.bin of=kernel.gz bs=1 skip=216 count=5790500
5790500+0 records in
5790500+0 records out
5790500 bytes (5.8 MB, 5.5 MiB) copied, 11.4056 s, 508 kB/s
$ gunzip kernel.gz
$ file kernel 
kernel: Linux kernel ARM64 boot executable Image, little-endian, 4K pages

However, when running QEMU with this kernel we don’t get any output, so we opted to use our own kernel instead.

Creating the right image structure#

After getting two ways of obtaining a valid kernel, of which one gave some debugging output, we still did not have a way of booting the image. The initramfs expects certain devices which hold the rest of the firmware, so we need to obtain these from the firmware image. The scripts which were identified in the previous section also dealt with mounting these, and so provided ample information on the required structure of the devices. The missing device is mounted as follows:

mount_configdev() {
    [ -b "${CONFIGDEV}" ] || ui_panic "${CONFIGDEV} not found"

    repair_or_mkfs ${CONFIGDEV}
    mkdir_mount_p -t ext4 -o sync ${CONFIGDEV} ${CONFIGDIR}
}

From this we know it is an ext4 image, which is mounted from whatever is configured in CONFIGDEV, to the directory CONFIGDIR. If we grep for the directory variable we get some ideas of what this is used for:

$ rg CONFIGDIR
usr/sbin/reset2defaults
4:CONFIGDIR=/mnt/.config
13:[ -d "${CONFIGDIR}" ] || mkdir -p ${CONFIGDIR}
15:if ! mount -t ext4 ${CONFIGDEV} ${CONFIGDIR}; then
17:    mount -t ext4 ${CONFIGDEV} ${CONFIGDIR} || exit 1
24:touch ${CONFIGDIR}/.reset-to-defaults
28:umount ${CONFIGDIR}

scripts/ubnt
28:CONFIGDIR="/config"
29:FLAG_FACTORY_RESET=${CONFIGDIR}/.factory-reset
30:FLAG_NET_UPGRADE=${CONFIGDIR}/.network-upgrade
31:FLAG_R2DEF=${CONFIGDIR}/.reset-to-defaults
32:FLAG_UPGRADE_BOOT=${CONFIGDIR}/.upgrade-bootup
33:FLAG_RESET_BOOT=${CONFIGDIR}/.reset-bootup
34:FLAG_RECOVER_BOOT=${CONFIGDIR}/.recover-bootup
37:CONFIG_VERSION_FILE=${CONFIGDIR}/version
146:    mkdir_mount_p -t ext4 -o sync ${CONFIGDEV} ${CONFIGDIR}
334:    umount ${CONFIGDIR}

At the very least it seems like this expects a file called ‘version’ in the directory. In the original extracted firmware we can find a file which is a likely candidate:

$ find . -type f -name version 
./usr/lib/version
./usr/share/perl/5.32.1/unicore/version

$ cat ./usr/lib/version
UDMPRO.al324.v4.1.13.1abc0d9.241224.2256

Emulating an actual mtdblock with QEMU is difficult, so we also patched the initramfs to use a different value for CONFIGDEV:

$ diff <(xxd mkinitramfs-MAIN_DQZkE3) <(xxd mkinitramfs-MAIN_DQZkE3-patched)
27,29c27,29
< 000001a0: 6576 2f62 6f6f 7433 0a43 4f4e 4649 4744  ev/boot3.CONFIGD
< 000001b0: 4556 3d2f 6465 762f 6d74 6462 6c6f 636b  EV=/dev/mtdblock
< 000001c0: 350a 4d41 494e 5f49 4e54 4552 4641 4345  5.MAIN_INTERFACE
---
> 000001a0: 6576 2f62 6f6f 7433 0a23 4c4f 4c0a 434f  ev/boot3.#LOL.CO
> 000001b0: 4e46 4947 4445 563d 2f64 6576 2f76 6461  NFIGDEV=/dev/vda
> 000001c0: 310a 4d41 494e 5f49 4e54 4552 4641 4345  1.MAIN_INTERFACE

We then create the ‘block device’ as follows:

$ qemu-img create mtdblock.img 1M
Formatting 'mtdblock.img', fmt=raw size=1048576
$ parted mtdblock.img -s -- mklabel msdos
$ parted mtdblock.img -s -- mkpart primary 0 100%
Warning: The resulting partition is not properly aligned for best performance: 1s % 2048s != 0s
$ sudo kpartx -v -a mtdblock.img
add map loop1p1 (252:1): 0 2047 linear 7:1 1
$ sudo mkfs.ext4 /dev/mapper/loop1p1
mke2fs 1.47.1 (20-May-2024)

Filesystem too small for a journal
Discarding device blocks: done
Creating filesystem with 1020 1k blocks and 128 inodes

Allocating group tables: done
Writing inode tables: done
Writing superblocks and filesystem accounting information: done

$ sudo mount /dev/mapper/loop1p1 /mnt/disk/
$ sudo cp ../b012-UDMPRO-4.1.13/usr/lib/version /mnt/disk/
$ sudo umount /mnt/disk
$ sudo kpartx -d -v mtdblock.img
del devmap : loop1p1
loop deleted : /dev/loop1

Lastly, we use it in QEMU:

$ qemu-system-aarch64 \
-M virt \
-cpu cortex-a57 \
-m 4G \
-kernel Image.gz \
-no-reboot -nographic \
-initrd mkinitramfs-MAIN_DQZkE3-patched \
-drive file=mtdblock.img,if=none,id=disk0,format=raw \
-device virtio-blk-device,drive=disk0
[...]
Ubiquiti Debian OS initialization...
Begin: Waiting device /dev/vda1 ... done.
Begin: Waiting device /dev/disk/by-partlabel/overlay ... 5 ...
4 ...
3 ...
2 ...
1 ...
done.
filesystem on the /dev/vda1 is in a consistent state
[    7.849804] EXT4-fs (vda1): mounted filesystem without journal. Opts: (null)
Begin: Mounting /dev/disk/by-partlabel/overlay to /mnt/.rwfs ... Recovering userdev
fsck.ext4 -pf /dev/disk/by-partlabel/overlay exits with 8
fsck.ext4 -nf /dev/disk/by-partlabel/overlay exits with 8
fsck.ext4 -yf /dev/disk/by-partlabel/overlay exits with 8
fsck.ext4 -nf /dev/disk/by-partlabel/overlay exits with 8
/dev/disk/by-partlabel/overlay repair failed, try mount
mount: mounting /dev/disk/by-partlabel/overlay on /tmp/.repair_fs_device_try_mount failed: No such file or directory
/dev/disk/by-partlabel/overlay is corrupted, reformat it
Begin: Waiting device /dev/disk/by-partlabel/overlay ... 5 ...
4 ...
3 ...
2 ...
1 ...
timed out waiting /dev/disk/by-partlabel/overlay

That’s some progress at least. We’re now looking for a partition called ‘overlay’. Fortunately, there are some scripts in the initramfs which deal with this:

$ rg overlay mkinitramfs-MAIN_DQZkE3_extract/
mkinitramfs-MAIN_DQZkE3_extract/usr/lib/modules/4.19.152-ui-alpine/modules.builtin
13:kernel/fs/overlayfs/overlay.ko

mkinitramfs-MAIN_DQZkE3_extract/scripts/ubnt
149:overlay_cleanup() {
154:    log_begin_msg "Cleaning up overlay filesystem"
234:    do_overlay_cleanup=0
326:        do_overlay_cleanup=1
349:        do_overlay_cleanup=1
353:    if [ ${do_overlay_cleanup} -ne 0 ]; then
354:        overlay_cleanup ${MNT_RWFS}
359:    log_begin_msg "Setting up overlay filesystem"
365:        -t overlay \
367:        overlayfs-root ${rootmnt}
370:    log_begin_msg "Moving mountpoints to overlay filesystem"

mkinitramfs-MAIN_DQZkE3_extract/scripts/product-override
6:USERDEV="/dev/disk/by-partlabel/overlay"
21:     mkpart overlay 10684416S 100%

mkinitramfs-MAIN_DQZkE3_extract/usr/lib/aarch64-linux-gnu/libmount.so.1.1.0: binary file matches (found "\0" byte around offset 7)

Of particular interest is the script mkinitramfs-MAIN_DQZkE3_extract/scripts/product-override, as it seems to build the partition we’re looking for, among other things:

fcd_init_layout() {
    wait_device /dev/boot
    /sbin/parted -s -- /dev/boot \
        mklabel gpt \
        mkpart boot 2048S 133119S \
        mkpart recovery 133120S 198655S \
        mkpart root 198656S 4392959S \
        mkpart log 4392960S 6490111S \
        mkpart persistent 6490112S 10684415S \
        mkpart overlay 10684416S 100%

    mkfs_p ${KERNELDEV}
    # NOTE: ${KERNELRDEV} uses block image here, please don't format it
    mkfs_p ${BOOTDEV}
    mkfs_p ${LOGDEV}
    mkfs_p ${PERSISTDEV}
    mkfs_p ${USERDEV}
}

Based on this script we create our own file with the expected structure and files, including the rootfs and kernel image we recovered earlier:

parted mmcblk.img -s -- \
mklabel gpt \
mkpart boot 2048S 133119S \
mkpart recovery 133120S 198655S \
mkpart root 198656S 4392959S \
mkpart log 4392960S 6490111S \
mkpart persistent 6490112S 10684415S \
mkpart overlay 10684416S 100%

sudo kpartx -v -a mmcblk.img
sudo mkfs.ext4 /dev/mapper/loop0p1
sudo tune2fs -O ^orphan_file /dev/mapper/loop0p1
sudo mkfs.ext4 /dev/mapper/loop0p2
sudo tune2fs -O ^orphan_file /dev/mapper/loop0p2
sudo mkfs.ext4 /dev/mapper/loop0p3
sudo tune2fs -O ^orphan_file /dev/mapper/loop0p3
sudo mkfs.ext4 /dev/mapper/loop0p4
sudo tune2fs -O ^orphan_file /dev/mapper/loop0p4
sudo mkfs.ext4 /dev/mapper/loop0p5
sudo tune2fs -O ^orphan_file /dev/mapper/loop0p5
sudo mkfs.ext4 /dev/mapper/loop0p6
sudo tune2fs -O ^orphan_file /dev/mapper/loop0p6

sudo mount /dev/mapper/loop0p1 /mnt/disk
sudo cp uImage /mnt/disk/
sudo umount /mnt/disk

sudo mount /dev/mapper/loop0p3 /mnt/disk
sudo cp rootfs /mnt/disk/{rootfs,rootfs.bkp}
sudo umount /mnt/disk

sudo kpartx -d -v mmcblk.img

Later on in the testing it was practical to be able to make adjustments to the rootfs as well, which could be done as follows:

LOOP_DEV="$(sudo kpartx -v -a mmcblk.img | rg -o 'loop[0-9]' | uniq)"
sudo mount /dev/mapper/${LOOP_DEV}p3 /mnt/disk/
sudo mount /mnt/disk/rootfs /mnt/disk2/
mkdir /tmp/{work,upper,target}
sudo mount --type="overlay" --options="lowerdir=/mnt/disk2,upperdir=/tmp/upper,workdir=/tmp/work" --source="overlay" --target="/tmp/target"
# make changes to the file system in the directory '/tmp/target' here
sudo mksquashfs /tmp/target rootfs-patched -noappend
sudo umount /tmp/target
sudo umount /mnt/disk2
sudo cp rootfs-patched /mnt/disk/rootfs
sudo umount /mnt/disk
sudo kpartx -v -d mmcblk.img

We then try again with QEMU with the new image:

$ qemu-system-aarch64 \
-M virt \
-cpu cortex-a57 \
-m 4G \
-kernel Image.gz \
-no-reboot -nographic \
-initrd mkinitramfs-MAIN_DQZkE3-patched \
-drive file=mtdblock.img,if=none,id=disk0,format=raw \
-device virtio-blk-device,drive=disk0 \
-drive file=mmcblk.img,if=none,id=disk1,format=raw \
-device virtio-blk-device,drive=disk1
[...]
Ubiquiti Debian OS initialization...
Begin: Waiting device /dev/vda1 ... done.
Begin: Waiting device /dev/disk/by-partlabel/overlay ... done.
[    4.240802] random: fast init done
filesystem on the /dev/vda1 is in a consistent state
[...]
[   10.827971] PPP generic driver version 2.4.2
[   10.828561] Unable to handle kernel paging request at virtual address 8080202000000097
[   10.828907] Mem abort info:
[   10.829016]   ESR = 0x96000004
[   10.829131]   Exception class = DABT (current EL), IL = 32 bits
[   10.829338]   SET = 0, FnV = 0
[   10.829449]   EA = 0, S1PTW = 0
[   10.829603] Data abort info:
[   10.829703]   ISV = 0, ISS = 0x00000004
[   10.829851]   CM = 0, WnR = 0
[   10.830227] [8080202000000097] address between user and kernel address ranges
[   10.830642] Internal error: Oops: 96000004 [#1] SMP
[   10.830932] Modules linked in: ppp_generic(+) slhc
[   10.831322] Process systemd-modules (pid: 322, stack limit = 0x(____ptrval____))
[   10.831793] CPU: 0 PID: 322 Comm: systemd-modules Tainted: G           O      4.19.152-ui-alpine #1
[   10.832033] Hardware name: linux,dummy-virt (DT)
[   10.832317] pstate: 80000005 (Nzcv daif -PAN -UAO)
[   10.832770] pc : ppp_init_net+0x18/0x74 [ppp_generic]
[   10.832963] lr : ops_init+0x84/0x130
[   10.833105] sp : ffffff80094b3a70
[   10.833227] x29: ffffff80094b3a70 x28: 0000000000000013 
[   10.833437] x27: 0000000000000100 x26: ffffffc0f7114200 
[   10.833581] x25: ffffff8008d14000 x24: ffffff8008c99000 
[   10.833724] x23: ffffff80094b3b28 x22: ffffff8008d14890 
[   10.833920] x21: ffffff8008d14980 x20: ffffffc0f7114480 
[   10.834105] x19: ffffff80009ae060 x18: ffffffffffffffff 
[   10.834275] x17: 0000000000000000 x16: 0000000000000000 
[   10.834482] x15: ffffff8008c99d88 x14: ffffffc0f822d000 
[   10.834666] x13: ffffffc0f876c300 x12: ffffffc0f876c200 
[   10.834818] x11: ffffffc0f98add00 x10: ffffffc0f98aab80 
[   10.835081] x9 : ffffffc0f98ada00 x8 : ffffffc0f8218a80 
[   10.835236] x7 : ffffffc0f81dde00 x6 : ffffffc0f732ca90 
[   10.835423] x5 : 0000000000000040 x4 : 0000000000000008 
[   10.835582] x3 : 0000000000000001 x2 : 0000000000002710 
[   10.835785] x1 : 0000000000000012 x0 : 8080202000000007 
[   10.836103] Call trace:
[   10.836254]  ppp_init_net+0x18/0x74 [ppp_generic]
[   10.836447]  ops_init+0x84/0x130
[   10.836567]  register_pernet_operations+0xf8/0x200
[   10.836714]  register_pernet_device+0x38/0x78
[   10.836899]  ppp_init+0x2c/0x1000 [ppp_generic]
[   10.837040]  do_one_initcall+0x5c/0x178
[   10.837161]  do_init_module+0x58/0x1a0
[   10.837286]  load_module+0x1f04/0x22a0
[   10.837403]  __se_sys_finit_module+0xd0/0x100
[   10.837521]  __arm64_sys_finit_module+0x18/0x20
[   10.837641]  el0_svc_handler+0xc0/0x1a0
[   10.837762]  el0_svc+0x8/0xc4
[   10.838047] Code: 910003fd b9410821 f9000bf3 f948b800 (f8615813) 
[   10.838591] ---[ end trace 98eb8021bf8615e9 ]---
[   10.838998] Kernel panic - not syncing: Fatal exception
[   10.839466] Kernel Offset: disabled
[   10.839733] CPU features: 0x0,20006002
[   10.840045] Memory Limit: none
[   10.840385] Rebooting in 3 seconds..
[   10.940881] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000008
[   10.941330] Mem abort info:
[   10.941480]   ESR = 0x96000005
[   10.941619]   Exception class = DABT (current EL), IL = 32 bits
[   10.941910]   SET = 0, FnV = 0
[   10.942042]   EA = 0, S1PTW = 0
[   10.942173] Data abort info:
[   10.942303]   ISV = 0, ISS = 0x00000005
[   10.942509]   CM = 0, WnR = 0
[   10.942706] user pgtable: 4k pages, 39-bit VAs, pgdp = (____ptrval____)
[   10.943006] [0000000000000008] pgd=0000000000000000, pud=0000000000000000

While the partitions work, we now run into a kernel panic…

After some debugging it seems that some of the kernel modules contained in the rootfs don’t play well with the kernel we built.

Kernel modules#

Since some kernel modules won’t load with the kernel we built we could try to disable them, as long as they are not relevant to the functions we’re trying to test. A short search in the rootfs for ppp_generic yields a seemingly custom config file:

$ rg ppp_generic
etc/modules-load.d/udapi-server.conf:ppp_generic
[...]

We tried removing the file, rebuilding the squashFS, and then emulating it with QEMU again. This actually works, and we’re greeted with a login prompt:

Debian GNU/Linux 11 UDMPRO ttyAMA0

UDMPRO login:

Future work#

Given that the basic emulation works, we can use it to look for vulnerabilities in the UDM Pro without requiring the device itself. A good first step would be to add networking functionality to the emulation, as this would allow a more accurate view of what is exposed.

References#

  1. https://www.zerodayinitiative.com/blog/2025/7/30/pwn2own-returns-to-ireland-with-a-one-million-dollar-whatsapp-target
  2. https://github.com/e-m-b-a/emba
  3. https://www.ui.com/download/software/udm-pro
  4. https://emulatedbox.wordpress.com/2024/12/12/emulating-ubiquity-dream-machine-firmware-booting-into-user-space/
  5. https://github.com/fabianishere/udm-kernel
  6. https://www.techpository.com/linux-unpacking-and-repacking-u-boot-uimage-files/