蓝牙CTF题目汇总
蓝牙CTF题目汇总
:::info
题目附件链接:https://wwi.lanzoup.com/b017uwjrc 密码:yichen
:::
CVVD2022:蓝牙钥匙的春天
下载下来是一个蓝牙流量包,随便翻翻发现存在 SMP 协议,全称是 Secure Manager Protocol,是蓝牙用来定义配对和密钥分发的
配对后的流量是被加密的,但是有个工具 crackle 是可以解密这种数据包的,这个工具解密蓝牙流量有三个前提,这也在官方的 FAQ 中提到了:
https://github.com/mikeryan/crackle/blob/master/FAQ.md
首先要有完整的配对过程流量,要使用链路层加密(有些开发者会自己实现加密),且只适合于传统配对(legacy pairing)
安装 crackle
git clone https://github.com/mikeryan/crackle.git
cd crackle
make
make install
如果提示下图则需要安装相关依赖 sudo apt-get install libpcap-dev
安装完成后我们使用 crackle 解密发现如下提示,它不支持 BLUETOOTH_LE_LL
但是在它的 issues 中发现有人讨论过这个问题
用这个人的 crackle 再解一遍发现已经可以识别
加个参数 -o 可以保存出解密的数据包
crackle -i uploads_2022_04_11_h5kAcZEg_ble.pcapng -o de.pcapng
保存出来的数据包用 wireshark 打开后发现了一些值
把这些值提取出来之后发现一共有四组不同的,对他们进行两次十六进制编码后拼接,得到了 flag
未知比赛:low_energy_crypto
仿的 Cyber Apocalypse 2021 Low Energy Crypto
wireshark 打开后并没有发现类似上一题那样的配对流量,但是翻了翻流量,发现了一些交互,RX 收到了下面这些信息
复制出来是一个公钥,直接保存为 pub.key:
-----BEGIN PUBLIC KEY-----
MGowDQYJKoZIhvcNAQEBBQADWQAwVgJBAKKPHxnmkWVC4fje7KMbWZf07zR10D0m
B9fjj4tlGkPOW+f8JGzgYJRWboekcnZfiQrLRhA3REn1lUKkRAnUqAkCEQDL/3Li
4l+RI2g0FqJvf3ff
-----END PUBLIC KEY-----
往下翻翻,发现了一些 TX 的数据,应该是用私钥加密后发出来的
密码学不太懂,直接用 RsaCtfTool 来解密,把密文复制出来的时候选择 as a Hex Stream:
然后用 winhex 创建一个新文件 enc,直接贴过来 ASCII Hex 格式
然后把后面的一堆 0 删掉,它正好是对齐的
python RsaCtfTool.py --publickey ./pub.key --uncipherfile ./enc
直接跑出来
2024 GEEKCON AVSS
GEEKCON 这次出的蓝牙题目很新颖,给蓝牙题的出题人点赞👍
传统 CTF 都是像上面那样给你个流量包,里面的流量或被加密或被编码,把 flag 藏在流量里。这次除了给流量包之外还会给你一个远程主机,通过 nc 连上去后就是一台插着蓝牙网卡的设备,你需要自己编写脚本扫描目标,连接后进行交互,比如发送消息、监听通知从而得到 flag
:::info
同时也因为题目是需要与服务端进行交互的,服务端的一些文件我们是缺少的,因此本文包含了如何自己搭建服务端的部分,这一部分是基于理解题目校验逻辑后写出来的,如果只是复现可以直接用我给的附件,将 Server 运行起来,使用选手附件文件夹的附件,按照解题部分中的步骤复现即可,无需关心环境搭建部分
:::
BLE_CHALLENGE01
环境搭建
这道题原本只给了两个文件,一个流量包一个 python 脚本
为了能够自己把题目跑起来,我需要创建几个用到的文件,flag.txt 就随意创建了,digital_key.txt 和 phone_id.txt 确实要固定的,这两个值会涉及到题目中的校验
digital_key.txt 可以直接复制 BLE_CHALLENGE02 中给的 digital_key.txt
phone_id.txt 十六进制为的:ce7efa94beab4d
为了不与原有的 python 库发生冲突,我们来创建一个虚拟环境专门安装题目用到的库,我的只需要装这三个库就够了,如果你运行脚本还缺什么库自己装一下
python -m venv .venv
source .venv/bin/activate
pip install more_itertools -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install bumble -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install pycryptodome -i https://pypi.tuna.tsinghua.edu.cn/simple
解题过程
这道题目选手拿到的附件只有两个,python 脚本是服务端的代码,pcapng 是客户端与服务端通信的空口流量,我们先来看代码
前面定义了一些变量和函数,后面定义了一个类 CcService,对 BLE 有所了解的话可以很明显看出来这就是定义了个 GATT 的服务端,有各种特性及其 uuid,这里面需要注意的是 00000001-DA58-11E1-4A69-0002A5D5C51B 这个特性是一个可写的,并且定义了一个 _on_write_1 函数,当该特性接收到写入的数据时就会调用 _on_write_1 来进行一系列的判断,具体判断内容后面细看
再往后看其实主要是 _main 函数的代码了,这段代码主要是构建了一段广播数据并使服务端进入广播状态,其中广播数据是 DARKNARY 加上 manufacturer 组合起来的,而 manufacturer 是由 gen_manufacturer() 函数随机产生的
manufacturer_data = struct.pack("<H", 0x01FE) + b"DARKNARY"+manufacturer
接下来详细看看 _on_write_1 函数,可以看到我们的想要的 flag 就在这个函数中,但是想要把 flag 带出来要通过几个检查
函数先通过 conn.att_mtu != 247 检查双方连接的 mtu 是不是 247,不是的话直接断开连接,如果是的话则进入后面的步骤( mtu 表示一次性可以传输到对方的最大字节数),我们需要在连接的时候配置 mtu 为 247,然后代码定义了两步,通过 step_state 进行判断,只有通过了检测 step_state 才会加一进入到下一环节
case 0 这一步调用 connect_confirm_verify 检查写入值是否符合要求,符合则将 ok 加密返回,并将 step_state 置为 1、authed 置为 True ,这是如果再次发送数据,调用 _on_write_1 会直接将 flag 加密后返回
async def _on_write_1(self, conn: Connection, value: bytes):
if conn.att_mtu != 247:
await conn.disconnect()
return
logger.info("write 1: %s", value)
logger.info("current_step: %d", self.step_state)
match self.step_state:
case 0:
try:
if(connect_confirm_verify(self.manufacturer,value)):
confirm_ok = encrypt_content(self.manufacturer,b"ok")
self.step_state = 1
await conn.device.notify_subscriber(
conn,
self.notify_1,
confirm_ok,
)
self.authed = True
else:
confirm_fail = encrypt_content(self.manufacturer,b"failed")
await conn.device.notify_subscriber(
conn,
self.notify_1,
confirm_fail,
)
raise
except:
await conn.disconnect()
case 1:
try:
if self.authed:
content = encrypt_content(self.manufacturer,flag)
await conn.device.notify_subscriber(
conn,
self.notify_1,
content,
)
else:
content = encrypt_content(self.manufacturer,b"not authed!!")
await conn.device.notify_subscriber(
conn,
self.notify_1,
content,
)
raise
except:
await conn.disconnect()
加解密函数都是以 manufacturer 和固定的 vin 为参数,调用 gen_connectkey 算出 connectkey 作为密钥进行计算的,因此首要任务是获取到 manufacturer,扫描的时候不能只扫描 MAC 地址也要记录下 manufacturer
class ScanDelegate(DefaultDelegate):
def __init__(self):
DefaultDelegate.__init__(self)
def handleDiscovery(self, dev, isNewDev, isNewData):
if dev.addr == target_mac:
global manufacturer
print(f"Found target device with MAC {dev.addr}")
for (adtype, desc, value) in dev.getScanData():
print(f" {desc} = {value}")
if "Manufacturer" in {desc}:
manufacturer = bytes.fromhex(value[20:])
获取到 manufacturer 后就是设置 mtu 连接服务端并且通信了,这时候就需要分析 connect_confirm_verify 是如何校验我们写入的输入了
可以看到 connect_confirm_verify 接收两个参数,随机的 manufacturer 和写入服务端的数据 value,其中 manufacturer 用来计算 connectkey 这个我们可以自己计算,使用 Connect_Confirm 创建了一个具有命名字段的元组子类,用于后面和写入的数据进行对比,通过 Connect_Confirm 我们就能看出来写入的数据每一段应该是什么样的,另外需要注意的是后面的一系列对比都是将 value 解密后操作的,因此我们获得的是明文数据的构造方式,发送之前还要使用 connectkey 进行加密
def connect_confirm_verify(manufacturer,value):
try:
assert len(value) > 9
assert len(value) < 244
connectkey = bytes(gen_connectkey(vin,manufacturer))
sha_val = hashlib.sha256(manufacturer).digest()
connect_confirm = Connect_Confirm(b'\x00\x01',int(time.time()).to_bytes(4,'big'),b'\x00',DKid,sha_val[sha_val[0]&15:(sha_val[0]&15)+8],Phoneid)
if not check_crc(value):
logger.error("crc error!!")
raise
decrypted_content = decrypt_content(connectkey,value)
if decrypted_content == None:
logger.error("cannot decrypt!!")
raise
else:
if (decrypted_content.nseq != connect_confirm.nseq):
logger.error("nseq not match!!")
raise
if (decrypted_content.DKid != connect_confirm.DKid):
logger.error("DKid not match!!")
raise
if (decrypted_content.PhoneId[:7] != connect_confirm.PhoneId):
logger.error("PhoneId not match!!"+str(decrypted_content.PhoneId))
raise
if (decrypted_content.Digest != connect_confirm.Digest):
logger.error("Digest not match!!")
raise
return True
except:
return False
那接下来详细分析一下数据构成,但是如果你对着上面的 Connect_Confirm 直接去构造数据那就错了,实际上加密函数也会再次封装一次数据,因此我们还要看一下加密函数的实现
def encrypt_content(manufacturer,req):
connectkey = bytes(gen_connectkey(vin,manufacturer))
nseq = b"\x00\x01"
mtimestamp = int(time.time()).to_bytes(4,'big')
req = pad(nseq+mtimestamp+req,16)
encrypted_bytes = AES.new(connectkey,AES.MODE_CBC,iv).encrypt(req)
req_header = b"\xfe\x02"+(9+len(encrypted_bytes)).to_bytes(2,'big')+b"\x01\x02\x02"
crc = gen_crc(req_header+encrypted_bytes)
return req_header+encrypted_bytes+crc
通过上面的加密函数可以看到 b'\x00\x01'、int(time.time()).to_bytes(4,'big') 由加密函数实现,我们只需要构造:DKid、sha_val[sha_val[0]&15:(sha_val[0]&15)+8]、Phoneid 即可
Connect_Confirm(b'\x00\x01',int(time.time()).to_bytes(4,'big'),b'\x00',DKid,sha_val[sha_val[0]&15:(sha_val[0]&15)+8],Phoneid)
但是 DKid 和 Phoneid 都是服务端直接读取的文件,我们怎么知道呢?这时候流量包就派上作用了,流量包中记录了一次完整的通信过程,我们需要先解密流量包中的数据,找到 DKid 和 Phoneid 以硬编码的形式写到我们的 exp 中进行数据拼接
那么接下来我们来看流量包,想要解密数据首先要找到 manufacturer 但是没必要将流量包中所有的 manufacturer 提取出来(题目环境中服务端每段时间重启一次会产生不同的 manufacturer)只需要提取建立连接前的一两个 manufacturer 即可
在 wireshark 中搜索分组字节流:DARKNARY 快速定位一下广播数据,右键以 Hex Stream 的形式复制出来,并将前面的 DARKNARY 固定码部分删除
689f497e19ddb865
7ad42fff975c872e
然后将客户端和服务端互相通信的数据提取出来
fe0200290101012285cf35816a3d9d7fe236ca79059ad36e425b1a8a133a8d4aaf9fee1596c31f7b88
fe020019010202c9a43f6c9d4b85cf8a849ab30a29589f817f
fe0200290101012285cf35816a3d9d7fe236ca79059ad36e425b1a8a133a8d4aaf9fee1596c31f7b88
fe0200290102020d4b88e9f4923856a50c73250a6061ce3acac05f84a63fa7553d648f7aeabe9424c6
直接写个 for 循环把 manufacturer 和 value 遍历解密一下看看哪个是对的
from Crypto.Cipher import AES
vin = b"DARKNAVYVIN00001"
iv = b"\x1d\x0a\x5d\xba\x3c\xa5\xd8\x07\x53\x1d\x61\x22\xA6\x6c\x26\x17"
def gen_connectkey(bArr,bArr2):
bArr3 = [48,49,50,51,52,53,54,55,56,57,65,66,67,68,69,70]
bArr4 = []
bArr5 = []
for i in range(8):
bArr4.append(bArr3[(bArr2[i]>>4)&15])
bArr4.append(bArr3[bArr2[i]&15])
for i in range(16):
bArr5.append(bArr[i]^bArr4[i])
return bArr5
def decrypt_content(manufacturer,resp):
connectKey = bytes(gen_connectkey(vin,manufacturer))
encrypted_content = resp[7:-2]
decrypted_bytes = AES.new(connectKey,AES.MODE_CBC,iv).decrypt(encrypted_content)
print(decrypted_bytes)
manufacturers = ['689f497e19ddb865','7ad42fff975c872e']
values = ['fe0200290101012285cf35816a3d9d7fe236ca79059ad36e425b1a8a133a8d4aaf9fee1596c31f7b88','fe020019010202c9a43f6c9d4b85cf8a849ab30a29589f817f','fe0200290101012285cf35816a3d9d7fe236ca79059ad36e425b1a8a133a8d4aaf9fee1596c31f7b88','fe0200290102020d4b88e9f4923856a50c73250a6061ce3acac05f84a63fa7553d648f7aeabe9424c6']
for i in manufacturers:
for j in values:
decrypt_content(bytes.fromhex(i),bytes.fromhex(j))
根据数据格式及解密的内容,很明显,manufacturer 应该是第二个
发送的数据便是这一条了
00016621e556001ba6e07d0bbe1a2e06866b21ce7efa94beab4d060606060606
按照数据格式进行解析,0001 是固定的 nseq、6621e556 是 timestamp、00 是固定的、1ba6e07d 是 dkid、0bbe1a2e06866b21 是 sha_val、ce7efa94beab4d 是 PhoneId
到此,整个通信过程对我们来说就是透明的了,我们来组织要发送的消息
DKid = bytes.fromhex('1ba6e07d')
Phoneid = bytes.fromhex('ce7efa94beab4d')
sha_val = hashlib.sha256(manufacturer).digest()
payload = b'\x00' + DKid + sha_val[sha_val[0]&15:(sha_val[0]&15)+8] + Phoneid
payload = encrypt_content(manufacturer,payload)
只需要连续发送两次 payload,并监听服务端的通知即可获得加密后的 flag,再对收到的通知进行解密,即可得到 flag
from Crypto.Cipher import AES
vin = b"DARKNAVYVIN00001"
iv = b"\x1d\x0a\x5d\xba\x3c\xa5\xd8\x07\x53\x1d\x61\x22\xA6\x6c\x26\x17"
def gen_connectkey(bArr,bArr2):
bArr3 = [48,49,50,51,52,53,54,55,56,57,65,66,67,68,69,70]
bArr4 = []
bArr5 = []
for i in range(8):
bArr4.append(bArr3[(bArr2[i]>>4)&15])
bArr4.append(bArr3[bArr2[i]&15])
for i in range(16):
bArr5.append(bArr[i]^bArr4[i])
return bArr5
def decrypt_content(manufacturer,resp):
connectKey = bytes(gen_connectkey(vin,manufacturer))
encrypted_content = resp[7:-2]
decrypted_bytes = AES.new(connectKey,AES.MODE_CBC,iv).decrypt(encrypted_content)
print(decrypted_bytes)
manufacturers = ['a730d0120e72924a']
values = ['fe020019010202f8a4f9f9c9de901f32ab856939bb99072981','fe0200390102025646ed9498aa608ee4d2d17dc3bdce06329f457857ec657ab309763ffea8929851d69437842f4b6627c6c28617e724ab2877']
for i in manufacturers:
for j in values:
decrypt_content(bytes.fromhex(i),bytes.fromhex(j))
当然也可以直接把解密函数放到接收通知的地方直接解密收到的通知
BLE_CHALLENGE02
环境搭建
第二题涉及到一些公私钥、证书什么的,需要准备不少东西,建议直接用我提供的附件或者跟着我搭一下环境,官方的附件因为缺少证书没法直接拿来复现了
生成车端私钥及证书
openssl genrsa -out private_key_vehicle.pem 2048
openssl req -new -key private_key_vehicle.pem -out csr.pem
openssl x509 -req -days 365 -in csr.pem -signkey private_key_vehicle.pem -out certificate_vehicle.pem
生成 app 端私钥及证书
openssl genrsa -out private_key_app.pem 2048
openssl req -new -key private_key_app.pem -out csr.pem
openssl x509 -req -days 365 -in csr.pem -signkey private_key_app.pem -out certificate_app.pem
生成云端私钥及证书
openssl genrsa -out private_key_cloud.pem 2048
openssl req -new -key private_key_cloud.pem -out csr.pem
openssl x509 -req -days 365 -in csr.pem -signkey private_key_cloud.pem -out certificate_cloud.pem
生成 digital_key.txt 这里需要具体分析明白 digital_key.txt 是干啥的才能写出来,我直接给出生成的代码,后面再具体将这个文件的构成,将这个 python 脚本与上面生成的各个私钥和证书放在一起,会生成一个 digital_key.txt 这是给选手的附件中的一个
import hashlib
from cryptography import x509
from cryptography.hazmat.primitives import hashes,serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
vcert = open("certificate_vehicle.pem","rb").read()
app_cert = open("certificate_app.pem","rb").read()
dkid = b"\x1b\xa6\xe0\x7d"
vcert_sha256 = hashlib.sha256(vcert)
vcert_sha256 = vcert_sha256.digest()
app_cert_sha256 = hashlib.sha256(app_cert)
app_cert_sha256 = app_cert_sha256.digest()
all_sha256 = hashlib.sha256()
all_sha256.update(dkid)
all_sha256.update(app_cert_sha256)
all_sha256.update(vcert_sha256)
all_sha256 = all_sha256.digest()
with open("private_key_cloud.pem","rb") as cloud_fd:
private_key_cloud = serialization.load_pem_private_key(cloud_fd.read(),password=None,backend=default_backend)
public_key = private_key_cloud.public_key()
data = dkid+app_cert_sha256+vcert_sha256
sig = private_key_cloud.sign(
data,
padding.PKCS1v15(),
hashes.SHA256()
)
#dkid+sha256(app_cert)+sha256(vehicle_cert)+sha256(DKid+sha256(acert)+sha256(vcert))+sign(DKid+acert+vcert)
digital_key = dkid+app_cert_sha256+vcert_sha256+all_sha256+sig
with open("digital_key.txt","wb") as kkey:
kkey.write(digital_key)
print(digital_key)
print("=====================",len(digital_key))
这里需要把 transport_name 后面改为 0(因为我们设备上就一个蓝牙适配器)
到此环境就搭建完成了
解题过程
这道题比上一道题多给了一个 digital_key.txt
使用 winhex 打开完全看不出来是啥,但开头的四个字节确实是 DKid
还是先看服务端的代码逻辑,比上一题多了很多代码,检查机制也多了很多步骤,但广播数据的构成还是一样
主要的差别在 _on_write_1 函数多了很多检查,我们来看这些个检查是怎么实现的,最开始的检查还是 mtu 是否是 247,如果是的话才会进入各个 case,鉴于代码涉及比较多,这里就不贴代码了,自己对照着看吧
case 0:客户端拼接 DKid 和 PhoneId 等数据后加密发送给服务端,服务端解密,验证是否正确,正确则进入下一阶段
:::info
与 challenge1 一样,不再多说
:::
case 1:客户端发送 app_cert(certificate_app.pem)给服务端,服务端接收到之后调用 cert_check 验证证书(仅验证证书有效性),验证通过后把 vehicle_cert(certificate_vehicle.pem)返给客户端并进入下一阶段,此过程明文传输
:::info
这一关发什么?不能发从流量包中提取的证书!后面需要使用发过去的证书对应的私钥进行签名,我们虽然能从流量包中拿到证书,但却拿不到私钥,发送流量包中的证书相当于给自己断了后路,cert_check 函数仅校验了证书是否有效,但却没校验证书是否合法,因此我们自己生成一个私钥 private_key_app.pem 然后用此私钥签发一个证书 certificate_app.pem 发过去,因为证书相普通的数据长,因此可以参考服务端源码中分段传输代码来实现发送
:::
case 2:客户端发送包含 ECC 公钥点、数据摘要、数据签名等的消息 factory 给服务端,服务端调用 factory_check 验证摘要,使用 app_cert 验证签名后提取公钥点,自己生成一个 ECC 的密钥,根据客户端的公钥点计算共享密钥,并把自己的公钥点发回给客户端,客户端同样使用两端的公钥点计算共享密钥,后续使用共享密钥进行加密通信,此过程明文传输,使用双方上一阶段交换的证书来验证签名
:::info
有了 case 1 的基础,我们已经有 private_key_app.pem,因此签名等操作自然是没问题的,直接从服务端代码复制 ecdh_generater 部分代码实现这个功能然后拼接数据即可,使用服务端返回的 ECC 公钥点计算出共享密钥
:::
skey = shared_secret[:16]
ivparams = shared_secret[32:44]
case 3:客户端生成一段由前面的各种参数、各种参数的摘要及一个云平台私钥(private_key_cloud.pem)签名的并经过共享密钥加密的数据发送给服务端,服务端解密后验证各个数据段是否正确,验证通过则进入下一阶段
:::info
该阶段需要往服务端发送的 digital_key 明文数据格式如下:
dkid+sha256(app_cert)+sha256(vehicle_cert)+sha256(DKid+sha256(acert)+sha256(vcert))+sign(DKid+acert+vcert)
这里不能使用我们生成的客户端证书计算摘要,而是要使用流量包中出现过的客户端证书,因为服务端会校验客户端证书摘要是否与其保存的在本地的客户端证书一致,这样一来 dkid、app_cert、vehicle_cert 都是确定值,可以在流量包中找到,所以其对应的 hash 也是确定值,最大的问题就是我们没有私钥 private_key_cloud.pem 对其进行签名
这时候 digital_key.txt 的作用就来了,结合文件名字以及 digital_key.txt 开头的几个字节是 dkid 这一情况来判断,digital_key.txt 就是明文的数据格式,其中也包含了经过 private_key_cloud.pem 签名的数据,所以我们应该做的就是使用共享密钥加密“digital_key.txt 及部分格式字段拼接而来的数据”,然后发给服务端,验证通过则进入下一阶段
:::
case 4:客户端发送经过共享密钥加密的数据,服务端调用 decrypt_key 解密数据并校验解密数据的 [6:] 是否为 b"\x00\x01\x00\x01\x00\x01\x00\x01",若符合,则发送经过共享密钥加密的 flag
:::info
这一步就简单多了,只需要构造明文数据的 [6:] 是 b"\x00\x01\x00\x01\x00\x01\x00\x01",然后使用共享密钥进行加密发过去就可以了,等收到服务端的通知后再使用共享密钥解密即可得到 flag
:::