For quite a while, I’ve had a Pax 3, a Bluetooth LE-connected vape. It’s a great device, but the iOS app leaves a lot to be desired. Finally, I had enough and decided to set out to figure out how their communication protocol works; with the end goal of implementing my own app to control it.
To be fair, many of these issues likely stem from the fact that Pax hasn’t been able to release an update to their iOS app after Apple banned vaping-related apps quite a while back. Folks who previously downloaded it can continue to use it, and download it on new devices, but it’s no longer listed on the App Store. Despite my complaints about the app (and to be fair, I do like to complain about most things,) I want to be clear that I think Pax Labs made something wonderful that still works as great as the first day that I got it.
Their Android app has received quite a few updates since then, so I am sure it works better, but I don’t have any Android devices, and buying one (and probably getting more annoyed at Android than the buggy iOS app) was out of the question. So of course, as any sane person does, I decided to look into reverse-engineering the app.
Update: After (rather predictably) far too much time, I finally wrote the iOS app that sparked this entire reverse engineering effort. Check out this follow-up post for more information.
Protocol Reversing
As it turns out, reversing the protocol was significantly easier than I expected, despite a few notable (or rather, annoying) quirks of the protocol implementation and a few roadblocks in actually getting an app to analyze. I relied entirely on static analysis (and a lot of more or less educated guesses…) to reverse the protocol and quite a few little test programs to validate my guesses as I went.
First, I tried to get the iOS app. There seemed to be no way to get at it through my Mac, nor backups. There was a solution using Apple Configurator that worked for other, still listed apps, but since the Pax app had become unlisted, that also was no longer an option. Lastly, there are ways to get the app off the device itself… if the device is jailbroken. Sadly I had no idea where any of my spare iDevices that I could go and jailbreak had gone, so I turned to the Android app as a possible alternative – after all, it’s talking to the same piece of hardware.
Bluetooth Services
At this point, it occurred to me that since these are Bluetooth LE devices, I could just fire up whatever Bluetooth scanner app I found first online and take a peek at what services and characteristics the device exposed. I didn’t know very much about how BLE works, but a few minutes of reading up on the relationship between centrals and peripherals, as well as services, characteristics, notifications, and so on was all that I needed.
We’ve got the standard system information service (which provides the manufacturer/model string, serial number, etc.) and is of course standardized and documented extensively. But there’s also an unknown service, identified by a full 128-bit UUID (8E320200-64D2-11E6-BDF4-0800200C9A66
;) I’ll refer to it as the “Pax Service” from now on. Since there were no other services, I guessed that this was probably how the device itself was controlled. This gave me something to search for in the app binaries since they would also have to store that UUID to discover the service.
The Pax service itself exposes three characteristics: one that’s read-only, one that’s write-only, and lastly, one that is read-notify. Again, I guessed that the read/write characteristics were used to exchange packets between the device and host and that the notify characteristic was used by the device to signal that it had packets available to read from its read endpoint.
Pax Mobile for Android
Android was a whole new world for me; about the only thing I knew about it was that I couldn’t stand using it a few years ago when I tried an Android phone, that I horribly failed trying to get some Android stuff working in a class once, and that there was a lot of Java – I wrote a lot of Java years ago, and used it in a few classes, but it has been a while since I’ve even touched a Java compiler – so this was certainly going to be a fun little adventure.
It wasn’t as bad as I expected, but it certainly wasn’t high up on my list of “fun” activities. I’m sure a lot of what I did below is overly complicated, or just outright wrong, so I wouldn’t rely on it as a guide of any sort. I had a few friends and coworkers provide me some suggestions and tips along the way, so I suggest finding someone who knows way more about this topic to bother with stupid questions 🙃
Thankfully, getting downloads of Android apps turns out to be significantly easier, with plenty of websites that let you directly download the app’s apk
file; which is also just a ZIP file by another name. I’m examining version 4.0.0 of the app; no particular reason, it was where I landed when I scrolled in the list of available versions.
Inside the extracted app binary are some .dex files which actually contain the bytecode of the application. This is roughly translatable back to Java class files by tools like dex2jar; those, in turn, can be run through your favorite Java decompiler for a somewhat tolerable experience1.
With the jar file loaded into a Java decompiler, at first glance, it looked like most of the class names were obfuscated; however, most of the package names were at least partially intact. I figured this would make reversing things more difficult, but thankfully, it didn’t end up being as much of a hindrance as I thought since most relevant code was still in the same class, even if it was just a random single character name.
I started by searching for the UUIDs advertised by the device and very quickly confirmed my theory on how the Pax service worked. While the decompiled code isn’t exactly easy to read, it beats raw Java bytecode by a long shot:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.pax.peace.a.b;
import e.f.b.j;
import java.util.UUID;
public enum f {
INTERNAL_LOG_SERVICE_NOTIFICATION, INTERNAL_SERVICE_NOTIFICATION, LOG_SERVICE_NOTIFICATION, PAX_SERVICE_NOTIFICATION;
private final UUID dataReadyCharacteristicUUID;
private final UUID readCharacteristicUUID;
private final g serviceType;
static {
UUID uUID1 = UUID.fromString("8E320203-64D2-11E6-BDF4-0800200C9A66");
UUID uUID2 = UUID.fromString("8E320201-64D2-11E6-BDF4-0800200C9A66");
f f1 = new f("PAX_SERVICE_NOTIFICATION", 0, g1, uUID1, uUID2);
PAX_SERVICE_NOTIFICATION = f1;
// ...
}
f(g paramg, UUID paramUUID1, UUID paramUUID2) {
this.serviceType = paramg;
this.dataReadyCharacteristicUUID = paramUUID1;
this.readCharacteristicUUID = paramUUID2;
}
}
The 8E320203
UUID corresponds to the read-notify characteristic, whereas the 8E320201
UUID corresponds to the read-only characteristic; and with the property names still intact, it does indeed confirm that the notify characteristic indicates data is available to read from the device.
At this point, I had a pretty good idea of how data was exchanged between the device and the app via this service and its characteristics. Since the Pax contains some Nordic Semi part, and BLE is relatively low bandwidth, I figured that the actual protocol is relatively simple. In most cases, this means some common packet header indicating the type of data that follows; essentially just taking some packed structs, reading them out of memory, and sending them over the air.2
Packet Format
The com.pax.peace
package ended up being where I focused my search on, as that was where those first string constants for the characteristic UUIDs were found. With the assumption that the packets had some sort of type field, I searched for constants that define those types. I was rewarded pretty quickly with a file that mapped string constants to integer values; all of these were less than 0xFF
so I again assumed that the type was a single byte, likely at the very start of the messages exchanged with the device:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.pax.peace.g;
import com.pax.peace.a.b.h;
import com.pax.peace.a.b.i;
import e.f.b.g;
public enum y implements i {
TEMPERATURE, HEATER_SET_POINT, BATTERY, USAGE; // ...
private final int value;
static {
y y1 = new y("TEMPERATURE", 0, 1);
TEMPERATURE = y1;
y y2 = new y("HEATER_SET_POINT", 1, 2);
HEATER_SET_POINT = y2;
y y3 = new y("BATTERY", 2, 3);
BATTERY = y3;
y y4 = new y("USAGE", 3, 4);
USAGE = y4;
// ...
}
y(int paramInt1) {
this.value = paramInt1;
}
}
I started writing some code at this point to try to communicate with one of my devices. Reading out system info was no problem, and I was even able to register for notifications on the Pax service and read out data… but it seemed to be random garbage. The first byte rarely matched up with one of the message type IDs I discovered. Random data meant there was likely some form of encryption at play. Yaaaaaay…
Key Derivation
Searching through the decompiled code for any references to key or AES brought up a few hits in the com.pax.peace
package, including a few debug strings that alluded to certain single letter fields originally being encryption keys of some kind, as well as some references to an authentication protocol3 – and plenty of functions the decompiler couldn’t deal with, so I figured I was reasonably close to the right place.
I assumed that there’d be some shared key that’s used to transform some device data (I suspected the mysterious 8-byte “system ID” device info characteristic) into the key that’s then used to encrypt the data packets.
After staring at obfuscated Java code for long enough, it became clear that the key derivation algorithm was pretty simple:
- Read the device serial number, which is 8 characters. Concatenate it to itself to yield a 16 character string.
- Encode the string as UTF-8 to get 16 bytes. Serial numbers just contain alphanumerics so I don’t think it really matters, but this is what the app does.
- Encrypt these bytes with the shared key4, using AES-128 in ECB mode.
That key is then stored away to encrypt/decrypt packets exchanged with the device. I’m not sure what the point of this is, since the device’s serial is easily readable through the device info service; if it was to waste a few hours of my time, it certainly worked.
Data Encryption
While I now had the key that the packets were encrypted with, it still left the question of what algorithm. Trying AES-128 ECB, like used in the key derivation, didn’t read any intelligible data; this meant that there was some IV that was computed or sent with the message. So back into the obfuscated Java code I went in hopes of figuring that one out.
This didn’t really yield anything useful, likely because I have no idea what I’m doing. What I did have, however, was the correctly derived key, and messages read from a device via a little test program; as well as the assumption that the packet was encrypted with that derived key, using AES-128, in some mode that requires an IV. Packets I read from the device were always 32 bytes, which happens to be twice the AES-128 block size, so I also assumed that the IV would either be prepended or appended to the actual data.
Because I had nothing better to do, I just wrote a little Python tool that tried every combination of getting the IV from the start/end of the packet and AES modes supported by the pyCrypto module. Pretty quickly I had this figured out, too: packets are encrypted with AES-128 in OFB mode, using the last 16 bytes of the packet as the IV.
Notifications
All the above described encryption stuff only applies to the read (8E320201-64D2-11E6-BDF4-0800200C9A66
) and write (8E320202-64D2-11E6-BDF4-0800200C9A66
) characteristics, which actually exchange useful data. There is however still the third notification characteristic (8E320203-64D2-11E6-BDF4-0800200C9A66
) that I haven’t paid much attention to yet. The way this one works is that any time the device has a packet ready to send – usually because some value changed, but it can also be in
response to packets sent by the host – a notification is sent to the host.
The value of this notification doesn’t matter, and seems to be discarded by the app; from my testing, however, it’s always the first byte of the packet that’ll be available from the read characteristic. Triggering a read of it in response to the notification seems to be sufficient to make things work.
Notifications work in conjunction with the STATUS_REQUEST
message type; its payload is a 64-bit bitfield, with a bit set for each property to read out. When the device receives this message, it’ll generate notifications for all of these properties so that the host can read them out.
Proof of Concept
Now I knew enough about the protocol to build a little something something to communicate with the device. While there’s Python libraries to do BLE stuff, I opted instead to write a little test app in Swift, mostly since it’s been a little bit since I’ve thrown together a Mac app. Additionally, Apple’s frameworks are, for the most part, pretty damn good – Core Bluetooth was no exception. Just a few lines of code are all that’s necessary to connect to, send, and receive data with the device. Any updates and notifications are delivered through delegate methods.
All of the Pax protocol stuff is hidden away in a base PaxDevice
class. There are then device-specific classes that support particular properties unique to the device. For example, this is a simplified example of how the Pax 3 specific device class could be used to configure the device:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Pax3Device: PaxDevice {
/// Current temperature of the oven, in °C
@Published private(set) public var ovenTemp: Float = Float.nan
/// Current target temperature of the oven, in °C
@Published private(set) public var ovenTargetTemp: Float = Float.nan
/// Desired oven set point temperature, in °C
@Published private(set) public var ovenSetTemp: Float = Float.nan
/// Oven heating profile
@Published private(set) public var ovenDynamicMode: DynamicModeMessage.Mode = .standard
// ...
}
// receive property change notifications
device.$ovenTemp.sink() { temp in
print("Oven temperature: \(temp) °C")
}
// update device state
try device.setOvenTemp(185)
try device.setOvenDynamicMode(.efficiency)
Using Combine made handling the notifications sent by the device, and the resulting property updates incredibly simple. Clients will always read the latest available value for a property, because the device will notify the host any time there are value changes and update the instance variables, thus notifying observers.
I quickly threw together a little graphical interface to control it all (Cocoa bindings made updating the UI really really easy… though I ought to learn Swift UI at some point) and was off using my vape with my own app. It did however have the same disconnecting issues that the official Pax app has, so that’s probably a firmware bug. Go figure. At least I should be able to work around it on the client.
Message Types
Here’s a list of all the message types, and some basic information about them. This currently describes all message types the app knows about, although I bothered with reversing and implementing only a few of them needed to control the device.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
enum PaxMessageType: UInt8, CaseIterable {
/**
* Indicates current oven temp as a 16-bit value, in °C multiplied by 10.
*
* Devices: Pax 3
*/
case ActualTemp = 1
/**
* Indicates the desired temperature of the oven/pod, in the same encoding as the actual
* temperature.
*
* Devices: All
*/
case HeaterSetPoint = 2
/**
* Current state of charge in one byte; 0-100.
*
* Devices: All
*/
case Battery = 3
case Usage = 4
case UsageLimit = 5
/**
* One byte indicating whether the device's user interface is locked.
*
* Devices: All
*/
case LockStatus = 6
case ChargeStatus = 7
/**
* One byte indicating whether a pod is inserted or not.
*
* Devices: Era
*/
case PodInserted = 8
case Time = 9
/**
* User visible name of the device. This is encoded as one byte indicating the string length,
* followed by the raw bytes of the string. The app treats this as UTF-8 encoded.
*
* Devices: All
*/
case DisplayName = 10
case HeaterRanges = 17
/**
* One byte indicating the dynamic heating mode to use
*
* Devices: Pax 3
*/
case DynamicMode = 19
case ColorTheme = 20
case Brightness = 21
case HapticMode = 23
/**
* Query the device for what attributes it supports. Its payload is a 64-bit unsigned integer,
* treated as a bitfield. If the given bit is set, the attribute is supported. Attribute
* numbers map directly to values in this enum.
*
* Devices: All
*/
case SupportedAttributes = 24
case HeatingParams = 25
case UiMode = 27
case ShellColor = 28
case LowSoCMode = 30
/**
* Current target temperature of the internal temperature controller.
*
* Devices: Pax 3
*/
case CurrentTargetTemp = 31
/**
* Current state of the oven.
*
* Devices: Pax 3
*/
case HeatingState = 32
case Haptics = 40
/**
* Request the device sends the current status of all indicated attributes. Attributes are
* encoded identically to the supported attributes query.
*
* Devices: All
*/
case StatusUpdate = 254
}
It’s worth noting that all multi-byte quantities are encoded in little endian byte ordering and that strings are not zero-terminated due to explicit lengths. All messages are 16 bytes minimum; I have not done any testing to figure out the maximum supported message length.
Wrapping Up
Hopefully, this has been an interesting little detour from the usual osdev sytems-y stuff I normally write about. Surprisingly I managed to do this all over the course of a few weeknights and a weekend, which was a surprise considering I had been toying with the idea of doing exactly this for a few years at this point.
There is still much work remaining; I haven’t bothered with looking into many of the protocol messages, particularly those related to configuring the device’s color scheme, haptics, or reading some more complicated information out.
However, the basics of what’s needed to talk to any Pax device are there. The Era uses the same protocol, and while I don’t have an Era Pro to test with, it seems to at least be compatible with it to some extent, despite the more complicated authentication handshake it does. It wasn’t clear to me whether the handshake is required to communicate with the device at all, or whether it’s only for the “fancy” pods that the device can identify.
Future Plans
Eventually, I’ll get around to cleaning up my notes that document more of the packet types, but what’s described above is enough to get started reading data from the device. Most packets are relatively simple to decode and encode; a Bluetooth packet capture would likely help wonders here, but getting that off an iOS device wasn’t something I wanted to bother with.
I also want to turn the proof of concept app I threw together in an evening into something more easily reusable in the form of a library. Maybe I’ll even get around to throwing together an iOS/macOS app to entirely replace the Pax app, but there’s only so much free time and entirely too many projects.
Assuming I don’t forget about this entire blog thing again for two months, I’ll hopefully have a part 2 of this adventure posted with more concrete information, and hopefully, actual ready to use code.
Update: I finally got around to publishing the promised second installment with more information about the protocol and some actual code.
A common theme throughout all the decompiled Java code is that sometimes we’ll get constructors with extra arguments, especially in enums. I have no idea what this means (it’s either obfuscation or how Java works…) but I’ve just been ignoring any extra arguments at the start of constructors. ↩
It seems like this is actually exactly what the firmware on the device does; packets are always at least 16 bytes, but many messages are much shorter than this. Any memory not occupied by the message in a packet always contains garbage, which is probably uninitialized memory. Oops. ↩
The authentication protocol is used only by the Era Pro so I didn’t bother looking into it. Instead of the described simple key derivation and packet encryption mechanism, there’s a handshake that takes place instead. ↩
From what I can tell, this key is common to all Pax devices and models. Finding the key is left as an exercise to the reader. Hint: \(\text{🔑 } \bmod \text{🥬} = 139\). ↩