暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Apache Log4j2日志框架中Lookup远程执行漏洞的分析与修复

然笑 2021-12-12
1118


本文将从以下几个方面来说说这次 Apache Log4j2 日志框架漏洞的相关情况:
  • 漏洞发现的背景;
  • 漏洞产生的原因以及Lookup相关源码的分析;
  • Log4j2 官方对漏洞的修复;
  • 项目上如何对 Log4j2 漏洞进行修复;


1、漏洞背景

近日,Apache 开源项目 Log4j2 的远程代码执行漏洞细节被公开(2021年12月10日),由于 Log4j2 被广泛使用,很多的 Java 应用都是使用 Log4j2 记录日志,如果该漏洞被攻击者利用,后果将不堪设想。这个消息一流出,顿时引起了广大互联网公司的关注,各个公司也都在加班加点紧急排查项目上关于 Log4j2 的使用情况,并制定相关的方案进行修复升级。
针对这一影响广泛的漏洞(CVE-2021-44228),Apache 官方也是第一时间发布了关于 Log4j2 漏洞的修复方案,在 Apache Log4j2 官网上可以看得这一则消息,如下:


这个问题早在2021年11月24日,阿里云安全团队就向Apache官方报告了Apache Log4j2远程代码执行漏洞(CVE-2021-44228),具体可以查看阿里云官方的安全通告,如下:


这则通告在今天(2021-12-12)凌晨有更新,根据阿里云官方给出的结果,本次漏洞影响的 Log4j2 版本主要是:Apache Log4j 2.x < 2.15.0-rc2,也就是说只要 Log4j2 的版本小于 2.15.0-rc2,都需要进行升级修复。
除了直接使用 Log4j2 会受到影响外,一些知名的使用了 Log4j2 的开源框架组件也会受到影响,包括但不限于以下的这些:
  • spring-boot-starter-log4j2/Apache Struts2/Apache Solr/Apache Druid/Apache Flink


2、漏洞原因及Lookup源码分析

根据阿里云官方安全通告可以得知,Apache Log4j2 漏洞的产生原因如下:
Apache Log4j2远程代码执行漏洞由Lookup功能引发。Log4j2在默认情况下会开启Lookup功能,用于将特殊值添加到日志中。此功能中也支持对JNDI的Lookup,但由于Lookup对于加载的JNDI内容未做任何限制,使得攻击者可以通过JNDI注入实现远程加载恶意类到应用中,从而造成RCE(远程代码执行)。

由于 Log4j2 的 Lookup 功能默认情况下是开启的,它允许通过一些协议去读取相应环境中的配置,但是它对于加载的 JNDI 内容未做验证和限制,所以可能会被恶意执行远程代码类,比如像类似下面这样使用 Log4j2 打印错误日志:
public class Test {


private static final Logger logger = LogManager.getLogger(Test.class);


public static void main(String[] args) {
logger.error("${jndi:ldap://127.0.0.1:8018/test}");
}
}


通过对源码进行跟踪调试,具体的主要调用链如下:
  • AbstractLogger#error(final String message)
  • AbstractLogger#logMessage()
  • AbstractLogger#tryLogMessage()
  • Logger#log()
  • DefaultReliabilityStrategy#log()
  • LoggerConfig#log()
  • AppenderControl#tryCallAppender()
  • PatternLayout#encode()
  • MessagePatternConverter#format()
  • StrSubstitutor#substitute()
  • StrSubstitutor#resolveVariable()
  • Interpolator#lookup()
  • JndiLookup#lookup()
  • JndiManager#lookup()
  • InitialContext#lookup()
  • ldapURLContext#lookup()
  • GenericURLContext#lookup()

整个调用链非常长,涉及到的类和方法非常多,首先来看看 Interpolator#lookup()
的方法,具体实现如下:
@Override
public String lookup(final LogEvent event, String var) {
if (var == null) {
return null;
}


// 默认前缀分隔符为 :
final int prefixPos = var.indexOf(PREFIX_SEPARATOR);
if (prefixPos >= 0) {
// 获取的前缀为 jndi
final String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US);
// 名称为 ldap://127.0.0.1:8018/test
final String name = var.substring(prefixPos + 1);
// 通过前缀 jndi 在 strLookupMap 集合中查找 StrLookup 实现类,返回的是 JndiLookup
final StrLookup lookup = strLookupMap.get(prefix);
if (lookup instanceof ConfigurationAware) {
((ConfigurationAware) lookup).setConfiguration(configuration);
}
String value = null;
// 如果 lookup 不为 null, 则会直接调用 StrLookup 的 lookup() 方法
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;
}
通过对 Interpolator#lookup()
方法源码的分析,可得知,最终会调用 JndiLookup#lookup()
,其源码如下:
@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;
}
}
而在 JndiLookup#lookup()
 方法中,最终会调用 JndiManager#lookup()
方法,其源码如下:
public <T> T lookup(final String name) throws NamingException {
return (T) this.context.lookup(name);
}
通过对 Interpolator#lookup()
JndiLookup#lookup()
JndiManager#lookup()
源码分析可知,整个调用的过程并没有对输入的参数 name 进行合法性校验,而是直接进行调用,这样就会给攻击者可乘之机,恶意地执行非法的远程调用。


3、漏洞修复

通过 Apache Log4j2 官方代码仓库 https://github.com/apache/logging-log4j2  的发版记录可以看出,当前 Release 的最新版本为 log4j-2.15.1-rc1
(截至 2021-12-12),而且在最近的几天时间里,Log4j2 连续发布了好几个版本,发布记录如下:


当前最新的版本是如何修复这个漏洞的呢?从提交的信息可以得知,具体修复的方法是:默认禁用JNDI的功能
通过跟踪修改记录,可以发现主要有以下几个类发生了变化:
  • log4j-core.jar
    • log4j-core/src/main/java/org/apache/logging/log4j/core/lookup/Interpolator.java
    • log4j-core/src/main/java/org/apache/logging/log4j/core/net/JndiManager.java
    • log4j-core/src/main/java/org/apache/logging/log4j/core/selector/JndiContextSelector.java
  • log4j-jms.jar
    • log4j-jms/src/main/java/org/apache/logging/log4j/jms/appender/JmsManager.java
除了以上的主要类之外,其他的都是测试相关的类。


3.1 Interpolator的修复

Log4j2 官方主要对 Interpolator
的两个构造方法进行了修改,具体如下:
  • public Interpolator(final StrLookup defaultLookup, final List<String> pluginPackages)
  • public Interpolator(final Map<String, String> properties)
#Interpolator(final StrLookup defaultLookup, final List<String> pluginPackages)
中,增加了对 JNDI 是否开启的判断,具体如下:
public Interpolator(final StrLookup defaultLookup, final List<String> pluginPackages) {
this.defaultLookup = defaultLookup == null ? new MapLookup(new HashMap<String, String>()) : defaultLookup;
final PluginManager manager = new PluginManager(CATEGORY);
manager.collectPlugins(pluginPackages);
final Map<String, PluginType<?>> plugins = manager.getPlugins();


for (final Map.Entry<String, PluginType<?>> entry : plugins.entrySet()) {
try {
final Class<? extends StrLookup> clazz = entry.getValue().getPluginClass().asSubclass(StrLookup.class);
// 判断JNDI是否开启,如果在 JndiManager 中未开启,则不添加 JndiLookup 至 strLookupMap 集合中
if (!clazz.getName().equals(JndiLookup.class.getName()) || JndiManager.isIsJndiEnabled()) {
strLookupMap.put(entry.getKey().toLowerCase(), ReflectionUtil.instantiate(clazz));
}
} catch (final Throwable t) {
handleError(entry.getKey(), t);
}
}
}

对于 public Interpolator(final Map<String, String> properties)
构造方法,同样增加了对 JNDI 是否开启的判断,具体如下:

public Interpolator(final Map<String, String> properties) {
this.defaultLookup = new MapLookup(properties == null ? new HashMap<String, String>() : properties);
// TODO: this ought to use the PluginManager
strLookupMap.put("log4j", new Log4jLookup());
strLookupMap.put("sys", new SystemPropertiesLookup());
strLookupMap.put("env", new EnvironmentLookup());
strLookupMap.put("main", MainMapLookup.MAIN_SINGLETON);
strLookupMap.put("marker", new MarkerLookup());
strLookupMap.put("java", new JavaLookup());
strLookupMap.put("lower", new LowerLookup());
strLookupMap.put("upper", new UpperLookup());
// JNDI
// 通过 JndiManager 判断 JNDI 是否开启,如果未开启,则不添加 JndiLookup 至 strLookupMap 集合中
if (JndiManager.isIsJndiEnabled()) {
try {
// [LOG4J2-703] We might be on Android
strLookupMap.put(LOOKUP_KEY_JNDI,
Loader.newCheckedInstanceOf("org.apache.logging.log4j.core.lookup.JndiLookup", StrLookup.class));
} catch (final LinkageError | Exception e) {
handleError(LOOKUP_KEY_JNDI, e);
}
}
//...省略其他代码
}

3.2 JndiManager的修复

对于 JndiManager
类,主要的修改有以下的几个地方:

  • 增加了 isIsJndiEnabled()
    方法,用于判断 JNDI 是否开启,默认为 false,可以通过 log4j2.enableJndi
    属性进行设置;
    public static boolean isIsJndiEnabled() {
    return PropertiesUtil.getProperties().getBooleanProperty("log4j2.enableJndi", false);
    }
  • 增加了 JndiManager(final String name)
    构造方法;

    private JndiManager(final String name) {
    super(null, name);
    this.context = null;
    this.allowedProtocols = null;
    this.allowedClasses = null;
    this.allowedHosts = null;
    }
  • 修改了 releaseSub(final long timeout, final TimeUnit timeUnit)
    方法;

    @Override
    protected boolean releaseSub(final long timeout, final TimeUnit timeUnit) {
    // if 判断是新增的
    if (context != null) {
    return JndiCloser.closeSilently(this.context);
    }
    return JndiCloser.closeSilently(this.context);
    }
  • 修改了 lookup(final String name)
    方法,这个方法变化相对来说是最大的,首先增加了对 context
    的判断,如果 context
    为空,直接返回 null
    ,其次增加了对协议的校验,如果是 ldap 和 ldaps 协议,还会再次验证 ldap server 主机地址是否为被允许的,以及验证属性类是否是被允许的,如果所有的验证都通过了,才会调用 this.context.lookup(name)
    方法;
    public synchronized <T> T lookup(final String name) throws NamingException {
    // 如果 context 为空,直接返回 null
    if (context == null) {
    return null;
    }
    try {
    URI uri = new URI(name);
    if (uri.getScheme() != null) {
    // 如果不是允许的协议,直接给出警告并返回 null
    if (!allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) {
    LOGGER.warn("Log4j JNDI does not allow protocol {}", uri.getScheme());
    return null;
    }
    // 下面的是针对 ldap 和 ldaps 协议的判断
    if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {
    // 首先验证是否为允许的 ldap server 主机地址,如果不是,直接返回 null
    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) {
    // In testing the "key" for attributes seems to be lowercase while the attribute id is
    // camelcase, but that may just be true for the test LDAP used here. This copies the Attributes
    // to a Map ignoring the "key" and using the Attribute's id as the key in the Map so it matches
    // the Java schema.
    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);
    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) {
    LOGGER.warn("Referenceable class is not allowed for {}", name);
    return null;
    }
    }
    }
    }
    } catch (URISyntaxException ex) {
    LOGGER.warn("Invalid JNDI URI - {}", name);
    return null;
    }
    return (T) this.context.lookup(name);
    }
  • 内部静态类 JndiManagerFactory
    createManager(final String name, final Properties data)
    方法的变化,增加了对 JNDI 是否开启的判断,如下:

    @Override
    public JndiManager createManager(final String name, final Properties data) {
    // 增加了对 JNDI 是否开启判断,如果开启了 JNDI,则会从属性数据中读取允许的主机、类和协议相关信息
    // 这些信息在调用 lookup 方法的时候会进行验证
    if (isIsJndiEnabled()) {
    String hosts = data != null ? data.getProperty(ALLOWED_HOSTS) : null;
    String classes = data != null ? data.getProperty(ALLOWED_CLASSES) : null;
    String protocols = data != null ? data.getProperty(ALLOWED_PROTOCOLS) : null;
    List<String> allowedHosts = new ArrayList<>();
    List<String> allowedClasses = new ArrayList<>();
    List<String> allowedProtocols = new ArrayList<>();
    addAll(hosts, allowedHosts, permanentAllowedHosts, ALLOWED_HOSTS, data);
    addAll(classes, allowedClasses, permanentAllowedClasses, ALLOWED_CLASSES, data);
    addAll(protocols, allowedProtocols, permanentAllowedProtocols, ALLOWED_PROTOCOLS, 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;
    }
    } else {
    return new JndiManager(name);
    }
    }


3.3 JndiContextSelector的修复

对于 JndiContextSelector
类而言,主要是增加了一个构造方法,在构造方法中,会对 JNDI 是否开启进行验证,如果未开启,则会直接抛出异常,具体方法如下:
public JndiContextSelector() {
if (!JndiManager.isIsJndiEnabled()) {
throw new IllegalStateException("JNDI must be enabled by setting log4j2.enableJndi=true");
}
}


3.4 JmsManager的修复

对于 JmsManager
类而言,主要修改了 createManager(final String name, final JmsManagerConfiguration data)
方法,增加了对 JNDI 是否开启的判断,如下:
@Override
public JmsManager createManager(final String name, final JmsManagerConfiguration data) {
if (JndiManager.isIsJndiEnabled()) {
try {
return new JmsManager(name, data);
} catch (final Exception e) {
logger().error("Error creating JmsManager using JmsManagerConfiguration [{}]", data, e);
return null;
}
} else {
logger().error("JNDI has not been enabled. The log4j2.enableJndi property must be set to true");
return null;
}
}

至此,关于本次 Log4j2 的安全漏洞原因,以及 Apache Log4j2 官方对其进行的修复具体实现已经简要分析完毕,感兴趣的可以根据 Log4j2 官方 github 上的更新记录,然后结合 Log4j2 的源码进行查看。

4、项目上Log4j2漏洞修复

对于本次的漏洞,首先需要对项目进行排查,检查一下项目中是否使用了 Log4j2,如果使用了 Log4j2 日志框架,肯定会在本次受影响的版本之内,没有使用的话,则不用太过于关注。


4.1 Log4j2版本排查

如果项目上直接引入了 log4j2 依赖,查看版本非常简单;如果项目上没有直接引入,而是其他的框架间接引入的,这个时候如何对 Log4j2 的使用版本进行排查呢?这里介绍两种参考方式:
  • 借助于IDE 工具,查看 pom.xml 的依赖树,然后进行搜索,找出 log4j2 的使用版本;
  • 借助于 Maven 命令,输出所有的依赖树结构,然后通过依赖树结构进行搜索查找;

借助于 Intellij IDEA
可以通过 Maven 页签,然后展开项目的 Dependencies进行查看,具体如下所示:


通过上面的依赖信息可以看出,当前引用的 log4j-core 版本为 2.14.1,需要进行升级修复。
除了通过 Maven 页签查看外,还可以直接查看 pom.xml 的依赖树图,先打开 pom.xml 文件,然后【右键 》Diagrams 》Show Dependecies】,具体如下:


选中 log4j-core,即可查看当前引用的是哪个版本。

借助于Maven命令
使用Intellij IDEA 的方式,需要将项目导入到IDEA中才可以,除此之外,还可以使用 Maven 命令直接输出依赖树,然后进行查找,具体命令如下:
mvn dependency:tree
首先进入到项目根目录,然后打开控制台,执行上面的命令,就可以看到类似如下的输出信息
[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ log4j-demo ---
[INFO] com.example:log4j-demo:jar:0.0.1-SNAPSHOT
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:2.6.1:compile
[INFO] | +- org.springframework.boot:spring-boot-starter:jar:2.6.1:compile
[INFO] | | +- org.springframework.boot:spring-boot:jar:2.6.1:compile
[INFO] | | +- org.springframework.boot:spring-boot-autoconfigure:jar:2.6.1:compile
[INFO] | | +- jakarta.annotation:jakarta.annotation-api:jar:1.3.5:compile
[INFO] | | \- org.yaml:snakeyaml:jar:1.29:compile
............
[INFO] +- org.springframework.boot:spring-boot-starter-log4j2:jar:2.6.1:compile
[INFO] | +- org.apache.logging.log4j:log4j-slf4j-impl:jar:2.14.1:compile
[INFO] | | +- org.slf4j:slf4j-api:jar:1.7.32:compile
[INFO] | | \- org.apache.logging.log4j:log4j-api:jar:2.14.1:compile
[INFO] | +- org.apache.logging.log4j:log4j-core:jar:2.14.1:compile
[INFO] | +- org.apache.logging.log4j:log4j-jul:jar:2.14.1:compile
[INFO] | \- org.slf4j:jul-to-slf4j:jar:1.7.32:compile
............
上面的信息是整个项目的jar依赖树,可以通过搜索查找 log4j 的使用版本,从上面的输出结果可以看出,引用了 log4j-core-2.14.1
 版本的是 spring-boot-starter-log4j2:jar

4.2 项目上漏洞修复

Apache Log4j2 官方给出了两种修复方案,一种是不升级 Log4j2 的临时修复方案,另一种是升级 Log4j2 的永久修复方案,可以根据实际的项目情况进行选择。

不升级 Log4j2
(1)下面的设置对于 Log4j2 的版本小于 2.15.0 并且大于 2.10 时可用
  • 设置 jvm 参数
    -Dlog4j2.formatMsgNoLookups=true
  • 日志设置
    log4j2.formatMsgNoLookups=True
  • 设置系统环境变量
    FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS = true
  • 关闭对应的应用程序的网络外网连接,并且禁止主动外连
(2)对于 Log4j2 的版本在 2.0-beta9 到 2.10 之间的版本,可以通过下面的方式移除 JndiLookup
zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class

升级 Log4j2 版本
根据第一步 Log4j2 使用版本的排查情况,先排除主依赖中的 log4j-core
版本,然后单独引入最新的 log4j-core
版本,示例如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
<!-- 排除spring-boot-starter-log4j2中引入的 log4j-core.2.14.1 版本 -->
<exclusions>
<exclusion>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
</exclusion>
</exclusions>
</dependency>


<!-- 单独引入当前最新的 log4j-core.2.15.0 版本 -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.15.0</version>
</dependency>
由于本次漏洞主要影响的是 log4j-core
版本,单独引入 log4j-core
的最新版本即可实现升级修复,可能在升级了  log4j-core
核心后,其他的 log4j2 版本还是之前的,比如 log4j-api
log4j-jul
等等,这些都可以按照上面的方式进行升级。

除了直接在 pom.xml 文件中升级 log4j-core 版本,还有一种不太推荐的方式就是获取最新的 log4j-core-2.15.0.jar,然后手动安装到本地Maven仓库中,安装的时候指定版本为之前的版本号,并把历史版本的 jar删除掉,这种方式可以不用修改 pom.xml 文件,直接重新对应用打包即可。

文章转载自然笑,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论