From ed915e51f7e5b7502a3ce7ebaa1ab3837a772480 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 21 Nov 2025 12:34:33 +0100 Subject: [PATCH 1/8] Create new Wrapper for the ProcessBuilder --- .../agent/wrappers/ProcessBuilderWrapper.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java new file mode 100644 index 00000000..0e246655 --- /dev/null +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java @@ -0,0 +1,75 @@ +package dev.aikido.agent.wrappers; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; +import net.bytebuddy.matcher.ElementMatchers; + +import java.lang.reflect.Executable; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; + +import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC; +import static net.bytebuddy.matcher.ElementMatchers.is; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +public class ProcessBuilderWrapper implements Wrapper { + public String getName() { + // Wrap ProcessBuilder constructor. + // https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/ProcessBuilder.html#%3Cinit%3E(java.lang.String...) + return ProcessBuilderAdvice.class.getName(); + } + public ElementMatcher getMatcher() { + return ElementMatchers.isDeclaredBy(ProcessBuilder.class) + .and(ElementMatchers.isConstructor()).and(takesArguments(String.class)); + } + + @Override + public ElementMatcher getTypeMatcher() { + return is(ProcessBuilder.class); + } + + public static class ProcessBuilderAdvice { + // Since we have to wrap a native Java Class stuff gets more complicated + // The classpath is not the same anymore, and we can't import our modules directly. + // To bypass this issue we load collectors from a .jar file. + @Advice.OnMethodEnter + public static void before( + @Advice.AllArguments String[] allArguments + ) throws Throwable { + String jarFilePath = System.getProperty("AIK_agent_api_jar"); + URLClassLoader classLoader = null; + try { + URL[] urls = { new URL(jarFilePath) }; + classLoader = new URLClassLoader(urls); + } catch (MalformedURLException ignored) {} + if (classLoader == null) { + return; + } + + try { + // Load the class from the JAR + Class clazz = classLoader.loadClass("dev.aikido.agent_api.collectors.CommandCollector"); + + // Run report with "argument" + for (Method method2: clazz.getMethods()) { + if(method2.getName().equals("report")) { + method2.invoke(null, allArguments); + break; + } + } + classLoader.close(); // Close the class loader + } catch (InvocationTargetException invocationTargetException) { + if(invocationTargetException.getCause().toString().startsWith("dev.aikido.agent_api.vulnerabilities")) { + throw invocationTargetException.getCause(); + } + // Ignore non-aikido throwables. + } catch(Throwable e) { + System.out.println("AIKIDO: " + e.getMessage()); + } + } + } +} From 495aef7e3da58e2f819bee0a32526f6d2e3d3aac Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 21 Nov 2025 12:34:48 +0100 Subject: [PATCH 2/8] Add new wrapper for ProcessBuilder to Wrappers.java --- agent/src/main/java/dev/aikido/agent/Wrappers.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agent/src/main/java/dev/aikido/agent/Wrappers.java b/agent/src/main/java/dev/aikido/agent/Wrappers.java index a71c13b2..87b3e0bc 100644 --- a/agent/src/main/java/dev/aikido/agent/Wrappers.java +++ b/agent/src/main/java/dev/aikido/agent/Wrappers.java @@ -23,7 +23,11 @@ private Wrappers() {} new SpringControllerWrapper(), new FileConstructorSingleArgumentWrapper(), new FileConstructorMultiArgumentWrapper(), + + // Shell wrappers new RuntimeExecWrapper(), + new ProcessBuilderWrapper(), + new MysqlCJWrapper(), new MSSQLWrapper(), new MariaDBWrapper(), From 13f7243585745dda4fb49a661170cb4f4ec32e64 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 21 Nov 2025 12:42:53 +0100 Subject: [PATCH 3/8] Make the ProcessBuilderWrapper less strict --- .../dev/aikido/agent/wrappers/ProcessBuilderWrapper.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java index 0e246655..241e8ca0 100644 --- a/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java @@ -5,7 +5,6 @@ import net.bytebuddy.matcher.ElementMatcher; import net.bytebuddy.matcher.ElementMatchers; -import java.lang.reflect.Executable; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.MalformedURLException; @@ -14,7 +13,6 @@ import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC; import static net.bytebuddy.matcher.ElementMatchers.is; -import static net.bytebuddy.matcher.ElementMatchers.takesArguments; public class ProcessBuilderWrapper implements Wrapper { public String getName() { @@ -24,7 +22,7 @@ public String getName() { } public ElementMatcher getMatcher() { return ElementMatchers.isDeclaredBy(ProcessBuilder.class) - .and(ElementMatchers.isConstructor()).and(takesArguments(String.class)); + .and(ElementMatchers.isConstructor()); } @Override @@ -38,7 +36,7 @@ public static class ProcessBuilderAdvice { // To bypass this issue we load collectors from a .jar file. @Advice.OnMethodEnter public static void before( - @Advice.AllArguments String[] allArguments + @Advice.AllArguments(typing = DYNAMIC) Object[] allArguments ) throws Throwable { String jarFilePath = System.getProperty("AIK_agent_api_jar"); URLClassLoader classLoader = null; From 8f172bf33c169f397bdc0f7e77d2d632bae07275 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 21 Nov 2025 12:50:40 +0100 Subject: [PATCH 4/8] Update RuntimeExecWrapper to make it more explicit --- .../agent/wrappers/RuntimeExecWrapper.java | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/RuntimeExecWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/RuntimeExecWrapper.java index 6032ae32..4fd12ee0 100644 --- a/agent/src/main/java/dev/aikido/agent/wrappers/RuntimeExecWrapper.java +++ b/agent/src/main/java/dev/aikido/agent/wrappers/RuntimeExecWrapper.java @@ -14,8 +14,7 @@ import java.net.URLClassLoader; import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC; -import static net.bytebuddy.matcher.ElementMatchers.is; -import static net.bytebuddy.matcher.ElementMatchers.nameContains; +import static net.bytebuddy.matcher.ElementMatchers.*; public class RuntimeExecWrapper implements Wrapper { public String getName() { @@ -24,8 +23,11 @@ public String getName() { return CommandExecAdvice.class.getName(); } public ElementMatcher getMatcher() { + // We only monkey-patch Runtime.exec(string), technically all Runtime.exec calls end up at the ProcessBuilder + // but at that point they are already split into arguments, so scanning a single command requires us to add + // a wrapper here, only for single strings. return ElementMatchers.isDeclaredBy(Runtime.class) - .and(ElementMatchers.nameContainsIgnoreCase("exec")); + .and(ElementMatchers.nameContainsIgnoreCase("exec")).and(takesArgument(0, String.class)); } @Override @@ -39,13 +41,8 @@ public static class CommandExecAdvice { // To bypass this issue we load collectors from a .jar file. @Advice.OnMethodEnter public static void before( - @Advice.This(typing = DYNAMIC, optional = true) Object target, - @Advice.Origin Executable method, - @Advice.Argument(0) Object argument + @Advice.Argument(value = 0, typing = DYNAMIC) String command ) throws Throwable { - if (!(argument instanceof String)) { - return; - } String jarFilePath = System.getProperty("AIK_agent_api_jar"); URLClassLoader classLoader = null; try { @@ -63,7 +60,7 @@ public static void before( // Run report with "argument" for (Method method2: clazz.getMethods()) { if(method2.getName().equals("report")) { - method2.invoke(null, argument); + method2.invoke(null, command); break; } } From fa3594273781ad082bafc76ee337cae5a498f09a Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 21 Nov 2025 14:12:55 +0100 Subject: [PATCH 5/8] ProcessBuilderWrapper: Move focus to start() and pass along the command --- .../dev/aikido/agent/wrappers/ProcessBuilderWrapper.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java index 241e8ca0..1916c457 100644 --- a/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java @@ -13,6 +13,7 @@ import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC; import static net.bytebuddy.matcher.ElementMatchers.is; +import static net.bytebuddy.matcher.ElementMatchers.named; public class ProcessBuilderWrapper implements Wrapper { public String getName() { @@ -22,7 +23,7 @@ public String getName() { } public ElementMatcher getMatcher() { return ElementMatchers.isDeclaredBy(ProcessBuilder.class) - .and(ElementMatchers.isConstructor()); + .and(named("start")); } @Override @@ -36,7 +37,7 @@ public static class ProcessBuilderAdvice { // To bypass this issue we load collectors from a .jar file. @Advice.OnMethodEnter public static void before( - @Advice.AllArguments(typing = DYNAMIC) Object[] allArguments + @Advice.This(typing = DYNAMIC) ProcessBuilder target ) throws Throwable { String jarFilePath = System.getProperty("AIK_agent_api_jar"); URLClassLoader classLoader = null; @@ -55,7 +56,7 @@ public static void before( // Run report with "argument" for (Method method2: clazz.getMethods()) { if(method2.getName().equals("report")) { - method2.invoke(null, allArguments); + method2.invoke(null, target.command()); break; } } From 311b85837a02efdc8958221f4e83b31dd554f4db Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 21 Nov 2025 14:13:21 +0100 Subject: [PATCH 6/8] CommandCollector: Allow for both Runtime.exec and ProcessBuilder's type --- .../collectors/CommandCollector.java | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/agent_api/src/main/java/dev/aikido/agent_api/collectors/CommandCollector.java b/agent_api/src/main/java/dev/aikido/agent_api/collectors/CommandCollector.java index 719deb57..35cbdf06 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/collectors/CommandCollector.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/collectors/CommandCollector.java @@ -7,23 +7,34 @@ import dev.aikido.agent_api.vulnerabilities.Scanner; import dev.aikido.agent_api.vulnerabilities.Vulnerabilities; +import java.util.Arrays; +import java.util.List; + public final class CommandCollector { private CommandCollector() {} private static final Logger logger = LogManager.getLogger(CommandCollector.class); - public static void report(Object command) { - if (command instanceof String commandStr) { - if (commandStr.isEmpty()) { - return; // Empty command, don't scan. - } + public static void report(String command) { + if (command.isEmpty()) { + return; // Empty command, don't scan. + } - logger.trace("Scanning command: %s", commandStr); + logger.trace("Scanning command: %s", command); - // report stats - StatisticsStore.registerCall("runtime.Exec", OperationKind.EXEC_OP); + // report stats + StatisticsStore.registerCall("runtime.Exec", OperationKind.EXEC_OP); - // scan - Vulnerabilities.Vulnerability vulnerability = new Vulnerabilities.ShellInjectionVulnerability(); - Scanner.scanForGivenVulnerability(vulnerability, "runtime.Exec", new String[]{commandStr}); - } + // scan + Vulnerabilities.Vulnerability vulnerability = new Vulnerabilities.ShellInjectionVulnerability(); + Scanner.scanForGivenVulnerability(vulnerability, "runtime.Exec", new String[]{command}); + } + + public static void report(List commandArgs) { + // This happens when Runtime.exec()'s are being called with multiple arguments -> gets forwarded. + // or when new ProcessBuilder() is called. While we don't protect for argument injections, we do protect + // against cases like ["sh", "-c", ""] + logger.trace("Scanning command arguments: %s", commandArgs); + StatisticsStore.registerCall("ProcessBuilder.start", OperationKind.EXEC_OP); + Vulnerabilities.Vulnerability vulnerability = new Vulnerabilities.ShellInjectionVulnerability(); + //Scanner.scanForGivenVulnerability(vulnerability, "runtime.Exec", commandArgs); } } From 36cf773c1f324ecec6513ce7883fb3b74d40fb78 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 21 Nov 2025 14:14:39 +0100 Subject: [PATCH 7/8] update ProcessBuilderWrapper comments --- .../java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java index 1916c457..7a9aa73f 100644 --- a/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ProcessBuilderWrapper.java @@ -17,8 +17,8 @@ public class ProcessBuilderWrapper implements Wrapper { public String getName() { - // Wrap ProcessBuilder constructor. - // https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/ProcessBuilder.html#%3Cinit%3E(java.lang.String...) + // Wrap ProcessBuilder start(). + // https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/ProcessBuilder.html#start() return ProcessBuilderAdvice.class.getName(); } public ElementMatcher getMatcher() { From fd8664dd3f799f96ee788cf4b59f23029e36e791 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 21 Nov 2025 17:15:55 +0100 Subject: [PATCH 8/8] Add test cases for ProcessBuilder based on AI - refining later --- .../java/wrappers/ProcessBuilderTest.java | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 agent_api/src/test/java/wrappers/ProcessBuilderTest.java diff --git a/agent_api/src/test/java/wrappers/ProcessBuilderTest.java b/agent_api/src/test/java/wrappers/ProcessBuilderTest.java new file mode 100644 index 00000000..2a325192 --- /dev/null +++ b/agent_api/src/test/java/wrappers/ProcessBuilderTest.java @@ -0,0 +1,185 @@ +package wrappers; + +import dev.aikido.agent_api.context.Context; +import dev.aikido.agent_api.storage.ServiceConfigStore; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import utils.EmptySampleContextObject; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +public class ProcessBuilderTest { + @AfterEach + void cleanup() { + Context.set(null); + } + @BeforeEach + void beforeEach() { + cleanup(); + ServiceConfigStore.updateBlocking(true); + } + private void setContextAndLifecycle(String url) { + Context.set(new EmptySampleContextObject(url)); + } + + @Test + public void testShellInjection() { + setContextAndLifecycle(" -la"); + Exception exception1 = assertThrows(RuntimeException.class, () -> { + new ProcessBuilder("yjytjyjty", "-c", "ls -la").start(); + }); + assertEquals("Aikido Zen has blocked Shell Injection", exception1.getMessage()); + + cleanup(); + setContextAndLifecycle("whoami"); + Exception exception2 = assertThrows(RuntimeException.class, () -> { + new ProcessBuilder("bash", "-c", "whoami").start(); + }); + assertEquals("Aikido Zen has blocked Shell Injection", exception2.getMessage()); + + cleanup(); + assertDoesNotThrow(() -> { + Runtime.getRuntime().exec("whoami && ls -la"); + }); + assertThrows(IllegalArgumentException.class, () -> { + Runtime.getRuntime().exec(""); + }); + } + + @Test + public void testOnlyScansStrings() { + setContextAndLifecycle("whoami"); + assertDoesNotThrow(() -> { + Runtime.getRuntime().exec(new String[]{"whoami"}); + }); + assertDoesNotThrow(() -> { + Runtime.getRuntime().exec(new String[]{"whoami"}, new String[]{"MyEnvironmentVar=1"}); + }); + + Exception exception1 = assertThrows(RuntimeException.class, () -> { + Runtime.getRuntime().exec("whoami", new String[]{"MyEnvironmentVar=1"}); + }); + assertEquals("Aikido Zen has blocked Shell Injection", exception1.getMessage()); + } + + // --- NEW TEST CASES --- + + @Test + public void testProcessBuilderCommandModification() { + setContextAndLifecycle("whoami"); + ProcessBuilder builder = new ProcessBuilder(); + assertDoesNotThrow(() -> { + builder.command("whoami"); + builder.start(); + }); + + Exception exception = assertThrows(RuntimeException.class, () -> { + builder.command("sh", "-c", "whoami"); + builder.start(); + }); + assertEquals("Aikido Zen has blocked Shell Injection", exception.getMessage()); + } + + @Test + public void testProcessBuilderWithDifferentShells() { + setContextAndLifecycle("whoami"); + Exception shException = assertThrows(RuntimeException.class, () -> { + new ProcessBuilder("sh", "-c", "whoami").start(); + }); + assertEquals("Aikido Zen has blocked Shell Injection", shException.getMessage()); + + Exception bashException = assertThrows(RuntimeException.class, () -> { + new ProcessBuilder("bash", "-c", "whoami").start(); + }); + assertEquals("Aikido Zen has blocked Shell Injection", bashException.getMessage()); + + Exception zshException = assertThrows(RuntimeException.class, () -> { + new ProcessBuilder("zsh", "-c", "whoami").start(); + }); + assertEquals("Aikido Zen has blocked Shell Injection", zshException.getMessage()); + } + + @Test + public void testProcessBuilderWithDirectCommand() { + setContextAndLifecycle("whoami"); + assertDoesNotThrow(() -> { + new ProcessBuilder("whoami").start(); + }); + } + + @Test + public void testProcessBuilderWithArguments() { + setContextAndLifecycle("whoami"); + assertDoesNotThrow(() -> { + new ProcessBuilder("ls", "-l", "/tmp").start(); + }); + } + + @Test + public void testProcessBuilderWithEnvironment() { + setContextAndLifecycle("whoami"); + ProcessBuilder builder = new ProcessBuilder("whoami"); + builder.environment().put("MY_VAR", "1"); + assertDoesNotThrow(() -> { + builder.start(); + }); + } + + @Test + public void testProcessBuilderWithShellInjectionInCommand() { + setContextAndLifecycle("whoami; ls"); + Exception exception = assertThrows(RuntimeException.class, () -> { + new ProcessBuilder("sh", "-c", "whoami; ls").start(); + }); + assertEquals("Aikido Zen has blocked Shell Injection", exception.getMessage()); + } + + @Test + public void testProcessBuilderWithComplexShellCommand() { + setContextAndLifecycle("whoami && ls -la"); + Exception exception = assertThrows(RuntimeException.class, () -> { + new ProcessBuilder("bash", "-c", "whoami && ls -la").start(); + }); + assertEquals("Aikido Zen has blocked Shell Injection", exception.getMessage()); + } + + @Test + public void testProcessBuilderWithSafeCommand() { + setContextAndLifecycle("whoami"); + assertDoesNotThrow(() -> { + new ProcessBuilder("whoami").start(); + }); + } + + @Test + public void testProcessBuilderWithEmptyCommand() { + assertThrows(IndexOutOfBoundsException.class, () -> { + new ProcessBuilder().start(); + }); + } + + @Test + public void testProcessBuilderWithNullCommand() { + assertThrows(NullPointerException.class, () -> { + new ProcessBuilder((String[]) null).start(); + }); + } + + @Test + public void testProcessBuilderWithCommandModificationAfterStart() { + setContextAndLifecycle("whoami"); + ProcessBuilder builder = new ProcessBuilder("whoami"); + assertDoesNotThrow(() -> { + builder.start(); + }); + // Modifying command after start should not affect previous process + builder.command("sh", "-c", "whoami"); + Exception exception = assertThrows(RuntimeException.class, () -> { + builder.start(); + }); + assertEquals("Aikido Zen has blocked Shell Injection", exception.getMessage()); + } +}