
追踪 Zsh 历史记录数据丢失漏洞多年来有人有时会发现自己确定已经执行过的命令在 [Z shell](https://en.wikipedia.org/wiki/Z_shell) 历史记录文件~/.zsh_history中却不见了。在本文中将展示如何追踪到这个漏洞。最终对 Zsh 打补丁使其崩溃并分析崩溃时的核心转储文件才是解决问题的关键策略。先说好消息Zsh 5.9.2于 2026 年 7 月 12 日发布已经修复了这个问题。在阅读完本次调查内容后再去查看修复内容这样才更有趣。问题症状偶尔会注意到前一天明明执行过的命令却无法在 shell 历史记录中找到也就是说按下 Ctrl R 进行历史记录反向搜索时没有任何结果。每当发现这种情况时shell 历史记录文件中 **只有非常旧的** 条目多年来的新条目都不见了。最初几次出现这种情况时只是从每日备份中恢复了 shell 历史记录并没有深入调查。但这个问题一直反复出现。注意到 .zsh_history 文件没有明显的损坏迹象没有不可打印字符或不完整的文本行而且文件中的行数也不总是相同。不清楚是 Zsh 本身、其他程序还是多个 [zsh(1)](https://manpages.debian.org/zsh.1) 进程的组合导致了这个问题。我的 Zsh 历史记录配置在 ~/.zshrc 中设置了以下与历史记录相关的选项# 加载 4000 行历史记录用于 CtrlR 反向搜索但保存无限多行 HISTSIZE4000 HISTFILE~/.zsh_history SAVEHIST10000000 # 不保存相邻的重复条目 setopt HIST_IGNORE_DUPS # 执行命令时将历史记录条目追加到 ~/.zsh_history setopt INC_APPEND_HISTORY # ……但不共享历史记录NixOS 的 /etc/zshrc 中默认启用 unsetopt SHARE_HISTORY实际上这意味着各个 shell 会话是相互独立的但都会将命令写入共享的 ~/.zsh_history 文件。历史记录不会自动共享所以当想访问其他 shell 写入的条目时需要显式运行 exec zsh。追踪行为2024 年 12 月在 Mastodon 上寻求帮助主要是希望有人已经遇到并诊断过这个问题有人建议使用 inotify 或 fsevents 等文件系统更改监控机制来找出截断或更改Zsh 历史记录文件的罪魁祸首。接下来介绍在 Linux 上尝试过的可用选项。inotify[inotify(7)](https://manpages.debian.org/inotify.7) 是 Linux 内核中最古老的文件系统更改监控 API 之一2005 年发布。要全面了解 Zsh 如何修改历史记录文件仅监控 .zsh_history 是不够的midna ~ % inotifywait --monitor .zsh_history Setting up watches. Watches established. .zsh_history OPEN .zsh_history ACCESS .zsh_history ACCESS […] .zsh_history ACCESS .zsh_history CLOSE_NOWRITE,CLOSE .zsh_history ATTRIB .zsh_history CLOSE_WRITE,CLOSE .zsh_history DELETE_SELF ^C文件被打开、访问即读取然后……被删除了通过监控包含该文件的目录我们可以看到完整的过程midna ~ % inotifywait --monitor ~ /home/michael/ OPEN .zsh_history /home/michael/ ACCESS .zsh_history /home/michael/ ACCESS .zsh_history […] /home/michael/ ACCESS .zsh_history /home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history /home/michael/ CLOSE_WRITE,CLOSE .zsh_history /home/michael/ OPEN .zsh_history /home/michael/ CLOSE_WRITE,CLOSE .zsh_history /home/michael/ OPEN .zsh_history /home/michael/ ACCESS .zsh_history /home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history /home/michael/ CREATE .zsh_history.new /home/michael/ OPEN .zsh_history.new /home/michael/ ATTRIB .zsh_history.new /home/michael/ MODIFY .zsh_history.new /home/michael/ CLOSE_WRITE,CLOSE .zsh_history.new /home/michael/ MOVED_FROM .zsh_history.new /home/michael/ MOVED_TO .zsh_history /home/michael/ CLOSE_WRITE,CLOSE .zsh_history原来Zsh 会读取旧的历史记录文件内容将其写入一个新文件然后将新文件重命名为旧文件从而删除旧文件。不幸的是无法看到文件系统事件对应的进程 IDPID即使使用其兄弟工具 [fsnotifywait(1)](https://manpages.debian.org/fsnotifywait.1) 也不行它使用的是 [fanotify(7)](https://manpages.debian.org/fanotify.7) API该 API 本应提供此信息检查过内核确实会发送 PID但 fsnotifywait 不会显示 PID。fatrace有 [fatrace(8)](https://manpages.debian.org/fatrace.8) 工具它可以显示进程名称和 PID。以下是使用 [fatrace(8)](https://manpages.debian.org/fatrace.8) 时 Zsh 重写历史记录的情况zsh(197994): CWO /home/michael/.zsh_history zsh(197994): O /home/michael/.zsh_history zsh(197994): R /home/michael/.zsh_history zsh(197994): R /home/michael/.zsh_history […] zsh(197994): R /home/michael/.zsh_history zsh(197994): C /home/michael/.zsh_history zsh(197994): /home/michael zsh(197994): O /home/michael/.zsh_history.new zsh(197994): W /home/michael/.zsh_history.new zsh(197994): W /home/michael/.zsh_history.new zsh(197994): W /home/michael/.zsh_history.new […] zsh(197994): W /home/michael/.zsh_history.new zsh(197994): CW /home/michael/.zsh_history.new zsh(197994): /home/michael zsh(197994): CW (deleted) zsh(197994): C /nix/store/80vwnjjgcrbp41pk927r8lzybjhy0k73-zsh-5.9.1/bin/zsh […]这样就得到了 PID现在可以验证是否有多个进程参与了 shell 历史记录的损坏。但是仍然不清楚每个 Zsh PID 读取/写入了 **多少数据**所以即使有 fatrace 日志也还是不清楚具体发生了什么。strace可以使用 [strace(1)](https://manpages.debian.org/strace.1)特别是使用 -k 标志进一步研究 Zsh 的行为。但要让每个交互式Zsh 进程都对应运行一个 strace 似乎是一场后勤噩梦而且不确定一直对 shell 进行 strace 是否会以微妙的方式改变其行为所以没有选择这条路。一旦有了可复现的问题strace 就变得很容易使用而且非常有用。bpftrace为了更深入了解 Zsh 的读写操作可以使用 [bpftrace(8)](https://manpages.debian.org/bpftrace.8)。首先创建了以下 bpftrace 程序它会在每个 [open(2)](https://manpages.debian.org/open.2) 系统调用时运行并记录哪个进程打开了 .zsh_history 文件包括用户堆栈跟踪tracepoint:syscalls:sys_enter_open, tracepoint:syscalls:sys_enter_openat, tracepoint:syscalls:sys_enter_openat2 /str(args.filename) /home/michael/.zsh_history || str(args.filename) .zsh_history/ { printf(%-6d %-16s open(%s)%s, pid, comm, str(args.filename), ustack); }在 NixOS 26.05 上可以按以下方式运行该程序midna ~ % nix shell nixpkgs#bpftrace midna ~ 2 % sudo bpftrace path.bt Attached 3 probes 212030 zsh open(/home/michael/.zsh_history) __internal_syscall_cancel142 __syscall_cancel20 __libc_open6487 lockhistfile642 readhistfile2213 zsh_main1118 __libc_start_call_main117 __libc_start_main_alias_2136 _start37 212030 zsh open(/home/michael/.zsh_history) __internal_syscall_cancel142 __syscall_cancel20 __libc_open6487 _IO_file_open51 _IO_file_fopenGLIBC_2.2.5303 __fopen_internal134 readhistfile2277 zsh_main1118 __libc_start_call_main117 __libc_start_main_alias_2136 _start37 212030 zsh open(/home/michael/.zsh_history) __internal_syscall_cancel142 __syscall_cancel20 __libc_open6487 lockhistfile642 savehistfile165 zexit204 zsh_main1522 __libc_start_call_main117 __libc_start_main_alias_2136 _start37 212030 zsh open(/home/michael/.zsh_history) __internal_syscall_cancel142 __syscall_cancel20 __libc_open6487 savehistfile752 zexit204 zsh_main1522 __libc_start_call_main117 __libc_start_main_alias_2136 _start37 212030 zsh open(/home/michael/.zsh_history) __internal_syscall_cancel142 __syscall_cancel20 __libc_open6487 _IO_file_open51 _IO_file_fopenGLIBC_2.2.5303 __fopen_internal134 readhistfile2277 savehistfile2498 zexit204 zsh_main1522 __libc_start_call_main117 __libc_start_main_alias_2136 _start37 ^C受到早期成功的鼓舞对程序进行了扩展以涵盖更多系统调用#!/usr/bin/bpftrace #include #include tracepoint:syscalls:sys_enter_open /comm zsh/ { printf(%s(%d) open: %s flags %x mode %x\n, comm, pid, str(args-filename), args-flags, args-mode); } tracepoint:syscalls:sys_enter_openat { if (!strcontains(str(args-filename), zsh_history)) { delete(openfn[tid]); return; } openfn[tid] 1; printf(%s(%d) openat: , comm, pid); if (args-dfd 0x7fffffff) { /* ought to be ! AT_FDCWD, but that does not work !?!? */ printf([at fd %d], args-dfd); } printf(%s flags %x mode %x\n, str(args-filename), args-flags, args-mode); } tracepoint:syscalls:sys_exit_openat /openfn[tid]/ { reads[tid,(int64)args-ret] 1; // TODO: bpftrace 0.22 introduces has_key writes[tid,(int64)args-ret] 1; // TODO: bpftrace 0.22 introduces has_key } tracepoint:syscalls:sys_enter_close /reads[tid,(int64)args-fd]/ { printf(%s(%d) close %d (reads: %d, writes: %d)\n, comm, pid, args-fd, reads[tid,(int64)args-fd]-1, writes[tid,(int64)args-fd]-1); delete(reads[tid,(int64)args-fd]); delete(writes[tid,(int64)args-fd]); } // tracepoint:syscalls:sys_enter_openat2 /comm zsh/ { // printf(%s(%d) openat: , comm, pid); // if (args-dfd INT_MAX) { /* ought to be ! AT_FDCWD, but that does not work !?!? */ // printf([at fd %d], args-dfd); // } // printf(%s \n, str(args-filename)); // } tracepoint:syscalls:sys_enter_rename /comm zsh/ { printf(%s(%d) rename:, comm, pid); printf(%s - %s\n, str(args-oldname), str(args-newname)); } tracepoint:syscalls:sys_enter_symlink /comm zsh/ { printf(%s(%d) symlink , comm, pid); printf(%s - %s\n, str(args-oldname), str(args-newname)); } tracepoint:syscalls:sys_enter_unlink /comm zsh/ { printf(%s(%d) unlink , comm, pid); printf(%s\n, str(args-pathname)); } tracepoint:syscalls:sys_enter_unlinkat /comm zsh/ { printf(%s(%d) unlinkat , comm, pid); printf(%s\n, str(args-pathname)); } tracepoint:syscalls:sys_enter_lseek /comm zsh/ { printf(%s(%d) lseek fd %d offset %d whence %d\n, comm, pid, args-fd, args-offset, args-whence); } tracepoint:syscalls:sys_enter_read /reads[tid,(int64)args-fd]/ { // printf(%s(%d) read fd %d size %d\n, comm, pid, args-fd, args-count); reads[tid,(int64)args-fd] args-count; } tracepoint:syscalls:sys_exit_read /comm zsh/ { if (args-ret 0) { printf(%s(%d) read %d\n, comm, pid, args-ret); } } tracepoint:syscalls:sys_exit_write /comm zsh/ { if (args-ret 0) { printf(%s(%d) write %d\n, comm, pid, args-ret); } } tracepoint:syscalls:sys_enter_write /writes[tid,(int64)args-fd]/ { // printf(%s(%d) write fd %d size %d\n, comm, pid, args-fd, args-count); writes[tid,(int64)args-fd] args-count; }创建了一个 systemd 单元让这个程序在后台永久运行似乎开销不大这样就可以通过以下命令查看日志midna % journalctl -fu zshhisttrace cp(2338700) close 3 (reads: 3407872, writes: 0) zsh(231222) symlink /pid-231222/host-midna - /home/michael/.zsh_history.LOCK zsh(231222) openat: /home/michael/.zsh_history flags 541 mode 180 zsh(231222) close 3 (reads: 0, writes: 0) zsh(231222) openat: /home/michael/.zsh_history flags 0 mode 0 zsh(231222) lseek fd 3 offset 0 whence 1 zsh(231222) read 0 zsh(231222) close 3 (reads: 52895744, writes: 0) zsh(231222) unlink /home/michael/.zsh_history.new zsh(231222) openat: /home/michael/.zsh_history.new flags c1 mode 180 zsh(231222) close 3 (reads: 0, writes: 52888907) zsh(231222) rename:/home/michael/.zsh_history.new - /home/michael/.zsh_history zsh(231222) unlink /home/michael/.zsh_history.LOCK有一天发现 shell 历史记录被截断了于是查看了日志发现了以下内容。注意这里没有 read 0 这一行也就是说Zsh 没有读取到文件末尾zsh(231233) symlink /pid-231233/host-midna - /home/michael/.zsh_history.LOCK zsh(231233) openat: /home/michael/.zsh_history flags 541 mode 180 zsh(231233) close 3 (reads: 0, writes: 0) zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0 zsh(231233) lseek fd 3 offset 0 whence 1 zsh(231233) lseek fd 3 offset 0 whence 1 zsh(231233) lseek fd 3 offset 11572944 whence 0 zsh(231233) close 3 (reads: 11575296, writes: 0) zsh(231233) unlink /home/michael/.zsh_history.new zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180 zsh(231233) close 3 (reads: 0, writes: 11572944) zsh(231233) rename:/home/michael/.zsh_history.new - /home/michael/.zsh_history zsh(231233) unlink /home/michael/.zsh_history.LOCK让它崩溃从上面的 bpftrace 输出可以看出Zsh 重写 .zsh_history 文件时出现了错误它读取的行数比平时少然后将这些内容正确地写入了 .zsh_history.new。这时决定研究代码找出 readhistfile 没有读取完整历史记录文件或者 savehistfile 没有写入完整历史记录文件的原因。[savehistfile 的控制流程](https://github.com/zsh-users/zsh/blob/zsh-5.9.1/Src/hist.c#L2899) 很难理解但修改代码zsh-5.9.1使其在写入少于 50000 行的 .zsh_history.new 文件后在将截断后的新文件替换 .zsh_history 之前崩溃还是比较容易的--- i/Src/hist.c w/Src/hist.c -2994,6 2994,7 savehistfile(char *fn, int err, int writeflags) if (out) { char *history_ignore; Patprog histpat NULL; int lines_written 0; pushheap(); -3048,6 3049,7 savehistfile(char *fn, int err, int writeflags) ret fputc( , out); if (ret 0 || (ret fputc(\n, out)) 0) break; lines_written; } if (ret 0 start writeflags HFILE_USE_OPTIONS) { struct stat sb; -3062,6 3064,10 savehistfile(char *fn, int err, int writeflags) } if (fclose(out) 0 ret 0) ret -1; if (tmpfile lines_written 50000) { char *crashptr (char*)0x23; *crashptr 42; } if (ret 0) { if (tmpfile) { if (rename(tmpfile, unmeta(fn)) 0) {在 Linux 上确保此类崩溃信息能被有效收集的最简单方法是安装 [systemd-coredump(8)](https://manpages.debian.org/systemd-coredump.8)安装后 systemd 会自动收集核心转储文件。可以使用 [coredumpctl(1)](https://manpages.debian.org/coredumpctl.1) 来列出和处理这些文件。请注意这些核心转储文件包含 shell 历史记录所以不要将它们上传到第三方服务。Fedora 的 [ABRT 似乎只发送微报告](https://abrt.readthedocs.io/en/latest/howitworks.html)即不包含完整的 shell 历史记录Ubuntu 的 [Apport 默认是禁用的](https://ubuntu.com/project/docs/contributors/debugging/apport/)但最好还是再次确认一下。安装了打了补丁的 Zsh 版本启用了调试符号并等待出现崩溃时生成的核心转储文件再进行进一步的调查。几天后当使用 coredumpctl 检查时果然发现了一次崩溃以下是回溯信息midna % coredumpctl debug gdb $ bt full #0 0x000056040d781e19 in savehistfile (fn0x56040f7a76b0 /home/michael/.zsh_history, err1, writeflags0) at hist.c:3086 crashptr 0x23 history_ignore 0x0 histpat 0x0 lines_written 45546 t 0x5604102a1f59 tmpfile 0x5604100ec210 /home/michael/.zsh_history.new start 0x5604102a1f40 make -j32 out 0x56040f939400 he 0x0 xcurhist 45546 extended_history 0 ret 10 #1 0x000056040d781f72 in savehistfile (fn0x56040f7a76b0 /home/michael/.zsh_history, err1, writeflags32771) at hist.c:3121 remember_histactive 0 history_ignore 0x0 histpat 0x0 lines_written 0 t 0x0 tmpfile 0x0 start 0x0 out 0x56040f939400 he 0x0 xcurhist 51183 extended_history 0 ret 0 #2 0x000056040d751197 in zexit (val0, from_whereZEXIT_NORMAL) at builtin.c:6055 writeflags 32768 #3 0x000056040d7888e2 in zsh_main (argc2, argv0x7ffd370c1758) at init.c:1950 errexit 0 t 0x7ffd370c1768 runscript 0x0 zsh_name 0x7ffd370c26bd zsh cmd 0x0 t0 162 #4 0x000056040d735d89 in main (argc2, argv0x7ffd370c1758) at ./main.c:93 No locals.回到源代码意识到很可能是 readhistfile 读取的历史记录较短导致 savehistfile 写出的历史记录文件也较短[readhistfile 的控制流程](https://github.com/zsh-users/zsh/blob/zsh-5.9.1/Src/hist.c#L2653) 相对容易理解。通读该函数发现有一个提前返回的可能性[当 Zsh 接收到信号时](https://github.com/zsh-users/zsh/blob/zsh-5.9.1/Src/hist.c#L2825-L2829)读取循环会通过 break; 语句终止// … if (errflag ERRFLAG_INT) { /* Cant assume fast read next time if interrupted. */ lasthist.interrupted 1; break; } // …看看在崩溃时 errflag 和 lasthist.interrupted 的值gdb $ p errflag $1 2 gdb $ p lasthist.interrupted $2 1找到了所以一定是某个信号导致了这个问题。由于某些超出本文讨论范围的原因使用 mosh 会话启动了一个长时间运行的 SSH 会话并在该会话上复用了更多会话。每天工作结束关闭这个设置时会在复用的会话中按 Ctrl D发送 EOF退出会话然后在长时间运行的 SSH 会话中按 Ctrl C最后按 Ctrl D 退出 mosh 会话。如果不彻底退出 mosh 会话它会在服务器上一直存在后续登录时会提示有孤立的会话。想避免积累这些孤立的会话。所以实际上会多次按 Ctrl D、Ctrl C直到所有窗口都关闭。在这个过程中如果历史记录重写耗时较长很可能是在退出 Zsh 会话Ctrl D后又中断了Ctrl Creadhistfile 的执行。有了这些线索构建了一个独立的复现程序并于 2025 年 3 月 [向 zsh-workers 邮件列表提交了一份 bug 报告](https://www.zsh.org/mla/workers/2025/msg00114.html)。Bart Schaefer 对此进行了调查并于 2025 年 4 月 [发布了一个修复方案](https://www.zsh.org/mla/workers/2025/msg00156.html)感谢他。由于 Zsh 长时间没有发布新版本这个修复方案过了很久才正式发布。后来当 5.9.1 版本发布时发现发布工程师遗漏了 Bart 的修复指出了这个疏忽幸运的是Zsh 5.9.2 包含了这个修复。一直在使用应用了 Bart 补丁的 Zsh 5.9 版本并会一直锁定这个版本直到 5.9.2 版本在计算机上可用。如果在 Debian 上锁定 Zsh 版本请同时锁定 zsh 和 zsh-common 包否则某天可能会发现 zsh 包不见了……漏洞原因是什么退出时zexit 会调用 savehistfile 来压缩历史记录在会话期间历史记录条目会逐步追加但在 shell 退出时历史记录文件会被压缩例如如果配置了大小限制因此 savehistfile 会读取整个历史记录readhistfile并重新写入。readhistfile 在收到信号时可能会被中断它会检查 errflag ERRFLAG_INT 并提前终止读取循环但 savehistfile 在退出时写入 shell 历史记录时没有检查是否被中断。因此savehistfile 会写入不完整的历史记录从而截断实际的历史记录。让我们来解读一下之前收集的 bpftrace 输出zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0 zsh(231233) lseek fd 3 offset 0 whence 1 # […] 读取操作会被汇总见下文 […] # […] 中断发生在这里 […] # lseek(3, 0, SEEK_CUR) 查询当前的查找偏移量 zsh(231233) lseek fd 3 offset 0 whence 1 # 按照 POSIX 要求在 fclose() 时进行 SEEK_SET见下文 zsh(231233) lseek fd 3 offset 11572944 whence 0 zsh(231233) close 3 (reads: 11575296, writes: 0) zsh(231233) unlink /home/michael/.zsh_history.new zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180 zsh(231233) close 3 (reads: 0, writes: 11572944) zsh(231233) rename:/home/michael/.zsh_history.new - /home/michael/.zsh_history为什么会有 lseek 操作呢根据 [POSIX.1 - 2017 关于 fclose() 的规定](https://pubs.opengroup.org/onlinepubs/9699919799/functions/fclose.html)如果文件未到达文件末尾且该文件支持查找操作那么当流是底层打开文件描述符的活动句柄时底层打开文件描述符的文件偏移量应设置为流的文件位置。Zsh 使用 fopen() 来获取流因此 glibc 会以 4096 字节为块进行读取。关闭流时需要将底层文件描述符的偏移量重置以便下一个流能正确读取当前 4096 字节块中已经读取的部分。