-
Notifications
You must be signed in to change notification settings - Fork 5
Track and protect WebClient outbound requests, fix private-IP SSRF regression #312
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,87 @@ | ||||||||||||||||||
| 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.InvocationTargetException; | ||||||||||||||||||
| import java.lang.reflect.Method; | ||||||||||||||||||
| import java.net.InetAddress; | ||||||||||||||||||
| import java.net.InetSocketAddress; | ||||||||||||||||||
| import java.net.MalformedURLException; | ||||||||||||||||||
| import java.net.SocketAddress; | ||||||||||||||||||
| import java.net.URL; | ||||||||||||||||||
| import java.net.URLClassLoader; | ||||||||||||||||||
| import java.nio.channels.SocketChannel; | ||||||||||||||||||
|
|
||||||||||||||||||
| public class SocketChannelWrapper implements Wrapper { | ||||||||||||||||||
| public String getName() { | ||||||||||||||||||
| // Wrap connect(SocketAddress) on SocketChannel. Clients that resolve hostnames with | ||||||||||||||||||
| // their own DNS resolver instead of InetAddress.getAllByName() (e.g. Reactor Netty's | ||||||||||||||||||
| // async resolver, used by default by Spring's WebClient) never trigger | ||||||||||||||||||
| // InetAddressWrapper, so this is the only point where we see the resolved address | ||||||||||||||||||
| // before the connection is made. Also catches literal IP targets, which never go | ||||||||||||||||||
| // through any resolver at all. | ||||||||||||||||||
| // https://docs.oracle.com/javase/8/docs/api/java/nio/channels/SocketChannel.html#connect-java.net.SocketAddress- | ||||||||||||||||||
| return SocketChannelAdvice.class.getName(); | ||||||||||||||||||
| } | ||||||||||||||||||
| public ElementMatcher<? super MethodDescription> getMatcher() { | ||||||||||||||||||
| return ElementMatchers.named("connect"); | ||||||||||||||||||
| } | ||||||||||||||||||
| @Override | ||||||||||||||||||
| public ElementMatcher<? super TypeDescription> getTypeMatcher() { | ||||||||||||||||||
| return ElementMatchers.isSubTypeOf(SocketChannel.class); | ||||||||||||||||||
| } | ||||||||||||||||||
| public static class SocketChannelAdvice { | ||||||||||||||||||
| // 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.Argument(0) SocketAddress remoteAddress | ||||||||||||||||||
| ) throws Throwable { | ||||||||||||||||||
| if (!(remoteAddress instanceof InetSocketAddress)) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| InetSocketAddress inetSocketAddress = (InetSocketAddress) remoteAddress; | ||||||||||||||||||
| InetAddress resolvedAddress = inetSocketAddress.getAddress(); | ||||||||||||||||||
| if (resolvedAddress == null) { | ||||||||||||||||||
| // Unresolved: nothing to report yet, connect() will throw on its own. | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| String hostname = inetSocketAddress.getHostString(); | ||||||||||||||||||
|
|
||||||||||||||||||
| String jarFilePath = System.getProperty("AIK_agent_api_jar"); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Creating a new URLClassLoader and doing reflective class/method lookups on every SocketChannel.connect() is expensive. Cache the loader/reflection results or use a preloaded bridge to avoid per-connection classloader and reflection overhead. Details✨ AI Reasoning 🔧 How do I fix it? Reply There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Loading a JAR path from System property then runtime-classloading and reflective method invocation hides executed code; avoid dynamic loading/reflection or document and validate the JAR path and expected class/method. Details✨ AI Reasoning 🔧 How do I fix it? Reply |
||||||||||||||||||
| URLClassLoader classLoader = null; | ||||||||||||||||||
| try { | ||||||||||||||||||
| URL[] urls = { new URL(jarFilePath) }; | ||||||||||||||||||
| classLoader = new URLClassLoader(urls); | ||||||||||||||||||
| } catch (MalformedURLException ignored) {} | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Empty catch swallowing MalformedURLException; at minimum log the exception or document why it can be safely ignored to avoid hiding classloader/URL setup failures. Show fix
Suggested change
Details✨ AI Reasoning Reply |
||||||||||||||||||
| if (classLoader == null) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| try { | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SocketChannelWrapper dynamically loads a JAR and reflectively invokes reportConnect, hiding execution via URLClassLoader/reflection and selective exception handling; this runtime indirection obscures intent and should be clarified or documented. Details✨ AI Reasoning 🔧 How do I fix it? Reply |
||||||||||||||||||
| // Load the class from the JAR | ||||||||||||||||||
| Class<?> clazz = classLoader.loadClass("dev.aikido.agent_api.collectors.DNSRecordCollector"); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Run reportConnect with "argument" | ||||||||||||||||||
| for (Method method2: clazz.getMethods()) { | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Iterating clazz.getMethods() to find and invoke reportConnect is verbose; use clazz.getMethod("reportConnect", String.class, InetAddress.class) and invoke it directly. Details✨ AI Reasoning 🔧 How do I fix it? Reply |
||||||||||||||||||
| if(method2.getName().equals("reportConnect")) { | ||||||||||||||||||
| method2.invoke(null, hostname, resolvedAddress); | ||||||||||||||||||
| break; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+70
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reflectively scanning/ invoking reportConnect on a runtime-loaded class obscures the actual operation performed; prefer a clearly referenced API boundary or documented explicit delegation. Show fix
Suggested change
Details✨ AI Reasoning Reply |
||||||||||||||||||
| classLoader.close(); // Close the class loader | ||||||||||||||||||
| } catch (InvocationTargetException invocationTargetException) { | ||||||||||||||||||
| if(invocationTargetException.getCause().toString().startsWith("dev.aikido.agent_api.vulnerabilities")) { | ||||||||||||||||||
| throw invocationTargetException.getCause(); | ||||||||||||||||||
|
Comment on lines
+77
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Relying on exception.getCause().toString().startsWith(...) to decide to rethrow hides error semantics; use explicit exception type checks instead of string-matching the cause. Show fix
Suggested change
Details✨ AI Reasoning Reply |
||||||||||||||||||
| } | ||||||||||||||||||
| // Ignore non-aikido throwables. | ||||||||||||||||||
| } catch(Throwable e) { | ||||||||||||||||||
| System.out.println("AIKIDO: " + e.getMessage()); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. System.out.println used for error reporting in SocketChannelWrapper. Use the project's Logger or remove this debug print.
Suggested change
Details✨ AI Reasoning Reply |
||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package dev.aikido.agent.wrappers.spring; | ||
|
|
||
| import dev.aikido.agent.wrappers.Wrapper; | ||
| import dev.aikido.agent_api.collectors.RedirectCollector; | ||
| 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.Field; | ||
| import java.lang.reflect.Method; | ||
| import java.net.URL; | ||
|
|
||
| public class SpringWebClientRedirectWrapper implements Wrapper { | ||
| // Package-private in Reactor Netty, referenced by name only. This is the internal method | ||
| // that runs once per redirect hop, for both WebClient and the Netty-backed RestClient - | ||
| // Spring's own request-adaptation layer (ExchangeFunction/ReactorClientHttpRequest) is | ||
| // only invoked once per top-level call and never sees redirect targets for bodiless (e.g. | ||
| // GET) requests, since Reactor Netty resends internally without going back through it. | ||
| // Mirrors HttpConnectionRedirectWrapper, which hooks the JDK's equally-internal | ||
| // followRedirect0 for the same reason. | ||
| private static final String HTTP_CLIENT_HANDLER_CLASS_NAME = | ||
| "reactor.netty.http.client.HttpClientConnect$HttpClientHandler"; | ||
|
|
||
| public String getName() { | ||
| return RedirectAdvice.class.getName(); | ||
| } | ||
| public ElementMatcher<? super MethodDescription> getMatcher() { | ||
| return ElementMatchers.isDeclaredBy(getTypeMatcher()) | ||
| .and(ElementMatchers.named("redirect")) | ||
| .and(ElementMatchers.takesArguments(1)); | ||
| } | ||
| @Override | ||
| public ElementMatcher<? super TypeDescription> getTypeMatcher() { | ||
| return ElementMatchers.named(HTTP_CLIENT_HANDLER_CLASS_NAME); | ||
| } | ||
| public static class RedirectAdvice { | ||
| @Advice.OnMethodExit(suppress = Throwable.class) | ||
| public static void after(@Advice.This Object handler) throws Exception { | ||
| // fromURI/toURI are UriEndpoint (also package-private), both reassigned by | ||
| // redirect() before this advice runs: fromURI is the hostname that redirected, | ||
| // toURI is where it redirected to. | ||
| String origin = externalForm(handler, "fromURI"); | ||
| String dest = externalForm(handler, "toURI"); | ||
| if (origin == null || dest == null) { | ||
| return; | ||
| } | ||
| RedirectCollector.report(new URL(origin), new URL(dest)); | ||
| } | ||
|
|
||
| // Must be public: after weaving, this is called as a real cross-class invocation from | ||
| // inside the target class's own bytecode, so a private method would raise IllegalAccessError. | ||
| public static String externalForm(Object handler, String fieldName) throws Exception { | ||
| Field field = handler.getClass().getDeclaredField(fieldName); | ||
| field.setAccessible(true); | ||
| Object uriEndpoint = field.get(handler); | ||
| if (uriEndpoint == null) { | ||
| return null; | ||
| } | ||
| Method toExternalForm = uriEndpoint.getClass().getDeclaredMethod("toExternalForm"); | ||
| toExternalForm.setAccessible(true); | ||
| return (String) toExternalForm.invoke(uriEndpoint); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package dev.aikido.agent.wrappers.spring; | ||
|
|
||
| import dev.aikido.agent.wrappers.Wrapper; | ||
| import dev.aikido.agent_api.collectors.URLCollector; | ||
| 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 org.springframework.web.reactive.function.client.ClientRequest; | ||
|
|
||
| import java.net.MalformedURLException; | ||
|
|
||
| public class SpringWebClientWrapper implements Wrapper { | ||
| // Referenced by name (not by .class) in the matchers below: ExchangeFunction is only on | ||
| // the target application's classloader (spring-webflux is compileOnly here), not on the | ||
| // agent's own classloader, so a .class literal would throw NoClassDefFoundError at premain. | ||
| private static final String EXCHANGE_FUNCTION_CLASS_NAME = | ||
| "org.springframework.web.reactive.function.client.ExchangeFunction"; | ||
|
|
||
| public String getName() { | ||
| // Wrap exchange(ClientRequest) on ExchangeFunction, the interface every WebClient | ||
| // request goes through before Reactor Netty resolves/connects. | ||
| // https://docs.spring.io/spring-framework/docs/5.3.20/javadoc-api/org/springframework/web/reactive/function/client/ExchangeFunction.html | ||
| return SpringWebClientAdvice.class.getName(); | ||
| } | ||
| public ElementMatcher<? super MethodDescription> getMatcher() { | ||
| return ElementMatchers.isDeclaredBy(getTypeMatcher()) | ||
| .and(ElementMatchers.named("exchange")); | ||
| } | ||
| @Override | ||
| public ElementMatcher<? super TypeDescription> getTypeMatcher() { | ||
| return ElementMatchers.hasSuperType(ElementMatchers.named(EXCHANGE_FUNCTION_CLASS_NAME)); | ||
| } | ||
| public static class SpringWebClientAdvice { | ||
| @Advice.OnMethodEnter(suppress = Throwable.class) | ||
| public static void before( | ||
| @Advice.Argument(0) ClientRequest request | ||
| ) throws MalformedURLException { | ||
| if (request == null || request.url() == null) { | ||
| return; | ||
| } | ||
| // Report the URL before the request is sent, so DNSRecordCollector can match the | ||
| // DNS lookup that follows to this outgoing request. | ||
| URLCollector.report(request.url().toURL()); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ | |
| import dev.aikido.agent_api.vulnerabilities.ssrf.SSRFException; | ||
| import dev.aikido.agent_api.helpers.logging.LogManager; | ||
| import dev.aikido.agent_api.helpers.logging.Logger; | ||
| import dev.aikido.agent_api.vulnerabilities.ssrf.IsPrivateIP; | ||
| import dev.aikido.agent_api.vulnerabilities.ssrf.StoredSSRFDetector; | ||
| import dev.aikido.agent_api.vulnerabilities.ssrf.StoredSSRFException; | ||
|
|
||
|
|
@@ -26,30 +27,46 @@ | |
| public final class DNSRecordCollector { | ||
| private DNSRecordCollector() {} | ||
| private static final Logger logger = LogManager.getLogger(DNSRecordCollector.class); | ||
| private static final String OPERATION_NAME = "java.net.InetAddress.getAllByName"; | ||
| private static final String INET_ADDRESS_OPERATION_NAME = "java.net.InetAddress.getAllByName"; | ||
| private static final String SOCKET_CHANNEL_OPERATION_NAME = "java.nio.channels.SocketChannel.connect"; | ||
|
|
||
| public static void report(String hostname, InetAddress[] inetAddresses) { | ||
| // InetAddress.getAllByName() resolves everything in one call, so it's safe to consume. | ||
| process(hostname, inetAddresses, PendingHostnamesStore.getAndRemove(hostname), INET_ADDRESS_OPERATION_NAME); | ||
| } | ||
|
|
||
| // For clients that resolve their own DNS (e.g. Reactor Netty, used by Spring's WebClient) or | ||
| // connect straight to an IP literal. A single request can trigger multiple connect() calls to | ||
| // the same hostname (IPv4 then IPv6), so unlike report(), this peeks the pending port instead | ||
| // of consuming it - consuming on the first attempt would let a later attempt bypass SSRF. | ||
| public static void reportConnect(String hostname, InetAddress resolvedAddress) { | ||
| process(hostname, new InetAddress[]{resolvedAddress}, PendingHostnamesStore.getPorts(hostname), SOCKET_CHANNEL_OPERATION_NAME); | ||
| } | ||
|
|
||
| private static void process(String hostname, InetAddress[] inetAddresses, Set<Integer> ports, String operationName) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Method 'process' is too vague; rename to reflect its responsibilities (e.g., handleDnsLookupRecordingAndSsrfChecks) and add Javadoc describing side effects and when it throws. Details✨ AI Reasoning 🔧 How do I fix it? Reply There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DNSRecordCollector.process() is large and mixes multiple unrelated responsibilities (stats, pending-port semantics, outbound blocking, SSRF detection and stored-SSRF checks); consider splitting into smaller focused methods. Details✨ AI Reasoning 🔧 How do I fix it? Reply |
||
| try { | ||
| logger.trace("DNSRecordCollector called with %s & inet addresses: %s", hostname, List.of(inetAddresses)); | ||
|
|
||
| // store stats | ||
| StatisticsStore.registerCall("java.net.InetAddress.getAllByName", OperationKind.OUTGOING_HTTP_OP); | ||
|
|
||
| // Consume pending ports recorded by URLCollector for this hostname. | ||
| // Removing them here ensures each (hostname, port) pair is counted exactly once. | ||
| Set<Integer> ports = PendingHostnamesStore.getAndRemove(hostname); | ||
| if (!ports.isEmpty()) { | ||
| for (int port : ports) { | ||
| HostnamesStore.incrementHits(hostname, port); | ||
| StatisticsStore.registerCall(operationName, OperationKind.OUTGOING_HTTP_OP); | ||
|
|
||
| // No pending port + private IP literal = infrastructure noise (Netty resolver bootstrap | ||
| // etc), not a real request - skip recording/blocking. SSRF checks below still run regardless. | ||
| if (!ports.isEmpty() || !IsPrivateIP.isPrivateIp(hostname)) { | ||
| if (!ports.isEmpty()) { | ||
| for (int port : ports) { | ||
| HostnamesStore.incrementHits(hostname, port); | ||
| } | ||
| } else { | ||
| // We still need to report a hit to the hostname for outbound domain blocking | ||
| HostnamesStore.incrementHits(hostname, 0); | ||
| } | ||
| } else { | ||
| // We still need to report a hit to the hostname for outbound domain blocking | ||
| HostnamesStore.incrementHits(hostname, 0); | ||
| } | ||
|
|
||
| // Block if the hostname is in the blocked domains list | ||
| if (ServiceConfigStore.shouldBlockOutgoingRequest(hostname)) { | ||
| logger.debug("Blocking DNS lookup for domain: %s", hostname); | ||
| throw BlockedOutboundException.get(); | ||
| // Block if the hostname is in the blocked domains list | ||
| if (ServiceConfigStore.shouldBlockOutgoingRequest(hostname)) { | ||
| logger.debug("Blocking DNS lookup for domain: %s", hostname); | ||
| throw BlockedOutboundException.get(); | ||
| } | ||
| } | ||
|
|
||
| // Convert inetAddresses array to a List of IP strings : | ||
|
|
@@ -62,7 +79,7 @@ public static void report(String hostname, InetAddress[] inetAddresses) { | |
| for (int port : ports) { | ||
| logger.debug("Hostname: %s, Port: %s, IPs: %s", hostname, port, ipAddresses); | ||
|
|
||
| Attack attack = SSRFDetector.run(hostname, port, ipAddresses, OPERATION_NAME); | ||
| Attack attack = SSRFDetector.run(hostname, port, ipAddresses, operationName); | ||
| if (attack == null) { | ||
| continue; | ||
| } | ||
|
|
@@ -81,7 +98,7 @@ public static void report(String hostname, InetAddress[] inetAddresses) { | |
|
|
||
| // We don't need the context object to check for stored ssrf, but we do want to run this after our other | ||
| // SSRF checks, making sure if it's a normal ssrf attack it gets reported like that. | ||
| Attack storedSsrfAttack = new StoredSSRFDetector().run(hostname, ipAddresses, OPERATION_NAME); | ||
| Attack storedSsrfAttack = new StoredSSRFDetector().run(hostname, ipAddresses, operationName); | ||
| if (storedSsrfAttack != null) { | ||
| attackDetected(storedSsrfAttack, Context.get()); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
7 Open source vulnerabilities detected - high severity
Aikido detected 7 vulnerabilities across 1 package, it includes 2 high, 2 medium and 3 low vulnerabilities.
Details
Remediation Aikido suggests bumping the vulnerable packages to a safe version.
Reply
@AikidoSec ignore: [REASON]to ignore this issue.More info