Schwachstellensuche mit CodeQL

Einsatz von CodeQL-basierender Variantenanalyse zur Identifikation neuer Schwachstellen

Dies ist eine übersetzte Version. Das englische Original finden Sie hier.

Einleitung

CodeQL ist ein semantisches Code-Analyse-Tool von GitHub, welches zur Schwachstellensuche in verschiedenen Sprachen verwendet werden kann. CodeQL erlaubt es Code abzufragen als wären es Daten. Es hilft dabei, unsichere Codepatterns zu beschreiben und in weiteren Programmen zu finden. Besonders beeindruckend ist die Fähigkeit, Programmflüsse von einer Quelle (Source) zu einer Senke (Sink) nachzuverfolgen, selbst wenn beispielsweise Variablen mehrfach neu zugewiesen oder geändert werden. In den letzten zwei Jahren wurden diverse Blogbeiträge und Schwachstellenanalysen veröffentlicht, die eindrucksvoll die Leistungsfähigkeit dieses Ansatzes demonstrieren.

Wir wollten eigene Erfahrungen mit CodeQL sammeln und sehen wie einfach (oder schwierig) es ist damit Schwachstellen zu finden, idealerweise mit einem Beispiel aus der Praxis. Da wir einige Erfahrung mit JMX haben nahmen wir uns vor CVE-2016-3427 und dessen Varianten per CodeQL zu analysieren. Dieser Artikel gibt zunächst einen Überblick über die eigentliche Schwachstelle und beschreibt wie wir die CodeQL-Abfrage zur Identifizierung entsprechender Codepfade gebaut haben. Er soll Interessenten eine allgemeine Vorstellung davon vermitteln, wie man mit CodeQL bei der Schwachstellensuche einsetzt, er enthält keine Einführung in CodeQL selbst.

Die Schwachstelle: CVE-2016-3427

CVE-2016-3427 ist auf eine Deserialisierungsschwachstelle im Autorisierungsprozess von Java JMX, die ursprünglich von Pierre Ernst an Oracle gemeldet wurde. Die Schwachstelle wird dadurch verursacht, dass die Methode “RMIServer.newClient” nicht nur Strings (Benutzername/Passwort), sondern beliebige Objekte als Aufrufargumente akzeptiert. Wir vermuten, dass Oracles ursprüngliche Absicht darin bestand, eine flexible Schnittstelle für unterschiedliche Authentifizierungstypen zu bieten.

Wenn ein Zugriff auf JMX per RMI möglich ist, können wir eigenen Objekte an das System senden, wo diese deserialisiert werden. Das folgende Beispiel implementiert eine minimale Version eines entsprechenden Exploits, unter Verwendung des CommonsCollections6-Gadgets von Ysoserial:

String hostName = "192.168.1.11";
int registryPort = 1099;
Registry registry = LocateRegistry.getRegistry(hostName, registryPort);
Object gadget = new CommonsCollections6().getObject("touch /tmp/pwned");
RMIServer rmiServer = (RMIServer) registry.lookup("jmx-rmi");
RMIConnection rmiConnection = rmiServer.newClient(gadget);
rmiConnection.close();

Als Informationen zu Schwachstelle veröffentlicht wurden schenkten wir ihr zunächst keine größere Beachtung da das bei JMX-RMI verwendete RMI Protokoll ebenfalls serialisierte Objekte verwendet. Die direkte Ausnutzung von Deserialisierungsschwachstellen in RMI selbst bietet einen “generischeren” Angriffsvektor der auch nach dem Update von CVE-2016-3427 durch Oracle noch funktionierte.

Oracles Sicherheitsupdate für CVE-2016-3427

Oracle hat diese Schwachstelle im JDK 8u91 behoben. Hierzu wurde ein Filter implementiert, welcher der standardmäßig nur die Verwendung von String-Objekten erlaubt. Aus den Release Notes zu zitieren:

A new java attribute has been defined for the environment to allow a JMX RMI JRMP server to specify a list of class names. These names correspond to the closure of class names that are expected by the server when deserializing credentials. For instance, if the expected credentials were a List, then the closure would constitute all the concrete classes that should be expected in the serial form of a list of Strings.

By default, this attribute is used only by the default agent with the following filter:*

{   
  "[Ljava.lang.String;",   
  "java.lang.String" 
}

Die meisten Java-Applikationen verwenden die von der Runtime bereitgestellte JMX-RMI-Implementierung, welche durch zusätzlicher Befehlszeilenargumente beim Start des Programms konfiguriert werden kann. Nach der Aktualisierung auf JDK8u91 sind diese Systeme nicht länger angreifbar, da die Standardimplementierung den zuvor beschriebenen Filter verwendet.

Es existiert jedoch auch das Szenario, bei dem der JMX-RMI-Dienst von der Applikation selbst bereitgestellt wird. Dies wird nur von einer geringen Anzahl von Applikationen verwendet, meistens weil mehr Kontrolle über den JMX-Dienst benötigen wird. Ein gängiges Beispiel ist wenn die Authentifizierung von JMX-Clients gegen die eigene Benutzerdatenbank der Anwendung unterstützt werden soll.

Dieser spezielle Fall ist für Angreifer interessant, da der verwendete Filter von den Entwicklern gesetzt werden muss. Konkret müssen diese das Attribut jmx.remote.rmi.server.credential.types als Teil des an den JMX-Dienst übergebenen Environments setzen. Die JDK 8u91 Release Notes beinhalten ein entsprechende Beispielimplementierung:

Map<String, Object> env = new HashMap<>(1);
env.put ( 
 "jmx.remote.rmi.server.credential.types",
   new String[]{
    String[].class.getName(),
    String.class.getName()
  }
);

JMXConnectorServer server = JMXConnectorServerFactory.newJMXConnectorServer(url, env, mbeanServer);

Dies ist ein Beispiel für das Fehlen des “fail savely” Security-by-Design Patterns: Es verlagert die Verantwortung für die Behebung der Schwachstelle auf die Entwickler, die sich aber der potenziellen Sicherheitsproblematik bewusst sein müssen.

Mit der Veröffentlichung von Java 10 gilt der im ursprünglichen Release verwendete Ansatz zur Behebung als veraltet, da mit Java 10 die Unterstützung für CREDENTIALS_FILTER_PATTERN eingeführt wurde. Der Code ist ähnlich, jedoch kann der Filter im von anderen Look-Ahead Filtern ObjectInputFilter.Config.createFilter(java.lang.String) gewohnten Format konfiguriert werden.

Map<String, Object> env = new HashMap<>(1);
env.put(RMIConnectorServer.CREDENTIALS_FILTER_PATTERN, "java.lang.String;!*"); // Allow java.lang.String, deny the rest

JMXConnectorServer server = JMXConnectorServerFactory.newJMXConnectorServer(url, env, mbeanServer);

Beispiele: CVE-2016-8735 (Apache Tomcat JmxRemoteLifecycleListener) and Apache Cassandra

Ein konkretes Beispiel für eine solche JMX-Implementierung ist Tomcats “JmxRemoteLifecycleListener”. Die von Tomcat bereitgestellte Version erlaubt Administratoren die Konfiguration eines festen Ports für den RMI-Listeners was die Erstellung von Firewall-Regeln erheblich vereinfacht.

Die Tomcat-Entwickler haben die Schwachstelle in diesem Commit behoben. Da dies vor der Veröffentlichung von Java 10 erfolgte wurde hier jmx.remote.rmi.server.credential.types verwendet.

Ein weiteres Beispiel ist Apache Cassandra, das ebenfalls eine eigene JMX-Implementierung bereitstellt, um dort das Cassanda-interne Authentifizierungssystem verwenden zu können. Die Cassandra-Entwickler haben die Schwachstelle im August 2020 stillschweigend gepatcht.

Erstellen der CodeQL Query

Zu allererst müssen wir uns bei den CodeQL-Göttern von GitHub bedanken die uns bei der Erstellen der Query gehofen haben. Ohne die ganzen kleinen (und manchmal) großen Hinweise und Tips hätte das Erstellen der Query wesentlich länger gedauert, zudem wäre die Abfrage deutlich weniger performant. Wir stehen in Sachen CodeQL selber noch am Anfang weshalb die folgenden Beschreibungen eventuell nicht 100% korrekt sind. Vieles an dem von uns verwendeten Ansatz und der finalen CodeQL-Abfrage kann vermutlich verbessert werden. Leser die Vorschläge für mögliche Optimierungen haben dürfen sich gerne bei uns melden.

Bevor wir mit dem Schreiben der eigentlichen Abfrage loslegen können, benötigen wir einen minimalen Testcode, den wir zum Erstellen einer CodeQL-Datenbank verwenden können. Wir haben hierzu ein auf Maven basierendes Java-Projekt mit dem folgenden “Test”-Code erstellt:

Map<String, Object> env = new HashMap<String, Object>();
env.put(RMIConnectorServer.CREDENTIALS_FILTER_PATTERN, "java.lang.String;!*");
JMXConnectorServerFactory.newJMXConnectorServer(null, env, null);

Die CodeQL-Datenbank wurde manuell per CodeQL-Kommandozeilentool erstellt. Die eigentliche Abfrageentwicklung wurde hauptsächlich im VS Code CodeQL-Workspace durchgeführt, da sich dieser einfach einrichten und verwenden lässt. Das Ganze funktioniert tadellos, solange dieselbe CodeQL Version in VS Code und auf der Kommmandozeile verwendet wurde.

Wir beginnen mit dem Import der für die Abfrage benötigten CodeQL Klassen

import java
import semmle.code.java.dataflow.DataFlow
import semmle.code.java.Maps

semmle.code.java.Maps ist eine Hilfsklasse, die Code für das Arbeiten mit Java Maps bereitstellt. CodeQL bietet mehrere solcher Hilfsklassen, was uns anfangs nicht recht klar war. Unsere ersten Versuche enthielten daher eine Menge unnötigen Code um den Zugriff auf Java Maps-Instanzen zu identifizieren.

CodeQL Data Flows

Die Klassen im DataFlow Modul erlauben die Identifikation von Datenströmen per CodeQL. Die in diesem Modul vorhandenen Klassen erlauben das Mappen von Code-Pfaden von einer Quelle zu einer Senke. Das sieht im Wesentlichen wie folgt aus, man muss nur die “isSource” und “isSink” Methoden (so genannte Predicates) überschreiben:

class MyDataFlowConfiguration extends DataFlow::Configuration {
  MyDataFlowConfiguration() { this = "MyDataFlowConfiguration" }

  override predicate isSource(DataFlow::Node source) {
    ...
  }

  override predicate isSink(DataFlow::Node sink) {
    ...
  }

  ...

In unserem Fall ist das Erzeugen der HashMap Instanz, die später beim Start des JMX Dienstes übergeben wird, die Datenstromquelle:

Map<String, Object> env = new HashMap<String, Object>();

Die Senke ist die Übergabe dieser HashMap, als zweites Argument an JMXConnectorServerFactory.newJMXConnectorServer:

JMXConnectorServerFactory.newJMXConnectorServer(url, env, mbeanServer);

Wir versuchten Anfangs den in manuell durchgeführten Code-Review gängigen Ansatz umzusetzen:

  1. Identifikation aller Quellen (Neue HashMap Instanzen wie beispielsweise env = new HashMap)
  2. Identifikation welche dieser HashMap Instanzen als Argument an die Senke (newJMXConnectorServer(...)) übergeben werden
  3. Analyse des eigentlichen Datenstroms um zu prüfen ob der dabei der Filter gesetzt wird. Hierzu müsste der Wert des ersten Arguments bei einem Aufruf von env.putgeprüft werden. Wenn hier die Eigenschaft RMIConnectorServer.CREDENTIALS_FILTER_PATTERN gesetzt wird ist die Implementierung sicher.

Leider führt dieser Ansatz zu einer sehr komplexen Abfrage, da wir zunächst alle Datenströme identifizieren und im Anschluss deren Sicherheit prüfen müssen. Viel einfacher ist es, nur “sichere” Datenströme zu mappen, indem wir den Aufruf env.put(RMIConnectorServer.CREDENTIALS_FILTER_PATTERN, ???) als Datenstromquelle angeben. Glücklicherweise bietet semmle.code.java.Maps bereits ein MapPutCall, wodurch sich recht einfach Code, der etwas in eine Map schreibt finden lässt. Wir erstellen daher ein CodeQL-Predicate was das Folgende tut:

  • Analyse ob ein Wert in der Map gesetzt oder geändert wird. Wir können MapPutCall dafür verwenden.
  • Prüfung ob der im Aufruf übergebene key die Konstante RMIConnectorServer.CREDENTIALS_FILTER_PATTERN oder jmx.remote.rmi.server.credentials.filter.pattern ist

Hier der Code des Predicates:

// Pass in our source and check if it matches with our conditions
private predicate putsCredentialtypesKey(Expr qualifier) {
    // Use the MapPutCall helper to track Map operations
    exists(MapPutCall put |
      // Check if a Map is populated with one of the following two keys 
      put.getKey().(CompileTimeConstantExpr).getStringValue() =
        ["jmx.remote.rmi.server.credentials.filter.pattern"]
      or
      put.getKey().(FieldAccess).getField().hasQualifiedName("CREDENTIALS_FILTER_PATTERN")
  }
}
// [...]

Dieses Predicate gibt true oder false zurück (technisch ist das vermutlich nicht ganz korrekt, aber einfacher zu verstehen). Die Verwendung dieses Predicates führt zu besseren Code und erlaubt falls notwendig das einfache Hinzufügen weiterer Quellen. Der Code wird später vom isSource Predicate der DataFlow Analyse aufgerufen.

Der Code erlaubt es Varianten des folgenden Aufrufs zu finden

env.put(RMIConnectorServer.CREDENTIALS_FILTER_PATTERN, "java.lang.String;!*"); // Allow java.lang.String, deny the rest

Die Verwendung dieser Aufrufe als Quelle unseres Datenstroms vereinfacht die Abfrage aus folgenden Gründen:

  • Da hierdurch nur gesicherte Datenströme identifiziert werden muss nicht mehr geprüft werden ob dies tatsächlich der Fall ist.
  • Wenn der Code Aufrufe an JMXConnectorServer enthält, die nicht Teil unserer als sicher eingestuften Datenflüsse sind, so kann man davon ausgehen dass diese unsicher sind.

Im nächsten Schritt definieren wir die Senke des Datenstroms. Wir möchten alle Map-Instanzen identifizieren, die als zweites Argument an JMXConnectorServerFactory.newJMXConnectorServer übergeben werden, weshalb wir zunächst Aufrufe dieser Methode erkennen müssen. Das “finale” Predicate ist ein wenig länger, da Java mehrere Möglichkeiten bietet einen RMI basierenden JMX Dienst zu erstellen. Um das Beispiel kompakt zu halten haben wir diese Funktion hier nicht aufgeführt (wer möchte kann sie aber in der finalen Version der CodeQL Abfrage einsehen)

// Evaluates true if the given method matches our definitions (must be call to newJMXConnectorServer)
predicate isRmiOrJmxServerCreateMethod(Method method) {
  // The method must have the name "newJMXConnectorServer"...
  method.getName() = "newJMXConnectorServer" and
  // ... and it must belong to "javax.management.remote.JMXConnectorServerFactory". (this reduces false-positives)
  method.getDeclaringType().hasQualifiedName("javax.management.remote", "JMXConnectorServerFactory")
}

Hier die DataFlow Implementierung unter Verwendung der zuvor definierten Predicates.


class SafeFlow extends DataFlow::Configuration {
  SafeFlow() { this = "SafeFlow" }

  override predicate isSource(DataFlow::Node source) { 
    putsCredentialtypesKey(source.asExpr()) 
  }

  /* We tell te DataFlow what we define as sink.
  The sink must be the seca "newJMXConnectorServer" call, but also...
  our defined sink must be the second parameter to the method call.
  */
  override predicate isSink(DataFlow::Node sink) {
    exists(Call c |
      // Define our expectations for the method call (it must be newJMXConnectorServer)
      isRmiOrJmxServerCreateMethod(c.getCallee())
    |
      // Define that our source must be the second parameter to our newJMXConnectorServer call
      sink.asExpr() = c.getArgument(1)
    )
  }
}

Die eigentliche CodeQL Abfrage

Bis jetzt haben wir nur unsere Bedingungen für einen Code definiert, aber noch nichts abgefragt. CodeQL verwendet eine SQL-ähnliche Syntax mit einem “select”, “where” und “from” Teil. Die offizielleEinführung für Java-Abfragen erklärt das recht gut.

Das Select Statement ist das einzige wesentliche Teil was noch für die Abfrage fehlt. Zunächst definieren wir die notwendigen Variablen (im from) und im Where Teil. Dabei muss beachtet werden das CodeQL die Abfrage von unten ab auswertet, es also mit dem select Teil anfängt. Hier “sammeln” die Ergebnisse in den Variablen c und envArg und stellen diese dar. Die eigentliche “Magie” passiert im where Teil der Abfrage. Von Oben nach unten wird folgendes gemacht.

  1. isRmiOrJmxServerCreateMethod(c.getCallee()) findet alle Aufrufe von newJMXConnectorServer. Wir rufen einfach das Predicate aus der DataFlow Implementierung auf.
  2. c.getArgument(1) holt sich das zweite Argument des Methodenaufrufs von newJMXConnectorServer. Das Ergebnis wird in unserer Expression-Variable envArg abgelegt.
  3. Ignoriere alle Ergebnisse, die Teil der “sicheren” Datenströme sind. (Die “env” Umgebung beinhalten den Schlüssel RMIConnectorServer.CREDENTIALS_FILTER_PATTERN)
from Call c, Expr envArg
where
  (isRmiOrJmxServerCreateMethod(c.getCallee())) and
  envArg = c.getArgument(1) and
  not any(SafeFlow conf).hasFlowToExpr(envArg)
select c, getRmiResult(envArg), envArg, envArg.toString()

Aufmerksame Lesern ist vermutlich der Aufruf von getRmiResult(envArg) innerhalb des select Teils aufgefallen. Diese Funktio wurde definiert um das Szenario, das der Wert null als Umgebung an newJMXConnectorServer übergeben wird zu berücksichtigen. In diesem Fall haben wir keinen Datenstorm mit einer Map als Senke. Daher prüfen wir ob envArg möglicherweise null ist und passen den ausgegebenen Text entsprechend an (beachten Sie den $@ String innerhalb der definierten Ergebnisse, dieser dient als Platzhalter für envArg):

string getRmiResult(Expr e) {
  // We got a Map so we have a source and a sink node
  if e instanceof NullLiteral
  then
    result =
      "RMI/JMX server initialized with a null environment. Missing type restriction in RMI authentication method exposes the application to deserialization attacks."
  else
    result =
      "RMI/JMX server initialized with insecure environment $@, which never restricts accepted client objects to 'java.lang.String'. This exposes to deserialization attacks against the RMI authentication method."
}

Bisher haben wir nur eine mögliche Art der JMX-Serverinstanziierung sowie nur eine Art der Filterinstanziierung, weshalb nicht alle möglichen Code-Varianten erkannt werden. Die Erweiterung der eigentlichen Abfrage ist jedoch recht einfach, da hier nur zusätzliche Methoden-/Konstruktoraufrufe sowie mögliche Schlüsselnamen für die Filter erweitert werden. Das kann durch eine entsprechende Anpassung der bereits definierten Predicateserfolgen. Die finale CodeQL-Abfrage findet sich hier.

Eventuell fällt auf, dass die Abfrage nicht den Wert der unter dem Schlüssel in die Map geschrieben wird überprüft. Dies würde ein entsprechendes Parsen des Wertes innerhalb von CodeQL erfordern. Das wäre zwar theoretisch (beispielsweise durch das Verwenden von Regex-Ausdrücken) machbar, würde aber die Komplexität sowie das Laufzeitverhalten der Abfrage erheblich erhöhen.

Ein Test der Abfrage gegen Eclipse Jetty kann in der interaktiven LGTM Queryconsole angesehen werden, hier das dazu passende GitHub issue. Im folgenden Screenshot sehen wir die Übersicht der LGTM-Queryconsole.

CodeQL-Abfrage gegen den Code von Jetty

Zusammenfassung

Der Umgang mit CodeQL ist nicht trivial, es wird jedoch durch die Veröffentlichung von weiteren Dokumentationen und Beispielen vereinfacht. Der Qualitätsunterschied zwischen einer einfachen Prototypabfrage und einem in das CodeQL gemergten Abfrage ist immens. Eine schnell erstellte CodeQL Abfrage, mit einer Person die anschließend mögliche False-Positives aussiebt kann bei der Analyse eines einzelnen Projekts schnell zu interessanten Ergebnissen führen. GitHub (zumindest nehmen wir das an) denkt hier jedoch “größer”. Die Abfrage wird gehen Millionen von Repositories laufen, weshalb die Laufzeit der Abfrage optimiert und die Anzahl von False-Positives auf ein Minimum reduziert werden muss. Das erhöht die Anforderung an eine finale Abfrage deutlich. Es war jedoch eine interessante Erfahrung an diesem Projekt zu arbeiten, das GitHub Team (und die CodeQL Community) waren sehr hilfsbereit. Es ist auch ein großartiges Gefühl eine Abfrage beizusteuern, die später zur Sicherung einer großen Anzahl von Repositories verwendet wird. Als Bonus erhält man zudem noch einen tolles Preisgeld vom Bugbounty Programm für CodeQL Abfragen.

Das Erstellen von CodeQL-Abfragen unterscheidet sich wesentlich von “normaler” Programmierung, daher benötigt man einige Zeit, um sich entsprechend einzuarbeiten. Unserer Meinung nach ist die Dokumentation noch verbesserungswürdig, was zumindest bei uns hin und wieder zu einigen vermeidbaren Fehlern führte. GitHub stellt jedoch eine Menge Lernstoff zur Verfügung beispielsweise CodeQL-Sessions YouTube, eine Lernumgebung oder auch CodeQL CTFs. Lesern, die sich eingehend mit CodeQL befassen wollen empfehlen wir das “Alles” durchzugehen bevor man sich an die Erstellung eigener Abfragen wagt. Zudem sollte man sich Zeit nehmen um ein wenig in der Dokumentation der vorhandenen Hilfsklassen zu stöbern und bereits vorhanden CodeQL Abfragen zu analysieren.

Viele DevOps Teams versuchen momentan den “Shift-Left” Ansatz so weit wie möglich umzusetzen, da dieser langfristig Entwicklerkosten und Ressourcen reduziert. Statische Code Analyse Tools wie CodeQL bieten hier ein praktisches und leistungsfähiges Werkzeug um das Sicherheitsnievau einer Applikation zu erhöhen. Die Anzahl von Schwachstellen wird dadurch reduziert da viele generische Schwachstellen schon sehr früh identifiziert werden können.


Vielen Dank an Florencia Viadana bei Unsplash für das Titelbild.