android - 记录一次水表热水APP破解

使用工具:Jadx, Frida, WebStorm, Redmi K60, Reqable

学校换水表了,破解一下,上次和他们反馈水表可以用app lsposed hook改掉计费上报,他们连学分都不给我发,什么反馈都没有,这次不告诉他们了!!!!

详细分析

获取设备信息接口

这里说一下这个api,用mac地址获取水表对应的一些信息,不知道有什么用,但是还是记录一下:

const axios = require('axios');

let config = {
  method: 'GET',
  url: 'https://pord.cskaihe.com/api/device/info/list?macList=C4:7F:0E:DA:16:B1&projectId=8&phoneSystem=android&version=5.2.3'
};

axios.request(config)
  .then(response => console.log(response))
  .catch(error => console.log('error', error));

根据上一次的逆向结果来看,搜索完设备会跳转到val Bath2Activity = XposedHelpers.findClass("com.klcxkj.zqxy_kaihe.ui.device.bt.Bath2Activity", loader) 这个界面,这次我们从搜索设备到进入开关热水的界面完整的分析一次!

余额获取

用Jadx分析得知进入Bath2Activity之前搜索设备,也就是首次使用软件,会在BleSearchBratheDeviceActivity进行搜索设备,搜索蓝牙设备但是他只用了搜索出来的设备列表里面的mac地址,其它数据没有使用,然后发包获取了一下这个水表的信息,这个就是上面的那个请求了,然后new Intent跳转到了Bath2Activity ,跳转之前设置了一堆Bundle的数据,这里用frida看一下:

    let Bath2Activity = Java.use("com.klcxkj.zqxy_kaihe.ui.device.bt.Bath2Activity")
    Bath2Activity.onCreate.implementation = function (savedInstanceState: any) {
        DMLog.i("Bath2Activity", "onCreate called with savedInstanceState: " + savedInstanceState);
        this.onCreate(savedInstanceState);
        DMLog.i("Bath2Activity", "onCreate completed");
    }

哦!没有走这里,用MT管理器看了一下,他跳进另外一个界面了!com.klcxkj.zqxy_kaihe.ui.device.ble.BleBath2Activity !这里jadx看了一下, 这里拦截一下看看能不能改掉余额不足的提示!

    let BleBath2Activity$a$a = Java.use("com.klcxkj.zqxy_kaihe.ui.device.ble.BleBath2Activity$a$a");
    BleBath2Activity$a$a.$init.overload('com.klcxkj.zqxy_kaihe.ui.device.ble.BleBath2Activity$a', 'java.lang.String').implementation = function (tz, resp: string) {
        let rsp = JSON.parse(resp);
        rsp.data.accountMoney = 500 * 1000;
        DMLog.i("BleBath2Activity$a$a", "获取个人信息回调执行, resp = " + JSON.stringify(rsp));
        return this.$init(tz, JSON.stringify(rsp));
    }

这里贴一下改余额的代码,实际上不行的,他是发了一个包请求下发费率,余额不足费率就会下发失败!下发费率失败就没办法!

根据逆向,下发费率之前,他和水表蓝牙交互获取了一些信息比如说macType什么的,这些数据用于下发水表的费率,这里我们去拦截一下蓝牙协议包的收发!

首次链接协议分析

根据日志的hex看得出来,都是可视化的数据,丢去CyberChef解一下,发现这些数据包(返回包和发送包)固定#字符开头!可惜搜索不到#字符有关的代码,从调用栈入手

这里获取了一下调用栈,一层层往上追,这个啥鸟软件写的和史一样!追到一层擦路口,有3个方法调用了putByteArray设置数据然后丢给handler,分别是 e.h.a.a.b.b e.h.a.a.b.h e.h.a.a.b.r 一个个插个hook,看看真正调用的是谁..(或者你在上层方法打印调用栈夜可以...)

走的是e.h.a.a.b.b 再往上追一路就到了com.klcxkj.bluesdk.utils.CmdBleUtils里面!到了这里一目了然!

    let CmdBleUtils = Java.use("com.klcxkj.bluesdk.utils.CmdBleUtils")
    CmdBleUtils.a.implementation = function (a1: number, a2: number, a3: any) {
        DMLog.i("CmdBleUtils", "packet build called, cmd = " + a1 + ", a2 = " + a2 + ", a3 = " + a3);
        return this.a(a1, a2, a3);
    }

这里看出来a方法的第一个参数是cmd id,习惯使然!

看样子第一个包的cmd=35,含义是查询设备,我们研究一下怎么解析数据,这个东西和gps的nmea协议一样整了一个check_sum,这些写通讯协议的程序员都喜欢整个这个东西,这里贴一下checksum校验以及获取实际的数据的代码:

fn main() {
    let data = "你收到的数据包!";
    let data = data
        .replace("\n", "")
        .replace(" ", "")
        .replace("\r", "");

    let data = hex::decode(data).unwrap();
    let data = hex::decode(data).unwrap();

    let len = (((u16::from(data[1])) << 8) | (u16::from(data[2]))).checked_sub(3).unwrap_or(0);
    if len == 0 {
        println!("Invalid length: {}", len);
        return;
    }
    let mut result = Vec::with_capacity(len as usize);
    let mut sum = 0i32;
    for i in 0..len as usize {
        result.push(data[i + 6]);
        sum += data[i + 6] as i32;
    }
    sum = sum + data[3] as i32 + data[4] as i32 + data[5] as i32;
    let check_sum = (sum & 0xff) as u8;
    if check_sum != data[data.len() - 2] {
        println!("Checksum mismatch: expected {}, got {}", check_sum, data[data.len() - 2]);
    } else {
        println!("Checksum is valid: {}", check_sum);
    }
}

获取到需要的数据之后发送请求下发费率的包,这个下发费率的包有一个md5的签名,在native里面,得敲开看看!敲开和明文一样混淆都没有,搜索符号getSk就有东西了,里面还有一个签名验证,没什么用的东西,不管他!这个getSk的正确返回值是sign-kailu= !

蓝牙协议Hook模块

/******************************************************************
 * 文件: src/BlePacketHook.ts
 * 说明: 统一抓取 Android 蓝牙收/发数据包(BLE & Classic)
 ******************************************************************/

import { DMLog } from "./utils/dmlog";
import { FCCommon } from "./utils/FCCommon";
import {FCAnd} from "./utils/FCAnd";

export function bleHook() {
    if (Java.available) {
        Java.perform(javaHook);
    }
}

function javaHook() {
    DMLog.i("BLEHOOK", "==  Java hook start ==");

    const BluetoothGatt = Java.use("android.bluetooth.BluetoothGatt");
    const BluetoothGattCharacteristic = Java.use(
        "android.bluetooth.BluetoothGattCharacteristic"
    );
    const BluetoothGattDescriptor = Java.use(
        "android.bluetooth.BluetoothGattDescriptor"
    );
    const BluetoothGattCallback = Java.use(
        "android.bluetooth.BluetoothGattCallback"
    );

    BluetoothGatt.writeCharacteristic.overload("android.bluetooth.BluetoothGattCharacteristic")
        .implementation = function (ch: any) {
        logWriteCharacteristic("pub", ch);
        //FCAnd.showStacks()
        return this.writeCharacteristic(ch);
    };

    if (BluetoothGatt.writeCharacteristic.overloads.length >= 2) {
        BluetoothGatt.writeCharacteristic.overload(
            "android.bluetooth.BluetoothGattCharacteristic", "[B", "int")
            .implementation = function (ch: any, data: any, writeType: number) {
            logWriteCharacteristic(`writeType=${writeType}`, ch);
            return this.writeCharacteristic(ch, data, writeType);
        };
    }

    /* 写 Descriptor (部分厂商把数据发在 descriptor 里) */
    BluetoothGatt.writeDescriptor
        .overload("android.bluetooth.BluetoothGattDescriptor")
        .implementation = function (dp: any) {
        try {
            const uuid = dp.getUuid().toString();
            const v = dp.getValue();
            DMLog.i(
                "BLEHOOK",
                `writeDescriptor  → uuid=${uuid}, data=${FCCommon.arrayBuffer2Hex(
                    v
                )}`
            );
        } catch {}
        return this.writeDescriptor(dp);
    };

    /* setValue(byte[]) —— 先占坑缓存数据 */
    BluetoothGattCharacteristic.setValue.overload("[B").implementation =
        function (payload: Java.Wrapper) {
            DMLog.i(
                "BLEHOOK",
                `setValue(len=${payload.length}) = ${FCCommon.arrayBuffer2Hex(
                    payload
                )}`
            );
            return this.setValue(payload);
        };

    /* ----------------   接收:Gatt 回调   ---------------- */
    BluetoothGattCallback.onCharacteristicChanged.overload(
        'android.bluetooth.BluetoothGatt',
        'android.bluetooth.BluetoothGattCharacteristic',
        '[B'
    ).implementation = function (
        gatt: any,
        ch: any,
        data: any
    ) {
        dumpCharacteristic("↘ notify", ch);
        return this.onCharacteristicChanged(gatt, ch, data);
    };

    BluetoothGattCallback.onCharacteristicRead.overload(
        'android.bluetooth.BluetoothGatt',
        'android.bluetooth.BluetoothGattCharacteristic',
        'int'
    ).implementation = function (
        gatt: any,
        ch: any,
        status: number
    ) {
        dumpCharacteristic(`↘ read(status=${status})`, ch);
        return this.onCharacteristicRead(gatt, ch, status);
    };

    BluetoothGattCallback.onCharacteristicWrite.implementation = function (
        gatt: any,
        ch: any,
        status: number
    ) {
        dumpCharacteristic(`↙ writeCb(status=${status})`, ch);
        return this.onCharacteristicWrite(gatt, ch, status);
    };

    /* ----------------   经典蓝牙 Socket   ---------------- */
    const BluetoothSocket = Java.use("android.bluetooth.BluetoothSocket");
    const OutputStream = Java.use("java.io.OutputStream");
    const InputStream = Java.use("java.io.InputStream");

    /* 取 OutputStream 时做一次包装 */
    BluetoothSocket.getOutputStream.implementation = function () {
        const os: Java.Wrapper = this.getOutputStream();
        hookOutputStream(os);
        return os;
    };

    /* 取 InputStream 时做一次包装 */
    BluetoothSocket.getInputStream.implementation = function () {
        const ins: Java.Wrapper = this.getInputStream();
        hookInputStream(ins);
        return ins;
    };

    DMLog.i("BLEHOOK", "==  Java hook ready ==");
}

/* 辅助:统一打印 characteristic 内容 */
function dumpCharacteristic(prefix: string, ch: Java.Wrapper) {
    try {
        const uuid = ch.getUuid().toString();
        const value: Java.Wrapper | null = ch.getValue();
        DMLog.i(
            "BLEHOOK",
            `${prefix} uuid=${uuid}, data=${FCCommon.arrayBuffer2Hex(value)}`
        );
    } catch (e) {
        DMLog.e("BLEHOOK", "dumpCharacteristic err: " + e);
    }
}

/* 辅助:打印 writeCharacteristic */
function logWriteCharacteristic(tag: string, ch: Java.Wrapper) {
    try {
        const uuid = ch.getUuid().toString();
        const value = ch.getValue();
        DMLog.i(
            "BLEHOOK",
            `writeCharacteristic(${tag}) → uuid=${uuid}, data=${FCCommon.arrayBuffer2Hex(
                value
            )}`
        );
    } catch {}
}

function hookOutputStream(os: Java.Wrapper) {
    if (os["__ble_hooked"]) return;
    const OutputStream = Java.use("java.io.OutputStream");
    OutputStream.write.overload("[B", "int", "int").implementation = function (
        buf: Java.Wrapper,
        off: number,
        len: number
    ) {
        DMLog.i(
            "BLEHOOK",
            `Classic write(${len}) → ${FCCommon.arrayBuffer2Hex(
                buf.slice(off, off + len)
            )}`
        );
        return this.write(buf, off, len);
    };
    os["__ble_hooked"] = true;
}

function hookInputStream(ins: Java.Wrapper) {
    if (ins["__ble_hooked"]) return;
    const InputStream = Java.use("java.io.InputStream");
    InputStream.read.overload("[B", "int", "int").implementation = function (
        buf: Java.Wrapper,
        off: number,
        len: number
    ) {
        const n: number = this.read(buf, off, len);
        if (n > 0) {
            DMLog.i(
                "BLEHOOK",
                `Classic read(${n})  ← ${FCCommon.arrayBuffer2Hex(
                    buf.slice(off, off + n)
                )}`
            );
        }
        return n;
    };
    ins["__ble_hooked"] = true;
}