首页
社区
课程
招聘
[原创]从0开始开发一个InlineHook第一篇
发表于: 2024-12-4 18:27 25413

[原创]从0开始开发一个InlineHook第一篇

2024-12-4 18:27
25413

如果觉得格式不美观,请拉到结尾下载pdf~
为什么市面上那么多成品的InlineHook,比如Dobby,SandHook,ShadowHook,还要再写一个?

因为这些hook大多数是项目级别的,不带教程讲解,上手难度略高,以及现在对InlineHook检测比较严重,有必要了解原理,从头写一个,知道哪里能检测,哪里需要注意,需要改的点在哪

我写这篇文章主要是记录开发过程,方便后边回顾,以及给一些感兴趣的朋友作为一个入手的点,快速的上手InlineHook的原理。

先放项目地址:d7aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6B7K9i4q4A6N6e0t1H3x3U0u0Q4x3V1k6d9k6g2A6W2M7X3!0t1L8$3!0C8

目前有两个分支,主分支只支持单个hook,newhook支持多个hook,都会开展讲解。

提交记录完全,可以看到排除各种坑的提交

CleanShot_2024_12_04_at_16_23_15

本项目还不完善,比如完全没写测试用例,只是简单的写了两个函数测试,但是不代表我以后不写

为什么这么着急写文章,因为也搞了一星期多了,想详细记录一下

感谢389K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6*7K9s2g2G2N6r3!0F1k6#2)9J5c8V1q4F1k6s2u0G2K9h3c8Q4y4h3k6u0L8X3I4A6L8X3g2t1L8$3!0C8
提供研究的动力,尽管项目很老了,但是非常清晰,我给修复支持了最新的NDK(之前只能在NDK20)并移植到了Cmake编译(只有Arm64)
修复后地址:67dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6B7K9i4q4A6N6e0t1H3x3U0u0Q4x3V1k6u0L8X3I4A6L8X3g2t1L8$3!0C8i4K6u0V1k6X3W2^5
欢迎大家直接学习
感谢c33K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6T1P5i4c8W2k6r3q4F1j5$3g2Q4x3V1k6S2L8X3c8J5L8$3W2V1i4K6u0V1K9h3&6D9K9h3&6W2i4K6u0V1K9r3!0G2K9H3`.`.
提供了指令修复的思路

什么是InlineHook,简单来说就是给函数(指令)的几条汇编指令备份,然后跳到我们自己的函数逻辑上。

注意:不要联想整个项目,现在只是流程讲解,项目实现会在后面接着讲,结合着流程。

举个例子:

比如open函数,我们查看地址

CleanShot_2024_12_04_at_16_29_27

前四条汇编指令是这样的,当我们hook后:

就变成了

CleanShot_2024_12_04_at_16_31_29

注意:在修改之前一定要备份这些指令,因为这些指令在后面要被执行

第一条是把地址的内容存到x17,第二条是跳转到x17

第三四条不是汇编指令,是目标要跳转到的地址,我们这个跳板用了16字节,当然你也可以继续跳转字节更少的跳板,本文用的是比较简单的跳板,让我们看看frida用的跳板是什么样的

CleanShot_2024_11_29_at_17_22_35

看来我们跟frida的跳板差不多~

跳转到我们自定义的函数之后呢?

要保存哪些寄存器呢?最简单的肯定包含X0-X30(包含了lr lr就是x30) 可以选择性保存sp(当然如果栈平衡就无所谓了) NZCV状态寄存器 以及Q系列寄存器(本项目还没有保存)

让我们看看frida是怎么做的

为什么要保存呢? 因为在函数调用前如果调用了pre_hookcallback 或者在函数调用后调用post_hookcallback,里面的逻辑会污染原来的寄存器,导致函数崩溃。

我们简单举个例子,现在有一个函数

这个函数主要用到了x0-x3

如果我们在pre_hookcallback调用了任意函数,会刷新x0寄存器,如果返回的是一个地址,那么x0的值在刷新后,会传入我们原来的函数中,造成寄存器污染。

frida是怎么做的?frida是直接将所有寄存器压入了栈中,然后在按顺序弹出,保证栈平衡

弹出过程(压入过程看上面)

这种设计需要保证栈平衡,在进入我们的跳板函数和出跳板函数 sp的值要一致,当然在不污染寄存器的情况下,你可以保存sp,在结束之前恢复(不推荐,很麻烦)

当然也可以和我一样,存在一个结构体中,我们的hook框架是怎么做的?

CleanShot_2024_12_04_at_16_53_09

然后获取结构体的地址,将数据全部压入结构体中(Q系列寄存器还没有加入):

这样不用考虑栈平衡的问题,因为我们没用到栈(当然会有新的分支,使用栈来储存,因为他真的很方便)

pre_hookcallback顾名思义就是在调用hook之前的回调函数,在这时,原函数还没有开始执行,但是参数已经被压入到了寄存器中

如何调用?

我们非常简单的就实现了调用,在这里我们可以读取寄存器,修改寄存器

CleanShot_2024_12_04_at_17_01_10

因为前面做了太多的操作,比如

我们需要把结构体里的寄存器复原,然后在调用原函数

做完了该做的事情以后,终于轮到被hook的函数执行了

第一种实现:

把之前备份的指令还原回被hook函数,然后跳转执行

因为函数是在我们这里被“主动调用的”

所以在执行完成后还是会返回我们的汇编指令

这种方法不提倡,因为如果被hook函数是多线程执行的(大部分都是这样)

会发生崩溃,具体大家可以思考一下

第二种实现:

将备份后的指令执行,然后跳回原来的函数(+16字节的位置)

第二种实现涉及到指令修复的问题:

为什么会出现指令修复,因为有一些特殊指令是和当前pc相关的,我们改变了指令的位置

对应的pc值也会发生改变:

我们采用shadowhook里面的代码进行修复(我给重写并加上了注释)

拿修复adrp的部分做例子:

其实就是把adrp(8字节)等价替换成了 (16字节的指令)

在调用完原函数以后,相应的寄存器的值发生了变化,我们再次保存,然后调用函数调用后的回调函数

注释写的很清楚,和上面高速相似,相信大家已经看懂了

就结束了吗?还没有结束,如果你在post_callback修改了寄存器的值,还需要恢复

当然这里可以只恢复x0(返回值)

到此我们已经完整完成了一个hook的流程

项目地址放在上面了。本次讲解的是newhook分支

项目的总hook管理结构体是:

这里重点注意三个函数:

registerHook​ 注册函数

removeHook​移除注册函数

getHook​ 获取hook状态(比如传入open地址,看是不是已经被hook了) 链式hook准备做,但是感觉没啥用

hook信息储存结构体,在汇编里获取的也是这个

每一个hook,对应一个hookinfo

重点讲一下createHook一些点:

backup_orig_instructions(hookInfo)​主要是备份原来的函数,将指令拷贝到结构体里

每次都创建一个页大小的内存,来存储跳板函数就是.s里面的

.s里面我更喜欢叫他模板函数,因为他不是最终执行的,每创建一个函数都会创建一块内存,然后memcpy到mmap出来的这一块内存里,在填入当前hook的hookinfo

这一部分对备份的指令进行修复,然后填入mmap的那块空间里

之后添加跳回原来函数的跳转指令:

mmap分配出的空间目前是

填充过的.s模板汇编地址|修复过的原函数指令|跳回原函数执行的地址

修改目标函数头部,跳转到模板函数+16字节处(16字节存储地址)

hookInfo->backup_func指针指向修复过的指令地址,并且执行完修复的指令回跳回原函数位置

由于

使用的是blr x30寄存器被我们修改在这里了,所以回跳回模板函数

取消hook就很简单了,把目标函数头部还原即可

目前框架还有很多bug,而且只支持arm64,我会不断发展,开出多个分支,并给出实战(配合我的注入框架,以及开发出server,像frida一样通过JIT生成代码进行hook)

有任何疑问欢迎留言,我会解答,第二篇就打算完善后继续写,或者结合我的注入框架写一篇实战。

目前TODO:

支持Q系列寄存器

使用栈作为参数,精简汇编

解决X16 X17寄存器污染问题

最后特别感谢先辈的伟大项目,能让我学习到特别多的技巧,特别感谢卓童老师开展这样一个教学性的项目,让我受益匪浅。

// 默认的寄存器打印回调函数
void default_register_callback(HookInfo *info) {
    RegisterContext *ctx = &info->ctx;
    LOGI("Register dump:");
    for (int i = 0; i < 31; i++) {
        LOGI("X%d: 0x%llx", i, ctx->x[i]);
    }
}
// 默认的寄存器打印回调函数
void default_register_callback(HookInfo *info) {
    RegisterContext *ctx = &info->ctx;
    LOGI("Register dump:");
    for (int i = 0; i < 31; i++) {
        LOGI("X%d: 0x%llx", i, ctx->x[i]);
    }
}
static size_t fix_adrp(uint32_t *out_ptr, uint32_t ins, void *old_addr, void *new_addr) {
    uint64_t pc = (uint64_t) old_addr;
 
    // 获取目标寄存器和立即数
    uint32_t rd = SH_UTIL_GET_BITS_32(ins, 4, 0);  // 目标寄存器
    uint64_t immlo = SH_UTIL_GET_BITS_32(ins, 30, 29);  // 低2位
    uint64_t immhi = SH_UTIL_GET_BITS_32(ins, 23, 5);   // 高19位
    uint64_t offset = SH_UTIL_SIGN_EXTEND_64((immhi << 14u) | (immlo << 12u), 33u);
 
    // 计算目标页地址
    uint64_t addr = (pc & 0xFFFFFFFFFFFFF000) + offset;
 
    // 生成新的LDR序列
    out_ptr[0] = 0x58000040u | rd;  // LDR Xd, #8
    out_ptr[1] = 0x14000003;        // B #12
    out_ptr[2] = addr & 0xFFFFFFFF;  // 低32位
    out_ptr[3] = addr >> 32u;        // 高32位
 
    return 16;  // 4条指令
}
static size_t fix_adrp(uint32_t *out_ptr, uint32_t ins, void *old_addr, void *new_addr) {
    uint64_t pc = (uint64_t) old_addr;
 
    // 获取目标寄存器和立即数
    uint32_t rd = SH_UTIL_GET_BITS_32(ins, 4, 0);  // 目标寄存器
    uint64_t immlo = SH_UTIL_GET_BITS_32(ins, 30, 29);  // 低2位
    uint64_t immhi = SH_UTIL_GET_BITS_32(ins, 23, 5);   // 高19位
    uint64_t offset = SH_UTIL_SIGN_EXTEND_64((immhi << 14u) | (immlo << 12u), 33u);
 
    // 计算目标页地址
    uint64_t addr = (pc & 0xFFFFFFFFFFFFF000) + offset;
 
    // 生成新的LDR序列
    out_ptr[0] = 0x58000040u | rd;  // LDR Xd, #8
    out_ptr[1] = 0x14000003;        // B #12
    out_ptr[2] = addr & 0xFFFFFFFF;  // 低32位
    out_ptr[3] = addr >> 32u;        // 高32位
 
    return 16;  // 4条指令
}
out_ptr[0] = 0x58000040u | rd;  // LDR Xd, #8
out_ptr[1] = 0x14000003;        // B #12
out_ptr[2] = addr & 0xFFFFFFFF;  // 低32位
out_ptr[3] = addr >> 32u;        // 高32位
out_ptr[0] = 0x58000040u | rd;  // LDR Xd, #8
out_ptr[1] = 0x14000003;        // B #12
out_ptr[2] = addr & 0xFFFFFFFF;  // 低32位
out_ptr[3] = addr >> 32u;        // 高32位
// 全局存储所有hook信息
class HookManager {
private:
    static std::map<void *, HookInfo *> hook_map; // key是目标函数地址
    static std::mutex hook_mutex;
 
public:
    static void registerHook(HookInfo *info) {
        if (!info) return;
        setCurrentHook(info);
        std::lock_guard<std::mutex> lock(hook_mutex);
        hook_map[info->target_func] = info;
    }
 
    static void setCurrentHook(HookInfo *info) {
        current_executing_hook = info;
    }
 
    static HookInfo *getCurrentHook() {
        return current_executing_hook;
    }
 
    static HookInfo *getHook(void *target_func) {
        std::lock_guard<std::mutex> lock(hook_mutex);
        auto it = hook_map.find(target_func);
        return (it != hook_map.end()) ? it->second : nullptr;
    }
 
    static void removeHook(void *target_func) {
        std::lock_guard<std::mutex> lock(hook_mutex);
        hook_map.erase(target_func);
    }
};
// 全局存储所有hook信息
class HookManager {
private:
    static std::map<void *, HookInfo *> hook_map; // key是目标函数地址
    static std::mutex hook_mutex;
 
public:
    static void registerHook(HookInfo *info) {
        if (!info) return;
        setCurrentHook(info);
        std::lock_guard<std::mutex> lock(hook_mutex);
        hook_map[info->target_func] = info;
    }
 
    static void setCurrentHook(HookInfo *info) {
        current_executing_hook = info;
    }
 
    static HookInfo *getCurrentHook() {
        return current_executing_hook;
    }
 
    static HookInfo *getHook(void *target_func) {
        std::lock_guard<std::mutex> lock(hook_mutex);
        auto it = hook_map.find(target_func);
        return (it != hook_map.end()) ? it->second : nullptr;
    }
 
    static void removeHook(void *target_func) {
        std::lock_guard<std::mutex> lock(hook_mutex);
        hook_map.erase(target_func);
    }
};
struct HookInfo {
    void (*pre_callback)(HookInfo *pHookInfo);  //储存hook前回调函数地址
    void (*post_callback)(HookInfo *ctx); //储存hook后回调函数地址
    void *backup_func; //mmap出来存汇编的地址
    void *target_func;//目标hook的地址
    void *hook_func;//无意义,历史遗留
  
 
    void *user_data;//无意义,历史遗留
 
    // 寄存器上下文
    RegisterContext ctx;  
 
    // 原始代码
    uint8_t original_code[1024];//存储备份函数字节的空间
    size_t original_code_size; //备份了多少字节
};
struct HookInfo {
    void (*pre_callback)(HookInfo *pHookInfo);  //储存hook前回调函数地址
    void (*post_callback)(HookInfo *ctx); //储存hook后回调函数地址
    void *backup_func; //mmap出来存汇编的地址
    void *target_func;//目标hook的地址
    void *hook_func;//无意义,历史遗留
  
 
    void *user_data;//无意义,历史遗留
 
    // 寄存器上下文
    RegisterContext ctx;  
 
    // 原始代码
    uint8_t original_code[1024];//存储备份函数字节的空间
    size_t original_code_size; //备份了多少字节
};
void * openaddr =dlsym(RTLD_DEFAULT, "open");
    HookInfo *hookInfo = createHook((void *) openaddr,
                                    my_register_callback,
                                    post_hook_callback,
                                    (void *) hello.c_str());
void * openaddr =dlsym(RTLD_DEFAULT, "open");
    HookInfo *hookInfo = createHook((void *) openaddr,
                                    my_register_callback,
                                    post_hook_callback,
                                    (void *) hello.c_str());
HookInfo *createHook(void *target_func,
                     void (*pre_callback)(HookInfo *) = nullptr,
                     void (*post_callback)(HookInfo *) = nullptr,
                     void *user_data = nullptr) {
    LOGI("Creating hook - target: %p", target_func);
    if (!target_func ) return nullptr;
    // 检查是否已经被hook
    HookInfo *existing = HookManager::getHook(target_func);
    if (existing) {
        LOGE("Function already hooked!");
        return nullptr;
    }
 
    // 创建HookInfo结构
    auto *hookInfo = new HookInfo();
    if (!hookInfo) return nullptr;
 
    // 初始化结构
    memset(hookInfo, 0, sizeof(HookInfo));
    hookInfo->target_func = target_func;
    hookInfo->pre_callback = pre_callback ? pre_callback : default_register_callback;
    hookInfo->post_callback = post_callback;
    hookInfo->user_data = user_data;
    // 备份原始指令
    if (!backup_orig_instructions(hookInfo)) {
        delete hookInfo;
        return nullptr;
    }
 
    // 分配跳板内存
    size_t trampoline_size = 1024;
    void *trampoline = mmap(nullptr, trampoline_size,
                            PROT_READ | PROT_WRITE | PROT_EXEC,
                            MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
 
    if (trampoline == MAP_FAILED) {
        delete hookInfo;
        return nullptr;
    }
    LOGI("Trampoline allocated at %p", trampoline);
 
    hookInfo->backup_func = trampoline;
//print two_jump_start two_jump_end addr
    LOGI("two jump start addr = %p",two_jump_start);
    LOGI("two jump end addr = %p",two_jump_end);
 
    size_t two_jump_size =two_jump_end-two_jump_start;
    memcpy(hookInfo->backup_func, two_jump_start, two_jump_size);
    LOGI("hook info addr = %p",hookInfo);
    // 在预留的NOP位置写入地址
    uint64_t info_addr = (uint64_t)hookInfo;
    uint64_t hook_addr = (uint64_t)hookInfo->hook_func;
 
// 填充HookInfo地址(前8字节)
    memcpy(hookInfo->backup_func, &info_addr, sizeof(info_addr));
 
// 填充hook函数地址(后8字节)
    memcpy((uint8_t*)hookInfo->backup_func + 8, &hook_addr, sizeof(hook_addr));
 
    // 修复指令时记录指令信息
    uint32_t *orig = (uint32_t *) hookInfo->original_code;
    for (size_t i = 0; i < hookInfo->original_code_size / 4; i++) {
        LOGI("Original instruction[%zu]: 0x%08x", i, orig[i]);
    }
 
    size_t fixed_size = ARM64Fixer::fix_instructions(
            (uint32_t *) hookInfo->original_code,
            hookInfo->original_code_size,
            hookInfo->target_func,
            (uint32_t *)((uintptr_t)hookInfo->backup_func + two_jump_size)
    );
    void *return_addr = (uint8_t *) target_func + hookInfo->original_code_size;
    // 添加跳回原函数的跳转
    if (!create_jump((uint8_t *) hookInfo->backup_func + fixed_size+two_jump_size,
                     return_addr, false)) {
        munmap(trampoline, trampoline_size);
        delete hookInfo;
        return nullptr;
    }
    // 在目标函数处写入跳转到hook函数的代码
    if (!create_jump(target_func, (uint8_t*)hookInfo->backup_func+16, false)) {
        munmap(trampoline, trampoline_size);
        delete hookInfo;
        return nullptr;
    }
    hookInfo->backup_func=(uint8_t*)hookInfo->backup_func+two_jump_size;
    HookManager::registerHook(hookInfo);
    LOGI("hookinfo addr %p",hookInfo);
    LOGI("ctx addr %p",&hookInfo->ctx.x[0]);
    return hookInfo;
}

[招生]系统0day安全-IOT设备漏洞挖掘(第6期)!

最后于 2024-12-5 10:31 被棕熊编辑 ,原因: 增加pdf版本
上传的附件:
收藏
免费 21
支持
分享
最新回复 (20)
雪    币: 10
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
现在inlinehook检测很多,大佬有什么比较好的思路过掉吗
2024-12-4 19:24
0
雪    币: 3161
活跃值: (1812)
能力值: ( LV9,RANK:170 )
在线值:
发帖
回帖
粉丝
3
mb_ldbucrik 现在inlinehook检测很多,大佬有什么比较好的思路过掉吗
基本都是crc,下硬件断点找检测干掉就行
2024-12-4 19:57
0
雪    币: 0
活跃值: (1898)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
mb_qzwrkwda 基本都是crc,下硬件断点找检测干掉就行
支持大佬的作品,不知道大佬有星球吗??
2024-12-4 21:48
0
雪    币: 10
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
5
好的,不过硬件断点对手机的要求不低了
2024-12-4 21:59
0
雪    币: 105
活跃值: (4800)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
已关注
2024-12-5 09:17
0
雪    币: 2562
活跃值: (10931)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7
小白看到文章讲解和项目留下眼泪
2024-12-5 09:24
0
雪    币: 1438
活跃值: (3200)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
感谢分享
2024-12-5 10:31
0
雪    币: 3161
活跃值: (1812)
能力值: ( LV9,RANK:170 )
在线值:
发帖
回帖
粉丝
9
bluegatar 支持大佬的作品,不知道大佬有星球吗??
没有星球,有问题直接评论即可,做的内容都发看雪
2024-12-5 13:39
0
雪    币: 5
活跃值: (2700)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
10
请问,是对自己改指令的地址打watch断点么?
2024-12-5 14:24
0
雪    币: 1058
活跃值: (960)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
11
感谢分享
2024-12-7 09:02
0
雪    币: 40
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
12
感谢感谢
2024-12-8 10:40
0
雪    币: 5
活跃值: (1105)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
13
大兄弟,能不能设计简单,调用简单点,接入简单点的,shadowhook现在需要依赖libshadowhook_nothing.so,越来越麻烦了
2024-12-9 16:39
0
雪    币: 51
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
14
棕熊 基本都是crc,下硬件断点找检测干掉就行
过不完过完了又会出现新的而且还有vm里面的指令读
2024-12-31 20:06
0
雪    币: 223
活跃值: (155)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
15
感谢楼主分享,有一个疑问,在写入 jump 的时候,多线程环境下,另一个线程正好执行在这里,可能会导致崩溃,这块有什么好的办法?
2025-1-3 17:39
0
雪    币: 3161
活跃值: (1812)
能力值: ( LV9,RANK:170 )
在线值:
发帖
回帖
粉丝
16
lonkil 感谢楼主分享,有一个疑问,在写入 jump 的时候,多线程环境下,另一个线程正好执行在这里,可能会导致崩溃,这块有什么好的办法?
不妨在较早的时机进行hook,比如在spawn的时候比较安全,如果有需求要在attach注入,并且目标函数还在高频次调用这种情况,可以用ptrace挂起其他线程(ida、gdb都可以),等待主线程注入后恢复即可~
2025-1-3 23:14
0
雪    币: 223
活跃值: (155)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
17
棕熊 不妨在较早的时机进行hook,比如在spawn的时候比较安全,如果有需求要在attach注入,并且目标函数还在高频次调用这种情况,可以用ptrace挂起其他线程(ida、gdb都可以),等待主线程注入 ...
感谢,不知道 Frida 有没有运行时 hook 的办法。
2025-1-4 14:01
0
雪    币: 3161
活跃值: (1812)
能力值: ( LV9,RANK:170 )
在线值:
发帖
回帖
粉丝
18
lonkil 感谢,不知道 Frida 有没有运行时 hook 的办法。
frida支持的,你注入后会有交互式命令框,按照语法去写即可
2025-1-5 10:59
0
雪    币: 200
活跃值: (201)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
19
666
2025-1-6 12:14
0
雪    币: 139
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
20
感谢分享,我也正准备自己写一个hook框架,现有的多多少少都有点不好用的点
2025-1-9 09:37
0
雪    币: 2181
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
21
没处理多线程
2025-1-9 20:14
0
游客
登录 | 注册 方可回帖
返回