☰
安卓虚拟大师系统镜像提取、解析与重建实践
2026/11/3 11:39:47 网站建设 项目流程

前阵子一个做安卓逆向的朋友丢给我一个问题:某款安卓虚拟大师类应用,装完之后里面那套系统跑得挺顺,能不能把那套“OS”从包里抠出来,单独拿去做研究、做轻量定制,或者换到另一台机器上跑。听着像拆个压缩包的事,真动手才发现坑是一层叠一层:资源是拆卷的、镜像是稀疏的、挂载要算偏移、改完还要面对校验和权限。我把整个过程从头重走了一遍,把每一步的判断依据、命令和踩过的坑都记了下来,下面这份就是完整记录。

先说清楚这篇东西的定位:它不是刷机教程,也不是让你去改别人的包分发出去。这里讨论的是安卓虚拟大师类应用内部系统镜像的提取、解析与重建,属于应用解包、文件系统分析这一类常规技术活。适合三类人看:想搞明白这类 App 内部结构的人、需要把镜像抠出来做个人研究备份的人、以及在做定制 ROM 或者轻量系统裁剪、想借鉴这套封装思路的人。基础要求不高,会用命令行、知道 ext4 是什么就能跟下来;如果你连 loop 挂载都没试过,建议先找台 Linux 机器练手,别直接上生产环境。


1. 先把概念掰开:虚拟大师里的“OS”到底是什么

1.1 应用级虚拟化和真机双系统的本质差别

很多人一想到“在手机里再装一个安卓”,脑子里浮现的是双系统、是分区、是 bootloader。这两种东西差得远。真机双系统要动分区表、要刷 boot、要处理 AVB 校验链,一步错就得进 fastboot 捞机器。而虚拟大师这类应用走的是完全不同的路:它就是一个普通 APK,装上之后在宿主安卓的用户态跑起一套虚拟化环境,常见的实现是裁过的 QEMU/KVM 方案、容器加系统调用转译的混合方案,或者干脆是“宿主内核 + 私有根文件系统 + proot 式路径映射”。

这个差别直接决定了你从哪下手。因为它不碰物理分区,那它的“硬盘”必然是以文件形式存在的;因为它是一个普通应用,那这些文件必然归它自己管,落在应用私有目录或者外部存储的某个子目录里。换句话说,你要找的“OS”,本质上是几个放在文件系统里的镜像文件:一个内核或者内核加 initrd、一个根文件系统镜像、一个数据分区镜像,可能还有额外的 vendor 或者 SD 卡镜像。它们不是玄学,就是文件。

理解这一层之后,思路就顺了:提取的工作量不在“能不能拿到”,而在“拿到之后认不认得、拆不拆得开、装不装得回去”。这三步里,认不认得是分水岭,很多人卡在第一步——明明文件就在眼前,file命令一跑出来是data,然后就没然后了。

1.2 一个安装包拆开之后能看到什么

APK 就是个 zip,这点没什么好神秘的,unzip -l直接列。但列出来的东西要看懂。跟系统镜像相关的位置通常集中在三处:assets/下、lib/<abi>/下、res/raw/下。assets里最常见的是system.img、data.img、boot.img、kernel、initrd.img、super.img这类命名,也可能被改成无后缀的名字躲扫描;lib里放的是虚拟化引擎的 so,从 so 的文件名和里面的字符串能反推它用的是什么方案,比如strings libxxx.so | grep -i virtio之类的粗筛,命中率高得离谱。

还有个现实问题:APK 对单文件大小有实际限制,而且应用商店上传也有体积门槛,所以大镜像几乎不会以原始形式躺在包里。你会看到三种典型形态。第一种是压缩打包,xxx.img.7z、xxx.img.zip、xxx.img.xz、xxx.img.zst,首次启动时解压到数据目录。第二种是拆卷,system.img.001、system.img.002这种,需要按序拼回去。第三种是运行时生成,包里只带一个基础骨架和一堆差量文件,真正的完整镜像是在首次启动或者某次更新时落盘的。

这就解释了一个很常见的困惑:有人解包之后发现 assets 里只有几十兆,觉得被耍了。不是被耍,是镜像还没生成。判断方法很简单,装完之后去应用私有目录里翻,看看哪个文件几百兆甚至上 G,那才是你要的东西。

1.3 镜像格式决定了你后面所有的操作

这一步是整条链路里最需要经验的地方。镜像的封装格式决定了你用什么工具、走什么流程,认错格式,后面全白干。我整理了一张识别表,基本覆盖了能遇到的绝大多数情况,判断依据是文件头 magic,用xxd -l 64 文件名或者binwalk 文件名就能看到。

文件头特征实际格式典型处理方式
偏移0x438处为53 EFext4 文件系统镜像mount -o loop、debugfs、7-Zip 直接浏览
起始3A FF 26 EDAndroid 稀疏镜像(sparse)simg2img还原成 raw
起始QFI FBqcow2 虚拟磁盘qemu-img convert -O raw
起始1F 8Bgzip 压缩gunzip或zcat
起始FD 37 7A 58 5A 00xz 压缩xz -d
起始28 B5 2F FDzstd 压缩zstd -d
起始4C 50且带分区元数据Android super 动态分区lpunpack拆出子镜像
起始ANDROID!Android boot 镜像unpack_bootimg拆内核与 ramdisk
起始位置像分区表MBR/GPT 整盘镜像fdisk -l看分区表再算偏移
前若干字节全零大概率是带偏移的整盘镜像用binwalk扫特征定位真实起点

这张表里最容易翻车的是最后两行。很多人拿到一个 2GB 的文件,file说是DOS/MBR boot sector,就以为坏了。没坏,那是一整块磁盘镜像,文件系统在某个偏移之后。你不算偏移直接挂,内核只会回你一句wrong fs type,然后你就开始怀疑人生。

还有一个隐蔽的坑:稀疏镜像和压缩镜像长得完全不一样,但都表现为“解开之后变大”。稀疏镜像里有一类 chunk 叫“不关心”(don't care),只记录块数和块大小,实际内容是零,所以一个 500MB 的稀疏镜像展开成 raw 可能是 2GB 甚至 4GB。动手之前先看一眼du -h和头部里的total_blks,把磁盘空间留够,别转到一半把盘写满。


2. 三条提取路线的选型与准备

2.1 三条路线的优劣对比

提取这件事不止一条路,选错路线会让你在错误的坑里耗上一整天。我实际走过三条,各有适用场景。

静态解包是最直接的一条:从 APK 里把资源掏出来,该解压解压、该拼卷拼卷。优点是干净,不依赖设备,不依赖 root,在一台 Linux 上就能全程搞定;缺点是只能拿到出厂状态的那份镜像,如果应用在首次启动时做了差分合成或者在线下载,你拿到的就是个半成品。

运行时导出是从跑起来的设备上抓:镜像已经落到磁盘上了,你直接从对应目录cp或者dd出来。优点是拿到的一定是完整可用的那份;缺点是需要设备有相应权限,而且要把文件从设备里弄出来,几个 GB 的数据走 adb 也够呛。

备份还原这条路是拿应用自带的备份或者导出功能,有些版本会提供“导出环境”“备份数据”之类的入口,导出来的包本身就是可解析的。优点是省事、风险低;缺点是入口不一定有,格式也不一定公开。

我的建议是:先静态,静态拿不到完整的再上运行时。静态那条路走通了,你对整个包的结构会有完整认知,后面运行时导出的时候你就知道该找哪个文件、该期望多大的体积。

2.2 工具清单与环境搭建

环境上我强烈建议用 Linux,实机或者虚拟机都行,WSL2 也能凑合,但 loop 挂载在某些配置下会有点别扭。Windows 上虽然有 7-Zip、DiskGenius 这类工具能直接浏览 ext4 镜像,但一到稀疏镜像转换、偏移计算、权限修复这些环节就捉襟见肘,最后还是得回到 Linux。

必装的几样:

# Debian/Ubuntu 系 sudo apt install -y e2fsprogs android-sdk-libsparse-utils qemu-utils \ p7zip-full binwalk file xxd fdisk gdisk

android-sdk-libsparse-utils这个包提供了simg2img和img2simg,是稀疏镜像处理的核心;e2fsprogs提供debugfs、e2fsck、resize2fs,负责文件系统层面的读写和修复;qemu-utils里有qemu-img,对付 qcow2 格式;lpunpack一般不在标准源里,需要自己编译或者找现成二进制,碰到 super 分区的时候才用得上。

手工活之外还要准备两样东西:一块至少 100GB 的空闲磁盘(镜像转换会膨胀得很厉害),和一个干净的工作目录。别在根目录下直接操作,镜像文件名普遍很朴素,system.img这种东西在不同项目里重名率极高,一不小心就覆盖了。

提示:所有提取出来的原始文件,在动手之前先跑一遍sha256sum存个基线。后面每做一次格式转换或者修改,都重新算一次对比。这一步看着多余,等你改坏了想回退又不知道从哪一步开始回退的时候,就知道它值钱了。

2.3 动手前必须先想清楚的边界问题

技术上能做到,不代表可以随便做。这类应用里的系统镜像,其内容构成涉及大量第三方组件和应用的封装工作,版权归属是明确的。你能做的和不该做的,界线其实很清楚。

个人学习研究、在本机做结构分析、研究它的封装思路、给自己做一份备份,这些属于正常的技术活动,我一直是这么用的,也确实从里面学到了不少关于镜像封装和裁剪的东西。把提取出来的镜像打包上传分享、拿去二次分发、改完之后当作自己的产品发布、绕过应用本身的授权机制去使用,这些踩的是另一条线,我不建议任何人往那边走。

还有个实际问题容易被忽略:修改镜像之后,应用本身的用户协议可能就不覆盖你的使用行为了,出问题没人兜底。所以我的习惯是,所有这类操作都在隔离环境里做,不上生产设备,不碰真实账号数据。听起来谨慎过头,但省下来的麻烦是真的。


3. 从安装包到可挂载镜像的完整实操

3.1 第一步:解包并锁定大体积资源

拿到 APK 之后别急着全解,先列一下,按体积倒序排,大头一眼就能看出来:

unzip -l app-release.apk | sort -k1 -nr | head -30

这一步会暴露很多东西。你会看到assets/下几个几百兆的条目,也会看到lib/arm64-v8a/下几十兆的 so。so 一般不用动,那是虚拟化引擎本体,除非你想研究它的实现;真正要找的是镜像。

确认目标之后全量解出来:

mkdir -p work/apk && unzip -o app-release.apk -d work/apk cd work/apk && find . -type f -size +50M -exec ls -lh {} \;

大于 50M 的文件全列出来,逐个file一遍。这时候会出现几种情况:直接就是 ext4 镜像的,说明没压缩,运气好;是 7z/zip/xz 的,先解一层再看;是无后缀的二进制,需要靠文件头判断。

拆卷的情况单独说一下。如果看到system.img.001、system.img.002这种命名,按数字顺序做二进制拼接,别用文本拼接:

cat system.img.00* > system.img

顺序一定要对,ls默认的字典序在小序号时是对的(001、002……010),但如果编号位数不齐,比如1、2、10,字典序会把10排在2前面,拼出来的东西就是废的。稳妥做法是用printf补齐序号再排,或者干脆ls -v。

3.2 第二步:识别文件真实类型

这一步是分水岭。file命令先跑一遍,但它对 Android 特有格式的识别能力有限,稀疏镜像它经常只报个data。所以我一般叠三层判断:file粗判、xxd看头、binwalk扫特征。

file system.img xxd -l 64 system.img binwalk system.img | head -20

binwalk在整盘镜像上特别好用,它会把里面所有的文件系统签名都扫出来,连带偏移一起给你。比如你看到0x100000处有一个 ext4 签名,那就意味着这个镜像的分区起点在 1MB 的位置,挂载的时候把偏移带上就行。

对于 super 动态分区,file一般会给出Linux rev 1.0 ext4 filesystem data之类的误判,实际要看头部是不是LP开头、后面跟着 metadata 结构。这种情况直接上lpunpack:

lpunpack super.img out_dir/ ls -lh out_dir/ # 通常会拆出 system_a.img、vendor_a.img、product_a.img 等

拆出来的子镜像多数还是稀疏格式,需要再走一次转换,这个顺序别搞反。

3.3 第三步:稀疏镜像还原与分卷合并

Android 从某个版本开始,系统镜像默认以稀疏格式打包,目的是减小刷机包体积、加快写入速度。它的结构是固定头加上一串 chunk,chunk 分三种:原始数据块、填充块(用一个 4 字节模式重复填满)、不关心块(全零)。还原的过程就是把 chunk 逐个展开成连续的 raw 数据。

有simg2img就一行:

simg2img system.img system_raw.img

但我在不止一台机器上遇到过工具版本不匹配、报Invalid sparse file format的情况,尤其是一些老版本源里带的二进制。这种时候自己写一段解析其实更快,逻辑不复杂:

import struct, sys def unsparse(src, dst): with open(src, 'rb') as f: data = f.read() magic, major, minor, fh_sz, ch_sz, blk_sz, total_blks, total_chunks, csum = \ struct.unpack('<IHHHHIIII', data[:28]) if magic != 0xED26FF3A: raise ValueError('not a sparse image') off = fh_sz out = bytearray() for _ in range(total_chunks): ctype, _r, csz, tsz = struct.unpack('<HHII', data[off:off + 12]) body = data[off + ch_sz: off + tsz] if ctype == 0xCAC1: # raw out += body elif ctype == 0xCAC2: # fill out += body * (csz * blk_sz // len(body)) elif ctype == 0xCAC3: # don't care out += b'\x00' * (csz * blk_sz) # 0xCAC4 是 crc32 校验块,跳过 off += tsz with open(dst, 'wb') as f: f.write(out) unsparse(sys.argv[1], sys.argv[2])

跑完对比一下体积:ls -lh system.img system_raw.img。raw 文件明显更大是正常的,如果只大了一点点,说明原文件本来就不是稀疏的,白忙一场,回头重新判断格式。

反向操作就是img2simg,做回填的时候用。这一步的坑在于:转换不可逆地会改变文件布局,稀疏镜像里的“不关心”块在还原时被写成零,再转回去的时候会被重新识别为零块压缩掉,这本身没问题,但如果你中间改过内容,某些原本是“不关心”的区域现在有数据了,转回去体积会变大,得留意被修改区域的边界。

3.4 第四步:定位分区偏移并挂载

到这里你手上有的是一个 raw 镜像。如果它是单分区文件系统,直接挂;如果它是整盘镜像,得先算偏移。

先看分区表:

fdisk -l system_raw.img

输出里Start那一列是起始扇区号,扇区按 512 字节算,偏移就是Start * 512。挂载的时候带上:

sudo mkdir -p /mnt/sysimg sudo mount -o loop,ro,offset=$((2048 * 512)) system_raw.img /mnt/sysimg

ro这个参数很重要,只读挂载能救你无数次。改东西的时候另开一个可写挂载点,或者干脆用debugfs操作,别图省事直接可写挂上去乱翻。

如果是不带分区表的单分区镜像,省掉 offset:

sudo mount -o loop,ro system_raw.img /mnt/sysimg

挂上去之后第一件事不是翻目录,是先确认挂对了:

ls /mnt/sysimg df -h /mnt/sysimg

正常应该能看到system/、bin/、etc/、app/这类目录结构。如果看到的是空目录,或者lost+found孤零零一个,八成是偏移算错了,或者这个镜像本身就不是 ext4。

没有 root 权限的话,debugfs是很好的替代品,它是只读的、不需要挂载:

debugfs -R "ls -l /" system_raw.img debugfs -R "dump /system/build.prop ./build.prop" system_raw.img

7-Zip 也能直接打开 ext4 镜像浏览,胜在图形化,适合快速定位某个文件在不在。

3.5 第五步:定制修改与回填打包

改镜像这事,能改的和不能改的差别巨大。安全区里改这些:删预装应用、改build.prop里的属性、替换启动脚本、调整默认配置。危险区里改这些:动init相关的二进制、动sepolicy、动系统签名相关的文件。前者改完大概率能跑,后者改完大概率开不了机,而且你很难从日志里看出是哪一步坏的。

修改流程我一般这么走:

# 1. 复制一份可写副本,原文件保持不动 cp system_raw.img system_edit.img # 2. 检查并修复文件系统(改之前先确认它是干净的) sudo e2fsck -f system_edit.img # 3. 可写挂载 sudo mount -o loop,rw system_edit.img /mnt/edit # 4. 改内容 sudo rm -rf /mnt/edit/app/某个预装目录 sudo sed -i 's/^ro.build.type=.*/ro.build.type=user/' /mnt/edit/system/build.prop # 5. 卸载前把缓存刷干净 sync sudo umount /mnt/edit

回填前的两件事不能省。一是扩容,如果你加进去的东西比删掉的多,文件系统会不够用,得先resize2fs:

sudo e2fsck -f system_edit.img sudo resize2fs system_edit.img 2G

二是权限修复。这是在 Linux 上改安卓镜像最容易翻车的地方。安卓里的文件权限、属主、SELinux 标签都是有讲究的,你在宿主机上cp一个文件进去,属主变成你自己的用户 ID,权限变成 644,SELinux 标签直接丢了。轻则应用找不到文件,重则系统起不来。正确的做法是改完之后用chown按数字 ID 修属主、用chmod按原文件修权限,SELinux 标签用setfattr打:

sudo chown 0:0 /mnt/edit/system/bin/某个文件 sudo chmod 0755 /mnt/edit/system/bin/某个文件 sudo setfattr -n security.selinux \ -v "u:object_r:system_file:s0" /mnt/edit/system/bin/某个文件

改完卸载,转回稀疏格式或者直接保持 raw,看你回填的目标要什么。转稀疏用img2simg system_edit.img system_new.img,然后跟原始文件比一下sha256sum,记录在案。


4. 运行态提取:安装包里找不到完整镜像时怎么办

4.1 从挂载信息反查 loop 设备

有些版本的应用在首次启动时才把镜像解出来,包里那份是压缩的或者干脆是骨架。这时候静态这条路只能拿到半成品,得换运行时导出。

前提是设备上能拿到足够的权限,这一步怎么拿不在本文讨论范围内,各家设备差异太大。假设你已经有了,思路是顺着挂载信息找。这类应用跑起来之后,它内部的镜像必然被挂成 loop 设备,/proc/self/mountinfo或者/proc/mounts里会出现loop字样:

cat /proc/mounts | grep loop # /dev/block/loop7 /data/.../rootfs ext4 ro,seclabel,relatime 0 0

拿到 loop 设备号之后,反查它对应的后端文件:

losetup -a | grep loop7 # /dev/block/loop7: [2049]:1234567 (/data/data/包名/files/system.img)

括弧里那个路径就是你要的原始镜像。这一步的价值在于,它告诉你“应用到底用的是哪个文件”,而不是让你去猜目录结构。

4.2 用 dd 抓取并校验一致性

拿到路径之后分两种情况。如果那个文件本身就是完整的镜像文件,直接复制出来就行:

cp /data/data/包名/files/system.img /sdcard/system_copy.img

如果它是个块设备,或者你想连块设备的状态一起抓,用dd:

dd if=/dev/block/loop7 of=/sdcard/system_copy.img bs=4M

bs=4M这个参数不是随便定的。默认的 512 字节块在几 GB 的数据量下会慢到让你怀疑设备卡死,4M 是个折中值,再往上提升不明显,反而增加内存压力。

抓完之后一定要校验。校验的方法不是看文件大小对不对,而是在设备上和应用内分别算一次 hash 做对比(如果应用有校验接口的话),或者至少把抓出来的镜像挂上去看结构完不完整:

sudo mount -o loop,ro system_copy.img /mnt/check ls /mnt/check/ # 目录结构完整、关键目录都在,才算抓对了

我遇到过抓出来的镜像大小对、能挂载、目录也全,但system/app下少了两个应用的情况。原因是dd执行的时候应用还在往镜像里写东西,抓了个中间态。规避办法很简单:先让应用停下来,再抓。停之前记一下sha256sum,抓完再算一次,两次一致才算数。


5. 常见故障排查实录

5.1 挂载类问题:wrong fs type和空目录

这两个是最常见的。mount: wrong fs type, bad option, bad superblock这个报错,八成不是文件系统坏了,是你挂错了位置。按顺序排查三步:先binwalk扫一遍看文件系统签名在哪个偏移,再fdisk -l看分区表,最后确认这个镜像到底是不是 ext4——也可能是 f2fs,那mount就得带-t f2fs,还得内核支持。

挂载成功但目录是空的,那是偏移算错了,或者你挂到了分区表上。分区表的特征是有0x55AA结尾,挂上去看起来像个极小的文件系统,里面只有几个字节。这种情况把 offset 加上再挂一次。

5.2 镜像完整性与校验类问题

e2fsck报Structure needs cleaning或者一堆 inode 错误,说明这个镜像是非正常关闭的,日志区里有未提交的事务。处理方式是先跑修复再挂:

sudo e2fsck -fy system.img

-y是自动回答 yes,谨慎使用,它会自作主张删掉它认为有问题的 inode。修之前先备份一份原始文件,这是铁律。

另一种情况是转换之后大小不对。比如稀疏镜像转 raw 之后体积没变大,或者变大得离谱。前者的原因是原文件压根不是稀疏格式,simg2img可能静默失败了;后者是正常的,“不关心”块展开成零之后体积会膨胀好几倍。判断依据是原始头部里的total_blks * blk_sz,这个值就是展开后的确切大小,跟实际输出对一下就知道了。

5.3 回填后无法启动类问题

这一类最难查,因为症状都不明显。整理了一张速查表:

现象大概率原因处理思路
应用启动即闪退关键文件权限或属主被改坏用debugfs -R "stat 路径"对照原始镜像的权限位
系统起来了但找不到某个应用修改时删了不该删的依赖,或者目录属主不对恢复该目录,确认属主是0:0、权限0755
卡在启动动画不动SELinux 标签丢失导致服务被拒挂载后对改动过的文件重新setfattr打标签
提示镜像损坏回填时格式不对(raw 塞进了期望稀疏的位置)用img2simg转回稀疏,或确认目标接受 raw
数据分区被清空换了data.img但没有迁移原有数据把原data.img挂载后迁移/data/data下的内容

这张表里第一条我踩得最多。表面上只是删了个文件夹,实际上是cp进去的文件属主变了,安卓在启动过程中按 UID 找文件找不到,直接判定完整性失败。后来我养成了习惯:每次改完,挂载状态下用ls -ln看一遍数字属主,而不是ls -l看用户名,宿主机上的用户名映射跟安卓完全对不上,看名字毫无意义。


6. 几年下来攒的几条实操心得

第一条是关于备份的。我现在做这类操作,第一步永远是cp一份原始文件到另一个目录,命名带上日期和来源标记。不这么做的人,在第三次改坏镜像之后一定会开始这么做,区别只是浪费了多少时间。镜像动辄几个 G,复制一次几分钟,但比重下一遍安装包快得多。

第二条是关于工具的。simg2img这类工具版本差异带来的问题比想象中大,我现在的做法是固定用一套自己编译的二进制放在固定路径,不依赖系统源里的版本。同理lpunpack,不同分支的行为不完全一致,遇到诡异报错先怀疑工具而不是文件。

第三条是关于判断顺序的。先认格式,再算偏移,最后才挂载,这个顺序不能乱。我见过太多人拿到文件就开始mount -o loop,报错了才开始file,然后在错误的方向上越走越远。养成习惯:任何新拿到的镜像,xxd -l 64看一眼,三十秒的事,能省半小时。

第四条是给做定制的人的:改动要小步走,每改一处就验证一次。一次性删十个应用、改五个属性,出问题了你根本不知道是哪一处。我的做法是先只删一个最不重要的,回填验证能起来,再继续。听着笨,实际比一次性改完再从头二分定位快得多。

最后一条,关于这件事本身的边界感。技术上的“能不能”和实际上的“该不该”是两回事。镜像里的东西是别人封装好的,拿来研究结构、学封装思路、做自己的备份,都没问题;把它当成自己的成果往外发,那就是另一回事了。我这些年从这类包里学到的东西,比从任何教程里学到的都多,前提是知道自己站在哪条线上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询