Schwachstellensuche mit CodeQL
Einsatz von CodeQL-basierender Variantenanalyse zur Identifikation neuer Schwachstellen
-
-
- Name
- Timo Müller
-
- Name
- Hans-Martin Münch
-
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:
- Identifikation aller Quellen (Neue HashMap Instanzen wie beispielsweise
env = new HashMap
) - Identifikation welche dieser HashMap Instanzen als Argument an die Senke (
newJMXConnectorServer(...)
) übergeben werden - 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.put
geprüft werden. Wenn hier die EigenschaftRMIConnectorServer.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 KonstanteRMIConnectorServer.CREDENTIALS_FILTER_PATTERN
oderjmx.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.
isRmiOrJmxServerCreateMethod(c.getCallee())
findet alle Aufrufe vonnewJMXConnectorServer
. Wir rufen einfach das Predicate aus der DataFlow Implementierung auf.c.getArgument(1)
holt sich das zweite Argument des Methodenaufrufs vonnewJMXConnectorServer
. Das Ergebnis wird in unserer Expression-VariableenvArg
abgelegt.- 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 Predicates
erfolgen. 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.
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.