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;
}