Reverse-engineering a Drone Camera Module

meekworth
7 min readJun 8, 2020

--

Reverse-engineering the “lewei_cam” server from a common Lewei camera module used in recreational drones.

TL;DR

I wanted to implement my own video-streaming client for a toy drone and created two applications:

  • LW Drone Cam — Android app available on Google Play.
  • pylwdrone — Python library that provides full streaming and command functionality (but not flying control yet).

Initial Investigation

I won a Force1 udiR/C Blue Heron drone from a CTF competition and didn’t feel comfortable using the recommended Android app due to its source and the permissions required, so I decided to investigate controlling it on my own. Since it came with a radio controller with a phone stand, I only needed the video streaming functionality. While reverse-engineering the streaming, I went down the rabbit hole and learned a lot about the drone.

Taking a look at the hardware, I found the board labeled with “lw-9621 rev 2.0”, which is the LW9621 720P Wi-Fi camera board from www.le-wei.com. There was no additional information or documentation from the site.

The first step was to figure out how the app communicated with the drone. Unpacking the Android app and trying Java decompilers did not yield much because all the functionality was in compiled dynamic libraries. I didn’t want to get into reverse-engineering the .so files yet, and looked into emulators. Those libraries were built for ARM and won’t run on x86-based emulators that Android Studio now supports. I found MEmu Play ran the app nicely.

With the app running, the next step was to capture network packets while using it. Capturing a video stream with Wireshark, some of the communication structure became apparent. From a sample hex dump:

Each application-level packet started with the lewei_cmd string, which I investigated for any prior work. I found a post by TJ Horner, who referred to another post, and both wrote about the protocol for flying the drone through UDP packets, but not much detail on the streaming protocol.

I captured more network traffic while streaming to see how I could initiate and collect a stream. Important protocol pieces that stuck out were:

  • Each application-level packet header started with “lewei_cmd\x00” and was 46 bytes long.
  • The stream command was #2, possibly a 4-byte integer 0x00000002 (\x02\x00\x00\x00 in little-endian byte order)
  • The length of the “body” of the application-level packet was at byte offsets [6:10] (using Python-style indexing).
  • The “body” had another header 32 bytes long. 0x00000001 was the first four bytes, the next four were the size of the payload, then some unknown number, and zeros.
  • The payload was an H264 stream, with individual NAL units delineated by the bytes \x00\x00\x00\x01, as specified by the standard.
  • The client sent the command #1 every second, possibly for a heartbeat. The server responded in the same connection with a 110-byte packet (46-byte header and a 64-byte response body).

I wrote a Python script to mimic the activity in the captured network traffic and save the H264 stream to a file. However, when playing the file in VLC, about half of each frame looked corrupted. From an example of the camera sitting at the end of my table looking at the wall:

Corrupted video frame.

I spent a significant amount of time trying to figure out what was wrong by studying the standard, decoding the NAL unit headers, trying to determine if this really was H264, converting the stream to different formats with FFmpeg, trying different versions of FFmpeg, all with no success. I decided I needed to check the software on the camera to see how it was actually encoding the stream, and began looking how to access it.

Hacking the Drone

Students from the University of Texas at Dallas evaluated the security threats of consumer drones, and documented some of the commands used for streaming and listing videos. They detailed an attack to change the root password over FTP and login over telnet. I ran a full TCP scan with nmap to see how similar my drone was to theirs:

PORT     STATE SERVICE
23/tcp open telnet
6789/tcp open ibm-db2-admin
7060/tcp open unknown
8060/tcp open unknown
9060/tcp open unknown

From their information and more experimenting with the app, the available ports are:

  • 23 — confirmed telnet server
  • 6789 — “daemon” process (found out later)
  • 7060 — streaming
  • 8060 — camera module commands
  • 9060 — drone movement

Port 21 was not available for FTP, so the access method to update the root password was not available. Trying some common default passwords for root over telnet yielded nothing.

From my tests of recording video, listing, and initiating a download, I captured the following traffic when downloading a saved video:

The request included the full file path (so the SD card was mounted to /mnt), followed by a bunch of zeros, which is probably the maximum path length supported. The response included a similar structure as the request, some bytes that looked to be the size, and the content of the MP4 file. I wrote a script to send a similar request where I could control the path. Capturing everything the server sent back, I saw I could download any from the filesystem:

I then immediately got /etc/shadow to get root’s password hash. Similar to the progress made in the UT Dallas paper, I also was unable to crack it after trying several methods over a few days.

I started pulling more files to learn more about the system, including some from /proc. It was running Linux 3.4.35 on ARM little-endian, used busybox, and the process using the streaming port was /usr/bin/lewei_cam. The /proc/mounts file showed /dev/root was a squashfs partition mounted on /. Downloading /dev/root worked, and I could extract the contents of the file system with binwalk, giving me everything on that partition.

Note: While working on pylwdrone (see below), I found a stack-overflow vulnerability that allowed remote code execution, giving me full command-line root access to the device, which I’ll write about later.

Edit: The exploit write-up is now available.

RE lewei_cam and Fixing the Video

Toward the goal of supporting my own streaming, I needed to break apart the lewei_cam process. Since IDA Free doesn’t support ARM disassembly (or decompilation), I tried out Ghidra. Using all the default settings, it decompiled into C pretty well, making it MUCH easier to investigate, and I began browsing through. The three threads handling connections to the three ports were:

  • client_thread() —handles clients for streaming on 7060.
  • lewei_tcp_cmd_thread() — handles clients for sending commands on port 8060.
  • lewei_tcp_to_uart_thread() — handles drone-flying commands on 9060.

The client_thread function did the following, as described by some (very high-level) pseudo code:

initialize variables
while true:
hdr := read 0x2e bytes from socket
if timeout (> 6 seconds)
disconnect
verify hdr[0:10] == 'lewei_cmd\x00'
switch (int hdr[10:14])
0x01:
send_heartbeat()
0x02:
start video stream thread
0x03:
stop any current stream
disconnect and return
0x09:
info := read x bytes from socket (x at bytes [22:26] of hdr)
start video replay with info
0x10:
goto 0x03 case
0x12:
info := read x bytes from socket (x at bytes [22:26] of hdr)
start download file thread with path in info

So I was on the right track with my stream-capture script, and I followed through more functions that read the raw frame from the camera device, performed the encoding, and sent the data back through the connection. After a lot of back-and-forth looking at the decompiled code and trying more decoding tests, I eventually came across a mysterious code segment:

lewei_cam flipping a byte in the H264 frame before sending.

Here, *param_2 was one of the unknown numbers in the 32-byte header, and param_2[3] was the byte length of the frame. The FUN_000120a0 function performed some munging of those two values, and returned an index into a byte in the frame (param_2[2]) whose bits got flipped. That function was pretty convoluted, so I went into the Android app’s liblewei-3.0.so file in Ghidra (which I already spent some time looking into trying to fix this corruption issue), and found the location where that byte-munging was undone to see if it was any clearer:

Top — small segment in avc_read_buffer_thread getting an index and flipping a byte. Bottom — decompiled encode_index() function.

The encode_index function was much simpler than the FUN_000120a0 function in the server. I implemented that in Python, made the call in my script when reading each frame, and it worked! I had a nice clear (up to the camera’s capabilities) H264 video stream:

Fixed video frame.

With that issue finally solved, I wrote an Android app that only implemented the live streaming and video recording. It is written only in Java using the official Android and Androidx APIs, no compiled developer-provided libraries, and doesn’t require any permissions (other than network access to communicate with the camera). I released the code as lwdronecam on GitHub and published the LW Drone Cam app on Google Play for download.

More RE

After spending a significant amount of time going through lewei_cam, I had a pretty good understanding of its functionality and decided to write a Python library to communicate with it over its streaming and command ports. I implemented the rest of the streaming functionality for replaying recorded videos and downloading files, and then went through the switch statement in the lewei_cmd_execute function (called by lewei_tcp_cmd_thread on port 8060), implementing the requests for each command, one by one, based on what the server uses from each packet. It supports commands like getting and setting the time, getting and setting configuration information, changing the WiFi settings, taking pictures, and more. The module is available on PyPi and GitHub:

pip3 install pylwdrone

TODO

I have not implemented the flying commands, but plan to incorporate that in pylwdrone later.

--

--