解剖苹果的稀疏映像格式(ASIF)

Hacker News Top 新闻

摘要

一篇技术博文解剖了苹果在macOS Tahoe中用于虚拟磁盘的新稀疏映像格式(ASIF),涵盖了从十六进制转储中逆向工程该文件格式的过程。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/29 08:02

# 剖析苹果稀疏镜像格式(ASIF)| schamper.dev 来源:https://schamper.dev/dissecting-apples-sparse-image-format-asif/ 2026-06-18·18 分钟阅读· 在 WWDC 2025 上,苹果发布了 macOS 26 Tahoe。macOS Tahoe 的一项新特性是一种新的磁盘镜像格式:ASIF。该格式专为虚拟机设计(其文档位于 Virtualization 框架下),ASIF 从现有的虚拟磁盘格式中汲取了大量灵感。实际上,这意味着它是另一种稀疏虚拟磁盘格式,其功能与稀疏的 VMDK、VHDX 或 QCOW2 文件非常相似(对于不熟悉的人来说,它允许你以更小、更“稀疏”的方式存储大型磁盘或文件)。在 macOS Tahoe 发布前不久(2025 年末),我想尝试为 ASIF 文件编写一个解析器,这会是个有趣的练习。自那以后已经过了一段时间,但我还是想回过头来展示一下我处理这类问题的过程。或许某个对逆向工程文件格式不熟悉的人能从中有所收获。因此,你会在本文中看到偶尔穿插的“研究笔记”,其中包含一些额外的见解。让我们用苹果文档中列出的命令创建一个测试文件,向其中写入一个测试模式,然后开始吧: 研究笔记 > 出于测试目的,我通常喜欢编写一个测试模式,以便验证内容是否与“偏移量”匹配。在这个例子中,基本上就是编号为 1 MiB 的字节块。肯定有更好的测试模式,但在初步窥探文件格式时,用任何内容填满文件也很重要。拥有一个可预测且可验证的模式会使后续步骤更容易。 ``` ❯ diskutil image create blank --fs none --format ASIF --size 1GiB file file.asif created ❯ diskutil image attach -nomount file.asif /dev/disk4 ❯ python3 Python 3.14.0 (main, Oct 7 2025, 09:34:52) [Clang 17.0.0 (clang-1700.3.19.1)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> fh = open("/dev/disk4", "wb") >>> for i in range(255): ... fh.write(bytes([i] * 1024 * 1024)) >>> fh.close() ❯ hdiutil detach disk4 "disk4" ejected. ``` ## 用肉眼查看十六进制转储 像往常一样,我们先凭借肉眼查看一些十六进制转储,看能否发现一些细节。 ``` ❯ xxd file.asif | head -5 ``` ``` 00000000 73686477000000010000020000000000 shdw············ 00000010 00000000000002000000000000041400 ················ 00000020 8af9ead2cf3849c08eec0095cf5c7899 ·····8I······\x· 00000030 00000000001dcd650000080000000000 ·······e········ 00000040 001000000200000000000000ffffffff ················ ``` 我们立即发现某种文件魔数,后面跟着一些看起来像是大端序的整数。 研究笔记 > 每当你逆向工程一个文件格式并看到一些魔数字节时,最好在网上搜索任何可用的信息。我通常会在各种搜索引擎(Google、GitHub、VirusTotal Retrohunt)中结合魔数字符串表示、大端十六进制和小端十六进制进行搜索。在这个例子中,我没有找到太多有用的信息。 > 至于“发现字节序”或整数字段,久而久之这几乎就像骑自行车一样自然。我的建议是,从左到右以 4 字节(`uint32`)为单位扫描,然后是 8 字节(`uint64`),再可能分成更小的块(`uint16`甚至`uint8`),直到你能解析出看起来合理的整数(基于 16 取整,或者与文件中的偏移量进行交叉引用,并可选地乘以你发现的其他值)。如果你看到“自然顺序”的整数,那就是大端序。如果看起来是反的,那就是小端序。 让我们快速整理一个粗略的结构,对整数宽度做最佳猜测,并用 `dissect.cstruct` 进一步检查: ```python # /// script # requires-python = ">=3.10" # dependencies = ["dissect.cstruct"] # /// import sys from dissect.cstruct import cstruct, dumpstruct asif_def = """ struct header { char magic[4]; uint32 field4; uint32 field8; uint32 fieldC; uint64 field10; uint64 field18; char field20[16]; uint64 field30; uint64 field38; uint32 field40; uint32 field44; uint32 field48; uint32 field4C; }; """ c_asif = cstruct(asif_def, endian=">") with open(sys.argv[1], "rb") as fh: header = c_asif.header(fh) dumpstruct(header) ``` ``` 00000000 73686477000000010000020000000000 shdw············ 00000010 00000000000002000000000000041400 ················ 00000020 8af9ead2cf3849c08eec0095cf5c7899 ·····8I······\x· 00000030 00000000001dcd650000080000000000 ·······e········ 00000040 001000000200000000000000ffffffff ················ header magic[4] 0x0000 4 bytes b'shdw' field4 0x0004 4 bytes 0x1 field8 0x0008 4 bytes 0x200 fieldC 0x000c 4 bytes 0x0 field10 0x0010 8 bytes 0x200 field18 0x0018 8 bytes 0x41400 field20[16] 0x0020 16 bytes b'\x8a\xf9\xea\xd2\xcf8I\xc0\x8e\xec\x00\x95\xcf\\x\x99' field30 0x0030 8 bytes 0x1dcd65 field38 0x0038 8 bytes 0x80000000000 field40 0x0040 4 bytes 0x100000 field44 0x0044 4 bytes 0x2000000 field48 0x0048 4 bytes 0x0 field4C 0x004c 4 bytes 0xffffffff ``` 一些有趣的数字,我们立即发现了两个值为 `0x200` 的实例,这是常见的扇区大小(512 字节)。通过尝试乘除不同的值,我们还可以推算出 `field30` 可能是虚拟磁盘的扇区数(`1GiB // 512 = 0x200000`)。如果我们暂时假设 `field8` 是扇区大小,那么 `field10` 和 `field18` 可能是文件中的偏移量。让我们看看更大的十六进制转储: ``` ❯ xxd -a file.asif | head -32 ``` ``` 00000000 73686477000000010000020000000000 shdw············ 00000010 00000000000002000000000000041400 ················ 00000020 8af9ead2cf3849c08eec0095cf5c7899 ·····8I······\x· 00000030 00000000001dcd650000080000000000 ·······e········ 00000040 001000000200000000000000ffffffff ················ 00000050 00000000000000000000000000000000 ················ * 00000200 00000000000000020000000000000004 ················ 00000210 00000000000000000000000000000000 ················ * 00041240 00000000000000000000000000000001 ················ 00041250 00000000000000000000000000000000 ················ * 00041400 00000000000000010000000000000000 ················ 00041410 00000000000000000000000000000000 ················ * 00082440 00000000000000000000000000000001 ················ 00082450 00000000000000000000000000000000 ················ * 00120030 c0000000000000020000000000000003 ················ 00120040 00000000000000000000000000000000 ················ * 00200000 6d657461000000010000020000000000 meta············ 00200010 00000200000000000000000000000000 ················ 00200020 00000000000000000000000000000000 ················ * 00200200 3c3f786d6c2076657273696f6e3d2231 <!DOCTYPE 00200230 20706c697374205055424c494320222d plistPUBLIC"- 00200240 2f2f4170706c652f2f44544420504c49 //Apple//DTDPLI 00200250 535420312e302f2f454e222022687474 ST1.0//EN""htt ``` 信息量更大了。我们在 `0x00000200` 和 `0x00041400` 处发现了一些数据,但目前还无法完全理解。在 `0x00200000` 处我们还能看到 `meta`,随后在 `0x00200200` 处是一个 XML plist。也许我们对 `field30` 是虚拟磁盘大小的判断错了?变量有点多,又没有足够清晰的结构让继续仅凭肉眼查看十六进制转储变得可行,所以我们来寻找二进制文件进行逆向。 ## 寻找我们的二进制文件 有几种方法可以查找我们的二进制文件,但既然我不赶时间,就采用了一种相当懒散的方式: ``` ❯ grep -r "ASIF" /System/Library/Frameworks /System/Library/PrivateFrameworks [...] Binary file /System/Library/PrivateFrameworks/DiskImages2.framework/Versions/A/XPCServices/diskimagescontroller.xpc/Contents/MacOS/diskimagescontroller matches [...] ``` 研究笔记 > 我经常使用 YARA 来代替 grep 进行此类操作,通常速度更快,但眼下这样做也足够了。我有时采用的另一种方法是,在原始磁盘(通常是虚拟机的虚拟磁盘)上执行针尖搜索,然后反向查找,看看哪些文件对应于我在磁盘上匹配到的偏移量。虽然这种方法对于当前任务来说有些过度,但在其他情况下很实用。 在匹配结果中,`diskimagescontroller` 最为突出,在其上运行 `strings` 命令发现 *大量* 与 ASIF 相关的内容,包括非常有价值的日志消息、修饰后的函数签名和类型名称! ``` ❯ strings diskimagescontroller | grep -i "asif" [...] N7di_asif7details3dirE N7di_asif7details8dir_baseE [...] Invalid value for asif header field: %s Size cannot exceed max ASIF size Unexpected ASIF header length ( [...] asif_header Couldn't read asif's header [...] ``` 让我们把这个小家伙丢进 IDA 里!逆向工程的第一步通常是查找对有趣字符串的引用,这次也不例外。对于一种文件格式,首先应该专注于如何解释文件头,然后再进一步。我们已经在 `strings` 命令输出中看到了一些与“asif's header”相关的有趣字符串,所以让我们看看那些引用这些字符串的函数。 研究笔记 > 在我做这项研究的时候,我曾在网上搜索遇到的某些符号名称。我发现 GitHub 上有一些项目,人们会对 iOS 固件更新进行差异分析,而且同一二进制文件的 iOS 版本 *可能* 包含更多的符号/字符串。我最终对这款二进制文件的 iOS 版本进行了逆向工程,但也不完全是出于这个原因。如果同一个二进制文件既有 x86 版本也有 ARM 版本,我喜欢同时打开它们。其中一个版本的反编译器输出有时会比另一个版本清晰得多。我没有什么实际数据来支持这一点,但我的直觉是,这是由于两个架构的编译器以及反编译器优化水平不同所致。不管怎样,我记得当时使用的 IDA 版本在 x86 版本的二进制文件上会随机崩溃,所以我大部分时间都在注释 iOS 版本,尽管我不认为两者之间实际上有什么有意义的差异。好了,我跑题了,让我们继续吧! 图 1:这看起来像是解析了一个 ASIF 文件头 图 1:这看起来像是解析了一个 ASIF 文件头 我们很快就找到了一个看起来是从原始 ASIF 头(`a2`)中解析出值并存储到内存结构(`a1`)中的函数,见图 1。在这个函数稍靠下的位置(未在图中显示),有一些针对这些字段的简单检查,并附有有用的错误/日志消息(例如“ASIF max_write size in header exceed the limit”、“Sector count is too large”等)。我们立即学到了很多: - 头中每个字段的偏移量和字节长度; - 其中一些字段的名称或用途。 如果我们更新之前的头定义,再添加一点点“画猫点睛”的功夫,最终会得到类似这样的结果: ```c struct asif_header { uint32 header_signature; uint32 header_version; uint32 header_size; uint32 header_flags; uint64 directory_offsets[2]; char guid[16]; uint64 sector_count; uint64 max_sector_count; uint32 chunk_size; uint16 block_size; uint16 total_segments; uint64 metadata_chunk; char unk_50[16]; uint32 read_only_flags; uint32 metadata_flags; uint32 metadata_read_only_flags; }; ``` ``` 00000000 73686477000000010000020000000000 shdw············ 00000010 00000000000002000000000000041400 ················ 00000020 8af9ead2cf3849c08eec0095cf5c7899 ·····8I······\x· 00000030 00000000001dcd650000080000000000 ·······e········ 00000040 001000000200000000000000ffffffff ················ 00000050 00000000000000000000000000000000 ················ 00000060 00000000000000000000000000000000 ················ asif_header header_signature 0x0000 4 bytes 0x73686477 header_version 0x0004 4 bytes 0x1 header_size 0x0008 4 bytes 0x200 header_flags 0x000c 4 bytes 0x0 directory_offsets[2] 0x0010 16 bytes [512, 267264] guid[16] 0x0020 16 bytes b'\x8a\xf9\xea\xd2\xcf8I\xc0\x8e\xec\x00\x95\xcf\\x\x99' sector_count 0x0030 8 bytes 0x1dcd65 max_sector_count 0x0038 8 bytes 0x80000000000 chunk_size 0x0040 4 bytes 0x100000 block_size 0x0044 2 bytes 0x200 total_segments 0x0046 2 bytes 0x0 metadata_chunk 0x0048 8 bytes 0xffffffff unk_50[16] 0x0050 16 bytes b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' read_only_flags 0x0060 4 bytes 0x0 metadata_flags 0x0064 4 bytes 0x0 metadata_read_only_flags 0x0068 4 bytes 0x0 ``` 看来我们之前猜测的几个字段是正确的! ## 让它变得合理 如果你在想 *“这个‘画猫点睛’的功夫也太大方了吧”*,那你的想法没错。我略过了一些细节,但我是怎么得出这些结论的其实并不那么有趣。在这个阶段,对 `diskimagescontroller` 中各种函数进行逆向工程的过程本身就有很高的“点睛”成分。这是一个相对容易逆向的二进制文件,因此逆向 ASIF 也相对简单。唯一“烦人”的部分是大量的动态分发,所以需要处理一些继承和虚函数表,但大部分情况下它们并不复杂。这些内容不太适合作为博客素材(而且我写这篇文章的时候距离最初逆向工程已经过了 *很久*,所以大部分细节我都记不清了,哎呀)。 好——吧,以下是一些重要的点: - 头中有一个字段 `directory_offsets`,它是一个字节偏移量,指向一种分配目录; - 每个目录以一个 `uint64` 版本号开头,版本最高的目录是当前活动目录。这允许原子更新。 - 每个目录有一个表列表,每个表有一个 `uint64`“数据条目”列表。 - 数据条目按一定比率分组,每组后面跟着一个“映射条目”,该条目是前面各组的位图。 - 每个数据条目指向 ASIF 文件中的一个数据块。 - 块大小在头中定义,通常为 1 MiB。 - 数据条目有 55 位用于块编号,9 位保留给标志位。 - 使用默认块大小(我认为目前尚不可配置)时,最大虚拟磁盘大小似乎略低于 4 PiB,末尾有一小部分保留给元数据。 - 虚拟磁盘的实际大小在头中定义,同时还有磁盘可以增长到的最大大小。 - 头中包含一个元数据块的偏移量,该偏移量通常是 `(4 PiB - 1 个块)`,意味着它位于保留区域内。 - 元数据块包含一个小型头部和一个 plist,该 plist 应包含一个 `internal metadata` 和 `user metadata` 字典。 数据条目的位格式如下: ``` 0b00000000 01111111 [...] 11111111 11111111 (块编号) 0b00111111 10000000 [...] 00000000 00000000 (保留) 0b11000000 00000000 [...] 00000000 00000000 (标志) ``` 当前已知的标志位(根据二进制文件中的字符串推导得出): ``` 0b00 (未初始化) 0b01 (完全初始化) 0b10 (未映射) 0b11 (包含位图) ``` 如果你曾经研究过其他稀疏磁盘格式(VMDK、VHDX、QCOW2)的工作原理,你会发现 ASIF 是一种相当优雅且简单的格式。诚然,它没有所有相同的功能,但至少基本的东西很容易理解。 将所有信息添加到我们的实现中是一件相当平淡无奇的事情。从头中读取一些字段,验证它们,跳转到某些偏移量,一切就绪了。不过,我们忽略了一个重要细节。我们还没有涵盖如何从虚拟磁盘内的任意偏移量映射到 ASIF 文件中的块。 研究笔记 > 快速回顾一下虚拟磁盘格式,如 ASIF 以及我目前提到的所有其他四个字母的缩写。它们都基于一个简单的原理:

相似文章

我在 Apple 的 fsck_hfs 中发现了一个 Bug

Hacker News Top

macOS Sequoia 上 Apple 的 fsck_hfs 工具存在一个 Bug,在配备 8GB RAM 的机器上,对大型 HFS+ 卷(24TB 以上)进行检测时会误报损坏错误,而文件系统本身并无问题。

ARM64 指令的真正编码方式

Lobsters Hottest

一篇科普文章,解释 ARM64 (AArch64) 指令如何以 32 位固定长度字编码,破除常见误解,并通过 Apple Silicon 上的 ADD immediate 指令提供动手解码示例。