[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[FD] Apache Struts2 Core and Plugins <=7.4.0 Multiple Null Pointer Exceptions
- To: "fulldisclosure@xxxxxxxxxxxx" <fulldisclosure@xxxxxxxxxxxx>
- Subject: [FD] Apache Struts2 Core and Plugins <=7.4.0 Multiple Null Pointer Exceptions
- From: Daniel Owens via Fulldisclosure <fulldisclosure@xxxxxxxxxxxx>
- Date: Sun, 8 Nov 2026 22:25:43 +0000
The Apache Struts2 Core, Apache Struts2 Convention Plugin, Apache Struts2 REST
plugin, and the Apache Struts2 JavaTemplates plugin, Apache Struts2 Tiles
plugin, etc., all versions <=7.4.0/<=6.12.0/etc., dereference or iterate over
data that can be non-null -- such as an unmapped configuration lookup, an
absent collection element, a failed backing-store lookup, an unselected
optional upload field, or an unresolved expression-language property, a
security pattern-matching call invoked with no candidate expression to test, or
a missing bundle entry's substituted default message -- without first checking
whether that value is `null`.
Impact
Any caller -- an attacker or an entirely ordinary user -- who executes a code
path in which the underlying value is absent or `null` (e.g., an incomplete
configuration hierarchy, a missing or empty form field, a non-existent lookup
key, an unselected optional upload, or application data that simply does not
expose the expected property) will cause an uncaught `NullPointerException` to
bubble up the stack, resulting in a `500 Internal Server Error`. This may
result in information disclosure -- either directly or otherwise - and, like
all `500` responses, signals to malicious entities that the information system
contains significant bugs, errors, and unintended side effects brought about by
unexpected input and edge cases.
While these may seem to merely be "reliability bugs", they undermine the
availability of systems that rely upon the underlying code, especially if the
victim is on behind a load balancer or other front-end system that sees a `500
Internal Server Error` as a sign of an unhealthy environment and tears it down.
Moreover, it is not inconceivable that a configuration file or variable is
non-null in development, staging, or elsewhere but not in production, resulting
in unexpected exceptions and other issues that either were not identified
during testing or could not have been identified in testing. It should also be
noted that these kinds of issues are examples of poor coding standards,
testing, and weak peer-review processes.
Examples
As with many of the previously released advisories, there are many instances of
this, so we will limit it to a subset of instances from which the others may be
derived -- again, for the sake of brevity and because it simply isn't our job
to enumerate all instances of a single vulnerable (like with the Struts2
insecure JSON deserialisation pathways where we made clear the issue,
mechanisms, and proof of concept while also citing sample responsible code and
providing detailed analysis thereof). Please excuse us for our terseness on
this one -- we have written up many of these, as of late, and the rather
glaring theme and sheer number of issues makes such write-ups time consuming
(old code or not, all of these are things that have been well-documented,
frowned upon coding paradigms and constructs for literal decades, long before
any of the code in question was written) -- and this is all without getting
into the more "juicy" findings.
RangeValidatorSupport -- Null Elements
`RangeValidatorSupport.validate(Object)`, in `struts2-core`, only `null`-checks
the top-level array- or `Collection`-typed field before iterating over the
elements and passing each one to `validateValue(Object, Comparable, T, T)`,
which can call to `value.compareTo(min)` and `value.compareTo(max)` -- so a
single `null` element inside an otherwise non-`null` collection will cause a
`NullPointerException`.
```java
public void validate(Object object) throws ValidationException {
Object obj = getFieldValue(getFieldName(), object);
// if there is no value - don't do comparison
// if a value is required, a required validator should be added to the field
if (obj == null) {
return;
}
T min = getMin();
T max = getMax();
if (obj.getClass().isArray()) {
Object[] values = (Object[]) obj;
for (Object objValue : values) {
validateValue(object, (Comparable<T>) objValue, min, max);
}
} else if (Collection.class.isAssignableFrom(obj.getClass())) {
Collection<?> values = (Collection<?>) obj;
for (Object objValue : values) {
validateValue(object, (Comparable<T>) objValue, min, max);
}
} else {
validateValue(object, (Comparable<T>) obj, min, max);
}
}
```
Code snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/validator/validators/RangeValidatorSupport.java,
lines 46-71
```java
protected void validateValue(Object object, Comparable<T> value, T min, T max) {
setCurrentValue(value);
// only check for a minimum value if the min parameter is set
if ((min != null) && (value.compareTo(min) < 0)) {
addFieldError(getFieldName(), object);
}
// only check for a maximum value if the max parameter is set
if ((max != null) && (value.compareTo(max) > 0)) {
addFieldError(getFieldName(), object);
}
setCurrentValue(null);
}
```
Snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/validator/validators/RangeValidatorSupport.java,
lines 73-87, showing the unconditional `value.compareTo` dereference
DoubleRangeValidator -- Null Elements
`DoubleRangeFieldValidator`, in `struts2-core`, does not extend
`RangeValidatorSupport<T>` the way that `IntRangeFieldValidator`,
`LongRangeFieldValidator`, `ShortRangeFieldValidator`, and
`LocalDateRangeFieldValidator` all do -- it implements its own
`validate(Object)`, `validateCollection(Double, Double, Double, Double,
Collection)`, and `validateValue(Object, Double, Double, Double, Double)`
methods -- and contains the identical problematic code but under the `validate`
to `validateCollection` pathway, which, again, only performs a nullness check
on the top-level object, but, this time, the exception occurs when
`validateValue` unconditionally calls `Object.toString()` inside of a try-catch
block that only handles `NumberFormatException`.
```java
public void validate(Object object) throws ValidationException {
String fieldName = getFieldName();
Object obj = this.getFieldValue(fieldName, object);
if (obj == null) {
return;
}
Double maxInclusiveToUse = getMaxInclusive();
Double minInclusiveToUse = getMinInclusive();
Double maxExclusiveToUse = getMaxExclusive();
Double minExclusiveToUse = getMinExclusive();
if (obj.getClass().isArray()) {
Object[] values = (Object[]) obj;
validateCollection(maxInclusiveToUse, minInclusiveToUse,
maxExclusiveToUse, minExclusiveToUse, Arrays.asList(values));
} else if (Collection.class.isAssignableFrom(obj.getClass())) {
Collection values = (Collection) obj;
validateCollection(maxInclusiveToUse, minInclusiveToUse,
maxExclusiveToUse, minExclusiveToUse, values);
} else {
validateValue(obj, maxInclusiveToUse, minInclusiveToUse,
maxExclusiveToUse, minExclusiveToUse);
}
}
protected void validateCollection(Double maxInclusiveToUse, Double
minInclusiveToUse, Double maxExclusiveToUse, Double minExclusiveToUse,
Collection values) {
for (Object objValue : values) {
validateValue(objValue, maxInclusiveToUse, minInclusiveToUse,
maxExclusiveToUse, minExclusiveToUse);
}
}
```
Code snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/validator/validators/DoubleRangeFieldValidator.java,
lines 109-136, showing the top-level-only null check at line 112 and
`validateCollection()`'s per-element iteration with no null check
```java
protected void validateValue(Object obj, Double maxInclusiveToUse, Double
minInclusiveToUse, Double maxExclusiveToUse, Double minExclusiveToUse) {
try {
setCurrentValue(obj);
Double value = Double.valueOf(obj.toString());
if ((maxInclusiveToUse != null &&
value.compareTo(maxInclusiveToUse) > 0) ||
(minInclusiveToUse != null &&
value.compareTo(minInclusiveToUse) < 0) ||
(maxExclusiveToUse != null &&
value.compareTo(maxExclusiveToUse) >= 0) ||
(minExclusiveToUse != null &&
value.compareTo(minExclusiveToUse) <= 0)) {
addFieldError(getFieldName(), value);
}
} catch (NumberFormatException e) {
LOG.debug("Cannot validate value {} - not a Double", obj, e);
} finally {
setCurrentValue(null);
}
}
```
Snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/validator/validators/DoubleRangeFieldValidator.java,
lines 138-153, showing the unconditional `obj.toString()` call and the `catch`
clause that handles only `NumberFormatException`
FileUploadAction/MultipleFileUploadUsingArrayAction -- Nullable Optional Upload
`FileUploadAction.getUploadedFile()` and
`MultipleFileUploadUsingArrayAction.upload()`, both in `struts2-showcase`,
dereference or iterate over their respective `uploadedFile`/`uploadedFiles`
fields without a nullness check, but `ActionFileUploadInterceptor`, in
`struts2-core`, only ever assigns either field when at least one file was
accepted from the multipart request -- leaving both fields `null` whenever the
form is submitted without a file having been selected. Importantly,
`struts2-showcase` is "sample code" and contains cross-site scripting (XSS) and
various other issues (OGNL injection, of course), but we felt it important to
show the code so that others are aware of how the interceptor operates and to
not use the sample code as any kind of a guide (for many reasons).
```java
public Object getUploadedFile() {
return uploadedFile.getContent();
}
```
Code snippet from `struts2-showcase`'s
apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/FileUploadAction.java,
lines 66-68
```java
public String upload() throws Exception {
System.out.println("\n\n upload2");
System.out.println("files:");
for (UploadedFile u : uploadedFiles) {
System.out.println("*** " + u + "\t" + u.length());
}
```
Snippet from `struts2-showcase`'s
apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/MultipleFileUploadUsingArrayAction.java,
lines 41-45
```java
if (acceptedFiles.isEmpty()) {
LOG.debug("No files have been uploaded/accepted");
} else {
LOG.debug("Passing: {} uploaded file(s) to action",
acceptedFiles.size());
action.withUploadedFiles(acceptedFiles);
}
// invoke action
return invocation.invoke();
```
Snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/interceptor/ActionFileUploadInterceptor.java,
lines 278-286, showing both fields left unset whenever no files are accepted
SelectHandle -- Option Group Rendering Dereferences an Unresolved Lookup
`SelectHandler.writeOptionGroup()`, in `struts2-javatemplates-plugin`, calls
`toString()` directly on the object returned by a value-stack lookup for a
configured list-key or list-value property without a nullness check, even
though the sibling top-level list-rendering code in the very same class already
null-checks the identical lookup before calling `toString()` on it.
```java
Object tmpKey = stack.findValue(listKey != null ? listKey :
"top");
String tmpKeyStr = StringUtils.defaultString(tmpKey.toString());
Object tmpValue = stack.findValue(listValue != null ? listValue
: "top");
String tmpValueStr =
StringUtils.defaultString(tmpValue.toString());
```
Code snippet from `struts2-javatemplates-plugin`'s
plugins/javatemplates/src/main/java/org/apache/struts2/views/java/simple/SelectHandler.java,
lines 135-138
```java
//key
Object itemKey = findValue(listKey != null ? listKey : "top");
String itemKeyStr = StringUtils.defaultString(itemKey == null ?
null : itemKey.toString());
//value
Object itemValue = findValue(listValue != null ? listValue :
"top");
String itemValueStr = StringUtils.defaultString(itemValue ==
null ? null : itemValue.toString());
```
Snippet from `struts2-javatemplates-plugin`'s
plugins/javatemplates/src/main/java/org/apache/struts2/views/java/simple/SelectHandler.java,
lines 82-87, showing the sibling null-guarded equivalent lookup
Accepted/Excluded Patterns Checker -- Unguarded Regex Match Against a Null
Expression
`DefaultNotExcludedAcceptedPatternsChecker.isAllowed(String)`,
`isAccepted(String)`, and `isExcluded(String)`, in `struts2-core`, forward
their `value` parameter to `DefaultAcceptedPatternsChecker.isAccepted(String)`
and `DefaultExcludedPatternsChecker.isExcluded(String)`, both of which call
`pattern.matcher(value)` for every configured pattern without a nullness check.
```java
@Override
public IsAllowed isAllowed(String value) {
IsExcluded isExcluded = isExcluded(value);
if (isExcluded.isExcluded()) {
return IsAllowed.no(isExcluded.getExcludedPattern());
}
IsAccepted isAccepted = isAccepted(value);
if (!isAccepted.isAccepted()) {
return IsAllowed.no(isAccepted.getAcceptedPattern());
}
return IsAllowed.yes(isAccepted.getAcceptedPattern());
}
@Override
public IsAccepted isAccepted(String value) {
return acceptedPatterns.isAccepted(value);
}
```
Code snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/security/DefaultNotExcludedAcceptedPatternsChecker.java,
lines 41-59, showing `isAllowed(String)` and `isAccepted(String)` forwarding
`value` onward without protecting against `null`s
```java
@Override
public IsExcluded isExcluded(String value) {
return excludedPatterns.isExcluded(value);
}
```
Snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/security/DefaultNotExcludedAcceptedPatternsChecker.java,
lines 81-84, showing `isExcluded(String)`similarly forwarding `value`
```java
@Override
public IsAccepted isAccepted(String value) {
for (Pattern acceptedPattern : acceptedPatterns) {
if (acceptedPattern.matcher(value).matches()) {
LOG.trace("[{}] matches accepted pattern [{}]", value,
acceptedPattern);
return IsAccepted.yes(acceptedPattern.toString());
}
}
return IsAccepted.no(acceptedPatterns.toString());
}
```
Snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/security/DefaultAcceptedPatternsChecker.java,
lines 122-131, showing `isAccepted(String)`'s unconditional
`pattern.matcher(value)` call
```java
@Override
public IsExcluded isExcluded(String value) {
for (Pattern excludedPattern : excludedPatterns) {
if (excludedPattern.matcher(value).matches()) {
LOG.trace("[{}] matches excluded pattern [{}]", value,
excludedPattern);
return IsExcluded.yes(excludedPattern);
}
}
return IsExcluded.no(excludedPatterns);
}
```
Snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/security/DefaultExcludedPatternsChecker.java,
lines 106-115, showing `isExcluded(String)`'s identical behaviour
DefaultTextProvider -- Null Default Value Omitted From the String-Array-Args
Overload's Null Guard
`DefaultTextProvider`'s `List<?>`-args overload of `getText(String, String,
List<?>)`, in `struts2-core`, explicitly substitutes `key` for `defaultValue`
whenever both the bundle lookup and the caller-supplied default are `null`, so
`new MessageFormat()` never receives a `null` pattern, but the
otherwise-parallel `String[]`-args overload omits that same check entirely, so
an identical bundle miss combined with a `null` `defaultValue` reaches `new
MessageFormat(defaultValue)`, after which an exception will be thrown.
```java
@Override
public String getText(String key, String defaultValue, List<?> args) {
String text = getText(key, args);
if(text == null && defaultValue == null) {
defaultValue = key;
}
if (text == null && defaultValue != null) {
MessageFormat format = new MessageFormat(defaultValue);
format.setLocale(ActionContext.getContext().getLocale());
format.applyPattern(defaultValue);
Object[] params;
if (args != null) {
params = args.toArray();
} else {
params = EMPTY_ARGS;
}
return format.format(params);
}
return text;
}
```
Code snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/text/DefaultTextProvider.java, lines
91-113, showing the `List<?>`-args overload's `text == null && defaultValue ==
null` substitution
```java
@Override
public String getText(String key, String defaultValue, String[] args) {
String text = getText(key, args);
if (text == null) {
MessageFormat format = new MessageFormat(defaultValue);
format.setLocale(ActionContext.getContext().getLocale());
format.applyPattern(defaultValue);
return format.format(requireNonNullElse(args, EMPTY_ARGS));
}
return text;
}
```
Snippet from `struts2-core`'s
core/src/main/java/org/apache/struts2/text/DefaultTextProvider.java, lines
115-127, showing the sibling `String[]`-args overload reaching `new
MessageFormat(defaultValue)` without an equivalent guard
Steps to Reproduce
The following are various snippets, etc., that will allow you to reproduce some
of the above. You can easily make sample Apache Struts2 applications that
prove each of these, but, for the sake of brevity, we will show you two harness
runs against the code, directly. Enjoy.
Range Validator Instance
```java
static void runCase(String label, Integer[] values) {
ArrayFieldStub validator = new ArrayFieldStub(values);
validator.setMin(1);
validator.setMax(100);
try {
validator.validate(new Object());
System.out.println(label + ":NO_EXCEPTION");
} catch (Throwable e) {
StackTraceElement top = e.getStackTrace().length > 0 ?
e.getStackTrace()[0] : null;
System.out.println(label + ":EXCEPTION:" + e.getClass().getName() + ":"
+ e.getMessage()
+ ":TOP_FRAME:" + (top != null ? top.getClassName() + "." +
top.getMethodName() + ":" + top.getLineNumber() : "none"));
}
}
```
Excerpt from the probe, running the NULL_ELEMENT case (stub array {5, null,
15}) against the ALL_VALID negative control ({5, 8, 9}), both with min=1/max=100
```text
VULNERABLE -- an Integer[] containing one null element ({5, null, 15}) with
min=1/max=100 configured threw
an uncaught java.lang.NullPointerException whose top stack frame is
'org.apache.struts2.validator.validators.RangeValidatorSupport.validateValue:77'
while the byte-identical in-range, null-free array {5, 8, 9} returned normally
without an exception
```
A run of the harness shows it vulnerable with the control passing and the test
failing
Double Range Validator Instance
```java
public static void main(String[] args) {
runCase("NULL_ELEMENT_ARRAY", new Object[]{"5.0", null, "15.0"});
runCase("ALL_VALID_ARRAY", new Object[]{"5.0", "8.0", "9.0"});
runCase("EMPTY_STRING_ELEMENT_ARRAY", new Object[]{"5.0", "", "9.0"});
runCase("NULL_ELEMENT_COLLECTION", Arrays.asList("5.0", null, "15.0"));
runCase("ALL_VALID_COLLECTION", Arrays.asList("5.0", "8.0", "9.0"));
}
```
Excerpt from the probe's `main()` method, exercising the array branch
(`NULL_ELEMENT_ARRAY` against the `ALL_VALID_ARRAY` and
`EMPTY_STRING_ELEMENT_ARRAY` controls) and the `Collection` branch
(`NULL_ELEMENT_COLLECTION` against the `ALL_VALID_COLLECTION` control), all
with `minInclusive=1.0`/`maxInclusive=100.0`
```text
NULL_ELEMENT_ARRAY:EXCEPTION:java.lang.NullPointerException:Cannot invoke
"Object.toString()" because "obj" is
null:TOP_FRAME:org.apache.struts2.validator.validators.DoubleRangeFieldValidator.validateValue:141
ALL_VALID_ARRAY:NO_EXCEPTION
EMPTY_STRING_ELEMENT_ARRAY:NO_EXCEPTION
NULL_ELEMENT_COLLECTION:EXCEPTION:java.lang.NullPointerException:Cannot invoke
"Object.toString()" because "obj" is
null:TOP_FRAME:org.apache.struts2.validator.validators.DoubleRangeFieldValidator.validateValue:141
ALL_VALID_COLLECTION:NO_EXCEPTION
```
A run of the harness shows it vulnerable with the control passing and the test
failing
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/