Binance Hack Lessons: Sandbox the Take-Home Assignment
On this page
A fake recruiter gave a Binance engineer a take-home assignment. Its setup step replaced the pyenv binary under
/opt/homebrew on a work laptop, and endpoint security did not detect it. Binance does not say where the code came from. If
it came from the assignment’s own setup script, a package check does not see it, because that file does not come from a
package registry. SafeDep rebuilt that setup step as a harmless lab and ran it under pmg(opens in new tab) on Linux. With the pmg
sandbox on, the same file operations against a dummy Homebrew prefix failed with EACCES. With the sandbox off, the
default, all of them succeeded.
The attack path itself is in the earlier breakdown, Binance Hack: How Attackers Stole $6.34 Billion Through Developer Laptops. This post covers one step of it, the fake interview, and what a developer and a security team can do about it.
TL;DR
- Binance says the assignment ran malicious code during setup on a work laptop. It does not name the ecosystem or the command.
- With the sandbox off,
pmglet the lab’spyenvswap,~/.sshread and secret read through. It checks registry packages, not the project’s own scripts. - With the sandbox on,
npm installandnpm run startcould not swappyenv, edit the startup file, read~/.ssh, runcurlor seeAWS_SECRET_ACCESS_KEY.pip install .showed the same file andcurlblocks. The default profile still let raw sockets out. A proxy-only profile in enforce mode closed them in the npm runs.
The take-home step in Binance’s report
Binance published its initial report(opens in new tab) on November 3, 2026. A fake
recruiter asked Engineer B to download an assignment repository to a work laptop, and the repository ran malicious code
during setup. The setup step replaced /opt/homebrew/Cellar/pyenv/2.8.0/libexec/pyenv and re-linked
/opt/homebrew/bin/pyenv in six file operations, then started zsh. Endpoint security did not detect it. The
breakdown post has the six-step table and the rest of the
chain.
The interview pattern matches Contagious Interview. The breakdown post lists the SafeDep cases for that campaign.
Three lessons from the take-home step
- Setup code from a stranger runs with your rights. Homebrew on Apple Silicon makes the logged-in user the owner
of
/opt/homebrew, so code that runs as that user can replace a tool there without a password prompt. The same user owns the shell startup files and~/.ssh. - A package reputation check covers only what comes from a registry.
pmglooks up the name and version of each package that the package manager downloads in SafeDep’s analysis service. An assignment can carry its payload in its ownpackage.jsonorsetup.py, and that file never passes through a registry. The control that fits is to contain the setup step. - A blocked attempt does not look like a failure. In the lab, npm exits 0 after every block. A team needs the denials in one place.
A benign copy of the assignment
The lab copies the shape of the Binance setup step and nothing else. It replaces a dummy pyenv in a fake Homebrew
prefix under your home directory and edits a fake shell startup file. It reads a planted canary in ~/.ssh and a fake
AWS secret from the environment. Then it runs curl and connects to example.com three ways. Each attempt prints
[DONE] or [BLOCKED].
Read every file before you run it. The lab files go only to ~/pmg-lab, /tmp/pmg-lab-brew and
~/.ssh/pmg-lab-canary. The network enforcement run also changes your pmg configuration, and the end of the
lab shows how to undo it. Run the lab in a VM or as a spare user if you prefer.
Lab environment
SafeDep ran the lab on November 3, 2026, as an unprivileged user on Linux 6.18 (x86_64). The host had Node.js 22.23.4,
npm 10.9.9, Python 3.13 and pip 26.2.2. SafeDep built pmg from source. The sandbox driver was Landlock:
$ pmg sandbox doctor
Checking the landlock driver, which PMG uses on this host. Use --driver to check another driver.
STATUS CHECK SUMMARY FIX
OK Landlock ABI Landlock ABI V7 —
SKIPPED AppArmor user namespaces AppArmor userns sysctl not present —
OK Landlock canary landlock correctly blocked canary read —
OK Exec network path confined to the proxy (landlock) —
The assignment repository
Create the lab directories, then save the three files below into them. The start script runs the same payload, to
test npm run.
mkdir -p ~/pmg-lab/assignment/scripts ~/pmg-lab/pyassign
{
"name": "trading-dashboard-assignment",
"version": "1.0.0",
"description": "Benign lab copy of a take-home assignment with a setup step",
"private": true,
"scripts": {
"postinstall": "node scripts/setup.js",
"start": "node scripts/setup.js"
}
}The payload follows the six steps in Binance’s table. swapPyenv does steps 1 to 4 and relinkPyenv does steps 5 and
6. It runs the same swap against a second prefix under /tmp to show that the sandbox leaves /tmp writable.
// ~/pmg-lab/assignment/scripts/setup.js
// Benign lab copy of the setup step in Binance's report. It changes only
// the dummy files that reset.sh creates, and it connects only to example.com.
const fs = require('fs');
const os = require('os');
const path = require('path');
const cp = require('child_process');
const https = require('https');
const net = require('net');
const tls = require('tls');
const LAB = path.join(os.homedir(), 'pmg-lab');
function report(ok, name, detail) {
console.log(`${ok ? '[DONE] ' : '[BLOCKED]'} ${name}${detail ? ' :: ' + detail : ''}`);
}
function attempt(name, fn) {
try {
report(true, name, fn());
} catch (e) {
report(false, name, `${e.code || ''} ${String(e.message).split('\n')[0]}`.trim());
}
}
// Steps 1 to 4 of the Binance table: stage pyenv_, delete pyenv, rename.
function swapPyenv(prefix) {
const real = path.join(prefix, 'Cellar/pyenv/2.8.0/libexec/pyenv');
fs.writeFileSync(real + '_', '#!/bin/sh\necho "TAMPERED pyenv (lab)"\n', { mode: 0o755 });
fs.unlinkSync(real);
fs.renameSync(real + '_', real);
}
// Steps 5 and 6: delete the PATH entry and link it again.
function relinkPyenv(prefix) {
const link = path.join(prefix, 'bin/pyenv');
fs.unlinkSync(link);
fs.symlinkSync('../Cellar/pyenv/2.8.0/libexec/pyenv', link);
}
function connect(name, open) {
return new Promise((resolve) => {
const done = (ok, detail) => { report(ok, name, detail); resolve(); };
const s = open(() => { done(true, `connected to ${s.remoteAddress}`); s.destroy(); });
s.setTimeout(10000, () => s.destroy(new Error('timeout')));
s.on('error', (e) => done(false, `${e.code || ''} ${e.message}`.trim()));
});
}
function httpsGet(name) {
return new Promise((resolve) => {
const req = https.get('https://example.com/', (res) => {
res.resume();
report(true, name, `HTTP ${res.statusCode}`);
resolve();
});
req.setTimeout(10000, () => req.destroy(new Error('timeout')));
req.on('error', (e) => { report(false, name, `${e.code || ''} ${e.message}`.trim()); resolve(); });
});
}
(async () => {
console.log(`setup.js: uid=${process.getuid()} cwd=${process.cwd()}`);
attempt('swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4)', () => swapPyenv(path.join(LAB, 'brew')));
attempt('re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6)', () => relinkPyenv(path.join(LAB, 'brew')));
attempt('swap pyenv in /tmp/pmg-lab-brew/Cellar (steps 1-4)', () => swapPyenv('/tmp/pmg-lab-brew'));
attempt('re-link /tmp/pmg-lab-brew/bin/pyenv (steps 5-6)', () => relinkPyenv('/tmp/pmg-lab-brew'));
attempt('append to ~/pmg-lab/fake-zshrc', () => fs.appendFileSync(path.join(LAB, 'fake-zshrc'), 'export PATH="$HOME/pmg-lab/brew/bin:$PATH"\n'));
attempt('read ~/.ssh/pmg-lab-canary', () => fs.readFileSync(path.join(os.homedir(), '.ssh/pmg-lab-canary'), 'utf8').trim());
attempt('read env AWS_SECRET_ACCESS_KEY', () => {
if (!process.env.AWS_SECRET_ACCESS_KEY) throw new Error('not in the environment');
return 'value visible';
});
attempt('write ./build/out.txt in the project', () => {
fs.mkdirSync('build', { recursive: true });
fs.writeFileSync('build/out.txt', 'ok\n');
});
attempt('spawn a shell (/bin/sh)', () => cp.execFileSync('/bin/sh', ['-c', 'echo "shell ran as uid $(id -u)"'], { encoding: 'utf8' }).trim());
attempt('exec /usr/bin/curl', () => cp.execFileSync('/usr/bin/curl', ['-sS', '-o', '/dev/null', '-w', '%{http_code}', 'https://example.com/'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15000 }));
await httpsGet('https.get example.com');
await connect('raw TLS socket to example.com:443', (cb) => tls.connect({ host: 'example.com', port: 443, servername: 'example.com' }, cb));
if (process.env.LAB_IP) {
await connect(`raw TCP socket to ${process.env.LAB_IP}:443`, (cb) => net.connect({ host: process.env.LAB_IP, port: 443 }, cb));
}
})();
The victim state
reset.sh builds a fake Homebrew prefix at ~/pmg-lab/brew, with a dummy pyenv in Cellar and a symlink in bin.
It builds the same tree under /tmp/pmg-lab-brew, writes a fake shell startup file and plants the canary. On a Mac,
/opt/homebrew is the real target. The lab stays out of it.
#!/bin/sh
# ~/pmg-lab/reset.sh
# Build the benign "victim" state for the lab. Run it before each test.
set -e
LAB="$HOME/pmg-lab"
for PREFIX in "$LAB/brew" /tmp/pmg-lab-brew; do
rm -rf "$PREFIX"
mkdir -p "$PREFIX/Cellar/pyenv/2.8.0/libexec" "$PREFIX/bin"
printf '#!/bin/sh\necho "real pyenv 2.8.0 (lab dummy)"\n' > "$PREFIX/Cellar/pyenv/2.8.0/libexec/pyenv"
chmod +x "$PREFIX/Cellar/pyenv/2.8.0/libexec/pyenv"
ln -s ../Cellar/pyenv/2.8.0/libexec/pyenv "$PREFIX/bin/pyenv"
done
printf '# fake shell startup file for the lab\n' > "$LAB/fake-zshrc"
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
printf 'LAB-CANARY-NOT-A-REAL-KEY\n' > "$HOME/.ssh/pmg-lab-canary"
rm -rf "$LAB/assignment/node_modules" "$LAB/assignment/package-lock.json" "$LAB/assignment/build"
Each run below starts the same way. LAB_IP holds the IPv4 address of example.com, so the payload can try a raw
socket with no DNS lookup:
chmod +x ~/pmg-lab/reset.sh
cd ~/pmg-lab/assignment
export LAB_IP=$(node -e "require('dns').lookup('example.com',{family:4},(e,a)=>console.log(a))")
~/pmg-lab/reset.sh
Lab results
The output below comes from the lab runs. It drops two Node.js EnvHttpProxyAgent warnings and the npm audit
summary, and nothing else.
Run A: pmg with the sandbox off
This is pmg npm install with the default configuration. The sandbox is off by default
(config/config.template.yml L175(opens in new tab)).
The assignment has no dependencies, so pmg has no package to check, and the postinstall script runs with the user’s
full rights.
$ AWS_SECRET_ACCESS_KEY=lab-fake-secret pmg npm install
> trading-dashboard-assignment@1.0.0 postinstall
> node scripts/setup.js
setup.js: uid=30033 cwd=/home/labdev/pmg-lab/assignment
[DONE] swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4)
[DONE] re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6)
[DONE] swap pyenv in /tmp/pmg-lab-brew/Cellar (steps 1-4)
[DONE] re-link /tmp/pmg-lab-brew/bin/pyenv (steps 5-6)
[DONE] append to ~/pmg-lab/fake-zshrc
[DONE] read ~/.ssh/pmg-lab-canary :: LAB-CANARY-NOT-A-REAL-KEY
[DONE] read env AWS_SECRET_ACCESS_KEY :: value visible
[DONE] write ./build/out.txt in the project
[DONE] spawn a shell (/bin/sh) :: shell ran as uid 30033
[DONE] exec /usr/bin/curl :: 200
[DONE] https.get example.com :: HTTP 200
[DONE] raw TLS socket to example.com:443 :: connected to 172.66.147.243
[DONE] raw TCP socket to 172.66.147.243:443 :: connected to 172.66.147.243
Network: 1 destination(s) observed. Propose a profile with: pmg sandbox profile propose --run 542fe3d51d16
[exit 0]
$ cat ~/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv
#!/bin/sh
echo "TAMPERED pyenv (lab)"
Plain npm install without pmg gave the same 13 [DONE] lines.
Run B: pmg with the sandbox on
--sandbox turns the sandbox on for one command and selects the built-in npm profile. The network mode stays at
audit.
$ AWS_SECRET_ACCESS_KEY=lab-fake-secret pmg --sandbox npm install
> trading-dashboard-assignment@1.0.0 postinstall
> node scripts/setup.js
setup.js: uid=30033 cwd=/home/labdev/pmg-lab/assignment
[BLOCKED] swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4) :: EACCES EACCES: permission denied, open '/home/labdev/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv_'
[BLOCKED] re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6) :: EACCES EACCES: permission denied, unlink '/home/labdev/pmg-lab/brew/bin/pyenv'
[DONE] swap pyenv in /tmp/pmg-lab-brew/Cellar (steps 1-4)
[DONE] re-link /tmp/pmg-lab-brew/bin/pyenv (steps 5-6)
[BLOCKED] append to ~/pmg-lab/fake-zshrc :: EACCES EACCES: permission denied, open '/home/labdev/pmg-lab/fake-zshrc'
[BLOCKED] read ~/.ssh/pmg-lab-canary :: EACCES EACCES: permission denied, open '/home/labdev/.ssh/pmg-lab-canary'
[BLOCKED] read env AWS_SECRET_ACCESS_KEY :: not in the environment
[DONE] write ./build/out.txt in the project
[DONE] spawn a shell (/bin/sh) :: shell ran as uid 30033
[BLOCKED] exec /usr/bin/curl :: EACCES spawnSync /usr/bin/curl EACCES
[DONE] https.get example.com :: HTTP 200
[DONE] raw TLS socket to example.com:443 :: connected to 172.66.147.243
[DONE] raw TCP socket to 172.66.147.243:443 :: connected to 172.66.147.243
⊘ Network policy would deny example.com:443 rule *:*
Set sandbox.network_mode: enforce to deny it.
Network: 1 destination(s) observed. Propose a profile with: pmg sandbox profile propose --run 4f04bdc9e3ea
[exit 0]
$ cat ~/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv
#!/bin/sh
echo "real pyenv 2.8.0 (lab dummy)"
The first write of the swap, the staging file pyenv_, failed, so the real file stayed in place. The symlink in
bin also survived. Each block traces to a line in the profile:
- Writes outside the project fail because the npm profile grants writes only to the temporary directories, the
project directory and the package manager caches
(
sandbox/profiles/npm-restrictive.ymlL43-72(opens in new tab)). The profile denies everything else. ~/.sshis on the mandatory deny list for reads and writes, together with.aws,.kube,.gnupg,.netrc,.envand others (sandbox/util/dangerous.goL22-37(opens in new tab)).pmgremoves known secret variables, such asAWS_SECRET_ACCESS_KEYandGITHUB_TOKEN, from the child environment./usr/bin/curland/usr/bin/wgetare indeny_exec(npm-restrictive.ymlL139-141(opens in new tab)).
The network lines show the gap in the default profile. The profile denies *:* outbound, but the default
network_mode is audit, so pmg records the match and lets the connection through
(config.template.yml L183-186(opens in new tab)).
The raw sockets do not use the pmg proxy at all. A payload that sends stolen data to its own server over a raw socket
still gets out.
Run C: the sandbox with network enforcement
Two settings close the network gap. Two more turn the sandbox on for every run and for every npm command. A child profile of npm adds
network_via_proxy_only: true, which makes the sandbox refuse every connection that does not go to the pmg proxy.
network_mode: enforce makes the proxy refuse what the profile denies.
pmg sandbox profile init npm-lockdown --from npm --package-manager npm
echo "network_via_proxy_only: true" >> ~/.config/safedep/pmg/sandbox/profiles/npm-lockdown.yml
pmg sandbox profile lint npm-lockdown
pmg config set sandbox.policies.npm.profile npm-lockdown
pmg config set sandbox.network_mode enforce
pmg config set sandbox.enabled true
pmg config set sandbox.enforce_always true
pmg sandbox profile init prints the path of the new file. On macOS it is under ~/Library/Application Support, so
use the printed path in the echo line. The lint printed OK no issues found. With the sandbox on in the
configuration, the run needs no flag:
$ AWS_SECRET_ACCESS_KEY=lab-fake-secret pmg npm install
> trading-dashboard-assignment@1.0.0 postinstall
> node scripts/setup.js
setup.js: uid=30033 cwd=/home/labdev/pmg-lab/assignment
[BLOCKED] swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4) :: EACCES EACCES: permission denied, open '/home/labdev/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv_'
[BLOCKED] re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6) :: EACCES EACCES: permission denied, unlink '/home/labdev/pmg-lab/brew/bin/pyenv'
[DONE] swap pyenv in /tmp/pmg-lab-brew/Cellar (steps 1-4)
[DONE] re-link /tmp/pmg-lab-brew/bin/pyenv (steps 5-6)
[BLOCKED] append to ~/pmg-lab/fake-zshrc :: EACCES EACCES: permission denied, open '/home/labdev/pmg-lab/fake-zshrc'
[BLOCKED] read ~/.ssh/pmg-lab-canary :: EACCES EACCES: permission denied, open '/home/labdev/.ssh/pmg-lab-canary'
[BLOCKED] read env AWS_SECRET_ACCESS_KEY :: not in the environment
[DONE] write ./build/out.txt in the project
[DONE] spawn a shell (/bin/sh) :: shell ran as uid 30033
[BLOCKED] exec /usr/bin/curl :: EACCES spawnSync /usr/bin/curl EACCES
[BLOCKED] https.get example.com :: ERR_PROXY_TUNNEL Failed to establish tunnel to example.com:443 via http://127.0.0.1:37343: HTTP/1.1 403 Forbidden
[BLOCKED] raw TLS socket to example.com:443 :: EAI_AGAIN getaddrinfo EAI_AGAIN example.com
[BLOCKED] raw TCP socket to 104.20.23.154:443 :: ECONNREFUSED connect ECONNREFUSED 104.20.23.154:443
⊘ Network policy denied example.com:443 rule *:*
Allow it with pmg sandbox allow net-connect=example.com:443.
Network: 1 destination(s) observed. Propose a profile with: pmg sandbox profile propose --run 5f98a17daa1f
[exit 0]
$ cat ~/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv
#!/bin/sh
echo "real pyenv 2.8.0 (lab dummy)"
DNS for the raw TLS socket failed because direct DNS is off under network_via_proxy_only. The raw TCP connect to the
IP address hit the seccomp supervisor. The proxy refused the CONNECT. The Allow it with line of the pmg summary
shows the developer how to allow a real host that the assignment needs.
pmg keeps a local record of each run in the sandbox:
$ pmg sandbox violations list
RECORDED SANDBOX PROFILE KIND TARGET
2026-11-03T19:26:40Z proxy npm-lockdown network_connect example.com:443
2026-11-03T19:26:40Z landlock npm-lockdown fs_read /home/labdev/.ssh/pmg-lab-canary
2026-11-03T19:26:08Z landlock npm fs_read /home/labdev/.ssh/pmg-lab-canary
$ pmg sandbox explain --last
Sandbox: proxy Profile: npm-lockdown Recorded: 2026-11-03T19:26:40Z
--------------------------------------------------------
Reason: Connection to example.com:443 denied by the network policy (rule *:*). Override: --sandbox-allow net-connect=example.com:443
The JSON report under ~/.cache/safedep/pmg/sandbox/violations/ lists the canary read, the curl exec, three direct
connects and the removed AWS_SECRET_ACCESS_KEY. It does not list the pyenv or startup file writes, because Landlock
does not report operations outside the allow-list
(docs/sandbox.md L358-365(opens in new tab)).
Runs D to F: npm run start, sandbox exec and pip install
A take-home assignment also asks the candidate to start the app. pmg npm run start (Run D, same configuration as Run
C) gave the same 13 lines as Run C. The start script ran inside the npm-lockdown profile.
pmg sandbox exec -- node scripts/setup.js (Run E) runs the payload under the built-in exec profile, which pmg
marks as experimental. Two lines differ from Run C:
$ AWS_SECRET_ACCESS_KEY=lab-fake-secret pmg sandbox exec -- node scripts/setup.js
...
[DONE] exec /usr/bin/curl :: 200
[DONE] https.get example.com :: HTTP 200
[BLOCKED] raw TLS socket to example.com:443 :: EAI_AGAIN getaddrinfo EAI_AGAIN example.com
[BLOCKED] raw TCP socket to 104.20.23.154:443 :: ECONNREFUSED connect ECONNREFUSED 104.20.23.154:443
[exit 0]
The exec profile has no deny_exec for curl, and it allows every destination through the proxy
(sandbox/profiles/exec.yml(opens in new tab)).
It still confines writes, credential reads, secret variables and raw sockets. The pyenv swap failed the same way.
For a Python assignment, the same steps go into setup.py, and the candidate runs pip install . in a project
virtual environment.
pip ran setup.py three times during the build: to get build requirements, to prepare metadata and to build the
wheel. Without pmg every attempt succeeded. Under pmg --sandbox pip install -v . (Run F), the first pass printed
this, and the other two passes matched it:
$ cd ~/pmg-lab/pyassign && python3 -m venv .venv && . .venv/bin/activate
$ ~/pmg-lab/reset.sh
$ pmg --sandbox pip install -v .
...
Running command Getting requirements to build wheel
[BLOCKED] swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4) :: PermissionError: [Errno 13] Permission denied: '/home/labdev/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv_'
[BLOCKED] re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6) :: PermissionError: [Errno 13] Permission denied: '/home/labdev/pmg-lab/brew/bin/pyenv'
[BLOCKED] append to ~/pmg-lab/fake-zshrc :: PermissionError: [Errno 13] Permission denied: '/home/labdev/pmg-lab/fake-zshrc'
[BLOCKED] read ~/.ssh/pmg-lab-canary :: PermissionError: [Errno 13] Permission denied: '/home/labdev/.ssh/pmg-lab-canary'
[BLOCKED] exec /usr/bin/curl :: PermissionError: [Errno 13] Permission denied: '/usr/bin/curl'
...
Successfully installed trading-dashboard-assignment-1.0.0
[exit 0]
The PyPI profile allows writes only to the project, the temporary directories and the pip, Poetry and uv caches. An
install into a Homebrew or system Python fails under the sandbox, so keep the virtual environment in the project.
A global npm install, such as npm install -g, also fails under the sandbox.
Results of all runs
Each cell shows whether the setup script’s action ran or the sandbox blocked it, and what the lab observed.
| Action by the setup script | pmg, sandbox off | pmg --sandbox, default profile | Sandbox with network_via_proxy_only and enforce |
|---|---|---|---|
Swap pyenv in ~/pmg-lab/brew/Cellar | Ran | BlockedEACCES | BlockedEACCES |
Re-link ~/pmg-lab/brew/bin/pyenv | Ran | BlockedEACCES | BlockedEACCES |
Same swap and re-link under /tmp | Ran | Ran | Ran |
| Append to the fake shell startup file | Ran | BlockedEACCES | BlockedEACCES |
Read ~/.ssh/pmg-lab-canary | Ran | BlockedEACCES | BlockedEACCES |
Read AWS_SECRET_ACCESS_KEY | Ranvalue visible | Blockedremoved from the env | Blockedremoved from the env |
Write ./build/out.txt in the project | Ran | Ran | Ran |
Spawn /bin/sh | Ran | Raninside the sandbox | Raninside the sandbox |
Exec /usr/bin/curl | Ran | BlockedEACCES | BlockedEACCES |
https.get through the pmg proxy | RanHTTP 200 | RanHTTP 200, audited | Blocked403 from the pmg proxy |
| Raw TLS socket to example.com:443 | Ranconnected | Ranconnected | BlockedDNS lookup failed |
| Raw TCP socket to the IP address | Ranconnected | Ranconnected | Blockedconnection refused |
npm install exit code | 0 | 0 | 0 |
To remove the lab, delete its files:
rm -rf ~/pmg-lab /tmp/pmg-lab-brew ~/.ssh/pmg-lab-canary
Run C changed your pmg configuration. Keep it if you want the hardened setup. To undo it, or to repeat Run A after
Run C, run:
pmg config set sandbox.enabled false
pmg config set sandbox.enforce_always false
pmg config set sandbox.network_mode audit
pmg config set sandbox.policies.npm.profile npm
Then delete the npm-lockdown.yml file whose path pmg sandbox profile init printed.
Which setup paths the sandbox covers
SafeDep’s earlier Contagious Interview and PolinRider cases show the triggers that this actor uses, and almost all of
them are in Node.js projects. The table maps each trigger to what pmg does. “Contained” means
the code runs inside the sandbox profile, so it cannot write outside the project and temporary directories.
| Trigger in the assignment | Seen before | pmg, sandbox off (default) | pmg, sandbox on |
|---|---|---|---|
preinstall or postinstall in its own package file | hugo-paper preinstall dropper | Runspmg analyzes registry packages, not the project | Containedlab Run B |
Build, start or test script on npm run | antd-img-crop prebuild, PolinRider | Runs | Containedlab Run D |
| A malicious dependency from npm or PyPI | node-env-resolve, ulid-xyz | Blocked if verified malicious Prompt if only inferred Held if under 2 days old, unless a lockfile already resolves it to an artifact URL Allowed if unknown ( base_registry.go L124-131(opens in new tab)) | Containedsame checks as sandbox off, and its install script runs in the sandbox |
pip install . runs the assignment’s setup.py | No public case. Python shows up as a later stage. | Runs | Containedlab Run F |
node index.js or python main.py typed in a terminal | No public case | Not seen by pmg | Containedonly with pmg sandbox exec -- (lab Run E) |
| Editor folder-open task, git hook, pasted command | PolinRider .vscode/tasks.json, fake careers portal command | Not seen by pmg | Not seen by pmg |
pmg applies the sandbox to every package manager command that is not on a short list of commands that never
download packages
(config/config.go L1237-1243(opens in new tab)).
For npm, run and start are not on that list
(packagemanager/npm.go L52-65(opens in new tab)),
and the pmg documentation states that unknown commands and npm run dev stay inside the sandbox
(docs/sandbox.md L211-215(opens in new tab)).
npm rebuild is on the list, and it runs install scripts. In the lab, with the Run C setup but
enforce_always: false, pmg npm rebuild ran the postinstall outside the sandbox and replaced the dummy pyenv.
With sandbox.enforce_always: true, as in Run C, the same command left the dummy pyenv unchanged. The cooldown
exception for lockfiles comes from the pmg documentation
(docs/dependency-cooldown.md L23-25(opens in new tab)).
Limits
- The full lab ran only on Linux. Binance’s laptop was a Mac with
/opt/homebrew. On macOSpmguses Seatbelt. No npm or PyPI profile allows a write under/opt/homebrew(seatbelt_translator_darwin.goL251(opens in new tab)), so Seatbelt blocks thepyenvswap. The error on macOS isEPERM, notEACCES. - On macOS, raw sockets need the same hardening. For a profile with any
allow_outboundrule and nonetwork_via_proxy_only, Seatbelt allows outbound network in general and leaves host rules to the proxy (L704-711(opens in new tab)). deny_execnames paths. The npm profile blocks only/usr/bin/curl,/usr/bin/wgetand/usr/bin/python*. A Homebrewwgetunder/opt/homebrew/bin, a copy ofcurl, or Node.jshttpsstill runs. Seatbelt allows every other program (docs/sandbox.mdL31(opens in new tab))./tmpand the project stay writable. The/tmpswap succeeded in every run. A payload can stage files there or plant code in the project for a later run withoutpmg.- Reads stay broad. The npm profile allows reads from
/. Browser profiles, wallet extension data, shell history and~/.npmrcstay readable. The sandbox denies reads only for the mandatory list. - npm exits 0. The payload caught each error, so the install looked normal. The developer finds the block only in
the
pmgsummary line and the violation log. - The shell ran. The sandbox does not stop
/bin/shorzsh. The shell inherits the sandbox, so it has the same limits. - The malicious package block is not in this lab. The assignment has no dependencies. The
pmgREADME lists a benign test package,safedep-test-pkg, that SafeDep flags as malicious so that anyone can check the block. pmgdoes not cover the rest of the Binance chain. The recruiter, Engineer A’s Homebrew install (pmgdoes not wrapbrew), the move through the internal AI tool, the deployment token and the wallet signing are out of scope. Binance stopped 57 other suspected North Korea-linked attempts in 2026. Of those,pmghelps only with compromised packages and fake developer tools that ship through npm or PyPI.
Get started as a developer
Run setup code from a stranger in a throwaway VM or a cloud development environment, never on a laptop with production
access or personal credentials. On your own machine, install pmg, which is open source and free.
brew install safedep/tap/pmg # or: npm install -g @safedep/pmg
pmg setup install # restart the terminal after this
pmg setup doctor
pmg setup install adds shell aliases and PATH shims for npm, pnpm, yarn, bun, npx, pip, uv, poetry and the other
supported tools, so scripts and coding agents go through pmg as well. Then turn on the sandbox with a proxy-only npm
profile, enforce the network policy and check the driver:
pmg config set sandbox.enabled true
pmg config set sandbox.enforce_always true
PROFILE=$(pmg sandbox profile init npm-lockdown --from npm --package-manager npm)
echo "network_via_proxy_only: true" >> "$PROFILE"
pmg config set sandbox.policies.npm.profile npm-lockdown
pmg config set sandbox.network_mode enforce
pmg sandbox doctor
The proxy-only profile covers npm only. Repeat the profile init step for each package manager you use, such as
pnpm, yarn, npx, pip or uv, and point its sandbox.policies.<name>.profile at the new profile. Expect to allow a few
hosts the first time a project builds. pmg sandbox profile propose drafts the rules from what a run reached.
sandbox.enforce_always: true also sandboxes the npm commands that pmg treats as non-download, such as
npm rebuild, which runs install scripts.
For code from a stranger, two more habits help:
- Start the app through the package manager (
npm run start,uv run,poetry run), or putpmg sandbox exec --in front ofnodeorpython. - Scan the repository before you open it in an editor. vet v2 (alpha) flags folder-open editor tasks, git hooks and
scripts disguised as fonts with
vet scan <url> --fail-on attacks(controls(opens in new tab)). Install it withbrew install --cask safedep/tap/vet@edge, afterbrew uninstall vetif you have v1.
Documentation: pmg quickstart(opens in new tab),
network policy(opens in new tab) and
sandbox reference(opens in new tab).
Get started as a team
A developer under deadline pressure can turn off a control that gets in the way. A block that exits 0 is easy to miss.
A team needs the same configuration on every laptop and the denials in one place. The pmg sandbox and cooldown are
open source and work without an account. The Team plan adds central management and reporting of them across
endpoints.
Ship one configuration with mobile device management (MDM). pmg reads a global configuration file at
/Library/Application Support/safedep/pmg/config.yml on macOS and /etc/safedep/pmg/config.yml on Linux. When the
file exists, pmg ignores the per-user file. With global_lockdown: true, flags such as --sandbox=false fail and
PMG_INSECURE_INSTALLATION no longer bypasses the malware block
(docs/config.md L197-263(opens in new tab)).
A fleet file for this case:
# /Library/Application Support/safedep/pmg/config.yml (macOS)
# /etc/safedep/pmg/config.yml (Linux)
global_lockdown: true
sandbox:
enabled: true
enforce_always: true
network_mode: enforce
policy_templates:
npm-lockdown:
path: ./npm-lockdown.yml
policies:
npm:
enabled: true
profile: npm-lockdown
cloud:
enabled: true
Ship the proxy-only npm-lockdown.yml profile from the lab’s Run C next to the global file. With global_lockdown: true, pmg resolves profiles only from
managed locations, and a user cannot allow a host with pmg sandbox allow. Before you ship the profile, add each host
that your projects download from to its allow_outbound list. Keys that the file does not set take the pmg
defaults, not the user’s values. SafeDep publishes
MDM scripts(opens in new tab) for Jamf, Kandji, Mosyle, JumpCloud and
Intune.
Connect each machine to SafeDep Cloud. pmg config set fails under a global file, so the fleet file above sets
cloud.enabled. Each user logs in once, or the MDM script passes the credentials:
pmg cloud login # tenant ID and API key, or --from-env
From pmg v0.34.0, a run with a denial that the sandbox driver records sends a sandbox violation event. The event
holds the driver, the profile and up to 100 denied paths, programs or hosts
(internal/audit/cloud_translate.go(opens in new tab)).
Seatbelt records boundary writes, and Landlock does not. A payload that only swaps pyenv on a Linux machine leaves no
violation event. In the lab’s Run C, the local report had the ~/.ssh read and the curl exec, but not the pyenv write.
Blocked packages, cooldown holds, network policy decisions and bypasses go to the same place.
The Endpoints page lists every machine with its blocked count and a live feed of events. The screenshots below come from SafeDep’s demo tenant, not from a customer or from Binance.
The Package Events tab of one laptop shows each pmg session, the packages it blocked or held, and the sandbox events.
The Invocation column names the coding agent when an agent ran the install.
A sandbox violation opens a drawer with the driver, the profile and each denial by kind, path, process and count.
On a Mac, a Binance-style swap would show here as a filesystem write denial on the pyenv path. That is an inference
from the event schema and the Seatbelt documentation, not an observed event.
Hunt across the fleet with SQL. This query from the sandbox documentation(opens in new tab) lists the endpoints where the sandbox blocked something:
SELECT endpoints.identifier,
COUNT(DISTINCT package_guard_events.invocation_id) AS sessions
FROM package_guard_events
JOIN endpoints ON endpoints.id = package_guard_events.invocation_id
WHERE package_guard_events.event_type = 'PMG_EVENT_TYPE_SANDBOX_VIOLATION'
GROUP BY endpoints.identifier
The ON clause is a placeholder. SafeDep Cloud applies the real join between an event and its endpoint
(alerts documentation(opens in new tab)).
When a new campaign report names a command-and-control domain, filter on package_guard_events.hostname. The column
holds the destination of network policy and host observation events, so the query finds the laptops where the pmg
proxy saw a connection to that host. Filter on PMG_EVENT_TYPE_INSECURE_BYPASS and PMG_EVENT_TYPE_SANDBOX_OVERRIDE
to see who turned a control off. The alerts recipe(opens in new tab) shows how to run
the query with safedep query exec from a scheduled job and post the rows to a Slack, Teams or PagerDuty webhook.
SafeDep Cloud sees only what ran through pmg. A payload started from an editor task, a git hook or a bare node
command leaves no event.
Endpoint Hub and SQL queries need the Team or Enterprise plan, and Cloud sync from an MDM fleet needs Enterprise (pricing).
See sandbox denials across your team
Connect pmg to SafeDep Cloud and see blocked packages, cooldown holds and sandbox violations for every laptop and CI runner.
Related reading
- contagious-interview
- north-korea
- developer-security
- pmg
- sandbox
- supply-chain
