使用操作挂钩动态备份macOS上的文件

哈Ha! 我叫Denis Kopyrin,今天我想谈一谈如何解决macOS上按需备份的问题。 实际上,我在研究所遇到的一项有趣的任务最终发展成为一个有关使用文件系统的大型研究项目。 所有细节都在剪裁中。

图片

我不会从头开始,只能说这一切都是从莫斯科物理技术学院的一个项目开始的,该项目是我与Acronis基础部门的主管共同开发的。 我们面临的任务是组织远程文件存储,或者维护其备份的当前状态。

为了确保数据安全,我们使用了macOS内核扩展,该扩展可收集有关系统中事件的信息。 针对开发人员的KPI具有KAUTH API,通过它,您可以接收有关打开和关闭文件的通知-仅此而已。 如果使用KAUTH,则在打开文件进行写入时必须完全保存该文件,因为开发人员无法使用写入文件的事件。 这些信息不足以完成我们的任务。 实际上,为了永久补充数据的备份副本,您需要确切了解用户(或恶意软件:)将新数据写入文件的位置。

图片

但是,哪些开发人员对操作系统的限制感到害怕呢? 如果内核API不允许您获取有关写操作的信息,那么您需要提出自己的通过其他内核工具进行拦截的方式。

起初,我们不想修补核心及其结构。 相反,他们尝试创建一个完整的虚拟卷,该虚拟卷将使我们能够拦截通过该卷的所有读取和写入请求。 但与此同时,事实证明,macOS的一个令人不快的功能是:操作系统认为它没有1个,而是2个USB闪存驱动器,两个磁盘等等。 由于第二卷在使用第一卷时会发生变化,因此macOS开始无法正确使用驱动器。 这种方法有很多问题,我不得不放弃了。

搜索其他解决方案


尽管有KAUTH的限制,但此KPI允许您在执行所有操作之前获得有关使用文件进行记录的通知。 开发人员可以访问内核-vnode中的BSD文件抽象。 奇怪的是,事实证明,修补vnode比使用卷过滤更容易。 vnode结构具有一个功能表,可用于处理实际文件。 因此,我们有替换该表的想法。

图片

这个想法立即被认为是一个好主意,但是对于实现它来说,必须在vnode结构中查找表本身,因为Apple不会在任何地方记录其位置。 为此,有必要研究内核的机器代码,并弄清楚是否有可能写入该地址,以免系统在此之后死亡。

如果找到该表,我们只需将其复制到内存中,替换指针,然后将指向新表的链接粘贴到现有vnode中。 因此,对文件的所有操作将通过我们的驱动程序,并且我们将能够注册所有用户请求,包括读取和写入。 因此,寻找珍贵的餐桌已成为我们的主要目标。

鉴于Apple确实不希望这样做,要解决该问题,您需要尝试使用启发式方法“猜测”字段的相对位置,或者采用一个已知的函数,对其进行反汇编并从该信息中寻找偏移量。

如何寻找偏移量:一种简单的方法

在vnode中查找表偏移量的最简单方法是根据结构中字段的位置( 链接到Github )进行启发式。

struct vnode { ... int (**v_op)(void *); /* vnode operations vector */ mount_t v_mount; /* ptr to vfs we are in */ ... } 

我们将使用以下假设:我们需要的v_op字段正好从v_mount中删除了8个字节。 后者的值可以使用公共KPI( 链接到Github )获得:

 mount_t vnode_mount(vnode_t vp); 

了解了v_mount的值后,我们将开始寻找“大海捞针”-我们将把指向vnode'vp'的指针的值视为uintptr_t *,将vnode_mount(vp)的值视为uintptr_t。 随后是i的“合理”值的迭代,直到满足条件“干草堆[i] ==针”。 如果关于字段位置的假设是正确的,则偏移量v_op为i-1。

 void* getVOPPtr(vnode_t vp) { auto haystack = (uintptr_t*) vp; auto needle = (uintptr_t) vnode_mount(vp); for (int i = 0; i < ATTEMPTCOUNT; i++) { if (haystack[i] == needle) { return haystack + (i - 1); } } return nullptr; } 

如何寻找偏移量:拆卸

尽管简单,但是第一种方法具有明显的缺点。 如果Apple更改了vnode结构中字段的顺序,则简单方法将中断。 一种更通用但更简单的方法是动态反汇编内核。

例如,请考虑在macOS 10.14.6上反汇编的内核函数VNOP_CREATE( 链接到Github )。 我们感兴趣的说明以箭头->标记。

_VNOP_CREATE:
1 push rbp
2 mov rbp, rsp
3 push r15
4 push r14
5 push r13
6 push r12
7 push rbx
8 sub rsp, 0x48
9 mov r15, r8
10 mov r12, rdx
11 mov r13, rsi
-> 12 mov rbx, rdi
13 lea rax, qword [___stack_chk_guard]
14 mov rax, qword [rax]
15 mov qword [rbp+-48], rax
-> 16 lea rax, qword [_vnop_create_desc] ; _vnop_create_desc
17 mov qword [rbp+-112], rax
18 mov qword [rbp+-104], rdi
19 mov qword [rbp+-96], rsi
20 mov qword [rbp+-88], rdx
21 mov qword [rbp+-80], rcx
22 mov qword [rbp+-72], r8
-> 23 mov rax, qword [rdi+0xd0]
-> 24 movsxd rcx, dword [_vnop_create_desc]
25 lea rdi, qword [rbp+-112]
-> 26 call qword [rax+rcx*8]
27 mov r14d, eax
28 test eax, eax
….

 errno_t VNOP_CREATE(vnode_t dvp, vnode_t * vpp, struct componentname * cnp, struct vnode_attr * vap, vfs_context_t ctx) { int _err; struct vnop_create_args a; a.a_desc = &vnop;_create_desc; a.a_dvp = dvp; a.a_vpp = vpp; a.a_cnp = cnp; a.a_vap = vap; a.a_context = ctx; _err = (*dvp->v_op[vnop_create_desc.vdesc_offset])(&a;); … 

我们将扫描汇编程序指令,以查找vnode dvp中的移位。 汇编代码的“目的”是从v_op表中调用一个函数。 为此,处理器必须遵循以下步骤:

  1. 上传dvp进行注册
  2. 解引用以获得v_op(第23行)
  3. 获取vnop_create_desc.vdesc_offset(第24行)
  4. 调用函数(第26行)

如果步骤2-4一切都清楚了,那么第一步就会遇到困难。 如何了解dvp加载到哪个寄存器? 为此,我们使用了一种模拟函数的方法,该函数监视所需指针的移动。 根据System V x86_64调用约定,第一个参数在rdi寄存器中传递。 因此,我们决定跟踪包含rdi的所有寄存器。 在我的示例中,这些是rbx和rdi寄存器。 同样,寄存器的副本可以保存在堆栈中,该堆栈可以在内核的调试版本中找到。

知道rbx和rdi寄存器存储了dvp,我们发现第23行解引用了vnode以获取v_op。 因此,我们假设结构中的位移为0xd0。 为了确认正确的决定,我们继续扫描并确保正确调用了该函数(第24和26行)。

该方法更安全,但是不幸的是,它也有缺点。 我们必须依靠这样一个事实,即函数的模式(即上面讨论的4个步骤)将相同。 但是,改变函数的模式的概率比改变场的顺序的概率小一个数量级。 因此,我们决定停止第二种方法。

替换表中的指针


找到v_op之后,出现了一个问题,如何使用该指针? 有两种不同的方法-覆盖表中的函数(图片中的第三个箭头)或覆盖vnode中的表(图片中的第二个箭头)。

最初,第一种选择似乎更有利可图,因为我们只需要替换一个指针即可。 但是,这种方法有两个明显的缺点。 首先,给定文件系统的所有vnode的v_op表都是相同的(对于HFS +为v_op,对于APFS为v_op,...),因此需要按vnode进行过滤,这可能非常昂贵-您将不得不在每次写入操作中过滤掉多余的vnode。 其次,将该表写在“只读”页面上。 如果通过IOMappedWrite64使用记录而绕过系统检查,则可以避免此限制。 另外,如果附带了带有文件系统驱动程序的kext,将很难弄清楚如何删除该修补程序。

第二个选项更具针对性和安全性-拦截器将仅针对必要的vnode进行调用,而vnode内存最初允许进行读写操作。 由于要替换整个表,因此有必要分配更多的内存(80个函数代替一个)。 而且由于表的数量通常等于文件系统的数量,因此内存限制是完全可以忽略的。

这就是为什么kext使用第二种方法的原因,尽管我再次重申,乍看之下,这种选择似乎更糟。

图片

结果,我们的驱动程序工作如下:

  1. KAUTH API提供了vnode
  2. 我们正在替换vnode表。 如果需要,我们仅拦截“有趣” vnode的操作,例如用户文档
  3. 截获时,我们检查正在记录的进程,我们过滤掉“我们的”
  4. 我们将同步的UserSpace请求发送给客户端,客户端决定究竟需要保存什么。

发生什么事了


今天,我们有一个实验性模块,它是macOS内核的扩展,并考虑了对文件系统的任何细粒度更改。 值得注意的是,在macOS 10.15中,Apple引入了一个新框架( 链接到EndpointSecurity )来接收文件系统更改的通知,该框架计划在Active Protection中使用,因此宣布本文中所描述的解决方案已弃用。

Source: https://habr.com/ru/post/zh-CN484816/


All Articles