从 g_gold = 1250 出发,点击一个按钮,用 mt_scan_u32 扫描内存找到地址,mach_vm_write 写入 8888,同一行代码都没动,UI 上 1250 变成了 8888。完整源码见文末获取方式。
1. 什么是内存修改,能解决什么问题
内存修改,就是绕过正常代码逻辑,直接读写进程的虚拟内存。App 里的任何变量最终都存在于进程地址空间的某个地址上。正常改 g_gold = 8888 要经过编译器生成的 store 指令。但如果你能直接往 g_gold 所在的内存地址写 8888——什么 setter、KVC、属性观察者、所有上层逻辑全部跳过。你直接改了物理事实。 | |
|---|
| |
| 把 check_jailbreak() 的入口指令 patch 成 return 0 |
| |
| |
内存修改 vs Hook vs 注入,对抗检测的能力差别
| | | |
|---|
| | 指针指向非预期 image,dladdr 立即可查 | |
| | __TEXT | |
| | dyld_all_image_infos | |
| mach_vm_write | | |
GOT Hook 改指针,dladdr 一查就暴露。Inline Hook 改指令,代码段哈希会变。注入多一个 image,dyld 有记录。内存修改不进 dyld、不改符号表、不改指令——你只改了数据段里一个值,或者临时改了代码段一个字节。安全 SDK 可以扫描 rwx 页面来发现代码段权限异常,但对数据段的修改几乎无迹可寻——除非服务端发现了行为异常(金币从 1250 突然变成 999999),客户端拿不出直接证据。内存修改的前提:同进程操作用 mach_task_self() 即可,跨进程操作需要越狱或 task_for_pid-allow。
2. Demo:一个按钮,让 1250 变成 8888
2.1 场景
ViewController 里有一个全局变量 g_gold,初始值 1250。界面上一个 Label 显示这个值,一个按钮。点击按钮 → 扫描内存找 1250 → 确认地址 → 写入 8888 → Label 刷新。2.2 完整代码
// 全局变量static uint32_t g_gold = 1250;- (void)onTap { if (self.hasRun) return; self.hasRun = YES; [self.actionBtn setTitle:@”执行中...” forState:UIControlStateNormal]; [self.actionBtn setTitleColor:[UIColor lightGrayColor] forState:UIControlStateNormal]; self.actionBtn.enabled = NO; self.stepLabel.text = @”扫描中...”; self.stepLabel.textColor = [UIColor orangeColor]; dispatch_async(dispatch_get_global_queue(0, 0), ^{ mt_ctx_t ctx; mt_init(&ctx, mach_task_self()); // 初始化,task port = 自身 // 步骤 1:扫描值为 1250 的 4 字节整数 dispatch_async(dispatch_get_main_queue(), ^{ self.stepLabel.text = @”1/3 扫描内存中... 搜索值 = 1250”; }); mach_vm_address_t addr = 0; bool found = mt_scan_u32(&ctx, 1250, &addr); // 核心:内存扫描 if (!found) { dispatch_async(dispatch_get_main_queue(), ^{ self.stepLabel.text = @”扫描失败:未找到值 1250”; self.stepLabel.textColor = [UIColor redColor]; }); return; } // 步骤 2:定位到地址 dispatch_async(dispatch_get_main_queue(), ^{ self.stepLabel.text = [NSString stringWithFormat: @”2/3 已定位到地址 0x%llx”, addr]; }); usleep(400000); // 步骤 3:写入新值 dispatch_async(dispatch_get_main_queue(), ^{ self.stepLabel.text = @”3/3 写入新值 8888...”; }); mt_write_u32(&ctx, addr, 8888); // 核心:直接写内存 // 验证 uint32_t verify = 0; mt_read_u32(&ctx, addr, &verify); dispatch_async(dispatch_get_main_queue(), ^{ self.valueLabel.text = [NSString stringWithFormat:@”%u”, g_gold]; if (verify == 8888 && g_gold == 8888) { self.valueLabel.textColor = [UIColor greenColor]; self.stepLabel.text = @”✅ 验证通过:1250 → 8888”; self.stepLabel.textColor = [UIColor greenColor]; [self.actionBtn setTitle:@”✅ 修改成功” forState:UIControlStateNormal]; } else { self.valueLabel.textColor = [UIColor redColor]; self.stepLabel.text = [NSString stringWithFormat: @”❌ 失败 verify=%u g_gold=%u”, verify, g_gold]; self.stepLabel.textColor = [UIColor redColor]; } }); });}
2.3 运行效果
左侧 1250(黄色)、右侧 8888(绿色)。g_gold = 1250 这一行代码从来没执行过 g_gold = 8888——值是被 mach_vm_write 从外部直接改的。下面拆解 onTap 里调用的四个核心函数,每一步在 Mach 内核里发生了什么。
3. 步骤一:mt_init — 拿到 task port
mt_ctx_t ctx;mt_init(&ctx, mach_task_self());
一切 Mach VM 操作的前提是 task port。同进程用 mach_task_self(),跨进程(外部工具改其他 App)需要越狱后通过 task_for_pid 拿目标 task port。
4. 步骤二:mt_scan_u32 — 扫描内存找到 1250
mach_vm_address_t addr = 0;bool found = mt_scan_u32(&ctx, 1250, &addr);
从输入(值 1250)到输出(地址 addr),内部做了三步:4.1 枚举所有可读写区域
mach_vm_region 遍历进程的虚拟地址空间,过滤出 VM_PROT_READ | VM_PROT_WRITE 的区域——全局变量和堆在这里。系统库在 dyld_shared_cache 里(地址 0x180000000+),数据不会存在那里,直接过滤掉。kern_return_t kr = mach_vm_region(task, &addr, &size, VM_REGION_BASIC_INFO_64, (vm_region_info_t)&info, &info_count, &object_name);// 过滤条件if ((info.protection & VM_PROT_READ) && (info.protection & VM_PROT_WRITE) && !is_in_shared_cache(addr)) { // 这个区域值得扫描}
一个坑:某些内核区域返回 size=0,不手动跳过就是无限循环。4.2 分块读取并匹配
对每个可读写区域,用 mach_vm_read_overwrite 逐块拉数据到本地缓冲区,然后搜索字节序列 0xE2 0x04 0x00 0x00(1250 的小端表示):#define CHUNK (1024 * 1024) // 1MB 块uint8_t buf[CHUNK];while (cur < region_end) { mach_vm_read_overwrite(task, cur, CHUNK, (mach_vm_address_t)buf, &bytes_read); // 在 buf 中搜索 1250 的 4 字节编码 for (size_t i = 0; i <= bytes_read - 4; i++) { if (*(uint32_t *)(buf + i) == 1250) { *out_addr = cur + i; // 命中!返回地址 return true; } } cur += bytes_read - 3; // 回退 3 字节,防止跨块漏匹配}
mach_vm_read_overwrite 比 mach_vm_read 高效——前者的数据直接写到调用方缓冲区,后者需要内核分配新内存再映射回来,多了两次上下文切换。4.3 更多搜索方式
// 带通配符的 AoB(Array of Bytes)mt_scan_aob(&ctx, pattern, mask, pat_len, &addr);// 多结果收集(所有命中地址)mt_result_set_t rs;mt_result_init(&rs);mt_scan_u32_all(&ctx, 1250, &rs);// 然后在结果集中缩小范围(经典游戏修改器流程)mt_narrow_search_u32(&ctx, &rs, 1180); // 只保留变成 1180 的// 各种宽度mt_scan_u8(&ctx, 42, &addr);mt_scan_f32(&ctx, 3.14f, &addr); // 游戏坐标常用mt_scan_u64(&ctx, 0xDEAD, &addr);// 指针链追踪(多级间接引用)uint64_t offsets[] = {0x10, 0x24, 0x08};mt_follow_pointer(&ctx, game_manager_base, offsets, 3, &gold_addr);
5. 步骤三:mt_write_u32 — 直接写入 8888
mt_write_u32(&ctx, addr, 8888);
底层就是 Mach 内核的 mach_vm_write:kern_return_t kr = mach_vm_write(task, addr, (vm_offset_t)&value, sizeof(value));// kr == KERN_SUCCESS
这一行执行完,addr 处的物理内存已经被改写。需要特别注意的是:mach_vm_write 直接写内存,完全绕过了任何语言运行时。没有 setter、没有 KVO、没有 willChange/didChange 回调——ObjC runtime 不知道这件事发生过。只有当你重新读出这个地址的值时,才会发现它已经变了。
6. 步骤四:mt_read_u32 — 验证写入
uint32_t verify = 0;mt_read_u32(&ctx, addr, &verify);// verify == 8888 ✓
通过 mach_vm_read_overwrite 读回确认。在 onTap 的最后一步,同时检查了 verify == 8888 和 g_gold == 8888——两者是同一个物理地址,读出来的结果一致,证明了 mach_vm_write 确实改到了 g_gold 的内存。
7. 完整源码获取
ios_mem_toolkit.h — 全部 API 声明ios_mem_toolkit.c — 完整实现,含分块扫描、缩小搜索、指针链、代码段 Patch、平台兼容层获取方式:关注后私信回复「内存工具箱」即可获取完整源码。
8. 参考资料
[1] Apple Mach VM API — mach_vm_region, mach_vm_read_overwrite, mach_vm_write, mach_vm_protect[2] XNU 源码 — vm_map.c,https://github.com/apple-oss-distributions/xnu[3] ARM Architecture Reference Manual — ARM64 指令编码(MOV/CBZ/B/SUBS)[4] — segment_command_64, max_protection 字段
技术交流
对 iOS 内存操作、Mach VM API 或逆向工程感兴趣,欢迎深入探讨。读完本文,如果你希望把零散的知识点串成完整的攻防体系,可以系统学习《iOS逆向安全从入门到攻防实战》: | |
|---|
| 逆向基础、越狱、环境搭建、Hook 入门,动手修改 IDFA / IDFV |
| MachOView / Hopper / IDA Pro 工具链、Mach-O 格式精讲、脱壳、反编译分析 |
| Method Swizzling / FishHook / Dobby / Frida 四套 Hook 方案,覆盖 OC 方法、C 函数、符号表、运行时注入 |
| 非 MonkeyDev 重打包、注入动态库、越狱 Tweak 插件开发、Tweak 与 App 通信 |
| 虚拟相机(替换摄像头帧 + 预览视频)、虚拟定位(开发者方式 / Hook / 注入三类方案) |
| Method Swizzling 检测、Got 表 Hook 检测、InlineHook 检测、重打包检测 |
| |