译文 | CAPTAIN HOOK - 半自动化寻找 JAVA 程序中的漏洞
2022-2-21 09:0:0 Author: mp.weixin.qq.com(查看原文) 阅读量:8 收藏

前言

在我 6 个月的实习期间,我开发了一个工具来简化对 Java 应用程序的漏洞研究。我使用了几个软件和库,在开发这个工具 Captain Hook 的过程中遇到了很多问题。本文从一开始就描述了胡克船长的开发过程及其挑战。

寻找 Java 应用程序漏洞的好时机!在过去的几个月里,我一直在尝试构建一个名为Captain Hook的工具,它使用动态方法来查找大型闭源 Java 应用程序的一些有趣(安全方面)特性。在此过程中,我尝试了许多仪器工具和技术,但很难找到满足我所有需求的工具和技术。在本文中,我将总结通过我的许多(一些失败,一些成功)尝试所学到的东西。

要求

由于 Synacktiv 专家在寻找大型 Java 项目中的漏洞时将使用 Captain Hook,它应该:

  • 易于在目标应用程序上设置。
  • 易于使用,直观。
  • 不要为我们列出漏洞列表,而是将分析师指向应用程序的隐藏功能,以便他可以专注于它。

因此,我和我的同事将工具的目标设定为能够跟踪任意方法调用,将有趣的方法及其堆栈跟踪和输入记录到专家中,并区分方法调用的输入是否是用户-控制与否。记录或不记录的内容应该是可定制的,并且默认为一组通常危险的本机 Java 方法。

我要分析的 Java 应用程序有时需要繁重而复杂的设置;有些只在 Windows 上运行,有些需要特定版本的 Java,等等。从这一点来看,我认为在虚拟机、容器或主机以外的任何地方设置 Java 应用程序会更容易。此外,为了使该工具尽可能通用,该工具必须独立于目标软件的执行环境。通过在自己的组件中运行该工具,确实应该可以使其与目标软件的要求无关,例如所需的操作系统。因此,我选择在 Docker 容器中开发我的工具,远程连接到运行正在调查的 Java 应用程序的 Java 虚拟机。

JAVA 代理

Java 提供了一种用于检测 Java 虚拟机的本机机制。根据官方Java文档:

package java.lang.instrument

提供允许 Java 编程语言代理检测在 JVM 上运行的程序的服务。检测机制是修改方法的字节码。

我以为这将是我的主要工具,但我很快意识到许多库都是基于这种机制编写的,以便在更高级别上进行编程并获得更有意义的错误。这将在本文后面进行开发。

第一次接触项目

当我第一次得到这个主题时,我对仪器的概念一点也不熟悉。我在学校练习过 Java,并且对 Java 虚拟机的内部结构有基本的了解,但仅此而已。因此,我开始学习 Java 中的不同检测机制,并很快将注意力转向了几个项目:

  • Frida,可能是最著名的检测框架,支持 Dalvik 虚拟机(用于 android 应用程序)已有几年时间,最近还支持 Hotspot 虚拟机,允许检测在标准桌面计算机上运行的 Java 应用程序。在 Java 进程中注入了一个 frida-agent,它允许我们通过 Javascript 绑定在 JVM 中执行代码;
  • ByteMan,一个直观的检测框架,基于 Java 提供的原生检测机制。它使用自定义脚本语言来描述加载代理后要运行的操作;
  • ByteBuddy,一个先进的、强大的、更可定制的原生检测框架。该代理使用 ByteBuddy 的类和方法用 Java 编写。

使用 Frida,我的设置是在应用程序 VM 上安装 frida-server,从 Captain Hook 的 docker 连接并注入 Frida 脚本,如下所示:

弗里达设置

使用本机 Java 代理,应将编译后的代理复制到应用程序 VM,并从此处注入正在运行的 JVM 中。然后它可以由 CLI 控制,例如使用 TCP 套接字:

本机代理设置

我认为这些将是我可能需要的所有工具,以便在 Java 应用程序中使用这种动态方法进行漏洞研究。

但是等等……你如何缓解漏洞的发现?

目标 0 - 选择一个典型的目标

为了创建一个工具来帮助审计人员发现大型闭源 Java 应用程序中的漏洞,其中很大一部分是识别典型的“大型闭源 Java 应用程序”并尝试使用我的工具重新发现公共漏洞。我在 Docker 容器中设置了多个应用程序,包括 Atlassian Jira & Confluence、ManageEngine OPManager、Oracle WebLogic 和 Jenkins。我最终选择专注于重新发现Orange Tsai 在 Jenkins 上使用的漏洞利用链 ,因为这是我遇到的记录最多的漏洞并且很容易重现。此漏洞利用链导致 Jenkins 版本低于 2.138 的预身份验证远程代码执行 (RCE)。在我的工具开发的不同阶段,我确保 Jenkins 的性能正常,并且可以使用我的工具发现 RCE(而不是完整的链)。

目标 1 -完整的堆栈跟踪

假设您想在 Java Web 应用程序中查找 RCE。要检测潜在的,您应该监视对类方法的调用。这可以使用我之前提到的三个工具很容易地完成,如图所示:exec java.lang.Runtime

  • 与弗里达:
Java.perform(function ({
    var runtimeClass = Java.use("java.lang.Runtime");
    runtimeClass.exec.overload("java.lang.String").implementation = function ({
        send("java.lang.Runtime exec called!");
        this.execute();
    };
});
  • 使用 ByteMan:
RULE trace exec entry
CLASS java.lang.Runtime
METHOD exec(java.lang.String)
AT ENTRY
IF true
DO traceln("java.lang.Runtime exec called!")
ENDRULE
  • 与 ByteBuddy:
public class Agent {

    public static void agentmain(String agentArgs, Instrumentation inst) {
        AgentBuilder mybuilder = new AgentBuilder.Default()
        .disableClassFormatChanges()
        .with(RedefinitionStrategy.RETRANSFORMATION)
        .with(InitializationStrategy.NoOp.INSTANCE)
        .with(TypeStrategy.Default.REDEFINE);
        mybuilder.type(nameMatches("java.lang.Runtime"))
        .transform((builder, type, classLoader, module) -> {
            try {
                return builder
                .visit(Advice.to(TraceAdvice.class)
                    .on(isMethod()
                        .and(nameMatches("exec"))
                    )
                )
;
            } catch (SecurityException e) {
                e.printStackTrace();
                return null;
            }
        }).installOn(inst);
    }
}

public class TraceAdvice {
    @Advice.OnMethodEnter
    static void onEnter(
        @Origin Method method,
    )
 
{
        System.out.println(method.getDeclaringClass().getName() + " " + method.getName() + " called!");
    }
}

请注意,在实际场景中,应该涵盖exec方法的所有重载,这仅适用于此处的 ByteBuddy 示例。

但随后,用户可能会想:“ 的论点从何而来?”。这就是事情开始变得奇怪的地方,因为很容易获得从线程开始到调用的堆栈跟踪,但是这个堆栈跟踪将不包括父调用的参数。为了澄清这个想法,让我向您介绍我的测试程序。这是一个简单的回声应用程序,我在整个工具的开发过程中都大量使用了它。与我之前提到的典型目标相比,它的启动速度非常快,这是救命的,因为我无法计算导致 JVM 崩溃的次数...... exec exec

import java.io.*;
import java.util.*;

public class Main {

    public static void a(String s) {
        System.out.println(s);
    }

    public static void a(String s, String t) {
        if ("hi".equals(s)) {
            System.out.println(t);
        }
        else {
            b(s);
        }
    }

    public static void b(String s) {
        a(s);
    }

    public static void main(String[] args) {
        Scanner myObj = new Scanner(System.in);
        while (true) {
            System.out.println("Type something");
            a(myObj.nextLine(), "test");
        }
    }
}

使用上面提到的三个框架并检查对 的调用,很容易得到以下列表:java.io.PrintStream println(java.lang.String)

  • Main main(java.lang.String[])
  • Main a(java.lang.String, java.lang.String)
  • Main b(java.lang.String)
  • Main a(java.lang.String)
  • java.io.PrintStream println(java.lang.String)

但是在对 javadoc 以及三个框架的文档进行了一些挖掘之后,我想不出一个简单的方法来获得以下列表:

  • Main main([])
  • Main a("Hello", "test")
  • Main b("Hello")
  • Main a("Hello")
  • java.io.PrintStream println("Hello")

所以我最终编写了一个肮脏的解决方案,它基本上包括挂钩每个加载的方法,以跟踪传递给每个方法调用的**每个参数。**很酷的是,我知道 JVM 中发生的一切。糟糕的是,你猜对了,它在我的 echo 程序上运行良好,但是,当需要在真实目标上对其进行测试时,它完全无法使用。

我在这个过程的早期就放弃了 ByteMan,因为当时我没有看到调用任意代码和修改方法参数的可能性。此外,尝试使用三个不同的框架将我的工具的每个功能开发 3 次有点繁重,我更喜欢当时只保留更有前途的两个(我也很快放弃了 ByteBuddy)。回想起来,我认为我应该花更多的时间来摆弄它,因为如果我掌握了它,它可能会满足我的需求。

回到主要问题:拥有完整的堆栈跟踪。我记得在这个话题上卡住了很长一段时间,直到一位同事告诉我从 Java IDE 的工作中获取灵感。事实上,其中一些能够打印这样的堆栈跟踪。所以我开始研究这些调试器是如何发挥这种魔力的。在这里,我发现了将成为该项目其余部分的主要工具:Java 调试接口。严格来说,它不是仪器,但它能够完全按照我的意愿行事。

根据官方Java文档:

Java 调试接口 (JDI) 是一种高级 Java API,它为需要访问(通常是远程)虚拟机运行状态的调试器和类似系统提供有用的信息。JDI 提供对正在运行的虚拟机的状态、类、数组、接口和原始类型以及这些类型的实例的内省访问。JDI 还提供对虚拟机执行的显式控制。暂停和恢复线程、设置断点、[...] 以及检查暂停线程状态、局部变量、堆栈回溯等的能力。

唯一的缺点是运行要分析的应用程序的 JVM 需要使用几个命令行参数启动。这略微增加了设置的复杂性,但大多数主流 Java 应用程序都提供了一个配置文件,可以在其中指定额外的 JVM 启动选项。

所以我写了一个 Java 程序,就像一个调试器,它通过 UNIX 套接字与我的主 CLI(用 Python 编写)进行通信,这个过程很简单:

  1. 在所需方法上设置断点;
  2. 当断点命中时,调用一组 Java 调试接口方法来检索父调用和这些调用的参数;
  3. 恢复 JVM。

这种方法的性能比上面提到的两种方法要好得多,并且允许我通过 CLI 显示我想要的信息。

在这一点上,是我放弃 ByteBuddy 的时候了。事实上,我没有看到保留它的意义,因为 Frida 也能够重新实现我选择的方法。我几乎不知道这个功能会给我带来这么多麻烦......

目标 2 - 对象检查

拥有完整的堆栈跟踪很酷,但是如果传递给您感兴趣的方法(或其任何父方法)的参数是? 您不能只是打印出来并展示给审核员。它由许多实例变量组成,每个变量要么是“简单”类型(我的意思是,您可以直接打印)或复杂对象本身。org.eclipse.jetty.server.Request

复杂对象的树

再一次,Java 调试接口在那里救援。当断点命中时,每个参数都以在我的调试器中实现接口的对象的形式检索,这是对虚拟机中实际对象的引用。只要对象没有在主 JVM 中被垃圾收集,该引用就有效。Java 调试接口为对象提供了一组方法和属性,这使我能够递归地获取对复杂对象属性的引用,并使用Jackson以 JSON 格式输出每个对象,Jackson是一个流行的用于 JSON 格式化和对象检查的 Java 库。com.sun.jdi.Value Value

完成后,我的工具使审核员能够在通过可疑方法时彻底检查调用堆栈,从而了解调用的来源以及对他通过应用程序提供的数据进行的操作。

目标 3 - 重新实现方法

如果堆栈跟踪看起来像这样:

  • input()
  • executeSafe(userData)
  • execute(userDataSanitized)

我希望我的工具对审核员尽可能方便,因此,我认为能够更改任何方法的实现会很酷。在前面的示例中,重写该方法可能会很有趣,以便它直接调用用户输入,而无需清理部分。Frida 是完美的工具,所以我决定将它与 Java 调试接口结合使用。该工具的架构如下所示:executeSafe execute

Frida & JDI 组合设置

在这里,我发现自己遇到了另一面墙:Java 调试接口在字节码级别(在 JVM 中)起作用,而 frida-agent 在本机代码级别(在操作系统中修改 JVM 进程的程序流和内存)等级)。因此,两者结合起来很容易导致 JVM 崩溃。由于 Java 的 Frida 绑定的内部结构目前还没有文档,所以我花了很长时间调试这个问题,最后发现在使用 Frida 重新实现设置断点的方法时发生冲突(无论顺序如何两者中)。这是一个根深蒂固的问题,当时我没有找到解决办法,所以我把这个目标放在一边,继续前进。

目标 4 - 在主 JVM 上执行任意代码

尽管如此,我还是被 Frida 提供的可能性大肆宣传,并希望将其保留在我的项目中。这将有助于例如通过注入这样的脚本来模糊过滤器方法:

Java.perform(function ({
    var myList = ["fuz1z""fuz2z""fuz3z"];
    var myClass = Java.use("my.Class");
    var myMethod = myClass.filter.overload("java.lang.String")
    for (var i=0; i<myList.length; i++) {
        myResult = myMethod.invoke(myList[i]);
        if (myResult.indexOf("fuzz") > -1) {
            send(myList[i] + " bypasses the filter!");
        }
    }
});

因此,我添加了一个在 JVM 中注入任意 Frida 脚本的功能,并在此功能的文档中添加了一个关于重新实现方法的重大警告。这很容易实现和测试。

因为我想让设置过程尽可能简单,所以这个功能是可选的,如果没有安装 Frida 并在主机上监听,该工具的其余功能运行完全正常。

目标 3,返回 - 设置方法调用的参数,模拟方法

在实习结束前几周,我有了重新引入 ByteBuddy 的想法,以恢复我的第三个目标,即重新实现方法。我想看看它是否与 Java 调试接口兼容。

ByteBuddy 是一个 Java 库,旨在简化本地 Java 代理的创建。本机 Java 代理是一个 Java 程序,其工作是在 JVM 中在运行时转换给定类或方法的字节码。它可以在启动时或之后附加到 JVM。ByteBuddy 提供类和方法,它们是库(例如 ASM)的包装器,它们本身就是原生 Java 字节码转换器方法的包装器。

为了重新实现方法,我使用 ByteBuddy 创建了一个简单的代理,并借助Maven插件将 ByteBuddy 依赖项捆绑在代理 JAR 文件中。这个插件是为经典的 JAR 文件而不是代理制作的,所以我必须在构建之后手动修改以添加代理运行所需的条目。然后,我在目标机器上手动安装了代理,并将其加载到 JVM 中。这让我可以试验 ByteBuddy 和 Java 调试接口之间的兼容性,这看起来很棒。maven-assembly  MANIFEST.MF

然后,我记得我想保持设置简单。这并不容易,因为代理 JAR 文件必须在主机上才能注入 JVM。我知道,当我们在安全评估期间遇到侦听开放端口的 Java Debug Wire Protocol(Java 调试接口使用的端口)服务时,我们可以轻松地从中获取 shell。因此,我将调试器编程为在可能的情况下获取 shell,并将 ByteBuddy 代理和启动器 JAR 文件发送到主机。完成后,调试器启动启动JAR,它将代理注入主 JVM。

这就是我停下来的地方。此功能仍在开发中,需要大量工作才能运行,但这个概念证明让我对它的可行性充满信心。

怎么办?

在实习期间,由于糟糕的设计选择,我在开发上浪费了很多时间。例如,Java 调试器和 Python CLI 之间的通信机制首先是使用 Frida 编写的;在 Java 调试器中注入了一个 frida-agent,它允许 Python CLI 控制调试器正在做什么。起初我选择这种方式,因为我认为在我的工具中使用 Frida 会更容易,但很快意识到通信是面向深度的,从 CLI 到调试器。以相反的方式传递消息非常复杂,我最终在 CLI 中生成了一个线程,只是为了不时主动轮询调试器并获取它的消息,这很糟糕  设计。最后,我退后一步,从头开始将 CLI 和调试器之间的通信机制重新构建为基于套接字的,这既解决了我的问题,也提高了工具的整体稳定性。

截至今天,该工具是可用的。它为查找 Java 应用程序中的漏洞提供了宝贵的反馈,我不知道有任何其他工具可以帮助研究人员遵循专门用于查找这些应用程序中的漏洞的动态方法。开发这个工具教会了我很多关于 Java 虚拟机和 Java 语言的内部知识,这些获得的技能将帮助我在未来提高 Synacktiv 的工具集。虽然 Hook 船长还没有准备好成为下一个行业标准,但随着最近发现的 Log4j 漏洞,在截至 2022 年 1 月仍充满惊喜的领域中,这是一个良好的开端。


文章来源: http://mp.weixin.qq.com/s?__biz=MzU0MDcyMTMxOQ==&mid=2247485380&idx=2&sn=9b7a20b5ccea22560669acc84edb6a14&chksm=fb35ae0ccc42271a4fcaa6a5c87597285d4db0595c8b9569249709b385d9fc9ec2022bf85617#rd
如有侵权请联系:admin#unsafe.sh