Skip to content

deploy: package.json "name" is interpolated into a shell string passed to execSync (CWE-78) #14

Description

@bilguunbicktivism

Reported against @opensea/tool-sdk@0.28.5 (npm latest) and present unchanged at main. Filed as a public issue because private vulnerability reporting is not enabled on this repository and there is no SECURITY.md; enabling PVR would be worth doing.

CWE-78, OS command injection. Severity: low — the reasoning is in “The arguments against this report” below, which I have written out rather than left for you to find.

deploy builds a shell command string by interpolation and hands it to execSync, which runs it through cmd.exe / /bin/sh. One of the interpolated values is the name field of the package.json sitting in the working directory.

src/cli/commands/deploy.ts:222    projectName = pkg.name.replace(/^@[^/]+\//, "")   // scope strip only
src/cli/commands/deploy.ts:231    linkParts.push(`--project=${projectName}`)
src/cli/commands/deploy.ts:236    const linkCmd = linkParts.join(" ")
src/cli/commands/deploy.ts:244    execCmd(linkCmd, { inheritStderr: true })
src/cli/commands/deploy.ts:32       return execSync(cmd, { encoding: "utf-8", input: options?.input, stdio })

The only transformation applied to pkg.name is stripping an npm scope. &, |, ;, backticks and $() all survive into a string the shell parses.

Steps to reproduce

  1. Put a package.json in a directory with this name field (<marker> stands for any path the
    injected command can write to):
{ "name": "@scope/tool & echo INJECTED > \"<marker>\" & rem", "version": "1.0.0" }
  1. Run tool-sdk deploy --host vercel in that directory, logged in to Vercel.
  2. The echo runs.

Proof of concept

The shipped construction code, run over that file, builds:

npx vercel link --yes --project=tool & echo INJECTED-VIA-PACKAGE-JSON > "<marker>" & rem

and executing it writes the marker:

*** COMMAND EXECUTION — the marker was written by the injected segment ***
   INJECTED-VIA-PACKAGE-JSON

evidence/execcmd_injection.txt.

How the harness was kept safe, and what that costs the proof. The run replaces the leading npx vercel link with the inert builtin ver, so the Vercel CLI is never invoked and no project is linked. Everything after that first token — including the injected & — is byte-identical to what the shipped code builds. What is demonstrated is the shell's handling of the constructed string, not an end-to-end deploy.

One correction recorded because it nearly produced a false negative: the first attempt used rem as the inert command. rem comments out the rest of the line in cmd.exe, which swallowed the injected segment and printed "no execution". The instrument was wrong, not the target.

A second instance of the same construction, which is gated

deploy.ts:315 does execCmd(\npx vercel env add ${envVar.name} production`), and parseEnvExampletakes **everything left of the first=** on a line of .env.local.example` as the name, with no charset check. The string it builds is injectable in the same way. I am not claiming it as a working vector, because both paths to it are gated and I verified the gates:

  • --non-interactive (deploy.ts:284-293): value = process.env[envVar.name] ?? "", and a crafted name is never a real environment variable, so it hits process.exit(1) before execCmd.
  • interactive (deploy.ts:294-311): the crafted name is the prompt label the developer reads, and a non-empty value must be typed before the command runs.

It is reported as a second instance of the defective construction, not as a second exploit.

A third instance, also not claimed: vercelScope — the captured stdout of npx vercel whoami (deploy.ts:199) — is interpolated into --scope=${vercelScope} on the same line.

Impact, stated narrowly

Command execution as the developer, in the shell running deploy, with that shell's environment and its authenticated Vercel session.

I want to be precise about what that environment is, because two obvious overstatements are wrong
and I checked both.

deploy.ts does not read wallet credentials by name — PRIVATE_KEY, PRIVY_APP_SECRET,
TURNKEY_API_PRIVATE_KEY, FIREBLOCKS_API_SECRET and BANKR_API_KEY are read by auth.ts,
pay.ts, smoke.ts and by the @opensea/wallet-adapters dependency. But it does read the
environment: deploy.ts:285 evaluates process.env[envVar.name] where the name comes from the
attacker-authored .env.local.example
, and pipes the value into the subprocess as stdin at
deploy.ts:315-318. So the injected command inherits the developer's shell environment, and a
crafted variable name additionally causes deploy to read a chosen environment variable.

I also checked whether those five names are simply absent from the shipped artifact, which would
have made the point stronger. They are not: only three are missing from dist/cli.js itself, and
@opensea/wallet-adapters — a direct dependency — reads all five. My first grep returned zero for
all of them because it searched the wrong path; a positive control on process.env (40 hits)
caught that.

The argument against this report, which I think triage should hear from me rather than find

Two arguments cut against this report and triage should hear both from me.

First, the documented route into such a directory installs first — README.md:14 is cd my-tool && npm install, and the examples use pnpm install --ignore-workspace (examples/nft-appraisal-tool/README.md:157, examples/wallet-personality-tool/README.md:132). An attacker who authored the package.json could add an install lifecycle script and get execution before deploy ever runs. I checked the templates: vercel, cloudflare and express ship no lifecycle scripts at all, so that route is not something the project already relies on — but it is available to an attacker.

Second, and more damaging: deploy.ts:344 is npx vercel deploy --prod. The command's whole purpose is to publish the working directory to the developer's own production Vercel account. Anyone who runs it in an attacker-authored directory is already shipping that attacker's code under their own name. Against that, the marginal impact of local command execution is real but narrow — it moves execution from Vercel's build sandbox to the developer's shell.

What is left after conceding that, and why I still think it is worth fixing:

  • the defect is in tool-sdk's own code, not in npm's threat model, and it is trivially fixable;
  • deploy can be run in a directory whose package.json was edited after install — a pulled branch, an agent-scaffolded change, a dependency-bot commit — without a fresh npm install;
  • --project= accepts the payload with no interaction at all, in both interactive and --non-interactive mode, which is a materially different bar from a lifecycle script a developer might inspect or disable with --ignore-scripts.

Weighing both, this is not a P3. I am reporting it at P4: a genuine, trivially fixable injection in the SDK's own code, whose realistic marginal impact over what the same workflow already concedes is small.

On the Bugcrowd brief, for completeness

The brief excludes "issues requiring control of the client environment or application integrating the SDK". A hostile package.json is arguably the integrating application's own manifest, and I would rather name that reading than have it found: if triage applies it, I accept the call. My argument the other way is that deploy chose to parse an untrusted file and hand its contents to a shell, and that is a defect in the SDK regardless of who wrote the file.

Suggested fix

execFileSync("npx", ["vercel", "link", "--yes", --project=${projectName}]) passes the value through argv, where & is a character rather than a separator, and the same change fixes all three sites. Validate pkg.name against the npm name grammar and env-var names against /^[A-Za-z_][A-Za-z0-9_]*$/ as well — argv alone does not help a value that begins with -.

Scope of testing

Entirely local: a throwaway directory under my own lab path, one file I wrote, and a marker file. No Vercel project was linked, no environment variable was set on any Vercel account, and no OpenSea service was contacted. An earlier version of this harness did invoke npx vercel link, which fetched the Vercel CLI and opened a login page; that run was aborted immediately, no credentials were entered, no account was linked, and the harness was rebuilt so it never invokes the Vercel CLI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions