首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
4
4
[原创]从0到1构建一个Hook工具之注入器篇(二)
发表于: 2026-3-18 23:03
22012
[原创]从0到1构建一个Hook工具之注入器篇(二)
n_1ng
2026-3-18 23:03
22012
## 前言 在上一篇文章中,我们已经了解了注入的基本概念并且实现了attach注入模式,这个时候我们很容易想到attach模式的一个缺陷:当我们想要观察或拦截app启动早期的行为时,使用attach往往已经错过了最有价值的时机。 spawn注入模式就可以解决这个问题,并且在逆向场景中这种模式也非常的常见。 项目地址:<mark class="encrypted">5f2K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6^5x3h3q4G2L8U0q4F1k6#2)9J5c8V1&6A6L8X3A6W2j5%4c8G2M7R3`.`.</mark> ## 目标 这里有两种实现spawn的方案,一种是传统的直接hook zygote实现,另一种则是参考了Frida的Zymbiote方案,这里我们分别对两种方案做一个介绍和实现。后续有什么讲的不对的或者可以补充改进的欢迎在评论区提出! ## 传统方案原理和实现 ### 知道这些基础后会更好的理解下文 1. zygote进程:Android中app的进程都是从zygote进程中fork出来的 2. dlsym函数:上一篇文章我们用到了dlopen函数用于加载so,dlsym和dlopen是同一系列的,而他的作用是找到函数的地址 3. setArgV0:这里先简单了解他存在于libandroid_runtime.so,和进程显示名/进程身份字符串的设置有关,在Android app启动过程中,系统会把子进程一步步塑造成目标app,其中一个典型的早期信号就是:进程名被设置成目标包名,而setArgV0就和这个有关。 4. SELinux:他其实就是Linux/Android上的一套强制访问控制系统,而selinux_android_setcontext他和进程身份、安全域有关,是子进程在启动早期、切换安全上下文的一个关键点 ### 为什么spawn模式更难实现 使用attach注入时,目标进程已经是启动的状态,我们很容易就可以知道他的pid,而spawn注入时,目标进程还没有被创建,pid也就无从所知了,我们必须就先在某个上游位置埋点,并且要保证能在目标子进程出现时被命中 ### 具体实现 spawn注入的核心思路其实就是:先用attach把一个so(后文记作helper)注入zygote,再由zygote内部常驻的这个so安装fork和子进程初始化hook,在命中目标包名后,最终由目标子进程自己执行dlopen(target.so),详细讲就是: 1. 主程序把helper注入到zygote 2. 主程序远程调用一个ainject方法 3. ainject方法中保存目标信息并安装fork/vfork hook 4. zygote派生出子进程时,子进程分支里调用install_child_hook 5. 子进程走到setArg0/selinux_android_setcontext 6. hook拿到当前进程名字 7. 如果名字命中目标包名,则在这个子进程中dlopen(target.so) 8. 卸载hook 这里其实可以分为三层结构:主程序、helper、目标app子进程。 第一步首先是复用attach,主程序先把helper注入到zygote中,这个helper的定位并不是一个playload,而是spawn模式中常驻在zygote内部的一个中间控制模块。 ```cpp handle = inject_so_handle_by_pid(zygote_pid, ncore_path); if (handle == nullptr) { LOGE("prepare_spawn_in_zygote: inject ncore failed"); return false; } ``` 此时我们已经成功将helper注入到了zygote中并且返回了一个handle,接下来要做的就是远程调用一个ainject方法,这里就需要用到dlsym函数,结合前一篇文章我们很容易就能完成 ```cpp remote_sym_name = remote_alloc_string(zygote_pid, "ainject"); remote_pkg = remote_alloc_string(zygote_pid, package_name); remote_so = remote_alloc_string(zygote_pid, so_path); remote_ainject = call_remote_function<void*, void*, const char*>( zygote_pid, reinterpret_cast<void*>(dlsym), handle, reinterpret_cast<const char*>(remote_sym_name) ); params[0] = reinterpret_cast<long>(remote_pkg); params[1] = reinterpret_cast<long>(remote_so); call_remote_call<void>(zygote_pid, reinterpret_cast<long>(remote_ainject), 2, params); ``` 上文我们简单提到ainject的作用是保存目标信息和安装hook,这里的hook我们先借助dobby框架实现,暂时不去理会他的具体实现。 ```cpp g_target_package = strdup(package_name); g_target_so = strdup(so_path); g_payload_loaded = false; INSTALL_HOOK(fork, fork); INSTALL_HOOK(vfork, vfork); ``` 到了这一步,在zygote内部的helper已经知道了目标包名、知道了target.so的路径并且进入了等待目标子进程的状态,但是由于此时子进程还没有完全的初始化,因此还不能判断出是否是我们的目标子进程,所以需要在子进程中安装hook,用于获取进程名,这里hook点选择了android_os_process_setArgV0和selinux_android_setcontext ```cpp static void install_child_hooks() { LOGI("ncore: installing child hooks pid=%d", getpid()); g_android_os_Process_setArg = DobbySymbolResolver( "libandroid_runtime.so", "_Z27android_os_Process_setArgV0P7_JNIEnvP8_jobjectP8_jstring"); if (g_android_os_Process_setArg != nullptr) { INSTALL_HOOK(android_os_Process_setArgV0, g_android_os_Process_setArg); } else { LOGE("ncore: android_os_Process_setArgV0 not found"); } g_selinux_android_setcontext = DobbySymbolResolver("libselinux.so", "selinux_android_setcontext"); if (g_selinux_android_setcontext != nullptr) { INSTALL_HOOK(selinux_android_setcontext, g_selinux_android_setcontext); } else { LOGE("ncore: selinux_android_setcontext not found"); } } ``` 然后当子进程中命中了Hook之后就会进入判断包名是否匹配,从而决定是否加载target.so,并且此时之前的hook已经不需要了可以执行unhook了 ```cpp static bool load_payload_if_needed(const char* package_name) { if (!matches_target(package_name)) { return false; } if (g_payload_loaded) { LOGI("ncore: payload already loaded for %s", package_name); return true; } LOGI("ncore: target matched, loading payload package=%s so=%s", package_name != nullptr ? package_name : "(null)", g_target_so != nullptr ? g_target_so : "(null)"); unhook_all(); void* handle = dlopen(g_target_so, RTLD_NOW | RTLD_NODELETE | RTLD_GLOBAL); if (handle == nullptr) { LOGE("ncore: dlopen failed for %s: %s", g_target_so, dlerror()); return false; } g_payload_loaded = true; LOGI("ncore: payload loaded for %s => %s", package_name, g_target_so); send_status_to_injector(package_name, g_target_so); return true; } ``` ## Zymbiote方案原理和实现 ### 知道这些基础你会更好的理解下文 1. ART槽位:在Android运行时里,某些方法调用最终不是简单靠符号名直接跳转,而是会经过ART管理维护的一些方法结构或入口槽位上。 2. /proc/<pid>/mem:这是Linux/Android提供的一个伪文件,表示某个进程的虚拟内存内容,常用于读取目标进程内存、配合/proc/\<pid>/maps确定模块加载地址和内存段范围 3. uid:在Android中每个应用通常分配独立UID 4. boot heap:这个Android启动类相关对象所在的一块堆内存区域,系统启动时boot class path 上那些核心类被加载,他们对应的类对象、方法元数据等很多会放在专门的boot image/boot heap相关区域里 5. libstagefright.so:和音视频编解码有关,常常已经被加载 6. libandroid_runtime.so:Android Framework和底层native层之间重要桥接库,在zygote/system server启动过程中参与初始化 7. 重定位:是linker把“编译/链接时还没确定的地址应用”修正成”进程实际运行地址“的过程,简单理解就是源码/目标文件里面先留一个占位,模块真正装入内存时再把这些地址补准 ### 原理简介 相较于之前的方案,Zymbiote不是直接给目标app打补丁,而是先“污染”zygote(很符合Symbiote这个名字),核心不再是将一个helper常驻到zygote中,而是直接patch zygote启动链中某个关键运行时入口的槽位,让目标app正常启动过程中自然进入一段预埋的stub,并在命中目标进程后执行dlopen实现注入。 ### 具体实现 我们先理一下详细的核心流程: 1. 获取注入所需的上下文信息 2. 解析setArgV0的实际地址 3. 在boot heap中搜索ArtMethod slot 4. 保存原始shellcode页,给后续恢复使用 5. 暂停zygote 6. 生成并回填stub配置 7. 把stub写入到libstagefright.so最后一页 8. 把ArtMethod slot改到stub的起始地址 9. 恢复zygote,目标app启动时触发stub 10. 等待手动停止并恢复 函数调用链大概如下 ```cpp main -> collect_spawn_source_pids -> inject_spawn_symbi_by_pids -> collect_symbi_context -> prepare_target_so_for_app -> stop_process -> open_remote_mem -> write_stub_and_patch_slot -> resume_process -> start_target_app_symbi -> 等待 Ctrl+C -> restore_original_slot 目标app启动后在子进程中走: android.os.Process.setArgV0Native -> stub_replacement_set_argv0 -> original_set_argv0 -> getuid -> dlopen(target_so) ``` 这部分代码主要参考了<mark class="encrypted">354K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6y4M7X3q4U0K9#2)9J5c8W2c8u0L8X3A6W2j5%4c8G2M7W2)9#2k6W2y4&6L8h3u0A6</mark> 链路开始是collect_spawn_source_pids获取zygote的pid然后inject_spawn_symbi_by_pids()执行注入流程,实现Zymbiote的核心逻辑就在这个方法中。接下来按顺序来介绍核心逻辑的流程: collect_symbi_context这是整个Zymbiote最核心的准备函数,他负责回答了5个问题: 1. 目标app uid是多少 2. stub 要写到zygote的哪个可执行地址 3. setArgV0Native的真实地址在哪 4. ArtMethod slot在boot heap的哪个位置 5. stub 运行时需要的远程函数地址是什么 ```cpp struct SymbiContext { pid_t zygote_pid = -1; uid_t target_uid = static_cast<uid_t>(-1); uintptr_t shellcode_base = 0; uintptr_t set_argv0_address = 0; uintptr_t art_method_slot = 0; uintptr_t original_ptr = 0; uintptr_t remote_getuid = 0; uintptr_t remote_dlopen = 0; uintptr_t remote_log_print = 0; std::string libandroid_runtime_path; std::string shellcode_map_path; uintptr_t shellcode_map_start = 0; size_t shellcode_map_offset = 0; std::string remote_so_path; std::vector<uint8_t> original_shellcode_area; }; ``` 先解析目标包的uid,具体实现就是读取/data/system/packages.list逐行匹配包名获取uid。 然后解析zygote的/proc/<pid>/maps,方便后续查找libandroid_runtime.so、libstagefright.so、boot heap等的位置。 接着是选择shellcode的落点,这里选择的是libstagefright.so的可执行段中选最后一页,因为这页本身就已经在进程中映射好了,而且是可执行页,避免需要额外通过mprotect修改内存页权限: ```cpp if (ctx->shellcode_base == 0 && map.pathname.find("libstagefright.so") != std::string::npos && map.perms[2] == 'x') { ctx->shellcode_base = map.end - static_cast<uintptr_t>(getpagesize()); ctx->shellcode_map_path = map.pathname; ctx->shellcode_map_start = map.start; ctx->shellcode_map_offset = map.offset; } ``` 通过libandroid_runtime.so基址 + 导出偏移求得真实地址,这是后面搜索ArtMethod的“目标指针值”的基础。 接下来是解析setArgV0Native的真实运行地址,在前面我们已经找到了libandroid_runtime.so,下一步就可以通过get_module_base()获取模块基址,再调用get_symbol_offset_from_elf()获取符号偏移从而计算出实际的地址。 ```cpp #define SYMBI_TARGET_SYMBOL "_Z27android_os_Process_setArgV0P7_JNIEnvP8_jobjectP8_jstring" uintptr_t libandroid_runtime_base = get_module_base(zygote_pid, ctx->libandroid_runtime_path); uintptr_t symbol_offset = get_symbol_offset_from_elf(ctx->libandroid_runtime_path, SYMBI_TARGET_SYMBOL); if (libandroid_runtime_base == 0 || symbol_offset == 0) { LOGE("symbi: failed to resolve setArgV0"); return false; } ctx->set_argv0_address = libandroid_runtime_base + symbol_offset; ``` boot heap:如果某段映射路径中包括boot.art、boot-framework.art、dalvik-LinearAlloc并且是可读写区域,就把他加入boot heap候选区heap_candidates,这一步实际就是在近似的定位ART的boot heap区域。 ```cpp if ((map.pathname.find("boot.art") != std::string::npos || map.pathname.find("boot-framework.art") != std::string::npos || map.pathname.find("dalvik-LinearAlloc") != std::string::npos) && map.perms[0] == 'r' && map.perms[1] == 'w') { heap_candidates.push_back(map); } ``` 为什么需要他呢?因为比如android.os.Process.setArgV0Native()对应的Java/native方法元信息最终会体现在ART的对象布局里,这些元信息一般会驻留在boot image相关heap中,如果我们知道原始native函数地址,就可以在这个heap_candidates中反向搜索“谁指向了他”,这正是后面定位ArtMethod slot的基础。 如何在boot heap中搜索到ArtMethod slot呢?在上面,我们已经得到了setArgV0Native的实际地址,因此我们直接在每一块heap内存中搜索其对应的指针值,如果找到了就把该地址记为art_method_slot即可,原理就如上文所述,android.os.Process.setArgV0Native() 对应的 ArtMethod 结构里,就会有一个 entry point 字段指向它真正的 native 实现。既然我们已经知道真实的 native 地址,就可以在 ART 的方法元信息区中,反向搜索“谁保存了这个地址”。 ```cpp for (const auto& heap : heap_candidates) { size_t region_size = static_cast<size_t>(heap.end - heap.start); if (region_size < sizeof(uintptr_t)) { continue; } std::vector<uint8_t> buffer(region_size); ssize_t read_size = pread(mem_fd, buffer.data(), region_size, static_cast<off_t>(heap.start)); if (read_size != static_cast<ssize_t>(region_size)) { continue; } auto* found = reinterpret_cast<uint8_t*>( memmem(buffer.data(), region_size, &ctx->set_argv0_address, sizeof(ctx->set_argv0_address))); if (found != nullptr) { ctx->art_method_slot = heap.start + static_cast<uintptr_t>(found - buffer.data()); break; } } ``` 然后是stub相关的逻辑,stub是一段“裸写入进程内存的二进制blob”,他不会像正常的so那样经过动态链接器的完整重定位流程,所以他内部要调用的函数地址都必须由注入器外部算好然后回填,比如在这里我们需要解析libc.so:getuid, libdl.so : dlopen,liblog.so:__android_log_print,这部分通过get_remote_symbol()函数完成,具体实现其实就是get_module_base()找模块基址,get_process_maps()找真实文件路径,get_symbol_offset_from_elf()算偏移,然后base + offset即可。 ```cpp ctx->remote_getuid = get_remote_symbol(zygote_pid, "libc.so", "getuid"); ctx->remote_dlopen = get_remote_symbol(zygote_pid, "libdl.so", "dlopen"); ctx->remote_log_print = get_remote_symbol(zygote_pid, "liblog.so", "__android_log_print"); if (ctx->remote_getuid == 0 || ctx->remote_dlopen == 0 || ctx->remote_log_print == 0) { LOGE("symbi: failed to resolve remote helper symbols"); return false; } ``` 接下来就到了write_stub_and_patch_slot的部分了,我们需要先暂停zygote进程,然后直接修改/proc/<pid>/mem即可,那写的内容是什么呢?当然是我们的stub,此时我们不仅已经有了编译好了二进制blob,并且需要的上下文信息都已经搜集完毕,只需要找到配置区并回填即可,这里就需要通过配置块的定位标记。 ```cpp char remote_pattern[] = "/ningningning123123"; uintptr_t marker = reinterpret_cast<uintptr_t>( memmem(stub_binary, stub_binary_size, remote_pattern, sizeof(remote_pattern))); if (marker == 0) { LOGE("symbi: failed to locate stub marker"); return false; } std::vector<uint8_t> stub_copy(stub_binary, stub_binary + stub_binary_size); uintptr_t offset = marker - reinterpret_cast<uintptr_t>(stub_binary); auto* stub_cfg = reinterpret_cast<TStub*>(stub_copy.data() + offset); stub_cfg->uid = static_cast<int>(ctx.target_uid); memset(stub_cfg->so_path, 0, sizeof(stub_cfg->so_path)); strncpy(stub_cfg->so_path, ctx.remote_so_path.c_str(), sizeof(stub_cfg->so_path) - 1); stub_cfg->original_set_argv0 = reinterpret_cast<int (*)(JNIEnv*, jobject, jstring)>(ctx.set_argv0_address); stub_cfg->slot_addr = ctx.art_method_slot; stub_cfg->getuid = reinterpret_cast<uid_t (*)()>(ctx.remote_getuid); stub_cfg->dlopen = reinterpret_cast<void* (*)(const char*, int)>(ctx.remote_dlopen); stub_cfg->log_print = reinterpret_cast<int (*)(int, const char*, const char*, ...)>(ctx.remote_log_print); ssize_t written_code = pwrite(mem_fd, stub_copy.data(), stub_copy.size(), static_cast<off_t>(ctx.shellcode_base)); if (written_code != static_cast<ssize_t>(stub_copy.size())) { LOGE("symbi: failed to write stub to shellcode_base=0x%lx", ctx.shellcode_base); return false; } ``` 回填好后,函数将stub写到shellcode_base位置,然后将art_method_slot改成shellcode_base,至此我们就完成了在zygote中放入一段可执行stub,并将setArgV0Native的ArtMethod entry point改指向这段stub。 ```cpp uintptr_t new_ptr = ctx.shellcode_base; ssize_t written_ptr = pwrite(mem_fd, &new_ptr, sizeof(new_ptr), static_cast<off_t>(ctx.art_method_slot)); if (written_ptr != static_cast<ssize_t>(sizeof(new_ptr))) { LOGE("symbi: failed to patch art_method_slot=0x%lx", ctx.art_method_slot); return false; } LOGI("symbi: wrote stub to 0x%lx and patched slot=0x%lx -> 0x%lx", ctx.shellcode_base, ctx.art_method_slot, new_ptr); ``` 当目标app启动之后,他会从已经打过patch的zygote中fork出来,随着app启动流程推进,android.os.Process.setArgV0Native()会被调用,然后进入stub的流程,命中uid后便会指向dlopen完成target.so的注入。 ```cpp if (stubApi.getuid() == (uid_t) stubApi.uid) { STUB_LOGI((&stubApi), "%s Attempting to inject: %s", name_utf8, stubApi.so_path); void* handle = stubApi.dlopen((const char*) stubApi.so_path, RTLD_NOW); if (handle != 0) { STUB_LOGI((&stubApi), "Successfully loaded SO at handle: %p", handle); } else { STUB_LOGE((&stubApi), "Failed to dlopen SO!"); } } ``` #### 一些辅助函数 **get_module_base** ```cpp uintptr_t get_module_base(pid_t pid, const std::string& lib_name) { char path[64] = {0}; snprintf(path, sizeof(path), "/proc/%d/maps", pid); std::ifstream maps(path); std::string line; while (std::getline(maps, line)) { if (line.find(lib_name) == std::string::npos) { continue; } uintptr_t start = 0; uintptr_t offset = 0; char perms[5] = {0}; if (sscanf(line.c_str(), "%lx-%*x %4s %lx", &start, perms, &offset) == 3) { if (offset == 0 && perms[3] != 's') { return start; } } } return 0; } ``` **get_symbol_offset_from_elf** ```cpp uintptr_t get_symbol_offset_from_elf(const std::string& elf_path, const char* symbol_name) { int fd = open(elf_path.c_str(), O_RDONLY); if (fd < 0) { return 0; } struct stat st{}; if (fstat(fd, &st) != 0 || st.st_size <= 0) { close(fd); return 0; } void* map_base = mmap(nullptr, static_cast<size_t>(st.st_size), PROT_READ, MAP_PRIVATE, fd, 0); close(fd); if (map_base == MAP_FAILED) { return 0; } auto* ehdr = reinterpret_cast<Elf64_Ehdr*>(map_base); auto* shdr = reinterpret_cast<Elf64_Shdr*>(reinterpret_cast<uintptr_t>(map_base) + ehdr->e_shoff); uintptr_t symbol_offset = 0; for (int i = 0; i < ehdr->e_shnum; ++i) { if (shdr[i].sh_type != SHT_DYNSYM) { continue; } auto* syms = reinterpret_cast<Elf64_Sym*>(reinterpret_cast<uintptr_t>(map_base) + shdr[i].sh_offset); int count = static_cast<int>(shdr[i].sh_size / sizeof(Elf64_Sym)); auto* strtab = reinterpret_cast<char*>(reinterpret_cast<uintptr_t>(map_base) + shdr[shdr[i].sh_link].sh_offset); for (int j = 0; j < count; ++j) { if (strcmp(strtab + syms[j].st_name, symbol_name) == 0) { symbol_offset = syms[j].st_value; break; } } if (symbol_offset != 0) { break; } } uintptr_t load_bias = 0; auto* phdr = reinterpret_cast<Elf64_Phdr*>(reinterpret_cast<uintptr_t>(map_base) + ehdr->e_phoff); for (int i = 0; i < ehdr->e_phnum; ++i) { if (phdr[i].p_type == PT_LOAD) { load_bias = phdr[i].p_vaddr; break; } } munmap(map_base, static_cast<size_t>(st.st_size)); if (symbol_offset < load_bias) { return 0; } return symbol_offset - load_bias; } ``` **get_remote_symbol** ```cpp uintptr_t get_remote_symbol(pid_t pid, const std::string& lib_name, const char* symbol) { uintptr_t base = get_module_base(pid, lib_name); if (base == 0) { return 0; } auto maps = get_process_maps(pid); std::string local_path; for (const auto& map : maps) { if (map.pathname.find(lib_name) != std::string::npos) { local_path = map.pathname; break; } } if (local_path.empty()) { return 0; } uintptr_t offset = get_symbol_offset_from_elf(local_path, symbol); if (offset == 0) { return 0; } return base + offset; } ``` ## TODO - [ ] 通讯模块 - [ ] 注入痕迹去除
登录后可查看完整内容
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
#HOOK注入
收藏
・
4
点赞
・
4
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
P1umH0
感谢你的积极参与,期待更多精彩内容!
2026-3-24 19:21
lanoche
非常支持你的观点!
2026-3-19 09:52
pexillove
为你点赞!
2026-3-19 00:51
我的小拇指啊
感谢你的积极参与,期待更多精彩内容!
2026-3-19 00:09
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
git_94327axixa518
雪 币:
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
9
粉丝
0
关注
私信
git_94327axixa518
2
楼
666
2026-5-28 19:10
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
n_1ng
7
发帖
7
回帖
80
RANK
关注
私信
他的文章
[原创]从0到1构建一个Hook工具之Frida-like风格的Hook
50511
[原创]从0到1构建一个Hook工具之Inline Hook篇(五)
55297
[原创]从0到1构建一个Hook工具之PLT Hook篇(四)
30296
[原创]从0到1构建一个Hook工具之Java Hook篇(三)
24473
[原创]从0到1构建一个Hook工具之注入器篇(二)
22012
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部