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] 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; +}