ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

qemu-img 缓冲区溢出 SIGABRT 崩溃分析:从 core dump 到系统性修复

qemu-img 缓冲区溢出 SIGABRT 崩溃分析:从 core dump 到系统性修复 QEMU-img 缓冲区溢出错误SIGABRT分析与系统性解决方案搞虚拟化的人多少都跟qemu-img打过交道转换镜像格式、创建磁盘、检查一致性被convert和check两个子命令反复摩擦。我这次翻车是在做一次跨存储迁移qemu-img convert -p -O qcow2跑了一个多小时进度条走到 47% 的时候终端里突然崩出一个./qemu-img: ... Aborted (core dumped)。第一反应是磁盘满了但df -h空间充足再仔细看日志发现是SIGABRT而且系统日志里有一句非常刺眼的话检测到基于堆栈的缓冲区溢出。这个报错并不是 Windows 独有在 Linux 下 qemu-img 同样会触发类似机制只是呈现方式不一样。整个过程到最终彻底解决花了将近一天中间反复换了三轮排查思路。今天把这段完整经历拆开讲清楚重点说明 SIGABRT 和缓冲区溢出在 qemu-img 里到底怎么回事、怎么定位、最后用什么方案系统性地解决希望在迁移和镜像管理时别再被这个坑绊倒。1. SIGABRT 和缓冲区溢出到底是怎么扯上关系的先别急着跑valgrind首先要搞清楚一个事实SIGABRT不是野生崩溃不是像SIGSEGV那样你去访问了非法地址、由内核直接掐断进程。SIGABRT 是进程主动放弃治疗——程序内部发现了不可挽回的错误主动调用abort()函数把自己干掉。所以当 qemu-img 崩出 SIGABRT它意味着 QEMU 的代码在某一个检查点上发现了致命问题并且决定不再继续执行。1.1 QEMU 自己触发的中止机制QEMU 代码里大量使用assert()、g_assert()和abort()来保证数据结构的强约束。比如解析 qcow2 镜像头的时候l1_size和cluster_bits会被用来计算偏移量如果这两个字段的取值组合大到超出了镜像物理大小的合理范围相关代码会走error_setg提前返回但某些版本在涉及内存映射和快照表重建的路径上就直接abort()了。glibc还有一道防线称为stack smashing protector栈破坏保护器也就是编译器的-fstack-protector-strong。当某个函数栈帧里的 canary 值被改写表明发生了栈缓冲区溢出__stack_chk_fail被触发最后同样走到 abort进程以 SIGABRT 终止。在 Windows 上看到的系统在此应用程序中检测到基于堆栈的缓冲区溢出就是 Windows 的/GS栈检查对应机制弹出来的Linux 下通常直接 Aborted没有弹窗但本质一样。1.2 qemu-img 在什么阶段会做内存安全自检从代码路径看qemu-img convert的崩溃高发区通常有三个阶段镜像解析阶段读取远端或本地文件的 header初始化BlockDriverState生成Qcow2L2Meta等结构体。这个阶段如果镜像元数据损坏容易出现越界读写。数据块传输阶段qcow2_co_preadv_part和qcow2_co_pwritev_part根据 L2 表项计算物理偏移和拷贝长度。如果 L2 表项指向的偏移和指定大小不匹配可能产生堆缓冲区溢出。快照处理阶段对镜像做快照链操作时qcow2_snapshot_goto会重新映射整个 L1 表版本不匹配时容易出现边界判断失误。蒙圈的时候最容易犯的错误是看到一个缓冲区溢出就去怀疑恶意软件或者入侵。真实情况中绝大多数 qemu-img 的 SIGABRT 是镜像文件自身元数据异常或QEMU 内部边界条件 bug导致的。所以下一步不是换电脑而是把崩溃现场抓下来分析。2. 崩溃现场取证从 core dump 到堆栈回溯的完整流程2.1 开启 core dump 并复现崩溃如果直接在终端跑qemu-img convert崩溃后经常会看到core dumped但当前目录并没有 core 文件。这跟 shell 的ulimit -c有关也跟 systemd 的 coredump 配置有关。临时开大一点ulimit -c unlimited # 让 core 文件生成到当前目录 sudo sysctl -w kernel.core_patterncore.%e.%p建议再配合systemd-coredump保留堆栈很多发行版里如果不设置会把 core 直接丢弃。设置好后再次执行崩溃命令最好把输入输出文件都放在本地路径避免网络文件系统带来的干扰。我这次重新执行同样命令稳定复现说明不是偶发的环境抖动而是确定性逻辑问题。此时如果core_pattern写的是core.%e.%p当前目录出现了一个core.qemu-img.pid然后赶紧用 gdb 挂上去。2.2 gdb 堆栈回溯秒锁定崩溃函数gdb -batch -ex thread apply all bt full /usr/bin/qemu-img core.qemu-img.12345通常能得到几条非常有特征的调用栈abort ()__stack_chk_fail ()qcow2_do_open ()qcow2_co_preadv_part ()qcow2_alloc_clusters ()日常经验里栈破坏保护器触发的栈会直接显示__stack_chk_fail说明是在某个具体函数里发生局部数组或结构体溢出而glibc malloc 检测到堆损坏时堆栈往往指向malloc_consolidate或_int_free但崩的位置和真实越界位置可能隔了很远。关键一步是看崩溃函数上方的帧。比如我的崩溃栈指向了qcow2_do_open说明是在打开镜像、解析 header 的阶段就挂了。这时候基本上可以认定是镜像的 qcow2 元数据运维异常而不是数据写入阶段的锅。可能出现的调用栈特征与原因定性可以按这张表对照调用栈特征可能原因验证方向__stack_chk_fail栈缓冲区被写坏检查 QEMU 版本与镜像格式兼容性malloc_consolidate或_int_free堆元数据损坏用 ASan 重新编译定位越界点qcow2_do_open内 abortqcow2 header 字段异常检查 L1/L2、cluster_bits、refcountqcow2_alloc_clusters内 abort磁盘空间与 refcount 不一致先qemu-img check排除一致性readv/preadv段错误资源限制或存储设备异常排查 ulimit、内存压力和设备健康度2.3 用 AddressSanitizer 辅助定位精确越界点纯靠 gdb 看调用栈还不够实锤的时候我建议直接用带 AddressSanitizer 的 QEMU 调试包重新编译或下载发行版提供的qemu-*-dbgsym包并启用 ASan 重新构建最小镜像场景验证。ASan 抓到的报错会精确给出READ of size N at 0x... thread T0以及对应的分配位置能把哪一行代码越界直接拍在脸上。不过ASan 模式性能开销大不适合在正式存储环境长时间跑。建议只用一个损坏的副本镜像文件和最小化的命令做离线分析比如qemu-img info --outputjson /path/to/corrupt-image.qcow2如果这一步都能崩溃那基本不用继续 convert 了先解决镜像本身的问题。3. 元数据嫌疑最大深入 qcow2 的 L1/L2 表与 cluster 分配机制3.1 qcow2 的核心结构为什么会引发越界qcow2 镜像由 header、L1 表、L2 表、refcount 表和 data cluster 组成。header 里记录了cluster_bits、l1_size、refcount_order等字段这些字段决定了后续所有表的内存分配和磁盘偏移计算。问题通常出在这样一个逻辑链上L1 表项的偏移值被解析得到一个guest 虚拟地址该地址对应到 L2 表物理偏移然后 L2 里每条 entry 的 host offset 最终决定实际写盘位置。如果镜像的 L1 或 L2 表项里写入了一个大得离谱的 host offsetqcow2_co_preadv_part()内部会先做边界检查但在某些旧版本 QEMU 中检查逻辑本身有漏洞导致越界读。举例来说正常镜像一个 L2 表能覆盖的 guest 大小是cluster_size * (cluster_size / 8)即每个 cluster 指针占 8 字节。如果cluster_bits正常值 16 表示 64K clusterL2 表覆盖 512MB 的 guest 地址空间。如果 header 被改成了cluster_bits 28表示 256MB cluster或者l1_size被写得非常大QEMU 分配 L1 表时会按这个畸形大小做内存分配但物理文件根本没有这么长读取时自然会出现越界或 abort。3.2 哪些次要字段也会搅局除了header主字段refcount 表和 snapshot 表同样值得检查。refcount 表的refcount_block_offset如果超出文件实际大小qcow2_check_refcounts()在遍历时可能会计算出一个超大的内存索引。snapshot 表里的l1_size和每个 snapshot 的l1_table_offset组合不当在回滚或列出快照时也有概率崩溃。这类问题在实际运维中常见的成因优先级由高到低如下虚拟机异常断电qcow2 的 refcount 和 L2 表没有完成 flush损坏概率最高存储后端出现坏块或网络文件系统写入失败镜像文件本身出现空洞或截断人为修改过镜像 header比如用十六进制编辑器调了 raw 转 qcow2 的标记快照链太长其中某一环被手工删除但没有正确合并对应 L1/L2不同 QEMU 版本对 qcow2 扩展字段feature bits的处理不一致旧版本创建镜像被新版本当作有 bug 处理3.3 第一步动作用qemu-img check验证一致性发现镜像可能损坏后切忌直接反复 convert这会加重损坏。先跑一致性检查qemu-img check -v /path/to/corrupt-image.qcow2输出里的Leaked clusters、Corruptions字段会告诉我们问题严重性。如果只是少量 leaked clusters用-r all进行修复但如果check本身在遍历 L2 表时就崩溃说明这台 qemu-img 已经无法安全处理这个镜像需要换一个更早版本或其他工具打开它。还需要说明的是qemu-img check -r all并不是万能神药。它最适合修复 refcount 不一致这类元数据出入问题但如果是 header 中的overlay字段被人为改动过-r all可能直接把快照链信息清洗掉造成更糟的后果。所以修复前一定做镜像副本。4. 环境因素排查版本、资源限制和文件系统的隐形陷阱4.1 版本不匹配带来的边界判断失误qemu-img 对 qcow2 的解析逻辑各版本差异很大。某些旧镜像用的 qcow2 v2 扩展功能在 QEMU 6.2 之后被列为 deprecated后续版本如果 编译选项没有打开CONFIG_QCow2_V2打开镜像会走兼容路径而这个路径在个别 patch 版本中确实出现过 abort。在这种场景下缓冲区溢出不一定是外部恶意输入导致更多是 QEMU 社区代码自身在边界分支上处理不完整。也就是说同样的镜像文件换一个 qemu-img 版本可能就不崩了。这不等同于乱试版本而要先通过qemu-img --version确认当前版本再去 QEMU 官方 changelog 搜索qcow2、abort、overflow关键词。我自己遇到的情况是qemu-img version 7.2.0无法打开一个由 QEMU 4.2 创建的 v2 格式镜像报assert(!(bs-open_flags BDRV_O_NO_IO)) failed然后 SIGABRT换到 8.1.x 后问题消失社区在中间版本确实修复了一个关于qcow2_do_open中s-l1_vm_state和磁盘快照偏移计算的越界问题。4.2 资源限制内存压力导致的微妙表现缓冲区溢出听起来像代码逻辑 bug但在实际操作中内存不足、swap 满、numa 绑定错误也常常以 SIGABRT 形式暴露。因为 QEMU 用g_malloc分配大块内存分配失败会返回 NULL但某些路径没做 NULL 检查直接对该指针做 memcpy于是产生堆缓冲区溢出。排查时注意这几个资源面# 检查 qemu-img 进程被限制的地址空间和栈大小 ulimit -a # 检查当前系统可用内存 free -h # 检查打开文件数qcow2 快照链深时会同时打开大量文件 cat /proc/sys/fs/file-nr如果你在跑qemu-img convert的同时宿主上还有多个 VM 在并发运行内存被压得很薄malloc失败概率会大幅上升。此时要么挑业务低峰期做转换要么用-m参数限制 QEMU 多线程并发量比如qemu-img convert -m 2减少内存突发。4.3 文件系统层面的空洞与稀疏文件qemu-img 支持稀疏文件也就是逻辑上文件很大但物理占用很小。在 EXT4、XFS 上fallocate和punch hole的不同实现会导致 qcow2 转换后出现意外空洞。如果源镜像文件因为文件系统 bug 或cp --sparsealways拷贝不当内部数据块偏移和文件物理大小不一致qemu-img 按元数据的偏移去读取就会超出实际文件末尾造成带外访问。遇到这类情况先用qemu-img map --outputjson查看块映射确认数据范围和文件大小是否合理。如果怀疑是稀疏拷贝造成的优先用cp --sparsenever重新落一份全量数据镜像再转换。5. 系统性解决方案修复、降级与绕行恢复策略既然 SIGABRT 是一大类问题的聚合表现解决方案也得分层。从安全修复到业务止损我按优先级拆成四套组合拳5.1 先保命立即停止原地操作并复制损坏镜像任何qemu-img check -r all和convert操作都不要再原文件上执行。先做全量副本哪怕是坏镜像也要保证一个不动的现场cp --sparsealways corrupt.qcow2 corrupt.backup.qcow2如果原文件很大用支持重映射的cp --reflinkautoXFS 或 Btrfs瞬间生成 COW 副本。有了副本以后后续所有修复动作都在副本上开展原文件始终保留一个原始案发现场避免反复尝试把可恢复的数据二次破坏。5.2 用qemu-img check -r all修复但要加限定条件跑一遍timeout 3600 qemu-img check -r all corrupt.backup.qcow2修复后会提示The following inconsistencies were found and repaired然后可以用qemu-img info验证镜像 header 是否恢复正常。这里有一个容易被忽略的操作教训修复后第一时间做一次无崩溃的convert验证而不是直接回到生产环境。如果check -r all修不动说明 L2 表错误已经深入到了无法用一致性算法恢复的程度。此时需要切换到绕过策略。5.3 分段转换绕行法让 qemu-img 不读损坏区域qemu-img convert之所以崩是因为它在读取到某个 cluster 时越界。如果损坏只集中在某一小段 guest 地址空间可以按地址范围分段转换跳过坏区。比较直接的方式是从 RAW 或另一格式中转。先尝试把 qcow2 解包成 raw有些问题会在格式转换的 边界处理 里绕过去qemu-img convert -f qcow2 -O raw corrupt.backup.qcow2 rescue.raw如果这一步仍崩再用qemu-nbd挂载镜像配合dd按偏移把数据一块块搬出来。qemu-nbd走的是另一套 block 层接口有时能躲过qcow2解析函数的边界 bug但需要内核支持 nbd。sudo modprobe nbd max_part8 sudo qemu-nbd -c /dev/nbd0 corrupt.backup.qcow2 sudo dd if/dev/nbd0 ofrescue.raw bs1M statusprogress这种方式对损坏 L2 映射的镜像尤为有效因为 nbd 每次读一个固定 block 由内核层处理不需要 QEMU 一次性分配整张 L1/L2 表。5.4 版本降级/升级与低层工具的组合切换如果怀疑是版本 bug 而不是镜像损坏找一台干净的机器安装当前发行版之前的 QEMU 版本比如 Ubuntu 的 qemu 7.2 换成 qemu 8.1或实测过 6.2 也能正常读取在副本上重新验证。人可以偷懒思路不能偷懒。可以同时用libguestfs作为第二工具guestfish --rw -a corrupt.backup.qcow2libguestfs内部使用 QEMU 但通过 daemon 方式运行FUSE 挂载后也可以把文件系统直接拷贝出来。如果 qemu-img 崩在元数据解析而 guestfish 里的 QEMU 版本新一点很可能直接绕过去。此外如果安装过qemu-utils的 debug 包也可以使用qemu-img info --force-share跳过文件锁相关的检查路径有些崩溃和 flock 的竞态有关--force-share能压低一部分冲突概率。5.5 数据抢救成功后的转换参数建议抢救出来的数据最后一步要安全转换为目标格式此时一定注意以下几点优先用-p -m 1单线程转换降低内存峰值和并发分配路径的复杂度目标格式如果是 qcow2建议-o compat1.1,lazy_refcountson转换前确保目标盘 1.5 倍于原镜像逻辑大小避免中途 ENOSPC转换完后立即做qemu-img check和一次启动级健康验证qemu-img convert -p -m 1 -f raw -O qcow2 rescue.raw rescue.qcow2 qemu-img check rescue.qcow26. 实验性验证一个损坏镜像从崩溃到恢复的完整过程为了把上述思路串起来我重新构造了一个最小复现环境。从一个正常的 qcow2 镜像开始故意在 L2 表区间注入错误偏移把之前的排查流程完整走一遍。6.1 构造损坏镜像并复现 SIGABRT先创建一个 1G 的 qcow2 镜像向里面写入部分数据qemu-img create -f qcow2 test.qcow2 1G sudo qemu-nbd -c /dev/nbd0 test.qcow2 sudo dd if/dev/urandom of/dev/nbd0 bs1M count200 sudo qemu-nbd -d /dev/nbd0然后通过修改 L2 表项破坏一个 cluster 偏移。这里不展开具体的十六进制改法思路是找出 header 中l1_table_offset指向的 L1 项再顺着 L1 拿到 L2 表偏移在 L2 表某一项中写一个超出文件大小的 host offset。改完以后执行qemu-img convert -t none -O raw test.qcow2 test.raw果然复现了 SIGABRTgdb 堆栈指向qcow2_co_preadv_part内memcpy越界。这个结果与前面分析的元数据损坏导致解析器越界完全一致。6.2 按方案顺序逐级处理先说结论qemu-img check -r all在这个案例里没有成功因为这不是简单的 refcount 不一致而是 L2 表项指向了不合法 offsetcheck 只报了 corruption但修不好。随后用 5.4 的 qemu-nbd dd 方案成功导出了几乎全部数据缺掉的部分正是被我刻意破坏的那个 cluster。这也映射到现实教训SIGABRT 只是表层信号真正决定能否恢复的是损坏发生的位置和严重程度。如果坏的是 header 主字段qemu-img甚至无法 open 镜像需要手工补 header如果坏的是 L2 区域还可以尝试 nbd 挂载并跳过坏块如果坏的是 data cluster那只是损失该区域的数据块整体结构还能保住。损坏位置check -r allqemu-nbd dd手工恢复 headerheader 字段部分修复无法挂载可尝试L1/L2 表部分修复可行较复杂refcount 表有效可行不需要data cluster无法识别损坏可行不需要6.3 一次完整恢复后的健康检查清单恢复完数据后不要以为能开机就算结束。按下面清单逐项验证少一项都可能在后续埋雷qemu-img check输出无 error仅允许有少量 leaked clusters用qemu-system-x86_64 -drive filerecovered.qcow2,formatqcow2,readonlyon做只读冷启动确认文件系统层挂载正常对关键分区做一遍文件系统完整性检查fsck或 Windows 的chkdsk看是否有文件系统层的残余错误原损坏镜像保留一周以上确认恢复镜像运行稳定再物理删除备份7. 经验沉淀镜像管理与崩溃预防的几条实用准则这类崩溃问题最让人后怕的不是损坏本身而是我们往往要等到 convert 或启动 VM 时才被动发现。既然已经踩过一次坑后面就该在管理和预防上做功课。7.1 例行巡检与早期发现把qemu-img check做成定时任务对重要虚拟机镜像每周跑一次并在快照合并后立即跑一次。对于长期不用的归档镜像至少每季度检查一次哈希和文件大小确保没有因为存储端数据静默损坏而带病入库。脚本很简单关键是要在异常出现时第一时间报警别等到迁移生产环境才炸出来。7.2 文件系统选型与快照规划优先把镜像放在 XFS 或 ext4 这类成熟文件系统上避免使用堆叠网络文件系统存活跃镜像快照链尽量控制在三层以内每做完一次快照合并观察宿主 IO 与内存大批量 convert 之前先sync宿主和源存储防止缓存中的脏数据影响一致性7.3 不要靠重试解决问题遇到 qemu-img 崩溃最错误的动作就是反复执行同一条命令。SIGABRT 是代码主动罢工盲目的重试不仅拿不到结果还会让存储设备承受无意义的扫描和读取甚至加重损坏。正确姿势是先采集 core、确认版本、检查镜像 check 输出再决定走修复还是复活流程。7.4 最后的运维小技巧近期实际体会最深的一点给 qemu-img 设置一个合理的超时和内存限制远比出了问题后再去查日志更省心。比如用systemd-run给转换任务配置临时资源上限避免进程因内存飙升被 kernel 或 cgroup 杀掉后留下一堆半完成文件systemd-run --scope -p MemoryMax2G -p TimeoutStopSec300 \ qemu-img convert -p -O qcow2 source.raw target.qcow2这样一来转换任务的内存峰值被控制住即使元数据存在问题进程也会在 cgroup 层面先被限制而不是直接冲到系统层引发莫名其妙的 abort留给你的排查现场也更干净。希望这篇排查记录能给还在跟 SIGABRT 死磕的人省下至少半天的功夫。
返回列表