-
-
[原创]某品牌电动牙刷小程序"越权控制"逆向
-
发表于: 1天前 1020
-
说是"越权",其实产品设计时可能根本没考虑鉴权。
wux1an\wxapkg 微信小程序解包工具 + 3.9 版本 Windows 端微信。
GitHub Copilot 代码辅助分析 AI + VS Code 编辑器。
Wireshark 流量分析工具。
使用 3.9 版本 Windows 端微信打开目标小程序。
使用 wxapkg 工具对微信小程序进行解包。


发现该小程序解包后的项目结构还是较为清晰的,主要关注 Page,packageHairdrier,packageBrush 这几个文件夹以及主目录下的这些 .js 文件。
通过文件名大致可以猜测 packageHairdrier 为该品牌吹风机控制的相关代码,packageBrush 为该品牌电动牙刷控制的相关代码。
而在 Page 文件夹中,有如下页面,应该是用户登录、设备连接等通用功能代码。


.js 的代码量不算少,为了能快速定位小程序控制电动牙刷的关键代码,需要结合前端显示的文字定位关键代码。
先逆向追踪 开关 这个按钮的功能。
直接在 VS Code 编辑器中搜索 开关 关键字。

因为 开关 按钮的开关文字是单独出现的,因此可以大致确定相关代码位于 .\packageBrush\chunk_7.appservice.js 和 .\packageBrush\app-service.js 两个文件中。
根据以往微信小程序解包的经验,app-service.js 一般包含所有分片代码,上述两个文件中所出现的 开关 相关代码实际是同一份代码。
因此,接下来只需要对 .\packageBrush\chunk_7.appservice.js 相关代码进行逆向分析。




此时可以联想到,进入小程序之后,如果是未登录的状态,依旧可以显示控制电动牙刷的界面,但是按下任意按钮后,都会跳转到登录页面。
在 .\packageBrush\chunk_7.appservice.js 代码的最上方,可以看到我们上文分析函数所处的页面,即 displayControl,看来我们现在所分析的页面,并非电动牙刷的真实控制页面,而是未登录状态下的展示页面。


尝试搜索 brushControl 和 brushControlPlus 关键词,试图找出它们在哪个 chunk_*.appservice.js 文件中出现。
最终确认 brushControl 对应 chunk_1.appservice.js,而 brushControlPlus 对应 chunk_2.appservice.js
但是进入 chunk_2.appservice.js 却发现其代码仅有 51 行,应该是一个未启用的页面。
而在 chunk_1.appservice.js 中发现代码引用了 ble 蓝牙相关的库函数,看来这个代码文件应该就是我们想要的了。

在该页面中继续寻找开关按钮,定位到了 开启 关键字。
这一点比较意外,在真正的控制界面中,开启电动牙刷按钮下写的是 开启,而非展示页面下显示的 开关,在这个地方浪费了点时间,刚开始就应该进入真实的页面去锁定 UI。
至此,基本定位到了真正的开启按钮。

开启 关键词是在 j 函数中(第 57 行),其功能看起来是控制该按钮的显示。
接下来需要查看该按钮所绑定的函数,于是尝试搜索 j 函数在哪里调用。
在 j 函数下方(第 66 行)发现了对 j 函数的调用。
同时,在同一个 g 函数中找到了绑定的控制函数 onIsSwitch 的相关代码,根据 g 函数的结构和 UI 的界面推断:第 61~63 行代码是对开关图片的事件绑定,而第 64~66 行是对开关下方文字的事件绑定。
所以 onIsSwitch 就是我们需要的函数。

在 onIsSwitch 函数中,可以明显看到 onOff() 和 onOpen() 函数。
继续分析这两个函数,但直接搜索后发现,发现这两个函数并未在 chunk_1.appservice.js 中定义,因此需要找到这两个函数所属对象(o)的定义位置。
向上查看代码,发现了 o 的包装位置,并且包装函数 h 也位于其下方方。







onOpen(): aa0f010101a4 (类型: 0f,数据: 01,响应: 0f) - 开启按钮。
onOff(): aa0f010100a5 (类型: 0f,数据: 00,响应: 0f) - 开启按钮。
onOpenAirplane(): aa07010101ac (类型: 07,数据: 01,响应: 07) - 旅行模式(开)
onOffAirplane(): aa07010100ad (类型: 07,数据: 00,响应: 07) - 旅行模式(关)
onOpenVibration(): aa0b010101a0 (类型: 0b,数据: 01,响应: 0b) - 换区提醒(开)
onOffVibration(): aa0b010100a1 (类型: 0b,数据: 00,响应: 0b) - 换区提醒(关)
onOpenTreble(): aa0e010101a5 (类型: 0e,数据: 01,响应: 0e) - 高频模式(开)
onOffTreble(): aa0e010100a4 (类型: 0e,数据: 00,响应: 0e) - 高频模式(关)


虽然本次只逆向了电动牙刷某一款的蓝牙协议,但是在该小程序中,无论是该品牌的吹风机还是其他款的牙刷,它们的蓝牙通信指令均未进行鉴权或加密。
由于这些设备缺少鉴权,理论上直接使用官方小程序即可连接附近的同类设备,这可能是产品设计阶段未充分考虑设备侧鉴权,毕竟牙刷和吹风机这种东西除了在宿舍这种场景,通常也较少出现连接他人设备的实际场景。
AA [TYPE] [LEN] [FUNC] [DATA] [CRC]
│ │ │ │ │ └─ CRC校验码 (XOR异或)
│ │ │ │ └──────── 数据字节
│ │ │ └─────────────── 功能码
│ │ └───────────────────── 数据长度
│ └──────────────────────────── 指令类型
└──────────────────────────────── 帧头 (固定)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import asyncio
from bleak import BleakClient, BleakScanner
WRITE_CHAR = "0000ff02-0000-1000-8000-00805f9b34fb"
NOTIFY_CHAR = "0000ff02-0000-1000-8000-00805f9b34fb"
CMD_OPEN = "aa0f010101a4"
CMD_OFF = "aa0f010100a5"
CMD_AIRPLANE_ON = "aa07010101ac"
CMD_AIRPLANE_OFF = "aa07010100ad"
CMD_VIBRATE_ON = "aa0b010101a0"
CMD_VIBRATE_OFF = "aa0b010100a1"
CMD_TREBLE_ON = "aa0e010101a5"
CMD_TREBLE_OFF = "aa0e010100a4"
class Controller:
def __init__(self, mac):
self.mac = mac.upper()
self.client = None
self.response = None
self.waiting = False
async def connect(self):
print(f"搜索:{self.mac}")
devices = await BleakScanner.discover(timeout=10.0)
target = None
for dev in devices:
if dev.address.upper() == self.mac:
target = dev
print(f"找到:{dev.name}")
break
if not target:
print("未找到设备")
return False
print("连接中……")
self.client = BleakClient(target.address)
await self.client.connect()
if self.client.is_connected:
print("连接成功")
await self.client.start_notify(NOTIFY_CHAR, self._on_notify)
return True
return False
def _on_notify(self, sender, data):
self.response = [f"{byte:02x}" for byte in data]
self.waiting = False
print(" ".join(self.response))
async def send(self, cmd, expected=None):
if not self.client or not self.client.is_connected:
return None
print(cmd)
self.waiting = True
self.response = None
await self.client.write_gatt_char(
WRITE_CHAR,
bytes.fromhex(cmd),
)
timeout = 0
while self.waiting and timeout < 30:
await asyncio.sleep(0.1)
timeout += 1
if timeout >= 30:
return None
if expected and self.response and len(self.response) > 1:
return self.response[1] == expected
return self.response
async def disconnect(self):
if self.client and self.client.is_connected:
await self.client.disconnect()
async def turn_on(self):
result = await self.send(CMD_OPEN, "0f")
print("已打开" if result else "操作失败")
async def turn_off(self):
result = await self.send(CMD_OFF, "0f")
print("已关闭" if result else "操作失败")
async def airplane(self, enable):
cmd = CMD_AIRPLANE_ON if enable else CMD_AIRPLANE_OFF
status = "开启" if enable else "关闭"
result = await self.send(cmd, "07")
print(f"旅行模式已{status}" if result else "操作失败")
async def vibrate(self, enable):
cmd = CMD_VIBRATE_ON if enable else CMD_VIBRATE_OFF
status = "开启" if enable else "关闭"
result = await self.send(cmd, "0b")
print(f"换区提醒已{status}" if result else "操作失败")
async def treble(self, enable):
cmd = CMD_TREBLE_ON if enable else CMD_TREBLE_OFF
status = "开启" if enable else "关闭"
result = await self.send(cmd, "0e")
print(f"高频模式已{status}" if result else "操作失败")
async def menu(ctrl):
while True:
print("\n" + "=" * 40)
print("1. 打开 2. 关闭")
print("3. 旅行 4. 换区")
print("5. 高频 q. 退出")
print("=" * 40)
choice = input("选择:").strip()
if choice == "1":
await ctrl.turn_on()
elif choice == "2":
await ctrl.turn_off()
elif choice == "3":
await ctrl.airplane(input("1 开 / 0 关:") == "1")
elif choice == "4":
await ctrl.vibrate(input("1 开 / 0 关:") == "1")
elif choice == "5":
await ctrl.treble(input("1 开 / 0 关:") == "1")
elif choice.lower() == "q":
break
input("按 Enter 继续……")
async def main():
mac = input("MAC 地址:").strip()
if not mac:
return
ctrl = Controller(mac)
if await ctrl.connect():
try:
await menu(ctrl)
finally:
await ctrl.disconnect()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n已中断")
说是"越权",其实产品设计时可能根本没考虑鉴权。
- 微信小程序:某品牌智能设备控制小程序。
wux1an\wxapkg 微信小程序解包工具 + 3.9 版本 Windows 端微信。
GitHub Copilot 代码辅助分析 AI + VS Code 编辑器。
Wireshark 流量分析工具。
使用 3.9 版本 Windows 端微信打开目标小程序。
使用 wxapkg 工具对微信小程序进行解包。
- 微信小程序本质上还是前端 JavaScript 代码,所以这里不需要使用 IDA 等二进制分析工具,直接使用 VS Code 打开解包后的项目文件夹,对微信小程序的项目结构进行整体分析。
发现该小程序解包后的项目结构还是较为清晰的,主要关注
Page,packageHairdrier,packageBrush这几个文件夹以及主目录下的这些.js文件。通过文件名大致可以猜测
packageHairdrier为该品牌吹风机控制的相关代码,packageBrush为该品牌电动牙刷控制的相关代码。而在
Page文件夹中,有如下页面,应该是用户登录、设备连接等通用功能代码。
- 因为身边只有该品牌的电动牙刷,为了方便验证,这里对电动牙刷的相关代码优先进行分析,即
packageBrush文件夹下的内容。
- 由于解包逆向的缘故,
pages文件夹下的.html代码并未有关键的内容,只有对其他文件的引用,所以逆向分析的重心还是放在packageBrush下的.js文件。
.js的代码量不算少,为了能快速定位小程序控制电动牙刷的关键代码,需要结合前端显示的文字定位关键代码。先逆向追踪
开关这个按钮的功能。直接在 VS Code 编辑器中搜索
开关关键字。
因为
开关按钮的开关文字是单独出现的,因此可以大致确定相关代码位于.\packageBrush\chunk_7.appservice.js和.\packageBrush\app-service.js两个文件中。根据以往微信小程序解包的经验,
app-service.js一般包含所有分片代码,上述两个文件中所出现的开关相关代码实际是同一份代码。因此,接下来只需要对
.\packageBrush\chunk_7.appservice.js相关代码进行逆向分析。
- 发现
开关位于btnList列表中,所以尝试去搜索这个列表的调用。
- 发现
btnList在这里被F()函数调用,根据经验猜测,F()函数应该对应类似for循环的结构,对btnList的按钮进行逐个渲染,并且在F()的参数中,发现传入了i函数,推测它就是具体的渲染函数。
[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。