Le point de départ
J'ai des écouteurs Marshall Motif II ANC et une enceinte Marshall WILLEN. Marshall ne fournit pas d'application Linux. L'appli Android officielle permet de contrôler le mode ANC, l'égaliseur, de voir la batterie - des trucs basiques, mais utiles. Sur Linux : rien. L'audio Bluetooth fonctionne, mais le confort d'utilisation s'arrête là.
La solution évidente : reverse engineer le protocole que l'appli officielle utilise, et le réimplémenter. Les appareils Marshall sont tous fabriqués par Zound Industries, une boîte suédoise qui produit aussi les marques Urbanears et Adidas headphones. Ça voulait dire qu'un seul protocole BLE couvre toute la gamme - une bonne nouvelle pour la portée du projet.
Ce que je ne savais pas encore : le chemin entre "j'ouvre jadx" et "l'appli fonctionne" allait passer par plusieurs heures de debug sur des erreurs BlueZ cryptiques, une découverte sur l'encodage de l'EQ qui vient directement du bytecode Kotlin, et une question d'authentification BLE que j'avais complètement ratée au départ.
Sommaire
- Anatomie Bluetooth du Marshall
- Reverse engineering de l'APK Android
- L'authentification BLE - le bug qui fait perdre 2h
- Les caractéristiques GATT importantes
- L'encodage de l'EQ
- L'application Linux - CLI et GUI
- Gestion multi-appareils
- La documentation du protocole
- Pour aller plus loin : observations sécurité
1. Anatomie Bluetooth du Marshall
Ce qui n'est pas évident au premier abord : des écouteurs Bluetooth comme le Motif II ANC exposent deux transports distincts, pas un seul.
- BR/EDR (Bluetooth Classic) avec une adresse publique - c'est ce qui transporte l'audio via A2DP/HFP. C'est ce que le gestionnaire audio du système voit.
- BLE avec une adresse séparée (publique ou aléatoire selon le modèle) - c'est là où vit le canal de contrôle GATT. ANC, EQ, batterie, firmware : tout passe par là.
BlueZ crée deux entrées séparées dans sa liste d'objets D-Bus pour le même appareil physique. La première pour le transport classique, la seconde pour le BLE. Quand on cherche l'appareil par nom, les deux matchent - mais seule l'entrée BLE a des caractéristiques GATT. Ça m'a coûté quelques recherches avant de comprendre pourquoi la connexion audio marchait mais les commandes ne passaient pas.

[LE] dans son Alias quand les deux transports coexistent, mais ce n'est pas systématique. Le plus fiable pour distinguer les deux : chercher l'entrée qui a des caractéristiques GATT enregistrées après connexion.Les UUID des services et caractéristiques Zound Industries suivent un schéma très spécifique :
Format UUID Zound Industries
0000{CODE}-1337-1dea-feed-c0ffee70c0de
Exemples :
00000013-1337-1dea-feed-c0ffee70c0de -> ANC configuration
00000017-1337-1dea-feed-c0ffee70c0de -> Equalizer settings
0000001c-1337-1dea-feed-c0ffee70c0de -> Party modeLe 0xC0FFEE dans le suffixe est intentionnel. Le suffixe 1337-1dea l'est aussi. Zound Industries s'est clairement amusé avec ses UUIDs.
2. Reverse engineering de l'APK Android
L'appli Android de Marshall s'appelle com.zoundindustries.marshallbt. Je télécharge le dernier APK (v3.7.1 au moment du RE), je l'ouvre avec jadx, et je commence à explorer.
Trouver les UUIDs
Première étape : un simple grep sur le suffixe Zound dans le code décompilé.
Recherche dans le code décompilé
grep -r "c0ffee70c0de" .Ça remonte directement une classe de constantes qui mappe chaque code UUID à un nom lisible. C'est de là, par exemple, que vient la liste complète des caractéristiques : ANCConfiguration (0x0013), EqualizerSettings (0x0017), VolumeLimit (0x0008), TouchLock (0x0014), et une trentaine d'autres.
Le FeatureManager : quels appareils supportent quoi
La partie la plus utile de la décompilation : une classe FeatureManager qui définit par type de device quelles fonctionnalités sont supportées. Les appareils y sont identifiés par des constantes internes - SAXON pour le Motif II ANC, par exemple. On y voit clairement que le SAXON a du CTKD (Cross-Transport Key Derivation) mais pas d'IMPLICIT_BOND, ce qui aura son importance pour l'authentification.
La structure du code Kotlin décompilé est lisible, même si jadx produit parfois des noms de variables génériques. L'essentiel est là : les flags de features par modèle, les valeurs des enums, et surtout les méthodes d'encodage des commandes.
Les valeurs des enums EQ
Dans le code décompilé, les presets EQ sont définis dans une enum. L'ordre compte : FLAT = 0, CUSTOM = 1, ROCK = 2, METAL = 3, POP = 4, HIP_HOP = 5, ELECTRONIC = 6, JAZZ = 7, BASS_BOOST = 8, MID_BOOST = 9, TREBLE_BOOST = 10, LOUD_PUSH_WORKOUT = 11.
3. L'authentification BLE - le bug qui fait perdre 2h
Premier essai de connexion aux écouteurs depuis Go via D-Bus/BlueZ. J'arrive à trouver l'appareil, je lance Device1.Connect(), ça passe. Mais dès que j'essaie d'écrire sur une caractéristique GATT, j'ai cette erreur :
Résultat
org.bluez.Error.Failed: No agent available for request type 2
Pas très explicite. Après quelques recherches, j'ai compris ce qui se passe : les écouteurs demandent un appariement BLE avant d'autoriser l'accès aux caractéristiques protégées. Et BlueZ, pour gérer cet appariement, a besoin qu'un agent d'authentification soit enregistré sur le bus D-Bus.
Le "request type 2" correspond à NoInputNoOutput - le mode Just Works de BLE. L'appareil n'a ni écran ni clavier, donc il accepte n'importe quelle connexion sans confirmation PIN. Mais BlueZ ne fait pas ça automatiquement : il cherche un agent enregistré pour valider la procédure, et si aucun n'est là, il rejette.
La solution : enregistrer un agent NoInputNoOutput
Il faut exporter un objet D-Bus qui implémente l'interface org.bluez.Agent1, l'enregistrer auprès de AgentManager1 avec la capacité NoInputNoOutput, et le passer en tant qu'agent par défaut. Après ça, quand BlueZ a besoin de valider le Just Works, il appelle l'agent, qui accepte silencieusement.
Enregistrement de l'agent BlueZ (Go)
// L'agent implémente org.bluez.Agent1 avec des méthodes vides
type agent struct{}
func (a *agent) RequestConfirmation(_ dbus.ObjectPath, _ uint32) *dbus.Error {
return nil // Just Works : on accepte sans confirmation
}
// ... autres méthodes de l'interface
func registerAgent(conn *dbus.Conn) error {
conn.Export(agentInstance, agentPath, "org.bluez.Agent1")
conn.Export(introspect.NewIntrospectable(agentInstance), agentPath,
"org.freedesktop.DBus.Introspectable")
mgr := conn.Object("org.bluez", "/org/bluez")
mgr.Call("org.bluez.AgentManager1.RegisterAgent", 0, agentPath, "NoInputNoOutput")
mgr.Call("org.bluez.AgentManager1.RequestDefaultAgent", 0, agentPath)
return nil
}Ensuite, appeler Device1.Pair() avec un timeout de 15 secondes. Si les écouteurs ne sont pas déjà appairés, BlueZ contacte l'agent, qui confirme automatiquement. Après ça, le bond est stocké et les connexions suivantes n'ont plus besoin de repasser par là.
SecureConnections = on est dans /etc/bluetooth/main.conf. Sans ça, certains firmwares Marshall refusent le pairing avec une erreur de sécurité différente. C'est la valeur par défaut sur la plupart des distros récentes, mais mieux vaut vérifier.4. Les caractéristiques GATT importantes
Une fois la connexion établie et le pairing fait, les caractéristiques GATT sont accessibles. Voici les principales que j'ai implémentées :
| UUID (code) | Nom | Description |
|---|---|---|
0x0013 | ANCConfiguration | 1 octet : 0x00 = Off, 0x01 = ANC, 0x02 = Transparency |
0x0017 | EqualizerSettings | 3 octets pour assigner, 2 pour activer (voir section 5) |
0x2a19 | BatteryLevel | Standard BT : 1 octet, pourcentage 0-100 |
0x2a24 | ModelName | Standard BT : chaîne UTF-8 |
0x2a26 | FirmwareRevision | Standard BT : chaîne UTF-8 |
Les caractéristiques standard BT (batterie, modèle, firmware) suivent le format GATT classique et fonctionnent avec n'importe quel appareil BLE - pas besoin de les déduire de l'APK. Les caractéristiques Zound propriétaires en revanche nécessitent du RE.
Le chipset du Motif II ANC est un SoC Airoha - je le déduis d'une chaîne AirohaBLE cachée dans certains UUID propriétaires (voir section 9), pas d'un teardown. Le modèle exact (l'AB1565, une puce BT 5.2 avec ANC, est un candidat plausible) je ne l'ai pas confirmé en ouvrant les écouteurs. Quoi qu'il en soit, le SDK Zound s'abstrait du chipset, donc le protocole reste indépendant du hardware.
5. L'encodage de l'EQ
L'ANC était trivial - un seul octet, trois valeurs. L'EQ m'a donné plus de fil à retordre.
Mon premier essai logique : écrire directement l'index du preset sur la caractéristique 0x0017. Un seul octet, valeur = index du preset. Les écouteurs répondent Invalid Length.

Ce que le code Kotlin dit vraiment
En creusant dans le code décompilé, je trouve deux opérations distinctes pour changer l'EQ. Le protocole Zound appelle ça ASSIGN_STEP_PRESET puis CHANGE_ACTIVE_STEP.
Le concept de "step" est une abstraction qui vient du fait que l'appareil peut stocker plusieurs configurations EQ et switcher entre elles. L'index de step est toujours 0 dans l'usage normal.
Encodage EQ - deux écritures successives
// Opération 1 : ASSIGN_STEP_PRESET
// [0x01, stepIndex, presetId]
func EncodeEQAssign(preset EQPreset) []byte {
return []byte{0x01, 0x00, byte(preset)}
}
// Opération 2 : CHANGE_ACTIVE_STEP
// [0x00, stepIndex]
func EncodeEQActivate() []byte {
return []byte{0x00, 0x00}
}
// Usage
client.Write(CharEqualizerSettings, EncodeEQAssign(EQRock))
client.Write(CharEqualizerSettings, EncodeEQActivate())Les deux écritures vont sur la même caractéristique. Le premier octet discrimine l'opération : 0x01 pour assigner un preset à un step, 0x00 pour activer un step. Sans la deuxième écriture, l'appareil stocke le preset mais ne l'active pas.
6. L'application Linux - CLI et GUI
Une fois le protocole compris, j'ai codé l'implémentation en Go. Deux interfaces : un CLI et une GUI. Le CLI d'abord, pour valider que toutes les commandes fonctionnent sans friction UI. La GUI ensuite, parce que lancer un terminal pour changer le mode ANC c'est bien, mais pas très pratique au quotidien.
Architecture du projet
Structure du projet marshall-linux
marshall-linux/
cmd/marshall/ -> CLI
internal/
ble/bluez.go -> connexion D-Bus, scan, pairing, GATT
protocol/uuids.go -> toutes les constantes UUID
protocol/commands.go -> encodage des commandes
device/device.go -> abstraction haut niveau
frontend/src/ -> UI Svelte
app.go -> bindings Wails
main.go -> entrée WailsLa couche ble/bluez.go gère tout ce qui touche à D-Bus : connexion au daemon BlueZ, scan des appareils, enregistrement de l'agent, pairing, et lecture/écriture des caractéristiques GATT. Le reste du code ne touche jamais D-Bus directement.
Le CLI
CLI usage
marshall <nom|adresse> <commande> [args]
Commandes :
info Modèle, firmware, batterie, mode ANC
anc [off|anc|transparency] Lire ou écrire le mode ANC
eq <preset> Changer le preset EQ
battery Niveau de batterie
scan Lister les caractéristiques GATT
Exemples :
marshall "MOTIF II A.N.C." info
marshall "MOTIF II A.N.C." anc transparency
marshall "MOTIF II A.N.C." eq rockLa GUI avec Wails
Pour la GUI, j'ai utilisé Wails v2 - l'équivalent de Tauri pour Go. Le backend est du Go pur, le frontend est du Svelte. Wails expose les méthodes Go au frontend via des bindings générés automatiquement, et le tout se compile en un seul binaire.
L'UI est volontairement minimale : thème sombre, pas d'emojis, icônes SVG. La connexion se fait par scan Bluetooth - l'appli liste les appareils détectés, on clique sur le sien, c'est tout.
7. Gestion multi-appareils
Le deuxième appareil sur lequel j'ai testé c'est mon enceinte Marshall WILLEN. Et là, première surprise : l'appli plante avec characteristic 00000017-... not found.
Logique : le WILLEN est une enceinte Bluetooth. Pas de mode ANC, pas le même EQ que des écouteurs. Les caractéristiques disponibles varient selon le modèle.
La solution : après connexion, interroger les caractéristiques réellement présentes sur l'appareil, et n'afficher que les sections UI correspondantes. Plus de crash, et l'interface s'adapte au device.
Détection des capacités post-connexion (Go)
func (a *App) GetCapabilities() *Capabilities {
if a.dev == nil {
return &Capabilities{}
}
set := map[string]bool{}
for _, uuid := range a.dev.ListCharacteristics() {
set[uuid] = true
}
return &Capabilities{
HasANC: set[protocol.CharANCConfiguration],
HasEQ: set[protocol.CharEqualizerSettings],
HasBattery: set[protocol.CharBatteryLevel],
// ...
}
}Côté Svelte, les sections ANC et EQ sont dans des blocs {#if caps.hasANC} et {#if caps.hasEQ}. Si ni l'un ni l'autre n'est disponible, un message discret indique que l'appareil ne supporte pas de contrôles avancés.
Le scan BLE et le cache BlueZ
Un détail pratique sur la découverte des appareils : quand des écouteurs ou une enceinte sont connectés en Bluetooth classique pour l'audio, leur interface BLE n'advertise pas forcément en continu. BlueZ garde cependant les appareils connus en cache.
L'appli fait donc deux choses en parallèle au lancement : lire le cache BlueZ immédiatement (instantané, affiche les appareils déjà connus), et lancer un scan BLE de 8 secondes en arrière-plan pour découvrir de nouveaux appareils. Le WILLEN apparaît dans le cache même quand il ne diffuse pas activement.
8. La documentation du protocole
En parallèle de l'implémentation, j'ai documenté tout ce que j'ai trouvé dans un repo séparé : marshall-protocol. UUIDs complets, encodage des commandes, valeurs des enums, table des firmwares par modèle, mécanisme d'authentification - tout ce qui a été reversé.
L'idée c'est que quelqu'un qui veut implémenter le protocole dans un autre langage n'ait pas à reprendre le RE depuis zéro. Le repo est séparé de l'appli pour être réutilisable indépendamment.
9. Pour aller plus loin : quelques observations sécurité
Une fois le protocole compris, difficile de ne pas regarder ce que ça implique côté sécurité. J'ai écrit quelques petits outils pour sonder mes propres appareils. Trois observations, avec des niveaux de certitude différents - je préfère être honnête sur ce qui est prouvé et ce qui reste à confirmer.
Observation 1 - traçables passivement, sans connexion
Un simple scan BLE passif suffit à repérer mes Marshall, sans jamais m'y connecter. Et surtout : le Motif II expose une adresse Bluetooth publique fixe sur son transport classique, plus un company ID fabricant (0x065a, enregistré au Bluetooth SIG sous Marshall Group AB - l'entité qui a succédé à Zound Industries) dans ses manufacturer data. Une adresse qui ne change jamais, c'est un identifiant de traçage permanent.
Scan passif - mes appareils + le voisinage
[!] 00:25:D1:41:DF:69 MOTIF II A.N.C. RSSI 0dBm
adresse : PUBLIC (traçable en permanence)
ZOUND : nom produit Marshall/Zound: MOTIF
[!] C8:E0:09:EA:5B:AF MOTIF II A.N.C. [LE] RSSI -59dBm
adresse : STATIC-RANDOM (traçable jusqu'au reboot)
vendor IDs : 0x065a
7E:F5:69:6B:52:BB (anonyme) RSSI -60dBm
adresse : RPA (rotative, privacy ok)
vendor IDs : 0x004cLe contraste est net dans le même scan : tous les appareils Apple autour (vendor 0x004c) tournent leur adresse via des RPA (Resolvable Private Addresses) pour empêcher justement ce tracking. Marshall, lui, diffuse une MAC fixe et le nom du produit en clair. Un capteur BLE passif à l'entrée d'un lieu peut détecter "cette personne porte des Motif II" et la suivre par son adresse - sans connexion, sans consentement.
11 = static-random (stable jusqu'au reboot), 01 = RPA (rotative), 00 = non-resolvable. Une adresse publique, elle, est gravée à vie.Observation 2 - le serveur GATT ne déclare aucun chiffrement
En énumérant les permissions de chaque caractéristique, j'ai été surpris : sur 41 caractéristiques, 16 sont déclarées écrivables sans le moindre flag de chiffrement. Pas un seul encrypt-authenticated-write, pas un secure-write. Tout est en read, write, notify brut - y compris les caractéristiques de contrôle propriétaires : ANC, EQ, contrôle audio, verrouillage tactile.
Nuance honnête sur ce décompte : sur ces 16, certaines sont écrivables par conception - notamment les caractéristiques fe2c12xx qui appartiennent à Google Fast Pair (le service 0xFE2C, où l'écriture de l'Account Key fait partie du protocole d'association). Ce qui m'intéresse, ce sont les caractéristiques de contrôle Zound propriétaires, qui pilotent le comportement de l'appareil et n'ont, elles, aucune raison évidente d'être ouvertes.
Permissions GATT déclarées (extrait)
[!] 00000013-... (ANC config) flags: read, write, notify
[!] 00000017-... (EQ) flags: read, write, notify
[!] 00000009-... (AudioCtrl) flags: read, write, notify
[!] 00000014-... (TouchLock) flags: read, write, notify
[*] 41 caracteristiques, 16 ecrivables sans auth forte declareeDétail amusant au passage : certaines caractéristiques propriétaires ont un UUID dont les octets, décodés en ASCII, se lisent CHAR-.0AirohaBLE (le . est un octet non imprimable, le 0 un compteur qui s'incrémente d'une caractéristique à l'autre). Le fondeur Airoha se trahit jusque dans ses identifiants.
Observation 3 - commandes stateless, reconnexion silencieuse
Les commandes du protocole sont déterministes et sans état : [0x01, 0x00, presetId] veut toujours dire la même chose, aucun nonce, aucun compteur, aucun challenge. Et une fois le bond établi, BlueZ le conserve même après déconnexion. Concrètement : j'ai déconnecté mes écouteurs de l'ordinateur, puis un petit programme local s'est reconnecté tout seul avec le bond stocké et a changé le mode ANC - sans aucun prompt, sans interaction.
Le périmètre exact de cette dernière observation, je veux être clair dessus : ça démontre qu'un process local déjà appairé peut piloter l'appareil en silence, pas qu'un attaquant distant le peut. La distinction compte, et c'est justement là que des attaques documentées comme BLESA (BLE Spoofing Attack, WOOT 2020) deviennent intéressantes : elles ciblent précisément la phase de reconnexion, où beaucoup de clients ne ré-authentifient pas le serveur.
La suite : tester pour de vrai
Pour transformer ces observations en vraies conclusions, le bon outillage existe et est documenté. Je note ça ici autant pour moi que pour qui voudrait creuser :
- GATTacker / BtleJuice - frameworks de MITM BLE : cloner le périphérique, intercepter et rejouer/modifier les writes GATT.
- nRF52840 sniffer ou Ubertooth + Wireshark - capturer le trafic over-the-air pour un vrai replay, pas un rejeu depuis un client déjà bondé.
- crackle (Mike Ryan) - casser le pairing BLE legacy et déchiffrer le trafic.
- ESP32 / nRF52840 comme client GATT jamais appairé - le test décisif pour l'observation 2.
- Côté académique : Becker et al., Tracking Anonymized Bluetooth Devices (PETS 2019) - la référence sur le tracking par adresse, qui recoupe l'observation 1.
Ce que j'en retire
Le RE de l'APK en lui-même n'était pas la partie difficile. jadx décompile bien le Kotlin, le code Zound est structuré, les noms de constantes sont expressifs. Ce qui a pris du temps c'est comprendre la couche BlueZ/D-Bus - et notamment l'histoire de l'agent NoInputNoOutput que je ne connaissais pas.
Ce que j'ai appris sur BLE en faisant ce projet : les appareils BLE ne sont pas des pipes passifs. Ils ont leur propre logique d'authentification, leurs propres contraintes sur l'ordre des opérations, et le OS/stack BT interprète beaucoup de choses en dessous de ce qu'on voit. La documentation de BlueZ sur ce sujet est sparse - le plus efficace reste de lire les logs D-Bus et de grep le code source de bluez.
Sur le fond du projet : l'appli fonctionne, les écouteurs répondent, l'enceinte aussi, l'ANC et l'EQ se contrôlent depuis Linux. C'est ce que je voulais au départ.
Les deux repos : marshall-linux (l'appli) et marshall-protocol (la doc du protocole).