Liberando el patinete Xiaomi M365 - Parte 1: La App

Hace ya unos meses descubrí la existencia de un nuevo producto de la marca Xiaomi. Concretamente, un patinete completamente eléctrico con una autonomía y potencia bastante interesante junto a un precio bastante asequible. Poco a poco el patín fue expandiéndose por la oficina como si de una plaga se tratase así que no me quedo más remedio que comprarme uno para mis trayectos diarios al trabajo.

Foto patinete

Entrando un poco más en detalles acerca del patín, éste dispone de una aplicación de Android/IOS para realizar actualizaciones de firmware y otras funciones, pero lo que nos interesa en este artículo es su funcionalidad de actualización del firmware. Originalmente, cuando los patinetes únicamente se vendían en China no limitados en cuestiones como la potencia del motor, pero al empezar su comercialización en Europa (y posiblemente para preparar el terreno para un nuevo modelo) procedieron a reducir su rendimiento en posteriores actualizaciones de firmware.

Aquí es donde empieza nuestra aventura. Siempre me ha gustado bastante trastear con aplicaciones de Android ya sea simplemente por ver ”endpoints” ocultos e interesantes en los servicios que utilizan o para realizar algún parcheo de alguna funcionalidad, todo esto sumado a rascarle algo más de potencia al patín eran motivos suficientes como para ponerme manos a la obra.

Analizando la aplicación

En primer lugar, procedí a interceptar el tráfico de la misma. Sorprendentemente no implementaba ningún tipo de “certificate pinning” por lo que esta labor fue muy sencilla, pero nada más empezar a recibir las primeras peticiones pude ver que el tráfico de la aplicación estaba cifrado.

Captura de tráfico

Como se puede observar en la imagen superior todo hace indicar que el cifrado se trataba de RC4, pero tras probar varias combinaciones con los parámetros enviados por la aplicación ninguno parecía ser la clave (Y más adelante veremos que tampoco se trata de RC4 al 100%).

Por lo tanto, tocaba comenzar a hacer ingeniería inversa a la aplicación en busca de las funciones encargadas de realizar el cifrado de la aplicación. Existen diversas formas de afrontar una tarea de este tipo. En mi caso suelo realizar búsquedas con los parámetros enviados en las peticiones sobre el decompilado de la aplicación y usar esto como punto de partida.

Frida

En una primera instancia intenté ir directo al grano buscando por la ruta sobre la que consulta si existe alguna versión nueva del firmware ‘/home/latest_version’ y bingo, vemos donde realiza la petición llamando a la función ‘callSmartHomeApi’.

callSmartHomeApi(str, "/home/latest_version", jSONObject, (Callback) callback, new 19(this));

Si continuamos hacia adelante en lo que sería el “call stack” llegamos a una función llamada ‘a’ (la mayoría de partes de la aplicación tiene ofuscación haciendo uso de la sobrecarga de métodos de java) de la clase ‘CoreApi (com.xiaomi.smarthome.frame.core)’ la cual recibe por parámetros varias cosas, entre ellas el contenido de la petición antes de ser cifrado, y el algoritmo de cifrado que se utilizará, dado que tiene diversos algoritmos para los diferentes servicios con los que se comunica.

public <R> AsyncHandle a(Context context, NetRequest netRequest, JsonParser<R> jsonParser, Crypto crypto, AsyncCallback<R, Error> asyncCallback) {
.......
}

Llegados a este punto decidí realizar un pequeño script que utilizase Frida. Para quien no lo conozca Frida es un potente toolkit de instrumentación dinámica ampliamente utilizado en el mundo de Android e IOS. No entraré en mucho detalle acerca de su funcionamiento interno pero a continuación dejo el código utilizado para interceptar todas las llamadas a la función ‘a’ de la clase ‘CoreApi (com.xiaomi.smarthome.frame.core)’. Los más observadores se darán cuenta de que se hace uso del método ‘overload’ dado que al estar el código ofuscado existen varias funciones llamadas ‘a’ en esta clase, por lo que la única forma de diferenciarlas es en función a los parámetros que reciben.

Java.perform(function () {

    var request = Java.use('com.xiaomi.smarthome.frame.core.CoreApi');
    request.a.overload('android.content.Context', 'com.xiaomi.smarthome.core.entity.net.NetRequest', 'com.xiaomi.smarthome.frame.JsonParser', 'com.xiaomi.smarthome.core.entity.net.Crypto', 'com.xiaomi.smarthome.frame.AsyncCallback').implementation = function (a,b,c,d,e) {
        console.log("------------------------")
        console.log('Call to xiaomi');
        console.log('Parameter data: '+b)
        console.log('Parameter crypto: '+d)
        console.log("------------------------")

        var ret = this.a(a,b,c,d,e)
        return ret
    };
});

Cargamos el “snippet” con Frida y podemos empezamos a ver en tiempo real las peticiones que realiza la aplicación en texto claro.

Snippet de Frida

Llegados a este punto podemos ver el contenido de las peticiones. Esto es bastante ‘divertido’ sobre todo lo mágico que puede llegar a parecer mediante el uso de Frida, pero no es suficiente para nuestro objetivo final el cual consiste en poder modificar las peticiones. Para lograr esto necesitamos obtener la clave de cifrado usada.

Obteniendo la clave de cifrado

Tras seguir indagando en las diferentes llamadas entre funciones decidí cambiar de estrategia y proceder a buscar un nuevo string en el código decompilado, concretamente uno de los parámetros que vemos en las peticiones que realiza la aplicación ‘rc4_hash__‘ Mediante esta búsqueda terminamos en el método ‘b’ de la clase ‘SmartHomeRc4Api (com.xiaomi.smarthome.core.server.internal.api)’.

private Pair<List<KeyValuePair>, String> b(NetRequest netRequest) {
    String a;
    String a2 = CloudCoder.a(this.d.e);
    try {
        a = Coder.a(Coder.b(a(Coder.a(this.d.d), Coder.a(a2)))); // Creacion de la clave de cifrado
    } catch (Exception e) {
        //Eliminado para aumentar legibilidad
    }
    if (a == null) {
        return null;
    }
    Map treeMap = new TreeMap();
    TreeMap treeMap2 = new TreeMap();
    List arrayList = new ArrayList();
    RC4DropCoder rC4DropCoder = new RC4DropCoder(a); // Inicializacion de la clase de cifrado
    List<KeyValuePair> d = netRequest.d();
    if (d != null) {
        for (KeyValuePair keyValuePair : d) {
            if (!(TextUtils.isEmpty(keyValuePair.a()) || TextUtils.isEmpty(keyValuePair.b()))) {
                treeMap2.put(keyValuePair.a(), keyValuePair.b());
            }
        }
    }
    treeMap2.put("rc4_hash__", CloudCoder.a(netRequest.a(), netRequest.b(), treeMap2, a));
    for (Entry entry : treeMap2.entrySet()) {
        String b = rC4DropCoder.b((String) entry.getValue()); //Cifrado de parametros
        treeMap.put(entry.getKey(), b);
        arrayList.add(new KeyValuePair((String) entry.getKey(), b));
    }
    arrayList.add(new KeyValuePair(Constant.KEY_SIGNATURE, CloudCoder.a(netRequest.a(), netRequest.b(), treeMap, a)));
    arrayList.add(new KeyValuePair("_nonce", a2)); // Parte de la clave de cifrado enviada en la peticion
    arrayList.add(new KeyValuePair("ssecurity", this.d.d)); // Parte de la clave de cifrado enviada en la peticion
    return Pair.create(arrayList, a2);
}

Como se puede ver en la función parece que nos acercamos a nuestro objetivo, el proceso de cifrado. El método recibe por parámetro un objeto de tipo ‘NetRequest’. Como se puede ver hacia la mitad de la función se instancia un nuevo objeto de tipo ‘RC4DropCoder’ el cual recibe como parámetro la variable local ‘a’, y a continuación se llama a ‘RC4DropCoder.b(…)’ con cada valor de la clase ‘NetRequest’. Esto rápidamente nos hace sospechar que la función ‘b’ es la función de cifrado y la variable local ‘a’ es la clave utilizada.

Si nos dirigimos a la creación de la variable ‘a’ (la clave de cifrado) nos encontramos con lo siguiente:

a = Coder.a(Coder.b(a(Coder.a(this.d.c), Coder.a(a2))));

Si analizamos detenidamente las funciones de la clase ‘Coder’ a las que llama y los demás usos de la variable ‘a2’ y ‘this.d.d’ terminamos con la siguiente operación. b64(sha256(concat(b64(ssecurity), b64(nonce))))

Ya sabemos cómo formar la clave de cifrado utilizando la información que contiene cada petición, y además sabemos que esta clave cambia cada vez, dado que el ‘_nonce’ varía en cada petición y el valor de ‘ssecurity’ cada vez que iniciamos sesión en la aplicación.

RC4? ¡No tan rápido!!

El próximo paso consiste en descubrir el algoritmo de cifrado utilizado. Todo apunta a que se trata de un simple RC4, pero tras intentar descifrar el contenido de la petición no obtenemos nada en claro por lo que se ha de continuar analizando la clase RC4DropCoder (com.xiaomi.smarthome.library.crypto.rc4coder)’.

public class RC4DropCoder {
    private static final byte[] b = new byte[1024];
    RC4 a;

    static {
        Arrays.fill(b, (byte) 0);
    }

    private static boolean c(byte[] bArr) {
        return bArr == null || bArr.length == 0;
    }

    public RC4DropCoder(byte[] bArr) throws SecurityException {
        if (c(bArr)) {
            throw new SecurityException("rc4 key is null");
        } else if (bArr.length != 32) {
            throw new IllegalArgumentException("rc4Key length is invalid");
        } else {
            this.a = new RC4(bArr);
            a(b);
        }
    }

    public RC4DropCoder(String str) throws SecurityException {
        this(Base64Coder.a(str));
    }

    public byte[] a(byte[] bArr) throws SecurityException {
        if (bArr == null) {
            try {
                throw new IllegalBlockSizeException("no block data");
            } catch (Throwable e) {
                throw new SecurityException(e);
            }
        }
        this.a.a(bArr);
        return bArr;
    }

    public String a(String str) {
        try {
            return new String(a(Base64Coder.a(str)), "UTF-8");
        } catch (Throwable e) {
            throw new SecurityException(e);
        }
    }

    //Funciones eliminadas para aumentar la legibilidad, básicamente lo mismo que las funciones 'a' para para la operación opuesta de descifrado
}

Rápidamente podemos ver cómo nos encontramos con la función de inicialización la cual recibe por parámetro la clave, comprueba su longitud (32 caracteres) y crea una nueva instancia de la clase ‘RC4’ utilizando la clave recibida por parámetro. Por último es importante mencionar que llama a la función ‘a’ pasándole un array de 1024 bytes nulos, por decirlo de alguna forma esto esta ‘desordenando’ aún más el array de valores usados para realizar el cifrado RC4. Por eso mismo cuando intentamos descifrar utilizando cualquier herramienta no obtenemos el resultado correcto.

Implementación de RC4 de la aplicación para los más curiosos.

Llegados a este punto ya sabemos crear la clave de cifrado y conocemos que el algoritmo es RC4 con unas ligeras ‘modificaciones’, por lo que podemos hacernos un pequeño script en Python para descifrar las mismas (Gracias a Patatas Fritas por la ayuda con la simplificación de la traducción a Python).

Código completo

def rc4mi(data, key):
    S, j, out = list(range(256)), 0, []

    for i in range(256):
        j = (j + ord(key[i % len(key)]) + S[i]) % 256
        S[i], S[j] = S[j], S[i]

    # 1024 fake rounds
    i = j = 0
    for x in range(1024):
        i = (i + 1) % 256
        j = (j + S[i]) % 256
        S[i], S[j] = S[j], S[i]

    for ch in data:
        i = (i + 1) % 256
        j = (j + S[i]) % 256
        S[i], S[j] = S[j], S[i]
        out.append(chr(ord(ch) ^ S[(S[i] + S[j]) % 256]))

    return "".join(out)

def create_key(ssecurity, nonce):
    return b64encode(hashlib.sha256(b64decode(ssecurity) + b64decode(nonce)).digest())

La siguiente captura muestra un ejemplo de cifrado/descifrado:

Ejemplo de cifrado y descifrado

Realizando el downgrade

Ahora que ya podemos descifrar y modificar tanto las peticiones como las respuestas entre la aplicación y los servidores de Xiaomi se nos abre la posibilidad a realizar un ”downgrade” del patinete hacia una versión con menos limitaciones mediante la modificación de la respuesta por parte del servidor cuando se consulta si existe alguna nueva versión.

Para consultar si existen actualizaciones la aplicación realiza una petición a ‘/home/latest_version’ donde enviará lo siguiente:

{"model":"ninebot.scooter.v1"}

Tras esto el servidor responderá el siguiente mensaje:

{"code":0,"message":"ok","result":{"version":"","url":"","changeLog":"","md5":""}}

Esto sucede porque el patín sobre el cual se realizaron las pruebas se encontraba en la última versión por lo que todos los valores se encuentran vacíos. El siguiente paso sería obtener la respuesta para un patín sin actualizar, pero por desgracia esto no fue posible así que se procedió a ‘intuir’ el formato correcto.

Tras diversas pruebas se determinó lo siguiente.

  • code: 0
  • message: ok
  • result
    • version: Indiferente Example 1.0.1_237
    • url: Url a fichero zip que contendrá el firmware
    • changeLog: Indiferente
    • md5: md5 del fichero zip

Por ultimo indicar el contenido del fichero zip el cual encontré en una expedición por uno de los dos mundos paralelos de internet, los foros rusos (el otro aun mayor es el del contenido en chino).

Contenido del ZIP

En primer lugar, los .bin son el firmware de las diferentes partes de la electrónica del patín, siendo BLE la controladora bluetooth, BMS la controladora de la batería y Driver la controladora principal. En posteriores entradas analizaremos estos firmwares (ARM Cortex M3) en busca de aumentar aún más la potencia del patín.

Por ultimo encontramos el fichero version.json con el siguiente contenido, siendo el primer valor de los arrays el número de versión y el segundo el tamaño del fichero .bin

{"NormalVersion":
    {
        "CtrlVersionCode":["0130","26024"],
        "BleVersionCode":["0068","23948"],
        "BmsVersionCode":["0107","13100"]
    },
"TestVersion":
    {
        "CtrlVersionCode":["0105","22308"],
        "BleVersionCode":["0053","23840"],
        "BmsVersionCode":["0103","13071"]
    },
"TestDevice":
    [{"serial":"M1GCA1601C0001","id":"0","name":"haley"},
     {"serial":"M1GCA1601C0002","id":"0","name":"haley"},
     {"serial":"M1GCA1601C0003","id":"0","name":"haley"}]
}

Próximas entradas

En las próximas entradas intentaré hablar sobre el análisis de la comunicación “Bluetooth Low Energy” y analizaremos el firmware del patinete en busca de modificar la potencia del mismo intentando forzar aún más los límites del motor.

En caso de cualquier duda o corrección acerca del articulo puedes contactarme en las plataformas del menú de la izquierda.