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-bcel.git


The following commit(s) were added to refs/heads/master by this push:
     new bdaca417 Utility.JavaReader escape decoding: OOB table index, bad-hex 
crash, silent non-canonical aliasing (f026).
bdaca417 is described below

commit bdaca417da39c49ae98c85c6a0a46831fd24aa24
Author: Gary Gregory <[email protected]>
AuthorDate: Fri Sep 4 19:02:02 2026 -0400

    Utility.JavaReader escape decoding: OOB table index, bad-hex crash,
    silent non-canonical aliasing (f026).
---
 src/changes/changes.xml                            |  1 +
 .../java/org/apache/bcel/classfile/Utility.java    | 53 ++++++++++++++++------
 .../bcel/classfile/UtilityEncodeDecodeTest.java    | 27 +++++++++++
 3 files changed, 66 insertions(+), 15 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 6261e348..4eab68ca 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -111,6 +111,7 @@ The <action> type attribute can be add,update,fix,remove.
       <action                  type="fix" dev="ggregory" due-to="Gary 
Gregory">Verifier pass 3a delayed checks are quadratic in attribute and code 
size (f023).</action>
       <action                  type="fix" dev="ggregory" due-to="Gary 
Gregory">Verifier cache grows unboundedly with attacker-chosen class names 
(f024).</action>
       <action                  type="fix" dev="ggregory" due-to="Gary 
Gregory">Static WIDE ThreadLocal survives exceptions, corrupting the next 
class's disassembly on the same thread (f025).</action>
+      <action                  type="fix" dev="ggregory" due-to="Gary 
Gregory">Utility.JavaReader escape decoding: OOB table index, bad-hex crash, 
silent non-canonical aliasing (f026).</action>
       <!-- ADD -->
       <action                  type="add" dev="ggregory" due-to="nbauma109, 
Gary Gregory">Add support for permitted subclasses #493.</action>
       <action                  type="add" dev="ggregory" due-to="nbauma109, 
Gary Gregory">Add RecordComponentInfo.getAttribute(byte tag)#494.</action>
diff --git a/src/main/java/org/apache/bcel/classfile/Utility.java 
b/src/main/java/org/apache/bcel/classfile/Utility.java
index a4cabd32..f9e65231 100644
--- a/src/main/java/org/apache/bcel/classfile/Utility.java
+++ b/src/main/java/org/apache/bcel/classfile/Utility.java
@@ -31,6 +31,7 @@ import java.io.PrintWriter;
 import java.io.Reader;
 import java.io.Writer;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.zip.GZIPInputStream;
 import java.util.zip.GZIPOutputStream;
@@ -49,18 +50,6 @@ import org.apache.commons.lang3.StringUtils;
 // @since 6.0 methods are no longer final
 public abstract class Utility {
 
-    /*
-     * Maximum nesting depth accepted by typeSignatureToString(). Signatures 
are attacker-controlled bytes from untrusted class files; without a limit, a 
deeply
-     * nested generic signature such as "LA<LA<LA<...>;>;>;" drives one stack 
frame per nesting level and kills the calling thread with a StackOverflowError.
-     */
-    private static final int MAX_SIGNATURE_NESTING = 512;
-
-    /**
-     * The maximum number of bytes that {@link #decode(String, boolean)} will 
decompress. Guards against decompression bombs: the compressed input is
-     * attacker-controlled and a small input can decompress to an enormous 
size.
-     */
-    private static final int MAX_DECODED_LENGTH = 64 * 1024 * 1024;
-
     /**
      * Decode characters into bytes. Used by <a 
href="Utility.html#decode(java.lang.String, boolean)">decode()</a>
      */
@@ -80,21 +69,35 @@ public abstract class Utility {
             if (i < 0) {
                 return -1;
             }
-            if (i >= '0' && i <= '9' || i >= 'a' && i <= 'f') { // Normal 
escape
+            if (isHex(i)) { // Normal escape
                 final int j = in.read();
                 if (j < 0) {
                     return -1;
                 }
-                final char[] tmp = {(char) i, (char) j};
+                if (!isHex(j)) {
+                    // Would otherwise reach Integer.parseInt and throw an 
undeclared NumberFormatException.
+                    throw new IOException("Invalid escape sequence: expected a 
second hexadecimal digit after '" + ESCAPE_CHAR + (char) i + "'");
+                }
+                final char[] tmp = { (char) i, (char) j };
                 return Integer.parseInt(new String(tmp), 16);
             }
+            if (i >= MAP_CHAR.length || MAP_CHAR[i] == UNMAPPED) {
+                // Reject instead of throwing an undeclared 
ArrayIndexOutOfBoundsException (i >= 256) or silently
+                // aliasing every unmapped character to MAP_CHAR's default 
slot value (the '$A' encoding).
+                throw new IOException("Invalid escape character after '" + 
ESCAPE_CHAR + "': 0x" + Integer.toHexString(i));
+            }
             return MAP_CHAR[i];
         }
 
         @Override
         public int read(final char[] cbuf, final int off, final int len) 
throws IOException {
             for (int i = 0; i < len; i++) {
-                cbuf[off + i] = (char) read();
+                final int ch = read();
+                if (ch < 0) {
+                    // Propagate end-of-stream instead of writing (char) -1 
and over-reporting the read length.
+                    return i > 0 ? i : -1;
+                }
+                cbuf[off + i] = (char) ch;
             }
             return len;
         }
@@ -145,6 +148,21 @@ public abstract class Utility {
         }
     }
 
+    /*
+     * Maximum nesting depth accepted by typeSignatureToString(). Signatures 
are attacker-controlled bytes from untrusted class files; without a limit, a 
deeply
+     * nested generic signature such as "LA<LA<LA<...>;>;>;" drives one stack 
frame per nesting level and kills the calling thread with a StackOverflowError.
+     */
+    private static final int MAX_SIGNATURE_NESTING = 512;
+
+    /**
+     * The maximum number of bytes that {@link #decode(String, boolean)} will 
decompress. Guards against decompression bombs: the compressed input is
+     * attacker-controlled and a small input can decompress to an enormous 
size.
+     */
+    private static final int MAX_DECODED_LENGTH = 64 * 1024 * 1024;
+
+    /** Marker for {@link #MAP_CHAR} slots that do not correspond to a valid 
special escape character. */
+    private static final int UNMAPPED = -1;
+
     /*
      * How many chars have been consumed during parsing in 
typeSignatureToString(). Read by methodSignatureToString(). Set
      * by side effect, but only internally.
@@ -168,6 +186,7 @@ public abstract class Utility {
     private static final char ESCAPE_CHAR = '$';
 
     static {
+        Arrays.fill(MAP_CHAR, UNMAPPED);
         int j = 0;
         for (int i = 'A'; i <= 'Z'; i++) {
             CHAR_MAP[j] = i;
@@ -852,6 +871,10 @@ public abstract class Utility {
         return buf.toString();
     }
 
+    private static boolean isHex(final int i) {
+        return i >= '0' && i <= '9' || i >= 'a' && i <= 'f';
+    }
+
     /**
      * WARNING:
      *
diff --git 
a/src/test/java/org/apache/bcel/classfile/UtilityEncodeDecodeTest.java 
b/src/test/java/org/apache/bcel/classfile/UtilityEncodeDecodeTest.java
index 87ceb06c..7d14f46b 100644
--- a/src/test/java/org/apache/bcel/classfile/UtilityEncodeDecodeTest.java
+++ b/src/test/java/org/apache/bcel/classfile/UtilityEncodeDecodeTest.java
@@ -20,6 +20,10 @@
 package org.apache.bcel.classfile;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
 
 import org.junit.jupiter.api.Test;
 
@@ -28,6 +32,29 @@ import org.junit.jupiter.api.Test;
  */
 class UtilityEncodeDecodeTest {
 
+    @Test
+    void testDecodeRejectsMalformedEscapes() throws Exception {
+        // Second character of a hex escape was never validated: used to throw 
an undeclared NumberFormatException.
+        assertThrows(IOException.class, () -> Utility.decode("$0z", false));
+        // Escape characters above 255 used to throw an undeclared 
ArrayIndexOutOfBoundsException.
+        assertThrows(IOException.class, () -> Utility.decode("$\u0141", 
false));
+        // Unmapped escape characters used to silently alias to the "$A" 
encoding (byte 0).
+        assertThrows(IOException.class, () -> Utility.decode("$!", false));
+        // Sanity check: valid escapes still decode ('$A' is the special 
escape for byte 0, '$3a' is hex 0x3a).
+        assertEquals(0, Utility.decode("$A", false)[0]);
+        assertEquals(0x3a, Utility.decode("$3a", false)[0]);
+    }
+
+    @Test
+    void testEncodeDecodeRoundTrip() throws Exception {
+        final byte[] bytes = new byte[256];
+        for (int i = 0; i < bytes.length; i++) {
+            bytes[i] = (byte) i;
+        }
+        assertArrayEquals(bytes, Utility.decode(Utility.encode(bytes, false), 
false));
+        assertArrayEquals(bytes, Utility.decode(Utility.encode(bytes, true), 
true));
+    }
+
     /**
      * Zero-filled data compresses far better than the fixed 3:1 ratio the 
decode buffer used to assume; the encode/decode round trip must still hold.
      */

Reply via email to