Skip to content

Commit 423407c

Browse files
committed
feat(bug-detectors): replace RCE substring check with global canary
The old detector checked whether the target string appeared in eval/Function source text. This false-positived on safely quoted occurrences (e.g. handlebars' lookupProperty(d, "jaz_zer")). Install a canary function on globalThis that fires only when attacker-controlled code actually executes. The before-hooks now provide fuzzing guidance only (guideTowardsContainment). The canary is propagated to the Jest vm.Context sandbox, following the same pattern as the prototype-pollution detector.
1 parent 335decb commit 423407c

5 files changed

Lines changed: 364 additions & 310 deletions

File tree

docs/bug-detectors.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,11 @@ using Jest in `.jazzerjsrc.json`:
100100
101101
## Remote Code Execution
102102
103-
Hooks the `eval` and `Function` functions and reports a finding if the fuzzer
104-
was able to pass a special string to `eval` and to the function body of
105-
`Function`.
103+
Installs a canary getter on `globalThis` and hooks the `eval` and `Function`
104+
functions. The before-hooks guide the fuzzer toward injecting the canary
105+
identifier into code strings. A finding is reported when dynamically compiled
106+
code accesses the canary property, avoiding false positives from the identifier
107+
merely appearing inside string literals.
106108
107109
_Disable with:_ `--disableBugDetectors=remote-code-execution` in CLI mode; or
108110
when using Jest in `.jazzerjsrc.json`:

packages/bug-detectors/internal/remote-code-execution.ts

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,70 @@
1414
* limitations under the License.
1515
*/
1616

17+
import type { Context } from "vm";
18+
1719
import {
20+
getJazzerJsGlobal,
1821
guideTowardsContainment,
1922
reportAndThrowFinding,
2023
} from "@jazzer.js/core";
21-
import { callSiteId, registerBeforeHook } from "@jazzer.js/hooking";
24+
import { registerBeforeHook } from "@jazzer.js/hooking";
25+
26+
const CANARY_NAME = "jaz_zer";
27+
28+
function createCanaryDescriptor(canaryName: string): PropertyDescriptor {
29+
return {
30+
get() {
31+
reportAndThrowFinding(
32+
"Remote Code Execution\n" +
33+
` attacker-controlled code accessed globalThis.${canaryName}`,
34+
);
35+
},
36+
enumerable: false,
37+
configurable: false,
38+
};
39+
}
40+
41+
function installCanaryIfMissing(
42+
target: object,
43+
canaryName: string,
44+
descriptor: PropertyDescriptor,
45+
): void {
46+
if (Object.getOwnPropertyDescriptor(target, canaryName)) {
47+
return;
48+
}
49+
Object.defineProperty(target, canaryName, descriptor);
50+
}
51+
52+
// The canary should be present in both globals used by Jazzer.js:
53+
// - globalThis in CLI mode
54+
// - vmContext in Jest mode
55+
const canaryDescriptor = createCanaryDescriptor(CANARY_NAME);
56+
installCanaryIfMissing(globalThis, CANARY_NAME, canaryDescriptor);
57+
58+
const vmContext = getJazzerJsGlobal<Context>("vmContext");
59+
if (vmContext) {
60+
installCanaryIfMissing(vmContext, CANARY_NAME, canaryDescriptor);
61+
}
2262

23-
const targetString = "jaz_zer";
63+
// Guidance: before-hooks steer the fuzzer toward getting the canary name into
64+
// eval/Function bodies. A finding is reported when compiled code reads it.
2465

2566
registerBeforeHook(
2667
"eval",
2768
"",
2869
false,
29-
function beforeEvalHook(_thisPtr: unknown, params: string[], hookId: number) {
70+
function beforeEvalHook(
71+
_thisPtr: unknown,
72+
params: unknown[],
73+
hookId: number,
74+
) {
3075
const code = params[0];
31-
// This check will prevent runtime TypeErrors should the user decide to call Function with
32-
// non-string arguments.
33-
// noinspection SuspiciousTypeOfGuard
34-
if (typeof code === "string" && code.includes(targetString)) {
35-
reportAndThrowFinding(
36-
"Remote Code Execution\n" + ` using eval:\n '${code}'`,
37-
);
76+
// eval with non-string arguments is a no-op (returns the argument as-is),
77+
// so guidance is only meaningful for actual strings.
78+
if (typeof code === "string") {
79+
guideTowardsContainment(code, CANARY_NAME, hookId);
3880
}
39-
40-
// Since we do not hook eval using the hooking framework, we have to recompute the
41-
// call site ID on every call to eval. This shouldn't be an issue, because eval is
42-
// considered evil and should not be called too often, or even better -- not at all!
43-
guideTowardsContainment(code, targetString, hookId);
4481
},
4582
);
4683

@@ -50,22 +87,27 @@ registerBeforeHook(
5087
false,
5188
function beforeFunctionHook(
5289
_thisPtr: unknown,
53-
params: string[],
90+
params: unknown[],
5491
hookId: number,
5592
) {
56-
if (params.length > 0) {
57-
const functionBody = params[params.length - 1];
93+
if (params.length === 0) return;
5894

59-
// noinspection SuspiciousTypeOfGuard
60-
if (typeof functionBody === "string") {
61-
if (functionBody.includes(targetString)) {
62-
reportAndThrowFinding(
63-
"Remote Code Execution\n" +
64-
` using Function:\n '${functionBody}'`,
65-
);
66-
}
67-
guideTowardsContainment(functionBody, targetString, hookId);
68-
}
95+
// The Function constructor coerces every argument to string via ToString().
96+
// Template engines (e.g. Handlebars) pass non-string objects like SourceNode
97+
// whose toString() yields executable code. Coerce here to match V8's
98+
// behavior so guidance works for those cases too.
99+
const functionBody = params[params.length - 1];
100+
if (functionBody == null) return;
101+
102+
let functionBodySource: string;
103+
try {
104+
functionBodySource = String(functionBody);
105+
} catch {
106+
// toString() would also throw inside the Function constructor, so
107+
// no code will be compiled, no RCE risk, no guidance needed.
108+
return;
69109
}
110+
111+
guideTowardsContainment(functionBodySource, CANARY_NAME, hookId);
70112
},
71113
);

0 commit comments

Comments
 (0)