前言

Project Hazelita 社群并非该漏洞的最早发现者,并且在该文章发布之前许久,该漏洞已经被修复。本文仅仅作 Project Hazelita 社群独立分析该漏洞全过程的记录。感谢 Project Hazelita 社群朋友们的帮助!这里的技术力真的越来越高了,我觉得大伙都应该教我 /求放过

初探之喜

零音正在西咸新区游玩,突然看到 Project Hazelita 社群里,AstralFlare-owo 发了一条消息,说,在 Zsh 中输入 !!3350964333 会触发 SIGSEGV。最初的零音并不太确信这类问题的存在,毕竟 Zsh 作为成熟的 shell 已经运行了几十年,而且若问题发生在 !! 处,即 history expansion 阶段,这一阶段仍然在以字符串解析指令,根本没有真正执行命令,在手机的 Termux 上亦无法复现。但后来经过 Hello8693 等群友的验证,确认了这一问题可能是普遍存在的。

焰在群里说,在 Zsh 中输入 !!3350964333 会触发 SIGSEGV
焰在群里说,在 Zsh 中输入 !!3350964333 会触发 SIGSEGV

这时的零音就非常感兴趣了。AstralFlare-owo 使用 pwndbg 调试了 zsh,截获到了 SIGSEGV 时的信息:

1
2
3
4
#3  0x000055acbc1375ba in zshlex ()
#4 0x000055acbc15afc7 in parse_event ()
#5 0x000055acbc1269f0 in loop ()
#6 0x000055acbc12aad4 in zsh_main ()

初步判定是 parse_event 的问题,并锁定了问题应当出现在整数溢出上。 此时的零音更加饥渴难耐了 于是一回到家便开始进行分析。根据 AstralFlare-owo 提供的情报:

焰说 4294967295、2147483648 等值都没问题
焰说 4294967295、2147483648 等值都没问题

阿焰: 02-24 16:44:38
猜测是这个数值不知怎么的歪打正着绕过了检测

阿焰: 02-24 16:44:55
以及这附近一小块的数值都能触发 bug

阿焰: 02-24 16:45:28
因为包括 4294967295 2147483648 之类的极端数值都没事

阿焰: 02-24 16:57:01
我试了一下 !!<=2147483647 都是正常行为

阿焰: 02-24 16:57:15
!!>=2147483648 就开始出现一些异常情况了

阿焰: 02-24 17:00:01
基本上来说 Bug 起始于 !!2147500000 +- 10000 附近 随历史记录数量变化 历史记录越多 故障越靠后

阿焰: 02-24 17:05:33
一直到 !!4294500000 +- 100000 附近

零音开始了第一轮自测,确定了该漏洞的存在。按照 Hello8693 的说法,「输入该指令会导致终端关闭」,结合零音在执行该指令后的情况,怀疑是 Zsh 因 SIGSEGV 而自动 killed。

零音在终端里输入完 !!3350964333 后终端直接飞了,错误码 0x8b
零音在终端里输入完 !!3350964333 后终端直接飞了,错误码 0x8b

但为何该漏洞会工作呢?零音打算使用 pwndbg 进行调试。使用 pwndbg --args zsh -f 以纯净模式打开 zsh 并接入调试,同时使用 set pagination off 关闭分页、set disassemble-next-line on 打开 RIP 附近指令的反汇编自动显示 虽然这俩默认好像是开着的?( 以及使用 set follow-fork-mode child 使得 pwndbg 自动跟随 fork() 的子进程,使用 set detach-on-fork off 关闭自 detach,运行并复现了该问题。

问题在 Nix 分发的 Zsh 上复现
问题在 Nix 分发的 Zsh 上复现

卧槽,情报好像是真的?求放过

事情从而有趣了起来。我们注意看 R8,它是一个位于堆上的合法的基地址;但 RCX 的值就跟诡异了:0xffffffff8f7750da!它的高位几乎全部是 f,这几乎可以笃定是 32 位负数被符号扩展(Sign-Extended)到 64 位的。并且在零音的复现中,程序依然是 SIGSEGV 在 parse_event 中的,这证明了崩溃并非随机事件,漏洞是真实存在的。另外,注意到,若在直接启用的 zsh 内调用了 zsh,则里层 zsh 的 segmentation fault 崩溃可被直接显示出来。 (你给我 core dumped 到哪去了?)

如果是 Zsh 里套 Zsh,则内层 Zsh 会提示 segmentation fault
如果是 Zsh 里套 Zsh,则内层 Zsh 会提示 segmentation fault

源码之审

但是…这漏洞,究竟存在在哪呢?零音不免会好奇究竟在 Zsh 的哪一部分出现了这一漏洞。考虑到系统自带的 Zsh 去掉了调试符号,在这里零音选择自己从源码构建一份带调试符号的 Zsh 以进一步分析。

此时的零音,仍然抱着对 Project Hazelita 挖出史诗级漏洞的期许,,

零音:我怎么感觉我们要见证历史了
零音:我怎么感觉我们要见证历史了

由于零音在 WSL2 Ubuntu 上使用 Nix 管理软件包,零音直接使用 nix-shell 获取纯净的编译环境:

1
nix-shell -p gcc gnumake ncurses.dev yodl valgrind

并下载 Zsh 5.9 源码:

1
2
3
wget https://sourceforge.net/projects/zsh/files/zsh/5.9/zsh-5.9.tar.xz
tar -xf zsh-5.9.tar.xz
cd zsh-5.9

随后编译即可。不过,在编译过程中,零音总共遇到了两个坑点,这里首先说明。

坑点 I:boolcodes 的冲突

在编译过程中遇到的第一个坑点是 boolcodes 的冲突。Zsh 为了能够在终端显示颜色和控制光标,需要依赖一个叫 ncurses 的底层库。在 Zsh 的源码中,会用到一些关于终端特性的数组,其中一个就是 boolcodes。Zsh 为了确保这个数组一定存在,便使用宏逻辑定义备用的 boolcodes,但零音使用的 ncurses 6.6 开发版为了规范,为 boolcodes 加上了严格的 const 常量限制,从而 gcc 判定类型冲突,编译中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
termcap.c:45:14: error: conflicting types for ‘boolcodes’; have ‘char *[]’
45 | static char *boolcodes[] = {
| ^~~~~~~~~
In file included from ../../Src/zshterm.h:1,
from ../../Src/zsh_system.h:932:
/nix/store/n7iyqawbhv90ikx7casy5sapwpqj870b-ncurses-6.6-dev/include/term.h:784:56: note: previous declaration of ‘boolcodes’ with type ‘const char * const[]’
784 | extern NCURSES_EXPORT_VAR(NCURSES_CONST char * const ) boolcodes[];
| ^~~~~~~~~
make[3]: *** [Makefile:230: termcap.o] Error 1
make[3]: Leaving directory '/mnt/d/Projects/CTF_Projects/competitions/Individuals/LyCecilion/zshcrash/zsh-5.9/Src/Modules'
make[2]: *** [Makemod:372: modobjs] Error 1
make[2]: Leaving directory '/mnt/d/Projects/CTF_Projects/competitions/Individuals/LyCecilion/zshcrash/zsh-5.9/Src'
make[1]: *** [Makefile:449: modobjs] Error 2
make[1]: Leaving directory '/mnt/d/Projects/CTF_Projects/competitions/Individuals/LyCecilion/zshcrash/zsh-5.9/Src'
make: *** [Makefile:188: all] Error 1

为了解决这一点,我们追加这一 CPPFLAGS,使用 -DHAVE... 强迫不使用 Zsh 源码中宏定义的备用版本。

1
CPPFLAGS="-g -O0 -DHAVE_BOOLCODES -DHAVE_NUMCODES -DHAVE_STRCODES -DHAVE_BOOLNAMES -DHAVE_NUMNAMES -DHAVE_STRNAMES"

坑点 II:Nix 的神秘 gcc wrapper

Nix 非常注重包的体积和安全性,从而在 nix-shell -p gcc 中调用的 gcc,是 Nix 的一个 Shell 包装器!在零音的第一次编译中,wrapper 将零音的最终编译产物的符号表剥离(strip)了,从而零音无法在 pwndbg 中看到具体符号。 不是你怎么这么自作主张😡

make clean 后,我们设置环境变量以明确 gcc 的行为。

1
2
export NIX_CFLAGS_COMPILE="-g -O0"
export NIX_HARDENING_ENABLE=""

总而言之,绕过这两个坑点后,我们使用下面的指令编译:

1
2
./configure --enable-zsh-debug CFLAGS="-g -O0" CPPFLAGS="-g -O0 -DHAVE_BOOLCODES -DHAVE_NUMCODES -DHAVE_STRCODES -DHAVE_BOOLNAMES -DHAVE_NUMNAMES -DHAVE_STRNAMES"
make -j$(nproc) CFLAGS="-g -O0" CPPFLAGS="-g -O0 -DHAVE_BOOLCODES -DHAVE_NUMCODES -DHAVE_STRCODES -DHAVE_BOOLNAMES -DHAVE_NUMNAMES -DHAVE_STRNAMES"

使用 file 可以查看最终的编译产物:

1
file ./Src/zsh
这次编译好的 zsh 带了符号表
这次编译好的 zsh 带了符号表

with debug_info, not stripped 指示了符号表的存在。从而,我们使用 pwndbg --args ./Src/zsh -f 重新调试 zsh,发现该崩溃仍然可以触发。

1
2
3
4
5
stella16% echo LyCecilion loves DryIce-cc forever
LyCecilion loves DryIce-cc forever
stella16% !!3350964333

Program received signal SIGSEGV, Segmentation fault.
这次编译过的 zsh 也会触发错误
这次编译过的 zsh 也会触发错误

报错指示了问题出现在 hists.c 的 2438 行附近,即一处边界检查:

1
2
3
4
5
6
if (arg2 < arg1 || arg1 >= nwords || arg2 >= nwords) {
/* remember, argN is indexed from 0, nwords is total no. of words */
herrflush();
zerr("no such word in event");
return NULL;
}

J2rQ(y) 给出了分析。 J2rQ(y) 分析啥了?零音也不知道,但给小孩一点人气吧。

小孩在攻击零音😭😭😭
小孩在攻击零音😭😭😭

这里,pwndbg 指示函数调用为

1
getargs (elist=0x5555557768a0, arg1=-944002963, arg2=-944002963) at hist.c:2447

因为整型溢出,我们输入的 3350964333 已经变成了 -944002963,作为 arg1arg2 被传入了 getargs。但 Zsh 的开发者在这里的边界检查出了问题:他们

  1. 校验了 arg2 < arg1,以确保提取的结束范围不能小于起始范围。
  2. 校验了 arg1 >= nwordsarg2 >= nwords,以确保提取的词的索引不能超过当前历史记录的总词数。

但遗憾地:两个 arg 已经变成了负数,从而第 2 点检验完全通过了!于是代码来到第 2447 行的 OOB Read,C 语言去读取 words[-1888005926] 的值,狂奔几十 GB 的内存空间后 SIGSEGV。

另外,由于这一特性,事实上我们可以做到更多。在执行 echo LyCecilion loves DryIce-cc forever 后:

  • 使用 !!2147483648,事实上会因为 int 的回环从而执行 echo 自身。
  • 使用 !!2147483649zsh 不会 SIGSEGV,但会提示 zsh: fatal error: out of heap memory
  • 使用 !!2147483650zsh 回显出 滚木

(零音 fuzzing 的时候发现了一个特别神秘的值,可以在崩溃后达成输入一个出现俩,但只能删掉一个字母的特性,但零音忘了,,贵人多忘事这一块……)

成神之路?

就在零音为这一发现而得意时,突然发现晴天霹雳:原来这个漏洞已经被人发现了?!

原来,就在几个月前(2025 年 7 月),一位 17 岁的安全研究员 livepwn 发现了该漏洞,并在其 GitHub Pages 上给出了一个 PoC,为该漏洞起名为 ZshShock,并 通过邮件 告知了 zsh-workers不得不说这 GitHub Pages 网站一眼就能看出来绝对是 AI 写的

livepwn 的 GitHub Pages 页面,一股 AI 味
livepwn 的 GitHub Pages 页面,一股 AI 味

然而,仅 7 小时后,Mikael Magnusson 便通过邮件回复 livepwn:

This seems to be mostly nonsense, maybe ai generated[1]? But the crash does happen, this patch fixes it but I don’t use history substitution much … Nobody was notified at those dates, and today is not may 19th. Also the rest is mostly nonsensical, why would you locate the pointer to system() in glibc when you’re already writing arbitrary commands into a shell session.

翻译: 废话怎么这么多,这玩意怎么看着是 AI 写的?不过崩溃是真的,这个补丁修了——虽然这功能我也不爱用。今天不是 5 月 19 日,那些日子(应该指的是 GitHub Pages 上的日期)也没通知我们啊。你写得其他东西也挺神秘的,都能在 shell 会话中写任意指令了,为啥还费那么大劲定位 glibc 的 system() 指针?

于是……就用俩补丁修了这个漏洞。不过这边看来 livepwn 应该是备受打击了,在 Medium 上发布文章 My last Writeup (0day in Zsh (RCE))

I am Rana M.Sinan Adil aka (livepwn). I am 17 years old i was working on bug and also created a exploit.I don,t know much about report writing, so i took help of chatgpt to write for me. I reported this in zsh-security but they said a very harsh words “This seems to be mostly nonsense, maybe ai generated[1]”. This broke my heart, my eye,s turned in to a glass of water and i am quiting cyber security,because i waited for their reponse that they will tell some words that will motivate me, but. This is my life last hacking blog about the bug i found and also the exploit that i wrote. Hope you will enjoy :)

翻译: 我是 Rana M. Sinan Adil,也就是那个 livepwn,今年 17 岁。我最近一直在看这个漏洞,我还写了个 exploit!但我真不知道怎么写漏洞报告,所以就让 ChatGPT 帮忙了。我把漏洞交给了 zsh-security,结果他们还骂我半天,我好难受啊😭!挖出来这漏洞,竟然都不夸我一下?反正,我以后再也不搞网络安全了。这是我最后一篇网络安全博客了,望多多包涵。😢

啧……零音读到这里心头一紧啊。其实零音有点同情 livepwn 来着。记得初一的零音第一次学全等三角形的时候,花了一晚上时间研究出来了相似,还取名为「半全等」,搞出来一堆有用的性质;谁知道第二天拿去给班上同学分享的时候,他们却只说这玩意初二会学了。哎……看着 livepwn 难受零音也难受啊😭零音还以为 Project Hazelita 首发这个漏洞了😭😭何意味……

零音:我好难受啊😭
零音:我好难受啊😭

但有一说一,livepwn 这个漏洞确实没法 RCE,尽管 livepwn 本人说可以。这个…根本不具备 RCE 的条件啊!livepwn 给的 exploit 竟然还要开 gdb,难道你要攻击一个人的电脑,还要让对方打开 gdb 改一堆寄存器输一堆指令吗?🤔所以感觉 livepwn 应该还是有点心急了。不过还是有点同情对方的——看了看 livepwn 的 Medium,livepwn 应该是一个 CTFer,Medium 上发了好多 CTF writeup,好像和零音一样,最近也在学 Heap(虽然零音已经淡坑 CTF 了)。不过真正遗憾的是——从这篇文章之后,livepwn 真的没有再发过 Medium 文章了——已经 5 个月了。😢不知道为啥啊,零音也感觉心里有点闷的慌。

我也曾经那么热烈地相信自己发现了宇宙的裂缝。

所以最终是怎么修好的?

Mikael Magnusson 起初的想法是修改 getargs 函数的声明,即

1
2
- getargs(Histent elist, int arg1, int arg2)
+ getargs(Histent elist, unsigned int arg1, unsigned int arg2)

但 Bart Schaefer 最终反驳了这一点。Bart 说:

Changing the integer type of getargs() does fix the specific reported crash but the erroneous code is called from other places as well, so better to detect the bad integer and report error. I’m not even sure the reported example is meant to be valid history syntax? “!!” followed by a word designator isn’t documented, and doesn’t seem to do anything except verify that the previous event has at least that many words. The entire event is still repeated as far as I can tell.

翻译:getargs() 的参数类型确实能行,但是问题是这段代码也会在其他地方被调用,所以最好还是在检查到无效整数的时候报错。我最纳闷的是到底是谁在用 !! 这种语法,文档里都没提过这个啊?难道只是验证了一下上一条指令真有这么多单词吗…?

并给出了最终 patch。

1
2
3
4
5
6
7
8
9
10
11
ret = 0;
while (idigit(c)) {
ret = ret * 10 + c - '0';
if (ret < 0) {
herrflush();
zerr("no such word in event");
return -2;
}
c = ingetc();
}
inungetc(c);

这个修改作用于 getargsspec(),它负责解析历史扩展里的词索引,将字符串中对应位置的字符以累乘的方式转成数字。每当进行一次 ret = ret * 10 + c - '0'; 后,若累乘得到的结果太大,则 ret 会变成负数,最终引起问题。新 patch 加入了 if (ret < 0) 的校验,直接从解析层面避免了这一问题。为何 Bart 不喜欢 unsigned int 的改法呢?事实上,getargs() 的崩溃来自于溢出后的负数仍能继续被使用。将类型改为 unsigned 后只是掩盖了负数的存在,但并不能确保非法值不再传入。故 Bart 的修改事实上更加稳妥。

终末之诗

虽然说不是最新最热震撼首发,但毕竟是自己从头到尾挖出来的,应该是开心的吧😋虽然零音也不知道这玩意到底严格意义上算不算一个漏洞,但总之……哎零音你不是说自己不打 CTF 了,要转前端了吗?怎么一天变三次脸啊😡可恶的水母 😭呜呜呜别骂我了我要抑郁了

感谢 LittleKan星澜曦光、Tusitala、FireworkRocket霜月澪真 等朋友们在分析过程中的帮助。特别的感谢献给 Gemini,Gemini 在零音的分析全程中提供了莫大的支持,掌声送给 Gemini! 这篇文章献给全体 Project Hazelita 社群成员。(* ̄3 ̄)╭♡ 致零音永远热爱的 Project Hazelita。