This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new 2e16df626 Attacker-controlled exception message forges frames in
getRootCauseStackTrace output AND suppresses all real frames, beyond cosmetic
log spoofing (f025).
2e16df626 is described below
commit 2e16df6260fb207cf45ae456e25e391cb7ef9bf8
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Sep 5 15:26:19 2026 -0400
Attacker-controlled exception message forges frames in
getRootCauseStackTrace output AND suppresses all real frames, beyond
cosmetic log spoofing (f025).
---
src/changes/changes.xml | 1 +
.../commons/lang3/exception/ExceptionUtils.java | 91 ++++++++++++++++++++--
.../lang3/exception/ExceptionUtilsTest.java | 91 ++++++++++++++++++++++
3 files changed, 178 insertions(+), 5 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 228772b45..5f075851e 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -274,6 +274,7 @@ java.lang.NullPointerException: Cannot invoke
<action type="fix" dev="ggregory" due-to="Gary
Gregory">StringEscapeUtils.escapeHtml4/3 apostrophe gap: single-quoted and
unquoted HTML attribute contexts are trivially breakable, and the javadoc
discloses the gap only as an HTML4 entity-legality footnote (f022).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">ArrayUtils.addAll/insert length arithmetic overflows (undeclared
NegativeArraySizeException) while sibling concat is overflow-checked
(f023).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">FormattableUtils.append pads right-justified output with insert(0) per
char; O(width^2); '%500000s' costs ~1.25e11 char moves (f024).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">Attacker-controlled exception message forges frames in
getRootCauseStackTrace output AND suppresses all real frames, beyond cosmetic
log spoofing (f025).</action>
<!-- ADD -->
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add JavaVersion.JAVA_27.</action>
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add SystemUtils.IS_JAVA_27.</action>
diff --git
a/src/main/java/org/apache/commons/lang3/exception/ExceptionUtils.java
b/src/main/java/org/apache/commons/lang3/exception/ExceptionUtils.java
index cbdac8f3c..064f951b9 100644
--- a/src/main/java/org/apache/commons/lang3/exception/ExceptionUtils.java
+++ b/src/main/java/org/apache/commons/lang3/exception/ExceptionUtils.java
@@ -344,6 +344,14 @@ public static String getRootCauseMessage(final Throwable
throwable) {
* exceptions separated by '[wrapped]'. Note that this is the opposite
* order to the JDK1.4 display.</p>
*
+ * <p>
+ * <strong>Note:</strong> the frames are recovered by re-parsing the text
produced by {@link Throwable#printStackTrace()}, they are not read from
+ * {@link Throwable#getStackTrace()}. A line inside an exception
<em>message</em> that mimics a stack frame (leading whitespace, then {@code "at
"},
+ * then a class/method reference with {@code '('}) is indistinguishable
from a real frame: untrusted message content can therefore inject fabricated
+ * frames into this output and cause the real frames that follow to be
dropped. Do not treat this output as forensic evidence when exception messages
+ * may contain untrusted input; read {@link Throwable#getStackTrace()} for
structured frames that cannot be forged by message content.
+ * </p>
+ *
* @param throwable The throwable to examine, may be null.
* @return An array of stack trace frames, never null.
* @since 2.0
@@ -360,6 +368,14 @@ public static String[] getRootCauseStackTrace(final
Throwable throwable) {
* its wrapping exceptions separated by '[wrapped]'. Note that this is the
opposite order to the JDK1.4 display.
* </p>
*
+ * <p>
+ * <strong>Note:</strong> the frames are recovered by re-parsing the text
produced by {@link Throwable#printStackTrace()}, they are not read from
+ * {@link Throwable#getStackTrace()}. A line inside an exception
<em>message</em> that mimics a stack frame (leading whitespace, then {@code "at
"},
+ * then a class/method reference with {@code '('}) is indistinguishable
from a real frame: untrusted message content can therefore inject fabricated
+ * frames into this output and cause the real frames that follow to be
dropped. Do not treat this output as forensic evidence when exception messages
+ * may contain untrusted input; read {@link Throwable#getStackTrace()} for
structured frames that cannot be forged by message content.
+ * </p>
+ *
* @param throwable The throwable to examine, may be null.
* @return A list of stack trace frames, never null.
* @since 3.13.0
@@ -393,8 +409,10 @@ public static List<String>
getRootCauseStackTraceList(final Throwable throwable)
* is not included. Only the trace of the specified exception is
* returned, any caused by trace is stripped.
*
- * <p>This works in most cases and will only fail if the exception
- * message contains a line that starts with: {@code "<whitespace>at"}.</p>
+ * <p>This works by re-parsing the text produced by {@link
Throwable#printStackTrace()}: a line is treated as a frame if, after leading
+ * whitespace, it starts with {@code "at "} followed by a class/method
reference and {@code '('} (see {@link #isStackFrame(String)}). It
+ * will mis-parse if the exception message contains a line of exactly that
shape: such a line is counted as a frame and the real frames
+ * that follow the remaining message lines are dropped.</p>
*
* @param throwable is any throwable.
* @return List of stack frames.
@@ -407,9 +425,7 @@ static List<String> getStackFrameList(final Throwable
throwable) {
boolean traceStarted = false;
while (frames.hasMoreTokens()) {
final String token = frames.nextToken();
- // Determine if the line starts with "<whitespace>at"
- final int at = token.indexOf("at");
- if (at != NOT_FOUND && token.substring(0, at).trim().isEmpty()) {
+ if (isStackFrame(token)) {
traceStarted = true;
list.add(token);
} else if (traceStarted) {
@@ -686,6 +702,47 @@ public static boolean isChecked(final Throwable throwable)
{
return throwable != null && !(throwable instanceof Error) &&
!(throwable instanceof RuntimeException);
}
+ /**
+ * Tests whether a line from {@link #getStackTrace(Throwable)} output
looks like a stack frame, mirroring the syntax emitted by
+ * {@link Throwable#printStackTrace()}: leading whitespace, then {@code
"at "}, then a class/method reference containing no whitespace,
+ * then {@code '('}, for example {@code "\tat
com.example.Foo.bar(Foo.java:42)"}. The reference is matched as any non-empty
run of
+ * non-whitespace characters, because {@link StackTraceElement#toString()}
never emits whitespace before the opening parenthesis: this
+ * accepts classic frames as well as class loader or module prefixes
({@code "app//"}, {@code "java.base/"}), module versions
+ * ({@code "[email protected]/"}), lambda and hidden-class names ({@code
"$$Lambda$17/0x..."}), {@code <init>}/{@code <clinit>} and
+ * JVM-language name mangling, without maintaining a character whitelist
that could reject a legitimate frame (and thereby suppress
+ * it and every frame below it).
+ *
+ * <p>This is deliberately stricter than matching any line whose first
non-whitespace characters are {@code "at"}, so that ordinary
+ * message text such as {@code " attack detected"} or {@code "at your
request"} is not mistaken for a frame; a message line crafted to
+ * match the full frame syntax is still indistinguishable from a real
frame.</p>
+ *
+ * @param token one line of printed stack trace text.
+ * @return whether the line has the syntax of a printed stack frame.
+ */
+ private static boolean isStackFrame(final String token) {
+ int i = 0;
+ final int len = token.length();
+ while (i < len && Character.isWhitespace(token.charAt(i))) {
+ i++;
+ }
+ // Frames printed by Throwable are indented: require leading
whitespace, then "at ".
+ if (i == 0 || !token.startsWith("at ", i)) {
+ return false;
+ }
+ i += 3;
+ final int paren = token.indexOf('(', i);
+ if (paren <= i) {
+ return false;
+ }
+ // StackTraceElement.toString() never emits whitespace between "at "
and '(': any whitespace there means message text.
+ for (int j = i; j < paren; j++) {
+ if (Character.isWhitespace(token.charAt(j))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
/**
* Checks if a throwable represents an unchecked exception
*
@@ -715,6 +772,14 @@ public static boolean isUnchecked(final Throwable
throwable) {
* that don't have nested causes.
* </p>
*
+ * <p>
+ * <strong>Note:</strong> the frames are recovered by re-parsing the text
produced by {@link Throwable#printStackTrace()}, they are not read from
+ * {@link Throwable#getStackTrace()}. A line inside an exception
<em>message</em> that mimics a stack frame (leading whitespace, then {@code "at
"},
+ * then a class/method reference with {@code '('}) is indistinguishable
from a real frame: untrusted message content can therefore inject fabricated
+ * frames into this output and cause the real frames that follow to be
dropped. Do not treat this output as forensic evidence when exception messages
+ * may contain untrusted input; read {@link Throwable#getStackTrace()} for
structured frames that cannot be forged by message content.
+ * </p>
+ *
* @param throwable The throwable to output.
* @since 2.0
*/
@@ -736,6 +801,14 @@ public static void printRootCauseStackTrace(final
Throwable throwable) {
* <p>The method is equivalent to {@code printStackTrace} for throwables
* that don't have nested causes.</p>
*
+ * <p>
+ * <strong>Note:</strong> the frames are recovered by re-parsing the text
produced by {@link Throwable#printStackTrace()}, they are not read from
+ * {@link Throwable#getStackTrace()}. A line inside an exception
<em>message</em> that mimics a stack frame (leading whitespace, then {@code "at
"},
+ * then a class/method reference with {@code '('}) is indistinguishable
from a real frame: untrusted message content can therefore inject fabricated
+ * frames into this output and cause the real frames that follow to be
dropped. Do not treat this output as forensic evidence when exception messages
+ * may contain untrusted input; read {@link Throwable#getStackTrace()} for
structured frames that cannot be forged by message content.
+ * </p>
+ *
* @param throwable The throwable to output, may be null.
* @param printStream The stream to output to, may not be null.
* @throws NullPointerException if the printStream is {@code null}.
@@ -765,6 +838,14 @@ public static void printRootCauseStackTrace(final
Throwable throwable, final Pri
* <p>The method is equivalent to {@code printStackTrace} for throwables
* that don't have nested causes.</p>
*
+ * <p>
+ * <strong>Note:</strong> the frames are recovered by re-parsing the text
produced by {@link Throwable#printStackTrace()}, they are not read from
+ * {@link Throwable#getStackTrace()}. A line inside an exception
<em>message</em> that mimics a stack frame (leading whitespace, then {@code "at
"},
+ * then a class/method reference with {@code '('}) is indistinguishable
from a real frame: untrusted message content can therefore inject fabricated
+ * frames into this output and cause the real frames that follow to be
dropped. Do not treat this output as forensic evidence when exception messages
+ * may contain untrusted input; read {@link Throwable#getStackTrace()} for
structured frames that cannot be forged by message content.
+ * </p>
+ *
* @param throwable The throwable to output, may be null.
* @param printWriter The writer to output to, may not be null.
* @throws NullPointerException if the printWriter is {@code null}.
diff --git
a/src/test/java/org/apache/commons/lang3/exception/ExceptionUtilsTest.java
b/src/test/java/org/apache/commons/lang3/exception/ExceptionUtilsTest.java
index 35d07b21d..47626fcf6 100644
--- a/src/test/java/org/apache/commons/lang3/exception/ExceptionUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/exception/ExceptionUtilsTest.java
@@ -26,6 +26,7 @@
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -35,6 +36,7 @@
import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@@ -391,6 +393,95 @@ void testGetRootCauseStackTraceList_Throwable() {
assertFalse(match);
}
+ /**
+ * Tests that ordinary message lines whose first non-whitespace characters
happen to be "at" are no longer mistaken for stack
+ * frames, so a multi-line untrusted message can neither inject a
non-frame-shaped line into the frame list nor suppress the
+ * real frames that follow it.
+ */
+ @Test
+ void testGetRootCauseStackTraceMessageLinesNotMistakenForFrames() {
+ final Throwable t = new IllegalArgumentException(
+ "denied" + System.lineSeparator() + " attack detected" +
System.lineSeparator() + "at your request, more text");
+ final String[] stackTrace = ExceptionUtils.getRootCauseStackTrace(t);
+ // No fabricated entries: every frame line after the header parses as
"at <ref>(...".
+ boolean sawRealFrame = false;
+ for (int i = 1; i < stackTrace.length; i++) {
+ final String element = stackTrace[i];
+ if (element.contains("attack detected") || element.contains("at
your request")) {
+ fail("message text classified as a stack frame: " + element);
+ }
+ if (element.contains(getClass().getSimpleName())) {
+ sawRealFrame = true;
+ }
+ }
+ // The real frames survive: this test method must be present in the
parsed trace.
+ assertTrue(sawRealFrame, "real frames were suppressed");
+ }
+
+ /**
+ * Tests that every frame shape {@code StackTraceElement.toString()} can
emit is accepted by the tightened frame matcher,
+ * in particular JDK 9+ module-versioned frames ({@code
mod@version/pkg.Class}), which a character whitelist without
+ * {@code '@'} would reject — silently dropping that frame and every real
frame below it. Uses a throwable that prints a
+ * fixed trace so the shapes are deterministic without constructing
module-versioned {@link StackTraceElement}s.
+ */
+ @Test
+ void testGetStackFrameListAcceptsAllRealFrameShapes() {
+ final String[] frames = {
+ "\tat com.example.Foo.bar(Foo.java:42)",
// classic
+ "\tat app//com.foo.Main.main(Main.java:10)",
// class loader prefix
+ "\tat [email protected]/com.foo.Helper.help(Helper.java:7)",
// module name @ version
+ "\tat java.base/java.lang.Thread.run(Thread.java:833)",
// module, no version
+ "\tat com.foo.Main$$Lambda$17/0x0000000800c02a48.run(Unknown
Source)", // lambda / hidden class
+ "\tat com.example.Foo.<init>(Foo.java:5)",
// constructor
+ "\tat java.base/java.lang.Object.wait(Native Method)"
// native
+ };
+ final StringBuilder text = new
StringBuilder("java.lang.RuntimeException:
boom").append(System.lineSeparator());
+ for (final String frame : frames) {
+ text.append(frame).append(System.lineSeparator());
+ }
+ final Throwable fixed = new RuntimeException("boom") {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void printStackTrace(final PrintWriter writer) {
+ writer.print(text);
+ }
+ };
+ final List<String> list = ExceptionUtils.getStackFrameList(fixed);
+ assertEquals(Arrays.asList(frames), list, "a legitimate frame shape
was rejected (and frames below it dropped)");
+ }
+
+ /**
+ * Tests that message text is still rejected by the frame matcher: forged
lines lacking the no-whitespace-before-'('
+ * frame syntax must not start or extend the frame list.
+ */
+ @Test
+ void testGetStackFrameListRejectsForgedMessageLines() {
+ final String[] forged = {
+ " attack detected", // "at" not
followed by space-delimited reference
+ "at your request, more text", // no leading
whitespace
+ "\tat your request, more text", // no '(' at all
+ "\tat forged frame entry(Evil.java:1)", // whitespace
between "at " and '('
+ "\tat (Evil.java:1)" // empty reference
+ };
+ final StringBuilder text = new
StringBuilder("java.lang.RuntimeException:
boom").append(System.lineSeparator());
+ for (final String line : forged) {
+ text.append(line).append(System.lineSeparator());
+ }
+ text.append("\tat
com.example.Foo.bar(Foo.java:42)").append(System.lineSeparator());
+ final Throwable fixed = new RuntimeException("boom") {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void printStackTrace(final PrintWriter writer) {
+ writer.print(text);
+ }
+ };
+ final List<String> list = ExceptionUtils.getStackFrameList(fixed);
+ assertEquals(Arrays.asList("\tat com.example.Foo.bar(Foo.java:42)"),
list,
+ "forged message text was classified as a stack frame");
+ }
+
@Test
/** getStackFrames returns empty string array when the argument is null */
void testgetStackFramesHappyPath() {