Skip to content

Commit 3ddf9b0

Browse files
authored
Merge pull request #626 from NodeSecure/feat/trace-class-instance-methods
feat(js-x-ray): improve unsafe-vm-context
2 parents 6b6a87e + e803246 commit 3ddf9b0

20 files changed

Lines changed: 820 additions & 157 deletions

.changeset/yummy-words-pay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nodesecure/js-x-ray": minor
3+
---
4+
5+
feat(js-x-ray): improve unsafe-vm-context

docs/unsafe-vm-context.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,31 @@
66

77
## Introduction
88

9-
Detects potentially dangerous use of `vm.runInNewContext()` from the vm module. Despite appearing to provide an isolated execution environment, this API does not constitute a real security sandbox and should be considered as dangerous as a command injection vulnerability. It only separates JavaScript global scopes while sharing the same underlying V8 heap. Any code executed through it can escape the context via prototype chain traversal and gain full access to the host process.
9+
Detects potentially dangerous use of `vm.runInNewContext()` and `(vm.Script(code,options)).runInContext` from the vm module. Despite appearing to provide an isolated execution environment, this API does not constitute a real security sandbox and should be considered as dangerous as a command injection vulnerability. It only separates JavaScript global scopes while sharing the same underlying V8 heap. Any code executed through it can escape the context via prototype chain traversal and gain full access to the host process.
1010

1111
## Example
1212

1313
```js
1414
import vm from "vm";
1515

16-
const userInput = req.body.expression;
17-
const result = vm.runInNewContext(userInput, { x: 10, y: 20 });
16+
// command injection
17+
18+
code = 'var x = this.constructor.constructor("return process.mainModule.require(\'child_process\').execSync(\'cat /etc/passwd\',{encoding:'utf-8'})")()';
19+
20+
const context = {y : 1}
21+
vm.runInNewContext(code,context);
22+
console.log(context.x);
23+
24+
// environment variables leak
25+
26+
code = 'var x = this.constructor.constructor("return process.env")()';
27+
28+
const context = {y : 1}
29+
30+
const script = new vm.Script(code);
31+
32+
vm.runInContext(vm.createContext(context));
33+
console.log(context.x);
1834
```
1935

2036
## References

workspaces/js-x-ray/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ Click on the warning **name** for detailed documentation and examples.
234234
| [monkey-patch](https://github.com/NodeSecure/js-x-ray/blob/master/docs/monkey-patch.md) | No | Modification of built-in JavaScript prototype properties |
235235
| [prototype-pollution](https://github.com/NodeSecure/js-x-ray/blob/master/docs/prototype-pollution.md) | No | Detected use of `__proto__` to pollute object prototypes |
236236
| [weak-scrypt](https://github.com/NodeSecure/js-x-ray/blob/master/docs/weak-scrypt.md) ⚠️ | **Yes** | Usage of weak scrypt parameters (low cost, short or hardcoded salt) |
237-
| [unsafe-vm-context](https://github.com/NodeSecure/js-x-ray/blob/master/docs/unsafe-vm-context.md) ⚠️ | No | Usage of dangerous vm.runInNewContext |
237+
| [unsafe-vm-context](https://github.com/NodeSecure/js-x-ray/blob/master/docs/unsafe-vm-context.md) ⚠️ | No | Usage of dangerous vm.runInNewContext and (vm.Script(code,options)).runInContext |
238238

239239
#### Information Severity
240240

workspaces/js-x-ray/docs/VariableTracer.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,19 @@ tracer.on(VariableTracer.AssignmentEvent, (payload) => {
296296
});
297297
```
298298

299+
### ReturnValueEvent
300+
301+
Emitted when a traced function's return value is assigned.
302+
303+
```js
304+
tracer.on(VariableTracer.ReturnValueEvent, (payload) => {
305+
306+
console.log(`${payload.name} assigned to ${payload.id}`);
307+
console.log(`Location:`, payload.location);
308+
});
309+
```
310+
311+
299312
**Payload:** `AssignmentEventPayload`
300313

301314
### ImportEvent

workspaces/js-x-ray/src/AstAnalyser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
PipelineRunner,
2222
type Pipeline
2323
} from "./pipelines/index.ts";
24+
import { Inline } from "./pipelines/inline.ts";
2425
import { ProbeRunner, type Probe } from "./ProbeRunner.ts";
2526
import {
2627
SourceFile,
@@ -163,8 +164,7 @@ export class AstAnalyser extends EventEmitter<AstAnalyserEvents> {
163164
collectables = [],
164165
sensitivity = "conservative"
165166
} = options;
166-
167-
this.#pipelineRunner = new PipelineRunner(pipelines);
167+
this.#pipelineRunner = new PipelineRunner([...pipelines, new Inline()]);
168168
this.#collectableSetRegistry = new CollectableSetRegistry(
169169
collectables ?? []
170170
);

workspaces/js-x-ray/src/Inlined.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Import Third-party Dependencies
2+
import type { ESTree } from "meriyah";
3+
4+
// Import Internal Dependencies
5+
import { VirtualVariableIdentifier } from "./VirtualVariableIdentifier.ts";
6+
7+
export interface SplitResult {
8+
/**
9+
* A virtual variable name that replaces the target
10+
*/
11+
virtualIdentifier: string;
12+
/**
13+
* Virtual variable declaration:
14+
* require: const __virtual_require_0__ = require("xxx")
15+
* new: const __virtual_new_0__ = new Foo("xxx")
16+
* Can be walked with standard ESTree walkers.
17+
*/
18+
virtualDeclaration: ESTree.VariableDeclaration;
19+
/**
20+
* The rebuilt expression with require() replaced by the virtual identifier.
21+
* For `require("x").spawn("y")`, this would be `__virtual_require_0__.spawn("y")`
22+
* For `(new Foo()).bar()`, this would be `__virtual_new_0__.bar()`
23+
* Can be walked with standard ESTree walkers.
24+
*/
25+
rebuildExpression: ESTree.Node | null;
26+
}
27+
28+
export class Inlined {
29+
static buildSplitResult(
30+
node: ESTree.Node,
31+
target: ESTree.Expression,
32+
identifier: string
33+
): SplitResult {
34+
const virtualIdentifier = VirtualVariableIdentifier.generate(
35+
identifier,
36+
node.loc
37+
);
38+
39+
return {
40+
virtualIdentifier,
41+
virtualDeclaration: {
42+
type: "VariableDeclaration",
43+
kind: "const",
44+
declarations: [
45+
{
46+
type: "VariableDeclarator",
47+
id: {
48+
type: "Identifier",
49+
name: virtualIdentifier
50+
},
51+
init: target
52+
}
53+
]
54+
55+
},
56+
rebuildExpression: Inlined.#rebuildWithVirtualIdentifier(
57+
node,
58+
target,
59+
virtualIdentifier
60+
)
61+
};
62+
}
63+
64+
static #rebuildWithVirtualIdentifier(
65+
node: ESTree.Node,
66+
target: ESTree.Node,
67+
virtualIdentifier: string
68+
): ESTree.Node | null {
69+
if (node === target) {
70+
return null;
71+
}
72+
73+
const virtualId: ESTree.Identifier = {
74+
type: "Identifier",
75+
name: virtualIdentifier
76+
};
77+
78+
return Inlined.#cloneAndReplace(
79+
node,
80+
target,
81+
virtualId
82+
);
83+
}
84+
85+
static #cloneAndReplace(
86+
node: ESTree.Node,
87+
target: ESTree.Node,
88+
replacement: ESTree.Identifier
89+
): ESTree.Node {
90+
if (node === target) {
91+
return replacement;
92+
}
93+
94+
if (node.type === "CallExpression") {
95+
const callee = Inlined.#cloneAndReplace(
96+
node.callee,
97+
target,
98+
replacement
99+
) as ESTree.Expression;
100+
101+
const args = node.arguments.map(
102+
(arg) => Inlined.#cloneAndReplace(arg, target, replacement)
103+
) as ESTree.Expression[];
104+
105+
return {
106+
...node,
107+
callee,
108+
arguments: args
109+
};
110+
}
111+
112+
if (node.type === "MemberExpression") {
113+
return {
114+
...node,
115+
object: Inlined.#cloneAndReplace(
116+
node.object,
117+
target,
118+
replacement
119+
) as ESTree.Expression
120+
};
121+
}
122+
123+
return node;
124+
}
125+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Import Third-party Dependencies
2+
import type { ESTree } from "meriyah";
3+
4+
// Import Internal Dependencies
5+
import { Inlined, type SplitResult } from "./Inlined.ts";
6+
7+
export class InlinedNew {
8+
static split(node: ESTree.Node): SplitResult | null {
9+
if (node.type !== "CallExpression" && node.type !== "MemberExpression") {
10+
return null;
11+
}
12+
const newExpression = InlinedNew.#findNewCall(node);
13+
if (!newExpression) {
14+
return null;
15+
}
16+
17+
return Inlined.buildSplitResult(node, newExpression, "new");
18+
}
19+
20+
static #findNewCall(
21+
node: ESTree.Node
22+
): ESTree.NewExpression | null {
23+
if (node.type === "CallExpression") {
24+
const callee = node.callee;
25+
26+
return InlinedNew.#findNewCall(callee);
27+
}
28+
29+
if (node.type === "MemberExpression") {
30+
return InlinedNew.#findNewCall(node.object);
31+
}
32+
33+
if (node.type === "NewExpression") {
34+
return node;
35+
}
36+
37+
return null;
38+
}
39+
}

workspaces/js-x-ray/src/ProbeRunner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,5 +347,6 @@ export class ProbeRunner {
347347
probe.finalize?.(this.#getProbeContext(probe));
348348
probe.context = probe[kProbeOriginalContext];
349349
}
350+
this.sourceFile.tracer.removeAllListeners();
350351
}
351352
}

workspaces/js-x-ray/src/VariableTracer.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,19 @@ export interface TracedIdentifierReport {
116116
assignmentMemory: AssignmentMemory[];
117117
}
118118

119-
export interface AssignmentEventPayload {
119+
interface Payload {
120120
name: string;
121121
identifierOrMemberExpr: string;
122122
id: string;
123123
location: ESTree.SourceLocation | null | undefined;
124124
}
125125

126+
export interface AssignmentEventPayload extends Payload { }
127+
128+
export interface ReturnValueEventPayload extends Payload {
129+
arguments: ESTree.Expression[];
130+
}
131+
126132
export interface ImportEventPayload {
127133
moduleName: string;
128134
value: string;
@@ -137,6 +143,7 @@ export interface LiteralIdentifier {
137143
export class VariableTracer extends EventEmitter {
138144
static AssignmentEvent = Symbol("AssignmentEvent");
139145
static ImportEvent = Symbol("ImportEvent");
146+
static ReturnValueEvent = Symbol("ReturnValueEvent");
140147

141148
// PUBLIC PROPERTIES
142149
literalIdentifiers = new Map<string, LiteralIdentifier>();
@@ -517,6 +524,7 @@ export class VariableTracer extends EventEmitter {
517524
* const g = eval("this");
518525
* const g = Function("return this")();
519526
*/
527+
case "NewExpression":
520528
case "CallExpression": {
521529
const fullIdentifierPath = getCallExpressionIdentifier(childNode);
522530
if (fullIdentifierPath === null) {
@@ -532,10 +540,21 @@ export class VariableTracer extends EventEmitter {
532540
type: "ReturnValueAssignment",
533541
name: id.name
534542
});
543+
const returnValueEventPayload = {
544+
name: tracedVariant.name,
545+
identifierOrMemberExpr: tracedVariant.identifierOrMemberExpr,
546+
id: id.name,
547+
location: id.loc,
548+
arguments: childNode.arguments
549+
};
550+
this.emit(VariableTracer.ReturnValueEvent, returnValueEventPayload);
535551
if (tracedVariant.followConsecutiveAssignment) {
536552
this.#assignedReturnValueToTraced.set(id.name, tracedFullIdentifierName);
537553
}
538554
}
555+
if (childNode.type !== "CallExpression") {
556+
break;
557+
}
539558
// const id = Function.prototype.call.call(require, require, "http");
540559
if (this.#neutralCallable.has(identifierName) || isEvilIdentifierPath(fullIdentifierPath)) {
541560
// TODO: make sure we are walking on a require CallExpr here ?

workspaces/js-x-ray/src/estree/functions/getCallExpressionIdentifier.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function getCallExpressionIdentifier(
2424
node: ESTree.Node,
2525
options: GetCallExpressionIdentifierOptions = {}
2626
): string | null {
27-
if (node.type !== "CallExpression") {
27+
if (node.type !== "CallExpression" && node.type !== "NewExpression") {
2828
return null;
2929
}
3030
const {

0 commit comments

Comments
 (0)