Reverse engineering the 70mai app

link

A photo of the dashcam including the rear camera
A photo of the dashcam including the rear camera

Overview of the camera

link

Recently I’ve bought a 70mai A500S dashcam for my car. Hardware-wise it’s pretty powerful, it records 2.5K video from the front camera and 1080p video from the rear camera. It also comes has a WiFi interface which is supposed to be used with the 70mai app for your Android or iOS phone. But you aren’t here for a dashcam review, are you?

What does the app exactly do?

link

The Android app is used to modify the settings of the camera, preview the live video feed from the cam, and to download videos from the SD card. It can also download and view GPS data saved along with the video.

To use the app you first have to connect to a Wi-Fi Hotspot provided by the camera, and then the app is able to communicate with the dashcam. At first glance there is no authentication required, save for the Wi-Fi passphrase and pressing a button on the dashcam. Naturally I wanted to sniff that communication and develop my own client to operate the dashcam.

Obtaining and setting up the app

link

I have a secondary Android phone set up for reverse engineering, it is rooted and runs LineageOS. The 70mai app is available for download on the Google Play Store, but I don’t have Google Play Services installed on that phone. So I decided to use the Evozi APK Downloader to get an .apk file. Then I used the adb install <apk file path> command to install the app on my phone.

Of course at first I had to create an account in the app (it’s 2021 and everybody wants your data). The app then instructed me to connect to the camera’s Wi-Fi network.

A screenshot of the 70mai app that says 'To ensure the security of your data, please authorize connection to your dash cam.  please click the power button to continue
A screenshot of the 70mai app that says 'To ensure the security of your data, please authorize connection to your dash cam. please click the power button to continue

After clicking the button on the camera I was shown a preview of the video feed and options to view past recordings and GPS data, using the menu in the top right corner I could adjust the camera’s settings.

A screenshot of the 70mai app main screen feat. my dirty keyboard
A screenshot of the 70mai app main screen feat. my dirty keyboard

Determining how the app communicates with the camera

link

I decided to also connect my PC with the camera’s Wi-Fi network and run nmap on the camera’s IP address. Since the built-in DHCP server gave my computer the IP address of 192.168.0.20 I concluded that the camera was available under 192.168.0.1. The scan looked as follows:

$ nmap -A 192.168.0.1
Starting Nmap 7.92 ( https://nmap.org ) at 2021-12-03 19:49 CET
Nmap scan report for 192.168.0.1
Host is up (0.032s latency).
Not shown: 997 closed tcp ports (conn-refused)
PORT   STATE SERVICE VERSION
21/tcp open  ftp     BusyBox ftpd (D-Link DCS-932L IP-Cam camera)
23/tcp open  telnet  BusyBox telnetd
80/tcp open  http    thttpd 2.29 23May2018
|_http-title: 403 Forbidden
|_http-server-header: thttpd/2.29 23May2018
Service Info: Host: NVTEVM; Device: webcam; CPE: cpe:/h:dlink:dcs-932l

Great! Both the telnet and ftp ports are open, and the username for them is root without any password. The FTP server allows me to browse the SD card, and I get a root shell via telnet. I used it to download the binaries stored on the root partition. Most notably /usr/bin/main_app, which is the program that does all of the dashcam stuff.

The only port left is :80, which of course is the HTTP port. After browsing it with my web browser I found out that the camera runs thttpd (I later found out that it is embedded into main_app).

The page you see when visiting the embedded web server
The page you see when visiting the embedded web server

After some fiddling I found out that it serves the whole root filesystem (yes, the entirety of /).

$ curl http://192.168.0.1/etc/passwd
root::0:0:root:/root:/bin/sh

Normally this would be considered a security risk, but since it only works in a password-protected network it’s not a big deal (and also the password-less telnet is more powerful than this).

After seeing all of this, I believe that the app is communicating with the camera via HTTP, using some hidden endpoints.

Sniffing the HTTP traffic

link

Since I wanted to see the HTTP traffic coming from the app I decided to use mitmproxy. It is easier to use on an Android phone than Wireshark, but it only captures HTTP. I used the following command to start the proxy while being connected to the camera’s Wi-Fi network:

$ mitmweb -p 9999

Then I also connected on my phone and set the HTTP proxy in Android WiFi settings to my PC’s IP address and port 9999:

A screenshot of Android WiFi settings with the proxy set to my PC's IP address and port 9999
A screenshot of Android WiFi settings with the proxy set to my PC's IP address and port 9999

I then used the app for a while, trying to use every feature possible, and sure enough, mitmproxy captured a handful of requests being made.

A screenshot of mitmweb showing an incomplete list of URLs
A screenshot of mitmweb showing an incomplete list of URLs

It looks like the protocol uses only GET requests to control the camera.

An example request

GET http://192.168.0.1/cgi-bin/client.cgi?&-operation=register&-ip=192.168.1.15&-timestamp=1638471985&-signkey=08eab1cd35d69036af066f2f7439efe7

Every single one of them includes the -timestamp and -signkey parameter. They are used to authenticate the request, the timestamp is just the current Unix time and I don’t know what the signkey is yet. I tried replaying the request with the same timestamp and signkey, and the camera did respond, but only until the next reboot. So the signkey must be generated by the app.

Reverse engneering how signkey is generated

link

So if I want to use the API in my alternative client I need to generate a signkey. But how does the original app do this? I decided to load up the com.banyac.midrive.app.intl APK into jadx-gui to decompile it and have look at the code.

After searching around for a while I found a class which is responsible for generating the signkey:

package com.banyac.key;

public class BanyacKeyUtils {
    /* renamed from: a */
    public static void m4085a() {
        System.loadLibrary("banyackey");
    }

    private native String sign(long j, String str);

    /* renamed from: a */
    public String mo12629a(long j, Long l, String str) {
        if (l == null) {
            return sign(j, str);
        }
        return sign(j, str + l);
    }
}

Unfortunately, this class only wraps a native library (banyackey.so), which is written in C++. This means I will need to use another tool to reverse engineer this shared library. What this class tells me is that the native method takes two parameters a long, and a string. It also returns a string, which supposedly is the signkey.

I exported the .so file from res/lib/arm64-v8a/banyackey.so using the “Save all” option in jadx. To dive deeper into this, I decided to use Ghidra, which is a free decompiler/dissasembler made by the NSA. When I loaded the binary, Ghidra dissassembled the library, but I could see a lot of functions, mostly related to the C++ standard library.

A screenshot of Ghidra with the `banyackey.so` file open
A screenshot of Ghidra with the `banyackey.so` file open

Now I needed to find the sign function. Unfortunately due to how JNI works the function wasn’t named as an ELF symbol, so I couldn’t just search for it’s name. But I know how JNI exported functions usually look like:

JNIEXPORT jstring JNICALL
Java_com_example_hellojni_HelloJni_stringFromJNI( JNIEnv* env,
                                                  jobject thiz )

With this knowledge I used the “Data Type Manager” to locate the JNIEnv* type, and then right-clicked and used the “Find Uses Of”. As I suspected this type was used only by one function. Here is how it looks like, as decompiled by Ghidra:



jstring sign_function(JNIEnv *jni_env,_jobject *thiz,long the_long,jstring the_string)
{
  long lVar1;
  char *string_passed;
  jstring p_Var2;
  size_t passed_length, magic_length;
  char *result_ptr;
  undefined8 uVar3, in_x4, in_x5, in_x6, in_x7;
  long iterator;
  char *magic_string;
  basic_string<char,std::__ndk1::char_traits<char>,std::__ndk1::allocator<char>> da_basic_str [16];
  void *local_d8;
  byte local_d0 [16];
  char *local_c0;
  MD5 md5_inst [112];
  long local_48;
  
  lVar1 = cRead_8(tpidr_el0);
  local_48 = *(long *)(lVar1 + 0x28);
  uVar3 = 0;
  string_passed = (*(*jni_env)->GetStringUTFChars)(jni_env,the_string,(jboolean *)0x0);
  iterator = 0;
  do {
    if ((int)(&array_of_longs)[iterator] == the_long) {
      magic_string = (&xiaomi_magic_strings)[iterator];
      passed_length = strlen(string_passed);
      magic_length = strlen(magic_string);
      result_ptr = (char *)malloc((long)(((ulong)(uint)((int)magic_length + (int)passed_length) <<
                                         0x20) + 0x100000000) >> 0x20);
      write_string_to_result_ptr
                (result_ptr,0xffffffffffffffff,uVar3,string_passed,in_x4,in_x5,in_x6,in_x7);
      strcat(result_ptr,magic_string); // concatenation
      std::__ndk1::basic_string<char,std::__ndk1::char_traits<char>,std::__ndk1::allocator<char>>::
      basic_string<decltype(nullptr)>(da_basic_str,result_ptr);
                    /* try { // try from 00129748 to 0012975f has its CatchHandler @ 00129810 */
      MD5::MD5(md5_inst,(basic_string *)da_basic_str);
      MD5::toStr(md5_inst);
      if (((byte)da_basic_str[0] & 1) != 0) {
        operator.delete(local_d8);
      }
      free(result_ptr);
                    /* try { // try from 00129780 to 001297b7 has its CatchHandler @ 001297fc */
      (*(*jni_env)->ReleaseStringUTFChars)(jni_env,the_string,string_passed);
      string_passed = (char *)((ulong)local_d0 | 1);
      if ((local_d0[0] & 1) != 0) {
        string_passed = local_c0;
      }
      p_Var2 = (*(*jni_env)->NewStringUTF)(jni_env,string_passed);
      if ((local_d0[0] & 1) != 0) {
        operator.delete(local_c0);
      }
      if (*(long *)(lVar1 + 0x28) == local_48) {
        return (_jstring *)p_Var2;
      }
      goto LAB_001297f8;
    }
    iterator = iterator + 1;
  } while (iterator != 0x6e);
  p_Var2 = (*(*jni_env)->NewStringUTF)(jni_env,"");
  if (*(long *)(lVar1 + 0x28) == local_48) {
    return (_jstring *)p_Var2;
  }
LAB_001297f8:
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

I took the liberty of renaming some stuff for clarity. I also imported the jni_all.gdt Ghidra Data Types Archive to my project, so I could get the method names for JNIEnv*.

What does this method do?

link

To sum up: this native library takes the input string, concatenates it with another string chosen from a hardcoded list (xiaomi_magic_strings). Which string is chosen depends on the value of the the_long parameter. I have extracted the strings and their respective ids in Appendix I|. So now we must see what parameters are passed to the function. I chose to modify the app so that it would log the parameters, and the return value every time this sign function was called. Unfortunately modyfing the app causes it’s checksum to change, and the app checks it, so I had to change my approach. If you want to see how I modified it, feel free to read this collapsed section. nowmal

My failed attempt at modyfing the app

Due to the fact that the java files generated by jadx usually cannot be compiled back into a working app (jadx is unable to decompile some methods, the deobfuscation also renames a lot of stuff). Patching apps with small changes is usually done with apktool. To dissassemble the app I ran:

$ apktool d com.banyac.midrive.app.intl

Then I found the BanyacKeyUtils.smali file and added the following lines to print the arguments and the return value:

--- ../BanyacKeyUtil_original.smali     2021-12-04 21:42:57.221623886 +0100
+++ ./smali/com/banyac/key/BanyacKeyUtils.smali 2021-12-05 12:51:41.643678335 +0100
@@ -46,18 +46,39 @@
     invoke-virtual {v0}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
 
     move-result-object p3
    
+    const-string v0, "THE LONG"
+    invoke-static {p1, p2}, Ljava/lang/Long;->toString(J)Ljava/lang/String;
+    move-result-object v4
+    invoke-static {v0, v4}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
+    const-string v0, "THE STRING WITH THE OPTIONAL LONG"        
+    invoke-static {v0, p3}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
+    
     invoke-direct {p0, p1, p2, p3}, Lcom/banyac/key/BanyacKeyUtils;->sign(JLjava/lang/String;)Ljava/lang/String;
 
     move-result-object p1
 
+    const-string v0, "THE RESULT"        
+    invoke-static {v0, p1}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
+
     return-object p1
 
     .line 3
     :cond_0
+
+    const-string v0, "THE LONG"
+    invoke-static {p1, p2}, Ljava/lang/Long;->toString(J)Ljava/lang/String;
+    move-result-object v4
+    invoke-static {v0, v4}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
+    const-string v0, "THE STRING"        
+    invoke-static {v0, p4}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
+
     invoke-direct {p0, p1, p2, p4}, Lcom/banyac/key/BanyacKeyUtils;->sign(JLjava/lang/String;)Ljava/lang/String;
 
     move-result-object p1
 
+    const-string v0, "THE RESULT"        
+    invoke-static {v0, p1}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
+
     return-object p1
 .end method

After saving the file, I assembled the apk back to one file and signed it using the jarsigner tool (it comes with any JDK installation or the Android SDK).


$ apktool b com.banyac.midrive.app.intl -o ./modified.apk

# run only once, this generates a keystore
$ keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000

$ jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore my-release-key.keystore modified.apk alias_name

$ adb install modified.apk

Please note that If you want to install the modified app, you have to uninstall the original one first. Otheriwse Android will complain that the app has a diffrent signature.

Immediately after the installation, I ran the app and it logged the app started logging invocations of the sign function, even without connecting to the camera. It looks like they are using this function to hash user passwords client-side and authenticate the requests to their servers. This is an example invocation captured:

12-05 12:27:21.875  4147  4147 D THE LONG: 2001005
12-05 12:27:21.875  4147  4147 D THE STRING WITH THE OPTIONAL LONG: 2001005
12-05 12:27:21.875  4147  4147 D THE RESULT: b3ef3dccf9ba266864a67003d27b5bb5

With this I could confirm that the native livrary indeed is just concatenating two strings and hashing them with MD5. Here the string passed to the function is "2001005" which is ASCII equivalent of the long paramete, which equals to 2001005. The magic string connected with that value is 0c5269735ed4b129

$ echo -n 20010050c5269735ed4b129 | md5sum
b3ef3dccf9ba266864a67003d27b5bb5  -

Bingo! The hash matches. Now that I know how the signkey is generated, I cen proceed to gathering information used as an input to the sign function.

To my dismay it turns out the app checks if it’s code has been modified and doesn’t even allow you to log in if it detects tampering. I could probably patch the code that does that, but I decided that taking another approach is the way to go.

Hooking the app using xposed

link

Since my phone is rooted and I have EdXposed installed, I can try a diffrent approach to log the invocations of interesting functions. EdXposed is an implementation of the Xposed framework. It’s a framework that allows you to hook any function in an Android app and modify its behavior. I opened Android studio and quickly created an Exposed module. You can follow these resources to get started:

I have written the main module class in kotlin and it looks like this:

package dog.alu.banyackeyutilsexposedmodule

import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
import de.robv.android.xposed.*
import de.robv.android.xposed.XposedHelpers.findAndHookMethod

class BanyacKeyUtilsHook : IXposedHookLoadPackage {
    @Throws(Throwable::class)
    override fun handleLoadPackage(lpparam: LoadPackageParam) {
        if (lpparam.packageName != "com.banyac.midrive.app.intl") {
            return
        }
        XposedBridge.log("DDD Loaded app: " + lpparam.packageName)
        val loggerHook = object : XC_MethodHook() {
            @Throws(Throwable::class)
            override fun afterHookedMethod(param: MethodHookParam) {
                // here we stringify the method's arguments and return value, then we print them to logcat
                XposedBridge.log("DDD a method called  " + param.method.declaringClass.canonicalName + "." + param.method.name + "(" + param.args.map {
                    if (it == null) return@map "null"
                    if (it is String) "\"" + it + "\"" else it.toString()
                }.joinToString(", ") + ") = " + param?.result?.toString())
            }
        }
        findAndHookMethod(
            "com.banyac.key.BanyacKeyUtils", // fully qualified name of he class to hook
            lpparam.classLoader,
            "a", // the name of the method
            "long", // the firest parameter, it is a primitive long, so it is denoted as "long"
            java.lang.Long::class.java,
            java.lang.String::class.java,
            loggerHook
        )

        findAndHookMethod(
            "com.banyac.midrive.base.c.l",
            lpparam.classLoader,
            "b",
            java.lang.String::class.java,
            loggerHook
        )
    }
}

It basically waits until the app is loaded and hooks two methods com.banyac.key.BanyacKeyUtils.a and com.banyac.midrive.base.c.l.b, the hook simply logs which method was called, what argumets were passed and what the return value was. I had to hook two methods, because from my analysis using jadx it became clear that Xiaomi does not always use the banyackey.so library for MD5 hashing, but sometimes uses a method written in Java. Therefore I had to hook both methods. If you are curious this is how b looks like decompiled:

b()

Here is the source code:


    /* renamed from: b */
    public static String m6677b(String str) {
        try {
            MessageDigest instance = MessageDigest.getInstance("MD5");
            byte[] bArr = new byte[0];
            try {
                bArr = str.getBytes("UTF-8");
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
            byte[] digest = instance.digest(bArr);
            StringBuffer stringBuffer = new StringBuffer();
            for (byte b : digest) {
                int i = b & 255;
                if (i &lt; 16) {
                    stringBuffer.append("0");
                }
                stringBuffer.append(Integer.toHexString(i));
            }
            return stringBuffer.toString();
        } catch (Exception e2) {
            System.out.println(e2.toString());
            e2.printStackTrace();
            return "";
        }
    }

As you can see this is just a helper method which hashes the string passed to it using MD5.

Capturing the pairing handshake

link

I am guessing that the signkey is also involved in the pairing of the camera and the phone. To capture it I decided to restore the dashcam to its factory defaults, wipe the app’s storage and create a new account. Then I would be able to capture all the requests being sent to the camera, and the process in which the keys are generated.

I installed the module like any other Android app created in Android Studio. Then I enabled it in EdXposed and rebooted the phone to fully activate it. After that the app stopped complaining about being modified and started to logcat. When I ran logcat and attepted to pair with the camera this is what I saw (I have annotaed the log with the requests that were captured by mitmproxy in the meantime):

$ adb logcat | grep DDD
12-05 21:22:12.133  4270  4270 I EdXposed-Bridge: DDD a method called com.banyac.key.BanyacKeyUtils.a(-1, null, "1935132") = b2426fa07ce70ff41695914749e2aa98 # 1935132 is my user id in the app
# GET /cgi-bin/BindByBanya.cgi?&-usr=1935132&-signkey=b2426fa07ce70ff41695914749e2aa98
# {"ResultCode":"0","Result":{"Token":"f8cfbe7b7c89f22753630bcf24ae37e4","timestamp":"1609430587"}} 

12-05 21:22:12.319  4270  4270 I EdXposed-Bridge: DDD a method called com.banyac.key.BanyacKeyUtils.a(-1, null, "1609430587") = 6a75e5041f9c63626c7df138aa20cdfd
# GET http://192.168.0.1/cgi-bin/UserconfirmByBanya.cgi?&-timestamp=1609430587&-signkey=6a75e5041f9c63626c7df138aa20cdfd
# {"ResultCode":"-6674"}

12-05 21:22:13.765  4270  4270 I EdXposed-Bridge: DDD a method called  com.banyac.key.BanyacKeyUtils.a(-1, null, "1609430587") = 6a75e5041f9c63626c7df138aa20cdfd
# GET http://192.168.0.1/cgi-bin/UserconfirmByBanya.cgi?&-timestamp=1609430587&-signkey=6a75e5041f9c63626c7df138aa20cdfd
# {"ResultCode":"-6674"}

# continues until I accept the pairing on the camera...

12-05 21:22:31.785  4270  4270 I EdXposed-Bridge: DDD a method called  com.banyac.key.BanyacKeyUtils.a(-1, null, "1609430587") = 6a75e5041f9c63626c7df138aa20cdfd
# GET http://192.168.0.1/cgi-bin/UserconfirmByBanya.cgi?&-timestamp=1609430587&-signkey=6a75e5041f9c63626c7df138aa20cdfd
# {"ResultCode":"0"}

# now normal requests can be made

12-07 17:03:49.778  5175  5175 I EdXposed-Bridge: DDD a method called  com.banyac.midrive.base.c.lb("setaccessalbum.cgi?&-enable=1&-timestamp=1638893029f8cfbe7b7c89f22753630bcf24ae37e4") = eee66d9b8866b8111b4817e5a2329a1f # a part of the URL is hashed using MD5 along with the Token obtained from BindByBanya.cgi
# GET http://192.168.0.1/cgi-bin/setaccessalbum.cgi?&-enable=1&-timestamp=1638893029&-signkey=eee66d9b8866b8111b4817e5a2329a1f
# ...

From these requests we can conclude that the app generates the signkey for the pairing request based on the user’s id. Then it gets a token from the camera, and waits until the pairing is approved on the screen. Then it signes every request with the token and token. The timestamp in each request is not checked so a replay attack is possible.

A simplified algorithm for paring the camera is as follows:

  1. Take a random integer that looks like an user id
  2. Calculate key1 = md5(userId + "73VpsAfdety8FDd0")
  3. Send a GET /cgi-bin/BindByBanya.cgi?&-usr=<userId>&-signkey=<key1> and save the returned token and timestamp
  4. Calculate key2 = md5(timestamp + "73VpsAfdety8FDd0") (Take the timestamp from the previous response)
  5. While waiting for the user to confirm the paring poll the camera with GET /cgi-bin/UserconfirmByBanya.cgi?&-timestamp=<timestamp>&-signkey=<key2> (Take the timestamp from the previous response) until you get a ResultCode=0 response
  6. Now you can make requests to the camera by adding a signkey to the URL. The signkey is calculated as md5(<requestUrlWithParamsAndTimestamp> + <token>)

Conclusion

link

As you can see I have successfully reverse engeenered the camera’s pairing process. This allows me to write an alternative client. Due to the fact that the camera hosts all files in / I can write the client in a form of a webapp and just serve it from the root of the server. This allows me to interact with the camera with an laptop, or a phone without the 70mai app.

Appendix I: Possible requests

link

Every request listed here must include the -timestamp and -signkey parameters, I have omitted them for the sake of brevity.

Register the connection

link
GET /cgi-bin/client.cgi?&-operation=register&-ip=<client ip>
{"ResultCode":"0"}

This kind of request is made by the app every few seconds. It causes the UI to show a message that a phone is connected, and the message is stopped being shown after the requests stop.

Get device information

link
GET /cgi-bin/getdeviceattr.cgi
{
  "ResultCode": "0",
  "Result": {
    "name": "DR2500",
    "softversion": "1.0.10ww",
    "softversion_date": "2021.07.02",
    "devchannel": "41002001",
    "devts": "20210102074106",
    "guidemode": "0",
    "gpsdataversion": "1"
  }
}

Returns general information about the device.

Switch album mode

link
GET /cgi-bin/setaccessalbum.cgi?&-enable=1
{"ResultCode":"0"}

Switches the “Album mode” on and off depending on the value of the -enable parameter. In this mode recording is paused and you are able to download and preview video files.

Get SD card information

link
GET /cgi-bin/getsdstate.cgi
{
  "ResultCode": "0",
  "Result": { "sdstate": "SDOK", "sdtotal": "14827MB", "sdused": "13962MB" }
}

Returns information about the SD card. Possible values for sdstate are (based on my analysis of main_app):

Get all settings

link
GET /cgi-bin/getAllMenu.cgi
{
  "ResultCode": "0",
  "Result": {
    "audioin_mute": "1",
    "volume": "3",
    "splittime": "180",
    "systime": "20210102074133",
    "wifibootenable": "0",
    "bootmusic": "1",
    "videoencode": "hevc",
    "resolution": "2592x1944P30",
    "video_format": {
      "front": {
        "hevc": ["2592x1944P30", "2560x1920P30", "1920x1440P30"],
        "h264": ["1920x1440P30"]
      }
    },
    "voicecontrolenable": "1",
    "collision_level": "3",
    "Parking_on": "0",
    "Parking_threshold": "0",
    "Parking_entertime": "0",
    "adas_on": "0",
    "adas_limber_launch": "1",
    "adas_lane_departure": "1",
    "adas_limber_crash": "1",
    "adas_environment_lable": "0",
    "lapserec_on": "0",
    "wdr_enable": "1",
    "watermark_on": "1",
    "speed_unit": "0",
    "language_default": "English",
    "language_support": [
      "English",
      "\xd0\xa0\xd1\x83\xd1\x81\xd1\x81\xd0\xba\xd0\xb8\xd0\xb9",
      "\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e",
      "\xed\x95\x9c\xea\xb5\xad\xec\x96\xb4",
      "Espa\xc3\xb1ol",
      "Portugu\xc3\xaas",
      "\xe7\xb9\x81\xe9\xab\x94\xe4\xb8\xad\xe6\x96\x87",
      "Polski",
      "\xe0\xb8\xa0\xe0\xb8\xb2\xe0\xb8\xa9\xe0\xb8\xb2\xe0\xb9\x84\xe0\xb8\x97\xe0\xb8\xa2"
    ],
    "P1N0enable": "1",
    "screenautosleep_time": "0",
    "dateformat": "0"
  }
}

Returns the settings of the camera.

Get date format

link
GET /cgi-bin/getdateformat.cgi
{"ResultCode":"0","Result":{"type":"0"}}

Returns the date format. I don’t know what the type is, but it’s always 0.

Get WiFi info

link
GET /cgi-bin/getwifi.cgi
{
  "ResultCode": "0",
  "Result": {
    "enablewifi": "1",
    "wifissid": "70mai_Pro Plus+_XXXX",
    "secretmode": "3",
    "whichkey": "0",
    "wifikey": "XXXXXXXX",
    "linkstatus": "1"
  }
}

Returns the Hotspot’s Wi-Fi settings.

Set Wifi on boot

link
GET /cgi-bin/setwifiboot.cgi?&-enable=1
{"ResultCode":"0"}

When enable is set to 1, the camera will turn on the Hotspot on boot, if it is set to 0, it will need to be turned on manually.

Get file count

link
GET /cgi-bin/getfilecount.cgi
{
  "ResultCode": "0",
  "Result": [
    { "type": "0", "count": "51" },
    { "type": "1", "count": "8" },
    { "type": "2", "count": "0" },
    { "type": "3", "count": "1" },
    { "type": "4", "count": "26" },
    { "type": "5", "count": "0" },
    { "type": "6", "count": "3" },
    { "type": "7", "count": "0" },
    { "type": "8", "count": "13" },
    { "type": "9", "count": "0" }
  ]
}

Returns the number of files in each of the different file types. Known file types are:

TypeDescription
0Front camera normal video
1Front camera emergency (event) video
3Photos
6Back camera emergency (event) video
8Back camera normal video

List files of type

link
GET /cgi-bin/getfilelist.cgi?&-start=1&-end=1&-type=4
{
  "ResultCode": "0",
  "Result": [
    {
      "path": "/mnt/sd/Photo",
      "name": "PH20210101-062950-000025.JPG",
      "size": "173233",
      "type": "3"
    }
  ]
}

Get a list of videos or photos of a given type. Start and end are offsets used for pagination. You could access the above photo via GETting /mnt/sd/Photo/PH20210101-062950-000025.JPG.

Delete a media file

link
GET /cgi-bin/delete.cgi?&-path=%2Fmnt%2Fsd%2FNormal%2FFront&-name=NO20210103-080052-000233F.MP4
{"ResultCode":"0"}

Deletes a video or photo. The path parameter is the path to the directory where the file is, and the name parameter is the name of the file. You can use the values returned by getfilelist to get the path and name.

Appendix II: strings embedded in banyackey.so

link

These are the strings that xiaomi uses when hashing various things in the app. They tried to hide them by putting them in a native library but I extracted them anyway.

{
  "-1": "73VpsAfdety8FDd0",
  "0": "9B8362773E966FD82BF1B7E96BBA08AF",
  "1001001": "d95c23f0f!-16a1dda91a20701c2433153%",
  "1001002": "d95c23f0f!-16a1dda91a20701c2433153%",
  "2001001": "0c5269735ed4b129",
  "2001002": "0c5269735ed4b129",
  "2001003": "0c5269735ed4b129",
  "2001004": "0c5269735ed4b129",
  "3001001": "0c5269735ed4b129",
  "3001002": "0c5269735ed4b129",
  "3001003": "0c5269735ed4b129",
  "4001001": "0c5269735ed4b129",
  "4001002": "0c5269735ed4b129",
  "5001001": "0c5269735ed4b129",
  "5001002": "0c5269735ed4b129",
  "6001001": "94f1462c3d114146",
  "6001002": "94f1462c3d114146",
  "6001003": "94f1462c3d114146",
  "6001004": "94f1462c3d114146",
  "6001005": "94f1462c3d114146",
  "6001006": "94f1462c3d114146",
  "6001007": "94f1462c3d114146",
  "6001008": "94f1462c3d114146",
  "6001009": "94f1462c3d114146",
  "6001010": "94f1462c3d114146",
  "7001001": "4e8ae9c106ef1b034785e43610846ced",
  "7001002": "3538944ad2c4f59e9915417b0ec0dc81",
  "7001003": "4e8ae9c106ef1b034785e43610846ced",
  "7002001": "3538944ad2c4f59e9915417b0ec0dc81",
  "7002002": "3538944ad2c4f59e9915417b0ec0dc81",
  "8001001": "51fcdd41f5ec9659__a!",
  "8001002": "51fcdd41f5ec9659__a!",
  "8001003": "51fcdd41f5ec9659__a!",
  "11001001": "755ba3c!7e87$db648",
  "11001002": "755ba3c!7e87$db648",
  "12001001": "8f58ed24(===+++)2e54a4d9",
  "12001002": "8f58ed24(===+++)2e54a4d9",
  "12001003": "8f58ed24(===+++)2e54a4d9",
  "12002001": "c02jv82-bb2-a8432f3f3f;lc93",
  "12002002": "c02jv82-bb2-a8432f3f3f;lc93",
  "13001001": "!cf55fc#91166bc_566",
  "13001002": "!cf55fc#91166bc_566",
  "14001001": "495!39e7a@319d1#1f2",
  "14001002": "495!39e7a@319d1#1f2",
  "2001005": "0c5269735ed4b129",
  "3001005": "0c5269735ed4b129",
  "8001005": "51fcdd41f5ec9659__a!",
  "2001007": "03lkhzxdcf6!&*^%)(*32jd4b129",
  "2001008": "03lkhzxdcf6!&*^%)(*32jd4b129",
  "3001007": "03lkhzxdcf6!&*^%)(*32jd4b129",
  "3001008": "03lkhzxdcf6!&*^%)(*32jd4b129",
  "1002001": "diujfdb789234hjkaff8+3!#53%",
  "1002002": "diujfdb789234hjkaff8+3!#53%",
  "2001009": "03lkhzxdcf6!&*^%)$&32j@4b129",
  "2001011": "0c5269735ed4b129",
  "3001009": "03lkhzxdcf6!&*^%)$&32j@4b129",
  "8001007": "51fcdd41f5ec9659__a!",
  "15001001": "8p5!39wrtd319e1#1f2",
  "15001002": "8p5!39wrtd319e1#1f2",
  "16001001": "965@39wrtd319e1#1f2",
  "16001002": "965@39wrtd319e1#1f2",
  "17001001": "sadlkf8231!^&$@O(!jkhz893xcvbol",
  "17001002": "sadlkf8231!^&$@O(!jkhz893xcvbol",
  "16001003": "965@39wrtd319e1#1f2",
  "16001004": "965@39wrtd319e1#1f2",
  "16001005": "965@39wrtd319e1#1f2",
  "16001006": "965@39wrtd319e1#1f2",
  "16001007": "965@39wrtd319e1#1f2",
  "16001008": "965@39wrtd319e1#1f2",
  "18001001": "89FSDGJd3HKL2347e89FD$1JKL!",
  "18001002": "89FSDGJd3HKL2347e89FD$1JKL!",
  "18001003": "89FSDGJd3HKL2347e89FD$1JKL!",
  "18001004": "89FSDGJd3HKL2347e89FD$1JKL!",
  "14002001": "38kjfdgb!7834klc0__3+dkoieig",
  "14002002": "38kjfdgb!7834klc0__3+dkoieig",
  "19001001": "sa_+dlkf823p1!^&$r0@O(!jkhpz893x^",
  "19001002": "sa_+dlkf823p1!^&$r0@O(!jkhpz893x^",
  "21001001": "38F33d}KJA)93df82134-0=-43781Vad3e",
  "21001002": "38F33d}KJA)93df82134-0=-43781Vad3e",
  "21001003": "38F33d}KJA)93df82134-0=-43781Vad3e",
  "14003001": "38kjfdgb!7834klc0__3+dkoieig",
  "14003002": "38kjfdgb!7834klc0__3+dkoieig",
  "26001001": "39VBCd240)@#&ZX,.M!O!ldv023jmn;",
  "26001002": "39VBCd240)@#&ZX,.M!O!ldv023jmn;",
  "14004001": "38dlc92lhdsag;kh))01d87235l__212lnkds",
  "14004002": "38dlc92lhdsag;kh))01d87235l__212lnkds",
  "27001001": "kf823p1!^&$r0@O(!ZXO!ldv023jmn4-",
  "27001002": "kf823p1!^&$r0@O(!ZXO!ldv023jmn4-",
  "26001003": "39VBCd240)@#&ZX,.M!O!ldv023jmn;",
  "26001004": "39VBCd240)@#&ZX,.M!O!ldv023jmn;",
  "27001003": "kf823p1!^&$r0@O(!ZXO!ldv023jmn4-",
  "27001004": "kf823p1!^&$r0@O(!ZXO!ldv023jmn4-",
  "27001005": "kf823p1!^&$r0@O(!ZXO!ldv023jmn4-",
  "27001006": "kf823p1!^&$r0@O(!ZXO!ldv023jmn4-",
  "29001001": "vd89203bv84;OP)@cdf72ascfb;+))",
  "29001002": "vd89203bv84;OP)@cdf72ascfb;+))",
  "29001003": "vd89203bv84;OP)@cdf72ascfb;+))",
  "29001004": "vd89203bv84;OP)@cdf72ascfb;+))",
  "30001001": "C9023GHJ;LKhnuydagdhf023=8f!1jhdfef",
  "30001002": "C9023GHJ;LKhnuydagdhf023=8f!1jhdfef",
  "30001003": "b9384bhlks)&t3bgl;)(&^%HJLef62?iedf",
  "30001004": "b9384bhlks)&t3bgl;)(&^%HJLef62?iedf",
  "25001001": "dsf29b43jkdf894gy_)(df&!0v3j",
  "25001002": "dsf29b43jkdf894gy_)(df&!0v3j",
  "25002001": "dsaf;30-2146v83;lkadsf234",
  "25002002": "dsaf;30-2146v83;lkadsf234",
  "32001001": "3fsadg_ag9h;5nm2agdft37aldb0UYF&23f9$@@@",
  "32001002": "3fsadg_ag9h;5nm2agdft37aldb0UYF&23f9$@@@",
  "32001003": "3fsadg_ag9h;5nm2agdft37aldb0UYF&23f9$@@@",
  "32001004": "3fsadg_ag9h;5nm2agdft37aldb0UYF&23f9$@@@"
}