From 3f3ff9730622fd8b081ee42cbdf634f2cf45ebe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 13 Dec 2024 09:12:47 +0100 Subject: [PATCH 1/5] Detect JS injections using eval(...) --- library/agent/Attack.ts | 5 +- library/agent/protect.ts | 2 + library/sinks/Eval.test.ts | 68 +++++++++++++++++++ library/sinks/Eval.ts | 30 ++++++++ .../checkContextForJsInjection.ts | 41 +++++++++++ .../js-injection/detectJsInjection.ts | 15 ++++ .../js-injection/shouldReturnEarly.ts | 26 +++++++ 7 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 library/sinks/Eval.test.ts create mode 100644 library/sinks/Eval.ts create mode 100644 library/vulnerabilities/js-injection/checkContextForJsInjection.ts create mode 100644 library/vulnerabilities/js-injection/detectJsInjection.ts create mode 100644 library/vulnerabilities/js-injection/shouldReturnEarly.ts diff --git a/library/agent/Attack.ts b/library/agent/Attack.ts index 6d1bbe473..96780962c 100644 --- a/library/agent/Attack.ts +++ b/library/agent/Attack.ts @@ -3,7 +3,8 @@ export type Kind = | "sql_injection" | "shell_injection" | "path_traversal" - | "ssrf"; + | "ssrf" + | "js_injection"; export function attackKindHumanName(kind: Kind) { switch (kind) { @@ -17,5 +18,7 @@ export function attackKindHumanName(kind: Kind) { return "a path traversal attack"; case "ssrf": return "a server-side request forgery"; + case "js_injection": + return "a JavaScript injection"; } } diff --git a/library/agent/protect.ts b/library/agent/protect.ts index 712e9f880..e227a1277 100644 --- a/library/agent/protect.ts +++ b/library/agent/protect.ts @@ -47,6 +47,7 @@ import { Postgresjs } from "../sinks/Postgresjs"; import { Fastify } from "../sources/Fastify"; import { Koa } from "../sources/Koa"; import { ClickHouse } from "../sinks/ClickHouse"; +import { Eval } from "../sinks/Eval"; function getLogger(): Logger { if (isDebugging()) { @@ -136,6 +137,7 @@ export function getWrappers() { new Fastify(), new Koa(), new ClickHouse(), + new Eval(), ]; } diff --git a/library/sinks/Eval.test.ts b/library/sinks/Eval.test.ts new file mode 100644 index 000000000..4d1d00694 --- /dev/null +++ b/library/sinks/Eval.test.ts @@ -0,0 +1,68 @@ +import * as t from "tap"; +import { runWithContext, type Context } from "../agent/Context"; +import { createTestAgent } from "../helpers/createTestAgent"; +import { Eval } from "./Eval"; + +const dangerousContext: Context = { + remoteAddress: "::1", + method: "POST", + url: "http://localhost:4000", + query: {}, + headers: {}, + body: { + calc: "1 + 1; console.log('hello')", + }, + cookies: {}, + routeParams: {}, + source: "express", + route: "/posts/:id", +}; + +const safeContext: Context = { + remoteAddress: "::1", + method: "POST", + url: "http://localhost:4000/", + query: {}, + headers: {}, + body: { + cakc: "1+ 1", + }, + cookies: {}, + routeParams: {}, + source: "express", + route: "/posts/:id", +}; + +t.test("it detects JS injections using Eval", async (t) => { + const agent = createTestAgent(); + agent.start([new Eval()]); + + t.same(eval("1 + 1"), 2); + t.same(eval("1 + 1; console.log('hello')"), undefined); + t.same(eval("const x = 1 + 1; x"), 2); + + runWithContext(dangerousContext, () => { + t.same(eval("1 + 1"), 2); + t.same(eval("const x = 1 + 1; x"), 2); + + const error = t.throws(() => eval("1 + 1; console.log('hello')")); + t.ok(error instanceof Error); + if (error instanceof Error) { + t.same( + error.message, + "Zen has blocked a JavaScript injection: eval(...) originating from body.calc" + ); + } + + const error2 = t.throws(() => + eval("const test = 1 + 1; console.log('hello')") + ); + t.ok(error2 instanceof Error); + if (error2 instanceof Error) { + t.same( + error2.message, + "Zen has blocked a JavaScript injection: eval(...) originating from body.calc" + ); + } + }); +}); diff --git a/library/sinks/Eval.ts b/library/sinks/Eval.ts new file mode 100644 index 000000000..c6a633688 --- /dev/null +++ b/library/sinks/Eval.ts @@ -0,0 +1,30 @@ +import { getContext } from "../agent/Context"; +import { Hooks } from "../agent/hooks/Hooks"; +import { Wrapper } from "../agent/Wrapper"; +import { checkContextForJsInjection } from "../vulnerabilities/js-injection/checkContextForJsInjection"; + +export class Eval implements Wrapper { + private inspectEval(args: any[]) { + const context = getContext(); + + if (!context) { + return undefined; + } + + if (args.length === 1 && typeof args[0] === "string") { + return checkContextForJsInjection({ + js: args[0], + operation: "eval", + context, + }); + } + + return undefined; + } + + wrap(hooks: Hooks) { + hooks.addGlobal("eval", { + inspectArgs: this.inspectEval, + }); + } +} diff --git a/library/vulnerabilities/js-injection/checkContextForJsInjection.ts b/library/vulnerabilities/js-injection/checkContextForJsInjection.ts new file mode 100644 index 000000000..6a8d80566 --- /dev/null +++ b/library/vulnerabilities/js-injection/checkContextForJsInjection.ts @@ -0,0 +1,41 @@ +import { Context } from "../../agent/Context"; +import { InterceptorResult } from "../../agent/hooks/InterceptorResult"; +import { SOURCES } from "../../agent/Source"; +import { extractStringsFromUserInputCached } from "../../helpers/extractStringsFromUserInputCached"; +import { detectJsInjection } from "./detectJsInjection"; + +/** + * This function goes over all the different input types in the context and checks + * if it's a possible JS Injection, if so the function returns an InterceptorResult + */ +export function checkContextForJsInjection({ + js, + operation, + context, +}: { + js: string; + operation: string; + context: Context; +}): InterceptorResult { + for (const source of SOURCES) { + const userInput = extractStringsFromUserInputCached(context, source); + if (!userInput) { + continue; + } + + for (const [str, path] of userInput.entries()) { + if (detectJsInjection(js, str)) { + return { + operation: operation, + kind: "js_injection", + source: source, + pathToPayload: path, + metadata: { + js: js, + }, + payload: str, + }; + } + } + } +} diff --git a/library/vulnerabilities/js-injection/detectJsInjection.ts b/library/vulnerabilities/js-injection/detectJsInjection.ts new file mode 100644 index 000000000..2a49abdb6 --- /dev/null +++ b/library/vulnerabilities/js-injection/detectJsInjection.ts @@ -0,0 +1,15 @@ +import { shouldReturnEarly } from "./shouldReturnEarly"; +// eslint-disable-next-line camelcase +import { wasm_detect_js_injection } from "../../internals/zen_internals"; + +export function detectJsInjection(query: string, userInput: string) { + const queryLowercase = query.toLowerCase(); + const userInputLowercase = userInput.toLowerCase(); + + if (shouldReturnEarly(queryLowercase, userInputLowercase)) { + return false; + } + + // The source type is currently hardcoded to 0 (CJS & ESM) + return wasm_detect_js_injection(queryLowercase, userInputLowercase, 0); +} diff --git a/library/vulnerabilities/js-injection/shouldReturnEarly.ts b/library/vulnerabilities/js-injection/shouldReturnEarly.ts new file mode 100644 index 000000000..e3dd399a5 --- /dev/null +++ b/library/vulnerabilities/js-injection/shouldReturnEarly.ts @@ -0,0 +1,26 @@ +export function shouldReturnEarly(code: string, userInput: string) { + // User input too small or larger than query + if (userInput.length <= 1 || code.length < userInput.length) { + return true; + } + + // User input not in query + if (!code.includes(userInput)) { + return true; + } + + // User input is alphanumerical (with underscores allowed) + if (userInput.match(/^[a-z0-9_]+$/i)) { + return true; + } + + // Check if user input is a valid comma-separated list of numbers + const cleanedInputForList = userInput.replace(/ /g, "").replace(/,/g, ""); + + if (/^\d+$/.test(cleanedInputForList)) { + return true; + } + + // Return false if none of the conditions are met + return false; +} From aeec6fd8569faa97545e8b217bd7e874a8a3d244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 13 Dec 2024 09:47:18 +0100 Subject: [PATCH 2/5] Add JS injection tests --- .../checkContextForJsInjection.test.ts | 35 +++++++ .../js-injection/detectJsInjection.test.ts | 92 +++++++++++++++++++ .../js-injection/detectJsInjection.ts | 22 ++++- 3 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 library/vulnerabilities/js-injection/checkContextForJsInjection.test.ts create mode 100644 library/vulnerabilities/js-injection/detectJsInjection.test.ts diff --git a/library/vulnerabilities/js-injection/checkContextForJsInjection.test.ts b/library/vulnerabilities/js-injection/checkContextForJsInjection.test.ts new file mode 100644 index 000000000..64f8af6c0 --- /dev/null +++ b/library/vulnerabilities/js-injection/checkContextForJsInjection.test.ts @@ -0,0 +1,35 @@ +import * as t from "tap"; +import { checkContextForJsInjection } from "./checkContextForJsInjection"; + +t.test("it returns correct path", async () => { + t.same( + checkContextForJsInjection({ + js: "const x = 1 + 1; fetch();", + operation: "eval", + context: { + cookies: {}, + headers: {}, + remoteAddress: "ip", + method: "POST", + url: "url", + query: {}, + body: { + calc: "1 + 1; fetch()", + }, + source: "express", + route: "/", + routeParams: {}, + }, + }), + { + operation: "eval", + kind: "js_injection", + source: "body", + pathToPayload: ".calc", + metadata: { + js: "const x = 1 + 1; fetch();", + }, + payload: "1 + 1; fetch()", + } + ); +}); diff --git a/library/vulnerabilities/js-injection/detectJsInjection.test.ts b/library/vulnerabilities/js-injection/detectJsInjection.test.ts new file mode 100644 index 000000000..4f707c059 --- /dev/null +++ b/library/vulnerabilities/js-injection/detectJsInjection.test.ts @@ -0,0 +1,92 @@ +import * as t from "tap"; +import { detectJsInjection } from "./detectJsInjection"; + +t.test("it detects JS injections", async (t) => { + t.same( + detectJsInjection( + '1 + 1; console.log("hello")', + '1 + 1; console.log("hello")' + ), + true + ); + t.same(detectJsInjection("const x = 1 + 1; fetch();", "+ 1; fetch()"), true); + t.same( + detectJsInjection("const test = 'Hello World!'; //';", "Hello World!'; //"), + true + ); + t.same( + detectJsInjection( + "if (username === 'admin' || 1 === 1) { return true; } //') {}", + "admin' || 1 === 1) { return true; } //" + ), + true + ); + t.same( + detectJsInjection( + "packet.readDateTimeString('abc'); process.exit(1); // ');", + "abc'); process.exit(1); //" + ), + true + ); + t.same( + detectJsInjection( + "const window={}; alert('!'); return window.__NUXT__", + "alert('!');" + ), + true + ); + t.same( + detectJsInjection( + "const obj = { test: 'value', isAdmin: true }; //'};", + "value', isAdmin: true }; //" + ), + true + ); +}); + +t.test("does not detect JS injections", async (t) => { + t.same(detectJsInjection("1 + 1", "1 + 1"), false); + t.same(detectJsInjection("1 + 1", "const x = 1 + 1; x"), false); + t.same(detectJsInjection("1 + 1", "1 + 1; console.log('hello')"), false); + t.same(detectJsInjection("1 + 1", "1"), false); + t.same(detectJsInjection("1 + 1", "abc"), false); + t.same(detectJsInjection("const x = 'test'", "test"), false); + t.same(detectJsInjection("const x = 'test'", ""), false); + t.same(detectJsInjection("const test = 'abcde_123';", "abcde_123"), false); + t.same(detectJsInjection("const test = [1, 2, 3];", "1, 2, 3"), false); + + t.same( + detectJsInjection("const test = 'Hello World!';", "Hello World!"), + false + ); + t.same( + detectJsInjection( + "if(username === 'admin' || 1 === 1) { return true; }", + "admin" + ), + false + ); + t.same( + detectJsInjection("const obj = { test: 'value', isAdmin: true };", "value"), + false + ); +}); + +t.test("test source type", async (t) => { + t.same( + detectJsInjection( + "const test: string = 'Hello World!'; console.log('test'); //';", + "Hello World!'; console.log('test'); //", + 0 + ), + false // Cannot be parsed as JS, it's TS + ); + t.same( + detectJsInjection( + "const test: string = 'Hello World!'; console.log('test'); //';", + "Hello World!'; console.log('test'); //", + 1 + ), + true + ); +}); diff --git a/library/vulnerabilities/js-injection/detectJsInjection.ts b/library/vulnerabilities/js-injection/detectJsInjection.ts index 2a49abdb6..951170d2b 100644 --- a/library/vulnerabilities/js-injection/detectJsInjection.ts +++ b/library/vulnerabilities/js-injection/detectJsInjection.ts @@ -2,14 +2,26 @@ import { shouldReturnEarly } from "./shouldReturnEarly"; // eslint-disable-next-line camelcase import { wasm_detect_js_injection } from "../../internals/zen_internals"; -export function detectJsInjection(query: string, userInput: string) { - const queryLowercase = query.toLowerCase(); +/** + * Detects if the user input is a JS injection + * The sourceType is used to determine the source of the user input + * https://github.com/AikidoSec/zen-internals/blob/4b7bf2c7796155731dc2736a04e3f4d99cdc712b/src/js_injection/helpers/select_sourcetype_based_on_enum.rs#L4 + */ +export function detectJsInjection( + code: string, + userInput: string, + sourceType = 0 +): boolean { + const codeLowercase = code.toLowerCase(); const userInputLowercase = userInput.toLowerCase(); - if (shouldReturnEarly(queryLowercase, userInputLowercase)) { + if (shouldReturnEarly(codeLowercase, userInputLowercase)) { return false; } - // The source type is currently hardcoded to 0 (CJS & ESM) - return wasm_detect_js_injection(queryLowercase, userInputLowercase, 0); + return wasm_detect_js_injection( + codeLowercase, + userInputLowercase, + sourceType + ); } From 1fc6452a28db34747f2d8f0258745398ca686b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 13 Dec 2024 12:55:45 +0100 Subject: [PATCH 3/5] Protect new Function(...) --- library/sinks/Eval.test.ts | 9 ++- library/sinks/Function.test.ts | 113 +++++++++++++++++++++++++++++++++ library/sinks/Function.ts | 41 ++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 library/sinks/Function.test.ts create mode 100644 library/sinks/Function.ts diff --git a/library/sinks/Eval.test.ts b/library/sinks/Eval.test.ts index 4d1d00694..2c5beb058 100644 --- a/library/sinks/Eval.test.ts +++ b/library/sinks/Eval.test.ts @@ -25,7 +25,7 @@ const safeContext: Context = { query: {}, headers: {}, body: { - cakc: "1+ 1", + calc: "1+ 1", }, cookies: {}, routeParams: {}, @@ -65,4 +65,11 @@ t.test("it detects JS injections using Eval", async (t) => { ); } }); + + runWithContext(safeContext, () => { + t.same(eval("1 + 1"), 2); + t.same(eval("const x = 1 + 1; x"), 2); + t.same(eval("1 + 1; console.log('hello')"), undefined); + t.same(eval("const test = 1 + 1; console.log('hello')"), undefined); + }); }); diff --git a/library/sinks/Function.test.ts b/library/sinks/Function.test.ts new file mode 100644 index 000000000..8d2f2e753 --- /dev/null +++ b/library/sinks/Function.test.ts @@ -0,0 +1,113 @@ +import * as t from "tap"; +import { runWithContext, type Context } from "../agent/Context"; +import { createTestAgent } from "../helpers/createTestAgent"; +import { Function as FunctionWrapper } from "./Function"; + +const dangerousContext: Context = { + remoteAddress: "::1", + method: "POST", + url: "http://localhost:4000", + query: {}, + headers: {}, + body: { + calc: "1 + 1; console.log('hello')", + }, + cookies: {}, + routeParams: {}, + source: "express", + route: "/posts/:id", +}; + +const safeContext: Context = { + remoteAddress: "::1", + method: "POST", + url: "http://localhost:4000/", + query: {}, + headers: {}, + body: { + calc: "1+ 1", + }, + cookies: {}, + routeParams: {}, + source: "express", + route: "/posts/:id", +}; + +t.test("it detects JS injections using Function", async (t) => { + const agent = createTestAgent(); + agent.start([new FunctionWrapper()]); + + t.same(new Function("return 1 + 1")(), 2); + t.same(new Function("1 + 1")(), undefined); + t.same(new Function("const x = 1 + 1; return x")(), 2); + + t.same( + new Function( + "const sumArray = (arr) => arr.reduce((previousValue, currentValue) => previousValue + currentValue); return sumArray" + )()([1, 2, 3]), + 6 + ); + + t.same( + Function( + "function findLargestNumber (arr) { return Math.max(...arr) }; return findLargestNumber" + ) + .call({}) + .call({}, [2, 4, 1, 8, 5]), + 8 + ); + t.same(new Function("a", "b", "return a + b")(2, 6), 8); + t.same(new Function("inp = 9", "const test = inp; return test;")(), 9); + t.same(new Function("a, b", "c = 5", "return a + b + c")(2, 6), 13); + + const error1 = t.throws(() => new Function("/*", "*/) {")); + t.ok(error1 instanceof Error); + if (error1 instanceof Error) { + t.same(error1.message, "Unexpected end of arg string"); + } + + runWithContext(safeContext, () => { + t.same(new Function("1 + 1")(), undefined); + t.same(new Function("const x = 1 + 1; return x")(), 2); + }); + + runWithContext(dangerousContext, () => { + t.same(new Function("1 + 1")(), undefined); + t.same(new Function("const x = 1 + 1; return x")(), 2); + + const error = t.throws(() => new Function("1 + 1; console.log('hello')")); + t.ok(error instanceof Error); + if (error instanceof Error) { + t.same( + error.message, + "Zen has blocked a JavaScript injection: new Function(...) originating from body.calc" + ); + } + + const error2 = t.throws( + () => new Function("const test = 1 + 1; console.log('hello')") + ); + t.ok(error2 instanceof Error); + if (error2 instanceof Error) { + t.same( + error2.message, + "Zen has blocked a JavaScript injection: new Function(...) originating from body.calc" + ); + } + + const error3 = t.throws(() => + new Function( + "a, b", + "c = 5", + "const x = a + b + c + 1 + 1; console.log('hello'); return x;" + )(2, 6) + ); + t.ok(error2 instanceof Error); + if (error2 instanceof Error) { + t.same( + error2.message, + "Zen has blocked a JavaScript injection: new Function(...) originating from body.calc" + ); + } + }); +}); diff --git a/library/sinks/Function.ts b/library/sinks/Function.ts new file mode 100644 index 000000000..cfdf624d9 --- /dev/null +++ b/library/sinks/Function.ts @@ -0,0 +1,41 @@ +import { getContext } from "../agent/Context"; +import { Hooks } from "../agent/hooks/Hooks"; +import { Wrapper } from "../agent/Wrapper"; +import { checkContextForJsInjection } from "../vulnerabilities/js-injection/checkContextForJsInjection"; + +export class Function implements Wrapper { + private inspectFunction(args: any[]) { + const context = getContext(); + + if (!context || !Array.isArray(args) || args.length === 0) { + return undefined; + } + + const findLastStringArg = (args: any[]) => { + for (let i = args.length - 1; i >= 0; --i) { + if (typeof args[i] === "string") { + return args[i]; + } + } + return undefined; + }; + + const lastStringArg = findLastStringArg(args); + + if (lastStringArg) { + return checkContextForJsInjection({ + js: lastStringArg, + operation: "new Function", + context, + }); + } + + return undefined; + } + + wrap(hooks: Hooks) { + hooks.addGlobal("Function", { + inspectArgs: this.inspectFunction, + }); + } +} From d6c776732e2c9ef4f43f1284d33aa7f5ad54aa72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 13 Dec 2024 13:07:23 +0100 Subject: [PATCH 4/5] Fix missing sink in getWrappers --- README.md | 1 + library/agent/protect.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 491e6ba56..8674a7bca 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Zen will autonomously protect your Node.js applications against: * 🛡️ [Prototype pollution](./docs/prototype-pollution.md) * 🛡️ [Path traversal attacks](https://owasp.org/www-community/attacks/Path_Traversal) * 🛡️ [Server-side request forgery (SSRF)](./docs/ssrf.md) +* 🛡️ JS injection Zen operates autonomously on the same server as your Node.js app to: diff --git a/library/agent/protect.ts b/library/agent/protect.ts index e227a1277..c045e57ca 100644 --- a/library/agent/protect.ts +++ b/library/agent/protect.ts @@ -48,6 +48,7 @@ import { Fastify } from "../sources/Fastify"; import { Koa } from "../sources/Koa"; import { ClickHouse } from "../sinks/ClickHouse"; import { Eval } from "../sinks/Eval"; +import { Function } from "../sinks/Function"; function getLogger(): Logger { if (isDebugging()) { @@ -138,6 +139,7 @@ export function getWrappers() { new Koa(), new ClickHouse(), new Eval(), + new Function(), ]; } From db81d593954e8a36e81c4adeb100363940e7ef42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 13 Dec 2024 15:58:56 +0100 Subject: [PATCH 5/5] Update Zen Internals --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 69203c5c9..08d7d7445 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -INTERNALS_VERSION = v0.1.31 +INTERNALS_VERSION = v0.1.32 INTERNALS_URL = https://github.com/AikidoSec/zen-internals/releases/download/$(INTERNALS_VERSION) TARBALL = zen_internals.tgz CHECKSUM_FILE = zen_internals.tgz.sha256sum