Shiro-550反序列化

前言

最近摸鱼了比较久了,处理了一些协会内的事然后也想着稍微让自己轻松一点,就休息了几天哈哈,所以就自从上次把博客整体换了个主题以后,就比较摆烂,中途还看了一遍关于内网的书,整理了一些笔记,然后准备继续学习关于Java安全的东西,学完了CC CB jdk7u21这些链,我准备先看几个组件漏洞吧,本来是计划学习jdk8u20那条链,但是由于找不到对应的openjdk版本,就暂时没打算先看,因为对shiro比较熟悉,选择了从shiro入手开始。这里为啥标题叫浅析呢,因为我也是比较菜不是很懂开发,所以不能带师傅们进行深入的了解,所以说如果文章里有哪些地方有问题希望师傅们多多指出

漏洞分析

Apache Shiro是一个使用比较多的Java安全框架,他执行身份验证,授权,加密和会话管理,可以通过Shiro易于理解的Api,快速轻松地保护你的程序

Apache Shiro框架功能主要由以下几个部分组成:

  • Authentication:身份认证-登录
  • Authorization:授权-权限验证
  • Session Manager:会话管理
  • Cryptography:加密
  • Web Support:Web 支持
  • Caching:缓存
  • Concurrency:多线程
  • Testing:测试模块
  • Run As:允许一个用户假装为另一个用户
  • Remember Me:记住我-Session过期后再次登录无需再次登录

Shiro-550这个漏洞的主要原因就是在服务端会rememberMe的cookie值进行先base64解码然后AES解密然后进行反序列化,但是我们如果此时知道了这个AES加密的密钥,我们就可以构造恶意的rememberMe的cookie值,来传入我们精心构造的Payload,那么就可以达到命令执行的效果,所以我们最重要的就是要找到AES加密的密钥,但是在很多地方,都使用了默认的AES密钥,导致远程命令执行的问题。Payload的产生过程如下:

命令->反序列化->AES加密->base64编码

这个漏洞主要存在的版本是Apache Shiro < 1.2.4因为在高版本中,shiro每次启动都会随机生成一个新的Key

额这里的话因为我也不熟悉Java开发的一些相关知识,我也就直接从网上找了一个Shiro的源码来进行分析。

由于Shiro中的Cookie是rememberMe,那么我们直接全局搜索含有rememberMe的地方image-20220411174336720

这里的话定义了一个DEFAULT_REMEMBER_ME_COOKIE_NAME = "rememberMe";,根据英文可以看出来是默认的rememberMe的Cookie名字,大概猜测就是这里吧,这里位于CookieRememberMeManager,看类名应该也是这,RememberMe的Cookie管理,来看看他的构造函数

public CookieRememberMeManager() {
    Cookie cookie = new SimpleCookie(DEFAULT_REMEMBER_ME_COOKIE_NAME);
    cookie.setHttpOnly(true);
    //One year should be long enough - most sites won't object to requiring a user to log in if they haven't visited
    //in a year:
    cookie.setMaxAge(Cookie.ONE_YEAR);
    this.cookie = cookie;
}

把cookie给到了自身的cookie属性,接着继续往下翻,其中的getRememberedSerializedIdentity引起了我的注意,单从方法名上看是和rememberMe的反序列化有关的

/**
 * Returns a previously serialized identity byte array or {@code null} if the byte array could not be acquired.
 * This implementation retrieves an HTTP cookie, Base64-decodes the cookie value, and returns the resulting byte
 * array.
 * <p/>
 * The {@code SubjectContext} instance is expected to be a {@link WebSubjectContext} instance with an HTTP
 * Request/Response pair so an HTTP cookie can be retrieved from the incoming request.  If it is not a
 * {@code WebSubjectContext} or that {@code WebSubjectContext} does not have an HTTP Request/Response pair, this
 * implementation returns {@code null}.
 *
 * @param subjectContext the contextual data, usually provided by a {@link Subject.Builder} implementation, that
 *                       is being used to construct a {@link Subject} instance.  To be used to assist with data
 *                       lookup.
 * @return a previously serialized identity byte array or {@code null} if the byte array could not be acquired.
 */
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;
    }

    WebSubjectContext wsc = (WebSubjectContext) subjectContext;
    if (isIdentityRemoved(wsc)) {
        return null;
    }

    HttpServletRequest request = WebUtils.getHttpRequest(wsc);
    HttpServletResponse response = WebUtils.getHttpResponse(wsc);

    String base64 = getCookie().readValue(request, response);
    // Browsers do not always remove cookies immediately (SHIRO-183)
    // ignore cookies that are scheduled for removal
    if (Cookie.DELETED_COOKIE_VALUE.equals(base64)) return null;

    if (base64 != null) {
        base64 = 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 {
        //no cookie set - new site visitor?
        return null;
    }
}

注释里是这么说的,Base64-decodes the cookie value, and returns the resulting byte,用base64解码了Cookie的值,返回了结果的字节,主要在base64 = getCookie().readValue(request, response);实现了base64解密,这里的话还进入了ensurePadding方法,本质上就是判断base64是否是4的倍数,如果不是就补上等号

private String ensurePadding(String base64) {
    int length = base64.length();
    if (length % 4 != 0) {
        StringBuilder sb = new StringBuilder(base64);
        for (int i = 0; i < length % 4; ++i) {
            sb.append('=');
        }
        base64 = sb.toString();
    }
    return base64;
}

上面也说了,rememberMe的cookie值是经过序列化以后再经过AES加密再用base64编码得到的,这里对Cookie进行base64解码,猜测是否这里是处理rememberMe的cookie值的一部分呢?因此我们往前去找是哪个方法调用了getRememberedSerializedIdentity方法

public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
    PrincipalCollection principals = null;
    try {
        byte[] bytes = getRememberedSerializedIdentity(subjectContext);
        //SHIRO-138 - only call convertBytesToPrincipals if bytes exist:
        if (bytes != null && bytes.length > 0) {
            principals = convertBytesToPrincipals(bytes, subjectContext);
        }
    } catch (RuntimeException re) {
        principals = onRememberedPrincipalFailure(re, subjectContext);
    }

    return principals;
}

发现有这么一个抽象类AbstractRememberMeManager实现了RememberMeManager接口,在他的类中存在着一个getRememberedPrincipals方法对getRememberedSerializedIdentity进行了一个调用,上面说到了getRememberedSerializedIdentity是对cookie进行base64解码然后返回字节,如果返回回来的字节不为空的话就进入到convertBytesToPrincipals方法,我们继续跟进

protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
    if (getCipherService() != null) {
        bytes = decrypt(bytes);
    }
    return deserialize(bytes);
}

代码很少,直接是使用了decrypt对传进来的字节数组进行了解密,这里的话看方法名也很清楚,把字节转换成凭证,继续跟进

protected byte[] decrypt(byte[] encrypted) {
    byte[] serialized = encrypted;
    CipherService cipherService = getCipherService();
    if (cipherService != null) {
        ByteSource byteSource = cipherService.decrypt(encrypted, getDecryptionCipherKey());
        serialized = byteSource.getBytes();
    }
    return serialized;
}

首先get到一个CipherService对象,然后使用decrypt方法对传入的字节数组进行处理,第二个参数是getDecryptionCipherKey()的结果,看名字应该是获取解密的密钥

public byte[] getDecryptionCipherKey() {
    return decryptionCipherKey;
}

这里继续跟进getDecryptionCipherKey方法,他是返回自身的decryptionCipherKey属性,那么我们可以对应去找找有无set方法进行了设置

image-20220411174344343

确实有找到setDecryptionCipherKey方法对密钥进行了一个赋值,我们寻找一下是哪里调用了这个setter

image-20220411174347219
public void setCipherKey(byte[] cipherKey) {
    //Since this method should only be used in symmetric ciphers
    //(where the enc and dec keys are the same), set it on both:
    setEncryptionCipherKey(cipherKey);
    setDecryptionCipherKey(cipherKey);
}

继续寻找调用setCipherKey的方法,然后我们就找到了AbstractRememberMeManager的构造函数

public AbstractRememberMeManager() {
    this.serializer = new DefaultSerializer<PrincipalCollection>();
    this.cipherService = new AesCipherService();
    setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}

这里的话使用DEFAULT_CIPHER_KEY_BYTES作为里面的key,我们寻找一下这个DEFAULT_CIPHER_KEY_BYTES

private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

额,到这里其实就差不多了,可以发现他这里的这个密钥是写死的,kPH+bIxk5D2deZiIxcaaaA==这个密钥也是个默认密钥,所以可能触发Shiro-550反序列化,这也就是这个漏洞的问题所在。