Reverse Engineering Minebea SALIOT Spotlights


Minebea SALIOT are motorised track spotlights: dimmer, pan, tilt, zoom, colour temperature.

You control them with the vendor’s tablet app, one fixture at a time, by hand. For four lamps this is fine. We have around 160, and anything that has to happen on many of them at the same time is not possible this way. There is no documented protocol and no API, so there is also nothing to automate against.

The question was simple: can the fixtures be driven from code at all? They can. It took about one week, and the fix in the end was two lines. The week is the interesting part, because almost all of it went into debugging a layer that was not broken.

Everything starts with the APK

There was only one thing to work with: the Android app. Android apps are just zip files, and this one was on a public APK mirror. No account, no device, no hardware needed. That is already enough for a lot of reverse engineering.

You take the APK apart with two tools that do different things:

apktool d saliot.apk -o app/     # -> smali + decoded resources, rebuildable
jadx -d src/ saliot.apk          # -> Java-like source, readable, NOT rebuildable

apktool decodes the app into smali, the assembly language of the Dalvik VM. It is verbose and hard to read, but it is lossless: you can edit the smali, run apktool b, and get a working APK back.

jadx decompiles the same bytecode into something that looks like Java. Much easier to read, but it is a reconstruction. It guesses the control flow, sometimes wrong, and you cannot recompile its output.

So the workflow is: read in jadx, patch in apktool. A common mistake for beginners is to do both in jadx, see that it does not rebuild, and think the app is protected. It is only the wrong tool for that half of the work.

You cannot grep for a name you do not have

Most write-ups skip this step. The usual advice is “grep for the mesh stack” — but that already assumes you know which stack to look for, and this is exactly what you do not know yet.

What I knew at the start was one thing: it is Bluetooth mesh. The fixtures relay to each other, so mesh was clear. But which mesh — that I did not know, and that is the part that decides how much work is ahead of you.

So you do not search, you look. I opened the decompiled com/ package tree and read down the list. Every third-party SDK the app bundles is right there, one directory each:

com/
├── csr/          <- Bluetooth mesh stack
├── parse/        <- backend / cloud SDK
├── estimote/     <- BLE beacons
├── bumptech/     <- image loading (Glide)
├── facebook/
├── google/
├── squareup/     <- networking (OkHttp / Retrofit)
└── ...

com/csr/mesh was the whole answer. CSRmesh is Qualcomm/CSR’s Bluetooth mesh stack — older than the official Bluetooth Mesh standard, and used in a large number of commercial lighting products.

This single directory changed the whole job. CSRmesh is not a secret protocol, and there is prior work, especially python-csrmesh. So I did not have to build a cryptosystem from nothing. I only had to find out which variant the app used, and what Minebea added on top.

The general point: the most useful first step is not reading code, it is listing the dependencies. A package tree tells you in seconds whether you have an original protocol in front of you, or someone else’s SDK with a wrapper around it. These are very different amounts of work, and you want to know which one you have before you start reading.

The frame

Reading the mesh classes gives this layout. This is the plaintext the app builds before it encrypts anything:

[0:3]  seq        24-bit, little-endian
[3:5]  source     controller address, 0x8000
[5:7]  dest_id    device id, or group id (0 = broadcast)
[7]    model op   0x8A for Light, 0x70/0x73 for Data, ...
[8:]   params + trailing transaction id (TID)

Then only part of it is encrypted:

nonce      = seq(3) || 0x00 || source(2) || 0x00 * 10     # 16 bytes
keystream  = AES_ECB(network_key, nonce)
ciphertext = plaintext[5:] XOR keystream                   # from dest_id onward

seq and source stay in the clear. Everything from dest_id on, including which lamp you address, is encrypted. This is on purpose: a relay node must read the header to forward the frame, but it does not need to decrypt the content.

The tag also covers the cleartext header, through a zero prefix:

prehmac = 0x00*8 || seq(3) || source(2) || ciphertext
mac     = reverse(HMAC-SHA256(network_key, prehmac))[:8]

And this is what goes on air:

seq(3) | source(2) | ciphertext | mac(8) | TTL(0xFF)

Three things here explain the rest of the story.

The MAC is the reason a wrong key fails silently. The node computes the tag, sees a mismatch, and drops the frame. There is nothing to answer with, so it stays quiet. Remember this.

seq is not a counter. It is a random 24-bit value per message, the same thing python-csrmesh calls random_seq(). Its only job is to make the nonce unique, not to order anything. So the deduplication that you would normally expect from a sequence number happens somewhere else: in the TID, the transaction id at the end of the parameters. If you get the TID wrong, the node treats your retry as a new command.

The encrypted part is limited to 16 bytes, because the app builds its keystream from a single AES-ECB block. Every real Light and Data message fits. If you implement it as AES-OFB, you get the same keystream for block zero, and it also extends correctly for longer payloads. So it is byte-for-byte identical to the app and to python-csrmesh, without the app’s size limit.

Obfuscated does not mean unreadable

The mesh crypto is in classes called j, a, f. This is a name-shrinking obfuscator. It is not really an anti-RE measure, but you lose every hint that a method name would give you.

You get the meaning back from the structure. A method that takes a byte array and returns 16 bytes, called right before an AES call, is a key derivation, no matter its name:

def network_key(passphrase: str) -> bytes:
    # The mesh network key is derived from the passphrase like this:
    #   1. append the fixed marker b"\x00MCP" to the passphrase
    #   2. take its SHA-256 hash (32 bytes)
    #   3. keep only the second half (bytes 16..31)
    #   4. reverse those 16 bytes -> this is the AES key
    marker = b"\x00MCP"
    digest = hashlib.sha256(passphrase.encode() + marker).digest()
    second_half = digest[16:32]
    return second_half[::-1]          # [::-1] reverses the byte string

The one thing not in the app

This function takes a passphrase, so it is a fair question where the passphrase comes from. It is the only part of the whole system that is really secret.

It is not complicated. The passphrase is the company id plus the mesh network name, and every fixture in one mesh shares it — one secret per network, and the key is derived from it with the function above. There is no per-device key, no pairing, no rotation. It is also not in the APK: the app gets a fleet’s configuration from the vendor’s backend during setup and caches it. So a mesh’s passphrase lives with the installation it was set up for, not in the software. We pulled ours once, so the fleet can run fully offline and no longer needs that backend.

This means the rest of this page — the framing, the crypto, the bearer selection, the chunking — is protocol, and reproducing it gets you nowhere against someone else’s lights. The key is the one part that is actually yours.

I am not publishing ours. A protocol write-up does not need it, and it is the key to the lighting of a real building. The code here is complete and will talk to your mesh as soon as you give it your own passphrase. Against mine it does nothing.

def nonce(seq: int, src: int) -> bytes:
    # The AES nonce is 16 bytes, built from the sequence number and the
    # sender address, padded with zeros to fill the block:
    #   seq   -> 3 bytes, little-endian
    #   0x00  -> 1 separator byte
    #   src   -> 2 bytes, little-endian
    #   0x00  -> 10 padding bytes
    seq_bytes = seq.to_bytes(3, "little")
    src_bytes = src.to_bytes(2, "little")
    return seq_bytes + b"\x00" + src_bytes + b"\x00" * 10   # 3+1+2+10 = 16

Two traps sit in there, and both are the same kind: details that give you a plausible wrong answer instead of an obvious failure.

The reverse. p.a() looks like it could be a real transform. It is only an in-place reverse. If you miss it, you get a correct-looking 16-byte key that is simply wrong.

The MAC prefix. The tag is computed over an eight-zero prefix. In the decompiled source, e.a and d.b are just new byte[8]. Two lines that look like boring initialisation, but are part of the spec.

Neither mistake gives an error. Both give you a key that encrypts fine and decrypts to noise on the other side.

One more thing I found while reading: instead of defining their own mesh message set, Minebea use the standard CSRmesh LIGHT_SET_RGB opcode as a generic command carrier. Pan, tilt, zoom, scene recall all travel inside a message that officially sets a colour. This is pragmatic: the opcode already existed in every certified node, so they did not have to extend the protocol.

From a Java call to a Python one

The reason you read the app is to reduce its call chain to something you can call yourself. Setting a level goes through a model API, which calls a mesh service, which builds the payload, encrypts, frames, and writes to GATT.

In Python this is a payload builder and a message builder:

LIGHT_OPCODE      = 0x8A     # "this is a Light message"
CONTROLLER_SOURCE = 0x8000   # our own address as the controller
TTL_DEFAULT       = 0xFF     # hop limit: as far as the mesh will carry it


def light_set_level_payload(level, tid, ack=True):
    # Build the little "set brightness" message. Four bytes:
    #   opcode : it is a Light command
    #   sub    : 0x00 = ask for an acknowledgement, 0x01 = fire and forget
    #   level  : brightness, 0..255
    #   tid    : transaction id -- the SAME value on every retry of this
    #            command, so the lamp can tell a retry from a new command
    sub = 0x00 if ack else 0x01
    return bytes([LIGHT_OPCODE, sub, level & 0xFF, tid & 0xFF])


def make_message(network_key, seq, dest_id, payload_bytes,
                 source=CONTROLLER_SOURCE, ttl=TTL_DEFAULT):
    # Wrap the payload into a full on-air packet:
    #   seq(3) | source(2) | encrypted(dest + payload) | mac(8) | ttl(1)
    # (encrypt with network_key + nonce, then append the MAC and TTL)
    ...

Note the sub byte: whether a command is acknowledged is not a transport flag. It is a sub-opcode inside the payload. And tid comes from the caller, because retries of one command must reuse it.

Then the packet has to reach the characteristic. CSRmesh’s LE bearer splits it the same way as the app’s LeBearer.sendPacket():

def split_for_gatt(packet):
    # One GATT write can carry at most 20 bytes here, and there are two
    # control points to write to: 0 = "low", 1 = "high".
    #   - up to 20 bytes  -> one write to control point 0
    #   - 21..40 bytes    -> first 20 to CP 0, the rest to CP 1
    #   - over 40 bytes   -> the app itself refuses it, so we do too
    if len(packet) > 40:
        raise ValueError("packet too long for LE bearer (>40 bytes), would be dropped")
    if len(packet) <= 20:
        return [(0, packet)]                          # fits in one write
    return [(0, packet[:20]), (1, packet[20:])]       # split across the two CPs

20 bytes per GATT write, two control points, and a hard limit of 40. The app drops anything longer, so you have to reproduce that limit and not be more generous than the app.

Last, the send loop. A mesh has no acknowledgement: you push a frame into a flood network and hope enough nodes relay it to the target. There is no retransmit on failure, because there is no detectable failure. So the transmit is parameterised, not clever:

async def send(self, dest_id, payload, repeat=1, gap=0.12):
    # Send the command `repeat` times, `gap` seconds apart.
    for _ in range(repeat):
        seq = mesh.next_seq()                         # fresh random seq each time
        pkt = mesh.make_message(self.network_key, seq, dest_id, payload)
        for cp, chunk in mesh.split_for_gatt(pkt):    # 1 or 2 GATT writes
            await self._write(self._cp[cp], chunk)
        if gap:
            await asyncio.sleep(gap)

A new seq for every repeat, 120 ms between them. This looks wrong at first — a retry should be the same packet, no? It should not, and here you see why the two identifiers are separate. seq only has to make the nonce unique, so it is re-rolled every time. The TID inside the payload is what tells a node “this is the same command you already saw”. Reusing seq would weaken the crypto for no reason. Reusing the TID is what makes the retry a retry.

Default repeat=1, because one write is usually enough at bench distance. State reads use repeat=2, because a lost reply only costs a timeout, not a lost command.

The lamp that ignored me

With an implementation ready, I pointed it at a bench fixture. It was connectable and its characteristics enumerated cleanly.

Power on: nothing. Brightness: nothing. identify, the command whose only purpose is to make a lamp announce itself: nothing.

No error, no NACK. The write was acknowledged on the Bluetooth layer, and the fixture behaved as if nothing was sent.

Silence is the worst failure mode. An error tells you where you are wrong. Silence gives you no direction — every wrong attempt and every almost-right attempt produce the same bytes, so there is nothing to bisect.

A very attractive wrong hypothesis

My first theory was that the key was wrong.

It is worth saying why this theory was so attractive, because it is the real trap of the story: a wrong key explains every symptom. A well-formed packet, correct framing, arrives at the node, fails the MAC check, gets dropped silently. Exactly what I saw. And after the two small traps in the key derivation, a wrong key was very plausible.

So I spent days on it. Derived the key again. Read the crypto helpers again to confirm p.a() was really just a reverse. Checked the passphrase bytes for stray whitespace or an encoding problem. Everything came back clean and correct. The lamp still ignored me.

The thing that ended it was something I had ignored for days, because it was working and I only looked at what was broken:

Reads worked. A state request came back and decrypted correctly. Only writes did nothing.

This one fact kills the hypothesis. If a GET decodes, then the key is right, the crypto is right, the framing is right, and packets arrive and are processed. So the problem was below the protocol, in how the bytes were delivered.

One week on the wrong layer. The lesson: when one thing works and another does not, the thing that works tells you more.

Not buying a sniffer

The next instinct is to watch the air and see what the app does differently. I priced it: an nRF52840 dongle is €10–15 with Nordic’s free sniffer firmware; better is Sniffle on a TI CC1352.

It helps to know why they differ. BLE hops across 37 data channels on a pseudo-random schedule. A cheap sniffer listens on one channel at a time, so it catches fragments and misses hops. Sniffle is better at following a connection through its hop sequence, which is what you need for reliable GATT writes instead of scattered packets.

Then the cheaper idea: I did not need to see the air at all. The difference was between the app’s writes and mine, and the app runs on hardware I control. So capture it at the source. No sniffer, no radio, no hopping.

How Frida gets inside an app without root

This is the most reusable part of the story, so it is worth explaining well.

The capture ran on an old phone from the drawer — a OnePlus 3T with LineageOS (Android 9), reached over wifi-adb so no cable was in the way. Exactly the kind of device that is useless for anything else and perfect for this: you install a patched build on it, point it at the lamp, and it does nothing but be instrumented.

Frida injects a JavaScript engine into a running process and lets you hook functions. On Android it hooks into ART and can replace any Java method at runtime. The normal way is frida-server, a daemon on the device that attaches to processes on demand. This needs root, because attaching to another app’s process is exactly what the Android sandbox is there to prevent.

And this old phone had no way to root it — LineageOS here with no addonsu and no Magisk image.

The way around this is the gadget. Instead of an external daemon that reaches into the app, libfrida-gadget.so is a shared library that the app loads itself at startup. Code inside your own process needs no special permission. So you repackage the APK: unpack it, add the library, add a System.loadLibrary call on an early code path, rebuild, and re-sign it. The re-signing is required — Android checks the signature at install, you do not have the vendor’s key, and this is also why the patched app installs next to the store version instead of over it.

That is the whole idea: root lets you reach into someone else’s process; repacking means you never have to.

With the gadget in place, the important hook sat on the GATT write path. For every outgoing write, it logged which characteristic and which write type the app used, together with the bytes.

Two bugs, one hiding the other

The capture answered it at once: the app and I were writing to different characteristics.

A BLE peripheral exposes services, each with characteristics — addressable endpoints with a UUID. Writing to one that does not exist on a device is not an error you see on the application layer. There is simply nothing there to answer.

CSRmesh has two generations of control point, and this fleet has both:

CP0_WRITE     = "0000d011-d102-11e1-9b23-00025b00a5a5"   # modern, low
CP1_WRITE     = "0000d012-d102-11e1-9b23-00025b00a5a5"   # modern, high
LEG_CP0_WRITE = "c4edc000-9daf-11e3-8003-00025b000b00"   # legacy, first chunk
LEG_CP1_WRITE = "c4edc000-9daf-11e3-8004-00025b000b00"   # legacy, continuation

The fixture I used for bench testing was older silicon than most of the fleet and only exposes the legacy pair. The app feature-detects; my code did not. So the fix is to mirror the app’s own check — it decides based on the modern characteristic, so we do the same:

# Does this fixture have the modern control point? If not, it is an old
# one and we must use the legacy pair instead. Same decision the app makes.
has_modern = self._client.services.get_characteristic(CP0_WRITE) is not None
if has_modern:
    self._cp = {0: CP0_WRITE, 1: CP1_WRITE}          # modern d011 / d012
else:
    self._cp = {0: LEG_CP0_WRITE, 1: LEG_CP1_WRITE}  # legacy c4edc000 pair

Of all the fixtures on the shelf, I took the one that was different from the rest, and then spent a week thinking my crypto was wrong.

Fixing the bearer selection got the writes onto a real endpoint. They still did nothing. This showed the second bug, which the first one had hidden. The two chunks of a split packet went to the swapped control points: index 0 is the first chunk and belongs on the low CP, index 1 is the continuation and belongs on the high one, and I had them the other way around.

And here the whole week makes sense. A packet of 20 bytes or less is never split — split_for_gatt returns one write. A short GET fits. A longer SET does not: it splits, arrives reversed, reassembles into garbage, and a well-behaved mesh node drops garbage silently.

The reads had worked because they were short. One bug produced both halves of the symptom that had made me suspect the key.

One more trap on the way out, and it is the opposite of what you expect. Write-without-response is the faster mode and the obvious default, but the legacy bearer rejects it on the BlueZ layer with Failed to initiate write, while the modern one accepts it. So the write path prefers it and falls back:

try:
    # Preferred: fast write, no acknowledgement. The modern bearer likes it.
    await self._client.write_gatt_char(uuid, chunk, response=False)
except Exception:
    # The legacy bearer refuses that mode, so fall back to a normal write.
    await self._client.write_gatt_char(uuid, chunk, response=True)

Replies split the same way as writes, so notifications from the legacy pair have to be reassembled. The honest way to know when you have a full packet is to try the combinations and let the MAC tell you which one verifies.

What the week was actually for

The fix was two lines. The week went into the wrong layer.

The way out was not a clever idea. It was building something that could show me the ground truth instead of letting me keep guessing, and every hour on the capture rig saved a day of theory. When a problem gives you no error signal, stop guessing and build the instrument.

And the short version, for next time: when reads work and writes do not, stop looking at your crypto. The broken layer is the one you have not instrumented.

The code here is the protocol — key derivation, nonce, frame layout, the fix — not our configuration or the tooling around it.