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:
- Puckungfu: A NETGEAR WAN Command Injection
- NETGEAR Routers: A Playground for Hackers?
- MeshyJSON: A TP-Link tdpServer JSON Stack Overflow
You can also keep notified of future research from the following Twitter profiles: