CVE-2025-20281: Cisco ISE API Unauthenticated Remote Code Execution Vulnerability

July 25, 2025 | Bobby Gould

On January 25th, 2025, the Trend Zero Day Initiative (ZDI) received a report from Kentaro Kawane of GMO Cybersecurity by Ierae regarding a deserialization of untrusted data vulnerability in Cisco Identity Services Engine (ISE). This pre-authentication vulnerability existed in the enableStrongSwanTunnel method of the DescriptionRegistrationListener class. While analyzing this vulnerability, I noticed that the same function was also vulnerable to command injection as root. Cisco patched this initially as CVE-2025-20281(ZDI-25-609), but also released CVE-2025-20337 (ZDI-25-607) to fully address the vulnerability. You’ll see why below.

Exploitation wasn't as straight forward as I'd originally hoped but was ultimately a lot more fun than a normal, run-of-the-mill command injection. In this blog, I'll share how I was able to achieve a root shell on affected installations of Cisco ISE, including an escape from a Docker container where the command injection was actually being executed!

More on all that later, let's take a look at the initial vulnerability.

Getting Command Injection

Below is the enableStrongSwanTunnelmethod, which leads to two vulnerabilities – one being the original report ZDI received, while the other is the subject of this blog. Notably, Cisco disagreed and initially assigned the same CVE to both submissions. Was that the right call? I'll leave that judgement to the reader.

At [1] and [2] above, the originally reported unsafe deserialization is seen.

At [3], though, we see that if an attacker provides a valid serialized Java string array, that value is used to invoke a shell script.

Not only that, but the invokeStrongSwanShellScript function contained another interesting goodie:

The sudo command at [4] was even more exciting. Not only did this look very promising as a command injection vulnerability, but that code would be executed as root.

The logger.debug call at [5] looked helpful, so after reconfiguring log4j to output debug logs, I started by sending some test inputs. A quick aside:

Each of the log outputs below came from making individual POST requests to /deployment-rpc/enableStrongSwanTunnel. The body of these requests were string representations of serialized Java String[] array objects, where the first element of the array contained the command injection.

The following array, as an example, shows in the logs as configureStrongSwan.sh enable x; touch /flag when serialized and sent to that endpoint.

      String[] arr = {"x; touch /flag", ""};

For brevity, in the rest of this blog I have included only the resulting commands executed with bash via exec(). In practice, the vector for triggering each was the vulnerable enableStrongSwanTunnel endpoint.

Now, back to those test inputs:

The log looks great; clearly we can control the value of the second concat at [4] above.

Unfortunately, as the subsequent ls call shows, exploitation wasn't quite so straightforward. Because the exec command calls a bash script on disk and passes the attacker-controlled input as a parameter, we cannot simply break the exec call and execute our own code.

Instead, we need to look at configureStrongSwan.sh to see how that parameter is used.

At [1], we call the enableStrongSwan function when using the enable operation specified at [3] above. Here OPERATION is enable and IKE_ID is our attacker-controlled payload.

At [2], we see the only usage of the passed IKE_ID in the function, as a parameter to another function verifyIpsecConnectionStatus At [3] and [4], we use our attacker-controlled payload to build a swanctl command. The argument $1 at [3] corresponds to the IKE_ID parameter.

Finally, at [5], note that we run this command in the docker container: strongswan-container. This was another piece of the puzzle in place. Perhaps the code is executing in that container? Let's take a look:

Still no dice, but a step closer!

A key thing to notice above is that the vulnerable parameter IKE_ID comes from the script argument stored in the variable $2. Consider the following example from the initial test inputs above:

      configureStrongSwan.sh enable x; touch /flag

Here, arg $2 is simply x;. Not as threatening as we hoped. Additional args $3 and $4 exist containing touch and /flag.txt, but those are never used by the script.

Even when we use quotes or backticks, which prevent bash from splitting the payload, nothing happens on disk. Why is that, though? The value of arg $2 in that example tells the story:

      configureStrongSwan.sh enable "x; touch /flag"

Interestingly, the arg value $2 used as the IKE_ID in configureStrongSwan.sh is actually "x;. This is odd. Normally when sending an argument to a bash script we can expect quoted arguments to be kept together. However, it turns out that Java is handling this command differently than bash would by itself.

Tokenization in Java exec() Calls

To properly craft our final exploit, we must first understand a little bit more about how Java's exec method works. Cisco ISE uses Java 8, so let's take a look at the Open JDK code for that version:

At [5], we see that when a string command is passed to exec(), Java tokenizes it using the StringTokenizer class.

More can be read about the StringTokenizer class in Java 8 here. For the purpose of this blog, though, I'll jump right to the point:

StringTokenizer, by design, does not respect quotes or backticks. Rather, its behavior is to simply split string by the provided token (default value is \t\n\r\f, which is used the exec method). This explains why the IKE_ID value from our test inputs was simply "x; when trying to use quotation marks. By the time bash received our input, it had already been tokenized and thus wasn't parsed as one single argument.

Luckily for us, Bash comes built in with a special variable called the Internal Field Separator (${IFS}). More can be read about the purpose and function of this variable here.

For the purposes of this exploit, the usage is simple: an attacker can simply replace all spaces with ${IFS} and bash will interpret everything as expected. The following examples are effectively the same from the perspective of bash:

With the ${IFS} version, however, Java will see the entire payload as a single token, maintaining the integrity of the command.

Additionally, exec is actually working for us now, and we don't even need to include quotes. Java will tokenize our input and ensure it is passed to bash as a single argument.

We can now run the payload using the ${IFS} variable and, viola!

Finally, code execution! This wasn't quite what I was looking for yet, though. The code is executed in the context of the Docker strongswan-container, but what I really wanted was a full root shell on the ISE server host.

Escaping the Container

We can see from the host machine that strongswan-container is running as privileged:

This is quite the gift from Cisco. Because it is running as privileged, we can leverage the "User-Mode Helpers" technique described in this talk [PDF] by Brandon Edwards and Nick Freeman at Black Hat USA 2019.

I've summarized the details related to this specific container setup. More generic information can be found here. The final payload looked like this:

At [1], a Linux cgroup is mounted.

At [2], we specify a shell script simulate.sh to be executed when the cgroup is emptied.

At [3], simulate.sh is created and configured. Here, I've chosen to echo an SSH public key to the /root/.ssh/authorized_keys file on the ISE server.

At [4], empty the cgroup. This causes simulate.sh to be executed on the host ISE server as root.

Putting It All Together

The above technique is great, but we still can't use spaces. Instead of applying ${IFS} everywhere, I chose to base64-encode the container escape script. Then, the payload sent to the vulnerable endpoint could include a base64 -d call to decode the script and pipe it to bash:

echo${IFS}[base64-encoded-escape-script]${IFS}|${IFS}base64${IFS}-d${IFS}|${IFS}bash

Altogether, the final malicious request looked like this:

And, finally, a root shell!

Conclusion

This bug was a lot of fun to exploit and highlights several good-to-know techniques that can come in handy even for a relatively straightforward bug class like command injection. To summarize, an attacker can leverage a perfect storm of mistakes in Cisco Identity Services Engine to achieve full system takeover as root.

By utilizing the ${IFS} variable in bash, we could get around Java's exec method restraining us from using the space character. Additionally, although the initial command injection was executed inside of a Docker container, we were able to break out of the sandbox and execute code on the host because Cisco configured the container in privileged mode.

Finally, for those following along at home: those two bugs Cisco deemed to be duplicates of each other? They were fixed by two different code changes. The definition of "duplicate" is, again, left to the reader.

Keep an eye out for future blogs where I will go into more detail on vulnerabilities I have found in this area. Until then, you can follow me on Twitter at @bobbygould5 and follow the team on Twitter, Mastodon, LinkedIn, or Bluesky for the latest in exploit techniques and security patches.