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 + * <exceptionName> 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 <Fault> whose + * <detail> carries an <exceptionName> 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()); + } +}