基于Frida实现的ceserver有内存问题

去年在GitHub发现一个iOS能用的ceserver,原仓库地址已经没了,好像作者重新换了一个GitHub账号提交了。仓库地址:frida-ceserver

去年使用正常,结合Cheat Engine用来搜索一些iOS单机小游戏内存,可完美替代IGG这种手机内存扫描工具。最近几天心血来潮用了一下,发现搜索过程中游戏会闪退。

尝试换了N个Frida版本也没任何效果,最终写了个内存监控插件,发现频繁调用js函数 Memory.readByteArray 的过程中,APP的可用内存一直在减少,并且不会自动释放。最终导致APP没有可用内存后,被系统杀死进程。

除非重启APP,否则 Memory.readByteArray 占用的内存一直不会释放。想不到有啥办法解决了,有大佬了解过这方面么。

6阿哥 牛逼

可能不是readByteArray的问题?我用下面的代码测了一下:
不手动执行gc,每次发送1M,平均每次会多占用2k内存,(除第一次gc外)会在内存占用10M左右进行gc,手动调gc的情况下内存占用维持2M上下

scr = '''
const test_base = Process.enumerateModules()[0].base;
console.log(JSON.stringify(Process.enumerateModules()[0]));
rpc.exports = {
    read: function(){
        return test_base.readByteArray(1024*1024);
    },
    mem: function(){
        return Frida.heapSize/1024;
    },
    gc:gc,
};
'''
device = frida.enumerate_devices[-1]
pid = device.spawn("com.apple.mobilesafari")
session = device.attach(pid)

script = session.create_script(scr)
script.load()
device.resume(pid)
count = 0
while True:
    count +=1
    if count >= 9:
        count = 0
        print(script.exports.mem())
        # script.exports.gc()
    script.exports.read()

看起来可能是多线程导致js没空gc?
可以给readprocessmemory加个每多少次执行调一次gc()试试
不玩了,ce好像和杀毒软件有冲突,点了一下进程监视器直接蓝屏了

我又测试了下,就算我不主动调用gc,Frida.heapSize 获取的内存占用最多也才10多M。间隔性调用gc,一直保持在2M左右,但这获取的并不是APP的内存占用。

ceserver.pyhandler 函数 CECMD.CMD_READPROCESSMEMORY 分支内,我尝试不执行 ReadProcessMemory 函数,APP内存占用没有任何变化。

elif (command == CECMD.CMD_READPROCESSMEMORY):
    handle = reader.ReadUInt32()
    address = reader.ReadUInt64()
    size = reader.ReadUInt32()
    compress = reader.ReadInt8()
    # ret = API.ReadProcessMemory(address, size)
    # if (compress == 0):
    #     if ret != False:
    #         writer.WriteInt32(len(ret))
    #         ns.sendall(ret)
    #     else:
    #         writer.WriteInt32(0)
    # else:
    #     if ret != False:
    #         compress_data = zlib.compress(ret, level=compress)
    #         writer.WriteInt32(len(ret))
    #         writer.WriteInt32(len(compress_data))
    #         ns.sendall(compress_data)
    #     else:
    #         writer.WriteInt32(0)
    #         writer.WriteInt32(0)
    writer.WriteInt32(0)

而执行 ReadProcessMemory 函数,内存就会暴涨。

    elif (command == CECMD.CMD_READPROCESSMEMORY):
    handle = reader.ReadUInt32()
    address = reader.ReadUInt64()
    size = reader.ReadUInt32()
    compress = reader.ReadInt8()
    ret = API.ReadProcessMemory(address, size)
    # if (compress == 0):
    #     if ret != False:
    #         writer.WriteInt32(len(ret))
    #         ns.sendall(ret)
    #     else:
    #         writer.WriteInt32(0)
    # else:
    #     if ret != False:
    #         compress_data = zlib.compress(ret, level=compress)
    #         writer.WriteInt32(len(ret))
    #         writer.WriteInt32(len(compress_data))
    #         ns.sendall(compress_data)
    #     else:
    #         writer.WriteInt32(0)
    #         writer.WriteInt32(0)
    writer.WriteInt32(0)

ReadProcessMemory 函数:

readprocessmemory: function (address, size) {
    try {
        if (ptr(address).isNull() == false) {
            return Memory.readByteArray(ptr(address), size);
        } else {
            return false;
        }
    } catch (e) {
        return false;
    }
},

获取占用的内存:

+ (double)usedMemory
{
    task_basic_info_data_t taskInfo;
    mach_msg_type_number_t infoCount = TASK_BASIC_INFO_COUNT;
    kern_return_t kernReturn = task_info(mach_task_self(), TASK_BASIC_INFO, (task_info_t)&taskInfo, &infoCount);
    if (kernReturn != KERN_SUCCESS) {
        return NSNotFound;
    }
    return taskInfo.resident_size / 1024.0 / 1024.0;
}

获取APP剩余可用内存:

+ (double)availableMemory
{
    if (@available(iOS 13.0, *)) {
        return os_proc_available_memory() / 1024.0 / 1024.0;
    }

    vm_statistics_data_t vmStats;
    mach_msg_type_number_t infoCount = HOST_VM_INFO_COUNT;
    kern_return_t kernReturn = host_statistics(mach_host_self(), HOST_VM_INFO, (host_info_t)&vmStats, &infoCount);
    if (kernReturn != KERN_SUCCESS) {
        return NSNotFound;
    }
    return (vm_page_size *vmStats.free_count) / 1024.0 / 1024.0;
}

当APP剩余可用内存被占用完,APP就会被系统杀死进程。

在 Cheat Engine 扫描内存过程中,会不断发送 CMD_READPROCESSMEMORY 指令,脚本收到指令后频繁调用 Memory.readByteArray 函数,最终通过 socket 把读取到的内存数据发送给 Cheat Engine。

我测试了不读取内存,只收发数据是不会出现内存波动,只有读取内存后才会持续占用APP内存。

我的测试过程

改回原代码后,在我开始测试前,usedMemory 获取的内存占用为600M左右,availableMemory 获取的可用内存为1000M左右。

Cheat Engine 开始扫描内存,指定搜索范围 0x105000000 - 0x110000000usedMemory 获取的内存占用持续增加到1300M,availableMemory 获取的可用内存慢慢减少到800M。

然后测试扫描指针,基址范围也设置 0x105000000 - 0x110000000usedMemory 获取的内存占用持续增加到1500M左右,又慢慢降低到700M左右。availableMemory 获取的可用内存慢慢减少到200M。

这个时候我要再扫描一次内存,或者扫描指针,APP必定闪退。

在这个过程中,Frida.heapSize 获取的内存占用一直都是在2M左右,我有主动调用gc,就算没有最多也就10多M。但APP的内存波动太大,并且 availableMemory 获取的可用内存不会恢复。

我按照你的方式,单独遍历APP内存,调用 Memory.readByteArray 函数并不会让APP内存增加。这就有点迷惑了。

既然读取这里应该是没问题的,
可以把js层的Memory.readByteArray调用去掉,换成一直返回同一个ByteArray,试一下是不是rpc的问题

在js层直接返回一个字节数组是不会有内存波动的,确实是因为执行了 Memory.readByteArray 才会这样。

这就更怪了,readByteArray里面也就两步 创建ByteArray然后memcpy
所以是qjs把js层对象释放了但是内存丢了?

      case GUM_MEMORY_VALUE_BYTE_ARRAY:
      {
        const guint8 * data = address;
        gpointer buffer_data;

        if (data == NULL)
        {
          result = JS_NULL;
          break;
        }

        buffer_data = g_malloc (length);
        result = JS_NewArrayBuffer (ctx, buffer_data, length,
            _gum_quick_array_buffer_free, buffer_data, FALSE);

        memcpy (buffer_data, data, length);

        break;
      }

我发现问题所在了,每次读取的内存太大。Cheat Engine每次读取524288,内存会暴涨。如果我每次读取8,APP内存看不出来有啥变化。

const test_base = Process.enumerateModules()[0].base;
var baseAddr = parseInt(test_base);
rpc.exports = {
  readprocessmemory: function (address, size) {
    baseAddr += 524288;
    try {
      if (ptr(address).isNull() == false) {
        //return Memory.readByteArray(ptr(address), size);
        return Memory.readByteArray(ptr(baseAddr), 524288);
      } else {
        return false;
      }
    } catch (e) {
      return false;
    }
  }
};

CE扫描缓冲区大小就是每次调用Memory.readByteArray读取内存的大小,最小能设置4096。就算4096内存也会暴涨,只是涨得慢一些,但最终扫描完后总共占用的内存也是一样的。

感觉,你需要弄下 frida 源码。

可以用仅用一块内存,dump memory 用,然后扫描

frida基本读内存: 就是 读一块,然后拷贝出来

从拷贝的动作来看,如果不固定某个 内存为dump的空间 ,那么就要申请内存…<内存释放不及时,就有内存泄露了…或者压根没释放内存,也会造成内存泄露>

c/c++ 直接遍历,优势: 先读内存,判断后,是否需要才copy 内存
而frida的 readbytearray ,是直接copy内存,然后,你在对其操作

如此说来,你需要的是 内存 搜索,不是frida 的 memory.readByteArray
即:(1/2/4/8…等字节,直接读内存,然后判断是否达到你需要的目的)

所以,
解决内存泄露: 修改frida源码,预置保留dump的内存,凡是读内存的,都保存到这里
gc回收机制嘛…按理可以,不过,我不习惯那种做法。

另外一种,就是自己直接实现 Memory.searchxxxxx() 接口,仅仅,去读内存,做判断用

我一直用lldb的,frida用的不太顺手…<主要原因,可能是他内存怎么操作的,我心里没底…> :rofl:

ce的原理,我没去深究。不过要我来写,肯定是远程注入一段代码进去,然后和那段代码通信。
对于,内存遍历,直接 读 app的内存。<也就值给他地址,总线直接访问…然后读固定字节(常规变量类型)>,然后把值 传递给 ce ui,让他展示…这样,目标进程,基本不存在和内存分配相关代码

对了,好像可以用 Memory.readlong 或者 Memory.readint等 代替…这样,就不涉及内存拷贝了,因为都是基础数据类型

Memory.readlong那些不太适用ceserver额,最小都是4096,默认都512kb了。实在搞不定准备根据这个脚本自己重写一个ceserver,不用Frida了

然而frida提供一个不需要大量传输数据的“本地”内存搜索 但ce的api不这么走的 :rofl:
可能直接扔了ce写个frida的专用内存追踪前端比较省事(

直接读呀,给一个地址,他返回一个 数据给你
和大小没关系,因为是基础数据类型,不需要分配内存

如果你读数组,读 字符串,就需要分配内存的

感谢各位大佬提供的宝贵意见,这个问题目前已经得到解决。和作者沟通后,作者已经单独实现了iOS的原生内存读取操作,现已开源。
ceserver-ios-mini

6啊哥牛哦

可以出一个链接教程吗?想利用调试器手动找指针,单独实现的内存读取操作不能注入调试器

Sorry for the delay in commenting.
https://github.com/DoranekoSystems/frida-ceserver/wiki/Debugger

You must be running debugserver and the following URL software.
https://github.com/DoranekoSystems/ceserver-ios-mini