Hoppscotch CLI sandbox escape through Node vm pre-request scripts (CVE-2024-34347)
Hoppscotch CLI ran collection scripts in Node's vm while exposing host-created objects
CVSS 3.1 base score
HighVector: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
Summary
Source
test
packages/hoppscotch-cli/src/commands/test.ts:14
Sink
runInContext
packages/hoppscotch-js-sandbox/src/pre-request/node-vm/index.ts:31
Hoppscotch CLI executes collection-supplied pre-request and test scripts while running hopp test. Unlike the web and desktop clients, the CLI could not rely on browser Web Workers and used the Node.js vm module through @hoppscotch/js-sandbox. The sandbox created a vm context, then injected host-created objects such as pw, atob, and btoa into that context before calling runInContext on attacker-controlled collection script text.
Node's vm module is not a security boundary for untrusted JavaScript, especially when code inside the context can reach references to objects created outside the context. A malicious collection script could use the exposed pw object to recover the host global object and load child_process, gaining command execution on the machine running Hoppscotch CLI. The GitHub advisory is GHSA-qmmm-73r2-f8xr / CVE-2024-34347. The fix, merged in PR #3973 as commit 22c6eabd133195d22874250a5ae40cb26b851b01, removed the Node vm implementation and replaced it with an isolated-vm based Node sandbox. The patched npm release for @hoppscotch/cli is 0.8.0.
Severity
- Attack vectorAV
- Network
- Attack complexityAC
- High
- Privileges requiredPR
- None
- User interactionUI
- Required
- ScopeS
- Changed
- ConfidentialityC
- High
- IntegrityI
- High
- AvailabilityA
- High
Metric values as published in the disclosure vector. Meters show how far each value raises exposure.
Source-to-sink trace
- Source · attacker-controlledpackages/
hoppscotch-cli/ src/ commands/ test.ts:14 test
- Step 01packages/
hoppscotch-cli/ src/ commands/ test.ts:14-20 The CLI
testcommand parses an attacker-supplied collection file and sends its requests through the collection runner.typescript export const test = (path: string, options: TestCmdOptions) => async () => { const envs = options.env ? await parseEnvsData(options.env) : <HoppEnvs>{ global: [], selected: [] } const collections = await parseCollectionData(path) const report = await collectionsRunner({ collections, envs, delay }) } - Step 02packages/
hoppscotch-cli/ src/ utils/ pre-request.ts:36-47 For every request, the CLI passed the collection-controlled
preRequestScriptstring intorunPreRequestScriptfrom@hoppscotch/js-sandbox/node.typescript export const preRequestScriptRunner = (request: HoppRESTRequest, envs: HoppEnvs) => pipe( TE.of(request), TE.chain(({ preRequestScript }) => runPreRequestScript(preRequestScript, envs) ) ) - Step 03packages/
hoppscotch-js-sandbox/ src/ pre-request/ node-vm/ index.ts:13-31 (before fix) The vulnerable Node implementation created a
vmcontext, injected host-createdpw,atob, andbtoareferences, then executed the untrusted script text in that context.typescript return createContext() const { pw, updatedEnvs } = getPreRequestScriptMethods(envs) context.pw = pw context.atob = atob context.btoa = btoa runInContext(preRequestScript, context) - Step 04packages/
hoppscotch-js-sandbox/ src/ test-runner/ node-vm/ index.ts:42-50 (before fix) Test scripts had the same trust-boundary break: the host-created
pwobject plus response data were exposed directly to untrustedtestScriptcode beforerunInContext.typescript const { pw, testRunStack, updatedEnvs } = getTestRunnerScriptMethods(envs) context.pw = { ...pw, response: responseObjHandle.right } context.atob = atob context.btoa = btoa runInContext(testScript, context) - Step 05packages/
hoppscotch-js-sandbox/ src/ node/ pre-request.ts:20-70 (fix) The patched implementation creates an
isolated-vmisolate and context, copies serialized API methods into the isolate, builds an in-isolate proxy, and runs the script inside that isolate.typescript const isolate: ivmT.Isolate = new ivm.Isolate() const context = await isolate.createContext() const jail = context.global const { pw, updatedEnvs } = getPreRequestScriptMethods(envs) const serializedAPIMethods = getSerializedAPIMethods(pw) jail.setSync("serializedAPIMethods", serializedAPIMethods, { copy: true }) const finalScript = ` const pw = new Proxy(serializedAPIMethods, { get: (pwObjTarget, pwObjProp) => { const topLevelEntry = pwObjTarget[pwObjProp] // "pw.env" set of API methods if (topLevelEntry && typeof topLevelEntry === "object") { return new Proxy(topLevelEntry, { get: (subTarget, subProp) => { const subLevelProperty = subTarget[subProp] if (subLevelProperty && subLevelProperty.typeof === "function") { return (...args) => subLevelProperty.applySync(null, args) } }, }) } } }) ${preRequestScript} ` const script = await isolate.compileScript(finalScript) await script.run(context) - Step 06packages/
hoppscotch-js-sandbox/ src/ node/ utils.ts:6-23 Host API functions are converted to
isolated-vmreferences recursively instead of passing a raw host object graph into a Nodevmcontext.typescript export const getSerializedAPIMethods = ( namespaceObj: Record<string, unknown> ): Record<string, unknown> => { const result: Record<string, unknown> = {} for (const [key, value] of Object.entries(namespaceObj)) { if (typeof value === "object" && value !== null && !Array.isArray(value)) { result[key] = getSerializedAPIMethods(value as Record<string, unknown>) } else if (typeof value === "function") { result[key] = new ivm.Reference(value) } else { result[key] = value } } return result } - Sinkpackages/
hoppscotch-js-sandbox/ src/ pre-request/ node-vm/ index.ts:31 runInContext
Impact
Reported impact
Successful exploitation gives arbitrary command execution on the victim's machine. The attacker can read or modify local files, exfiltrate secrets available to the CLI process, run additional payloads, and pivot through any credentials or network access available to that user.
Attack surface
Hoppscotch CLI users who run hopp test on untrusted or attacker-supplied Hoppscotch collection exports containing pre-request or test scripts.
Preconditions
The attacker must convince a victim to download or otherwise use a malicious Hoppscotch collection and run it with an affected @hoppscotch/cli version from 0.5.0 before 0.8.0. Hoppscotch Web and Desktop are not affected by this Node-specific sandbox path.
Attack path
- 1
Create a Hoppscotch collection with a malicious pre-request script or test script.
- 2
Use the exposed
pwhost object reference to escape the Nodevmcontext. - 3
Resolve the host global object and import
child_process. - 4
Execute arbitrary commands with the privileges of the user running Hoppscotch CLI.
Proof of concept
- 01
Environment setup
Install an affected
@hoppscotch/clirelease before 0.8.0, then prepare a Hoppscotch collection containing a request with a pre-request script. - 02
Target configuration
The vulnerable path is the Node CLI sandbox. The web and desktop clients use the browser worker sandbox and are not affected by this specific Node
vmescape. - 03
Exploit delivery
Place this script in the collection's pre-request script field:
js outside = pw.constructor.constructor('return this')() outside.process.mainModule.require('child_process').execSync('id > /tmp/pwnd')Then have the victim run the collection with Hoppscotch CLI.
- 04
Expected response
On vulnerable CLI versions, the command executes and writes the result of
idto/tmp/pwnd. On fixed versions, the Nodevmimplementation is gone and the script runs in the isolated-vm based implementation. - 05
Outcome
A collection-supplied script escapes the intended sandbox and executes operating-system commands as the CLI user.
Remediation
Guidance
Upgrade @hoppscotch/cli to 0.8.0 or later. Do not use Node's vm module as a security sandbox for untrusted collection scripts. If scripting cannot be isolated safely, disable scripting by default or require an explicit opt-in for untrusted CLI runs.
import { createContext, runInContext } from "vm"const context = createContext()const { pw, updatedEnvs } = getPreRequestScriptMethods(envs)context.pw = pwcontext.atob = atobcontext.btoa = btoarunInContext(preRequestScript, context)resolve(updatedEnvs)import { createRequire } from "module"import type ivmT from "isolated-vm" const nodeRequire = createRequire(import.meta.url)const ivm = nodeRequire("isolated-vm") const isolate: ivmT.Isolate = new ivm.Isolate()const context = await isolate.createContext()const jail = context.global const serializedAPIMethods = getSerializedAPIMethods(pw)jail.setSync("serializedAPIMethods", serializedAPIMethods, { copy: true }) const script = await isolate.compileScript(finalScript)await script.run(context)await isolate.dispose()
Check the upstream record for the project's current remediation status.
Investigate the paths that matter in your codebase.
Winfunc can trace relevant code paths, preserve supporting evidence, and prepare remediation suggestions for engineering review within an agreed scope.
