Activity Feed

3
Ratings
  • Attacker Value
    Very High
  • Exploitability
    Medium
Technical Analysis

CVE-2024-41874 is described as a critical unauthenticated remote code execution vulnerability affecting Adobe ColdFusion. The affected versions are ColdFusion 2021 before Update 16 and ColdFusion 2023 before Update 10. In this assessment, we’ll take a look at the patch. This write-up does not contain an RCE PoC, but I believe it does outline how to trigger the bug and demonstrate some of the implications of doing so. As far as I’m aware, there’s no public write-up or exploit yet published by the original researchers, @0xsapra, @MrHritik, and @a0xnirudh. Let’s dive in!

In chf20210016.jar!/coldfusion/filter/FilterUtils.java, we find a single small change between CF2021 Update 15 and CF2021 Update 16—the string “ARGUMENTCOLLECTION” has been added as a disabled scope for collection.

29c29
< /* 29*/        disabledScopesForArgumentCollection = new ArrayList<String>(Arrays.asList("FILE", "CLIENT", "COOKIE", "CGI", "SERVER", "APPLICATION", "SESSION", "REQUEST", "CFHTTP", "CFFILE", "LOCAL", "THIS", "THISTAG", "THREAD", "VARIABLES"));
---
> /* 29*/        disabledScopesForArgumentCollection = new ArrayList<String>(Arrays.asList("ARGUMENTCOLLECTION", "FILE", "CLIENT", "COOKIE", "CGI", "SERVER", "APPLICATION", "SESSION", "REQUEST", "CFHTTP", "CFFILE", "LOCAL", "THIS", "THISTAG", "THREAD", "VARIABLES"));

This disabledScopesForArgumentCollection security list originates from the patch for CVE-2023-44350, which was a mass assignment vulnerability. The scopes list relates to ColdFusion Components (“CFCs”), which are methods and properties defined in .cfc files for use by other code. More specifically, CFCs that are defined as “remote” are published as ColdFusion web services, which permits other code on the client and server to invoke and access server-side methods and properties. The previous mass assignment vulnerability, CVE-2023-44350, permitted passing an argumentCollection containing key-value pairs that clobbered existing sensitive global scopes, such as LOCAL or APPLICATION. These sensitive global scopes are heavily used by ColdFusion to store and retrieve sensitive data, such as file paths and global variable information, during a request’s lifecycle. Knowing this context, the ability to overwrite existing values within primary global scopes is a strong capability that can seemingly facilitate remote code execution.

The ARGUMENTCOLLECTION scope is also a special global scope, and it’s notably absent from the collection list before the patch. This scope is used when remote CFC method calls are invoked, when CFC methods are leveraged in the form of a web service. In this context, the ARGUMENTCOLLECTION value is sourced from a POST parameter called argumentCollection. This parameter has been the target of numerous other exploits, such as CVE-2023-44350, CVE-2023-29300, CVE-2023-38203, and CVE-2023-38204. In this case, nested argumentCollection JSON arrays in the argumentCollection POST parameter are recursively deserialized and concatenated at certain points in the request’s lifecycle. This permits an attacker to send a nested payload containing multiple argumentCollections that bypass the initial disabledScopesForArgumentCollection checks. Later on, after the checks have been performed, the nested data will be flattened and the attacker data will clobber global scope structs.

We’ll use a custom CFC scope dump method to dump the contents of some sensitive global scopes in the context of our invocation. That test file is below.

$ cat cfusion/wwwroot/CFIDE/custom/scope.cfc 
<cfcomponent output="true">

<cffunction name="dump" access="remote" returntype="void">
    <cfoutput>
        <h2>ARGUMENTS SCOPE</h2>
        <cftry>
        <cfdump var="#ARGUMENTS#">
        <cfcatch> <p>ARGUMENTS IS NOT CURRENTLY AVAILABLE</p></cfcatch></cftry>

        <h2>CFFILE SCOPE</h2>
        <cftry>
        <cfdump var="#CFFILE#">
        <cfcatch> <p>CFFILE IS NOT CURRENTLY AVAILABLE</p></cfcatch></cftry>

        <h2>CFFILE.Test SCOPE</h2>
        <cftry>
        <cfdump var="#CFFILE.Test#">
        <cfcatch> <p>CFFILE.Test IS NOT CURRENTLY AVAILABLE</p></cfcatch></cftry>
        </cfoutput>
    </cffunction>
</cfcomponent>

We’ll set a breakpoint prior to the argumentCollection security checks and deserialization, in cfusion.jar!/coldfusion/filter/ComponentFilter.class, and we’ll perform a request to an unauthenticated remote CFC. The PoC request we’ll send contains a nested argumentCollection JSON object, which we’ll observe being transformed and used by the application. That unauthenticated remote CFC request is below.

POST /CFIDE/custom/scope.cfc HTTP/1.1
Host: coldfusion:8500
User-Agent: curl/8.6.0
Accept: */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary9HrYoPaJ81kiKuUc
Content-Length: 468
Connection: keep-alive

------WebKitFormBoundary9HrYoPaJ81kiKuUc
Content-Disposition: form-data; name="method"

dump
------WebKitFormBoundary9HrYoPaJ81kiKuUc
Content-Disposition: form-data; name="returnFormat"

json
------WebKitFormBoundary9HrYoPaJ81kiKuUc
Content-Disposition: form-data; name="argumentCollection"

{"argumentCollection":{"argumentCollection":{"CFFILE":{"test":"value"}}}}
------WebKitFormBoundary9HrYoPaJ81kiKuUc
Content-Disposition: form-data; name="cfcName"

After hitting our initial breakpoint, we observe that the GetArgumentCollection function in the relevant FilterUtils file is called with the request context object as a parameter.

                    Map args = FilterUtils.GetArgumentCollection(context);

Within FilterUtils, we find the previously observed block list and the called function. At [0], the argumentCollection data is extracted as either a URL parameter or a POST body parameter, depending on which is present, and cast to a string. With that string, a new Struct called argumentCollection is created ([1]). Next, a check is performed to determine if a “{” character begins the string ([2]). If so, the data is deserialized as JSON. If not, the data is deserialized as a WDDX XML packet.

Note that the patch did not introduce any new classes that are not allowed to be deserialized, just a new scope that is not allowed to be collected. At [3], if the JSON or XML contains a top-level key on the block list, the server will throw an InvalidArgumentCollectionException error and terminate the request. Notably, in the pre-patched state, argumentCollection can contain a top-level key called ARGUMENTCOLLECTION; after the patch, argumentCollection cannot contain a top-level key called ARGUMENTCOLLECTION.

    public static final List<String> disabledScopesForArgumentCollection = new ArrayList(Arrays.asList("FILE", "CLIENT", "COOKIE", "CGI", "SERVER", "APPLICATION", "SESSION", "REQUEST", "CFHTTP", "CFFILE", "LOCAL", "THIS", "THISTAG", "THREAD", "VARIABLES"));

// [..SNIP..]

    public static Map GetArgumentCollection(FusionContext context) throws Throwable {
        ServletRequest request = context.request;
        String attr = (String)context.pageContext.findAttribute("url.argumentCollection"); // [0]
        if (attr == null) {
            attr = (String)context.pageContext.findAttribute("form.argumentCollection");
        }

        Struct argumentCollection;
        if (attr == null) {
            argumentCollection = new Struct(); // [1]
        } else {
            attr = attr.trim();
            if (attr.charAt(0) == '{') { // [2]
                argumentCollection = (Struct)JSONUtils.deserializeJSON(attr);
            } else {
                argumentCollection = (Struct)WDDXDeserialize(attr);
            }
        }

        String cfcName;
        if (!Boolean.getBoolean("coldfusion.argumentcollection.allowscopes")) {
            Enumeration keys = argumentCollection.keys();

            while(keys.hasMoreElements()) {
                cfcName = keys.nextElement().toString();
                if (disabledScopesForArgumentCollection.contains(cfcName.toUpperCase())) {
                    throw new InvalidArgumentCollectionException(cfcName, disabledScopesForArgumentCollection.toString(), "coldfusion.argumentcollection.allowscopes"); // [3]
                }
            }
        }

// [..SNIP..]

        return argumentCollection;
    }

Next, cfusion.jar!/coldfusion/filter/ComponentFilter.class calls into the invoke function defined in cfusion.jar!/coldfusion/runtime/TemplateProxy.class ([4]). The method string argument is the requested CFC remote method, and the args Map is the existing argumentCollection.

                    Object invoke;
                    try {
                        invoke = tp.invoke(method, args, pageContext); // [4]
                    } finally {
                        context.setCfclientCall(oldClientCall);
                    }

This function prepares to invoke the CFC method. We’ll follow the next two invoke calls ([5], [6]) to see what happens to our arguments.

    public Object invoke(String methodName, Map args, PageContext pageContext) throws Throwable {
        FusionContext ctx = FusionContext.getCurrent();
        this.initIfDeserialized(pageContext);
        UDFMethod method = this.resolveMethod(methodName, false);
        if (method instanceof ImplicitUDFMethod) {
            return method.invoke(this, methodName, this.page, args);
        } else if (method instanceof Closure) {
            return method.invoke(this, methodName, this.page, args);
        } else {
            CfJspPage invokePage = this.setupScopesForInvoke(pageContext, ctx);
            return this.invoke(method, methodName, (Object[])null, args, invokePage, ctx); // [5]
        }
    }
    private Object invoke(UDFMethod method, String methodName, Object[] args, Map mapArgs, CfJspPage invokePage, FusionContext ctx) throws Throwable {
        if (args != null && mapArgs != null) {
            throw new IllegalArgumentException("either args or mapArgs needs to be null");
        } else {
            DebuggingService debuggingService = ServiceFactory.getDebuggingService();
// [..SNIP..]
                if (missingMethodName == null) {
                    if (args != null) {
                        invokedObject = this.castReturnType(method.invoke(this, methodName, invokePage, args), method, invokePage.pageContext, ctx);
                    } else {
                        invokedObject = this.castReturnType(method.invoke(this, methodName, invokePage, mapArgs), method, invokePage.pageContext, ctx); // [6]
                    }
                }

Here, we see our arguments, now called namedArgs, being used. After some processing and comparisons take place, a new ArgumentCollection is instantiated at [7], with our namedArgs passed in as the second parameter.

    public Object invoke(Object instance, String calledName, Object parent, Map namedArgs) throws Throwable {
        Object obj = null;
        RequestMonitorEventProcessor.onFunctionStart(calledName, parent, namedArgs);

        try {
            ArgumentCollection args = null;
// [..SNIP..]

                if (args == null) {
                    args = new ArgumentCollection(this.paramNames, namedArgs); // [7]
                }

We enter chf20210015.jar!/coldfusion/runtime/ArgumentCollection.class. At [8] and [9], the code iterates through and extracts the first nested argumentCollection from inside our existing argumentCollection Map. At [10], handleParametrizedArgs is called with this first nested key-value pair as parameters.

    public ArgumentCollection(Object[] keys, Map namedArgs) {
        Object key;
        Object key1;
        if (keys != null) {
// [..SNIP..]
        }

        Iterator i = namedArgs.entrySet().iterator(); // [8]

        while(true) {
            do {
                if (!i.hasNext()) {
                    Object argsObj = namedArgs.get("argumentCollection"); // [9]
                    if (argsObj != null && argsObj instanceof Map) {
                        Map args = (Map)argsObj;
                        Iterator argIt = args.keySet().iterator();

                        while(argIt.hasNext()) {
                            key1 = argIt.next();
                            Object value1 = args.get(key1);
                            key1 = this.normalizeKey(key1);
                            this.handleParametrizedArgs(key1, value1); // [10]
                        }
                    }

                    return;
                }
// [..SNIP..]

In handleParametrizedArgs, if the instantiated ArgumentCollection class (“this”) does not already contain the key (“argumentCollection”), the nested key-value pair is put in the Map ([11]). One layer of nesting has been flattened.

    private void handleParametrizedArgs(Object key, Object value) {
        if (key instanceof Integer) {
            int keyIndex = (Integer)key - 1;
            if (keyIndex < this.entryOrderValues.size() && this.get(this.entryOrderValues.get(keyIndex)) == null) {
                key = this.entryOrderValues.get(keyIndex);
            }
        }

        if (this.get(key) == null) { // [11]
            this.put(key, value);
        }

    }

We return to the previous invoke function in UDFMethod.class. Now, UDFMethod.runFilterChain is called with the transformed args variable as a parameter.

                obj = this.runFilterChain(instance, parent, args, calledName);

At [12], context.args, which is null, is stashed in the oldArgs ArgumentCollection. Next, context.args is set to our partially flattened args variable ([13]).

    private Object runFilterChain(Object instance, Object parent, ArgumentCollection args, String calledName, FusionContext fusionContext) throws Throwable {
        FusionContext context = FusionContext.getCurrent();
        if (context == null) {
            context = fusionContext;
        }

        Object oldInstance = context.instance;
        CFPage oldParent = context.parent;
        ArgumentCollection oldArgs = context.args;
        Object oldReturnValue = context.returnValue;
        String oldmethodname = context.methodCalledName;
        context.instance = instance;
        context.parent = (CFPage)parent;
        context.args = args; // [13]

A couple of layers of invocations take place, ultimately landing in cfusion.jar!/coldfusion/runtime/UDFMethod.class. Our second nested ARGUMENTCOLLECTION layer is flattened at [14] and [15], when the key value is extracted and putAll is called on args within our FusionContext. Finally, the requested CFC remote method is invoked with our context object, which includes our ArgumentCollection object wherein we control arbitrary global scope structs.

    static class ArgumentCollectionFilter extends FusionFilter {
// [..SNIP..]
        public void invoke(FusionContext tc) throws Throwable {
            Map argumentCollection = (Map)tc.args.get(Key.ARGUMENTCOLLECTION); // [14]
            if (argumentCollection != null) {
                tc.args.remove(Key.ARGUMENTCOLLECTION);
                tc.args.putAll(argumentCollection); // [15]
            }

            this.next.invoke(tc);
        }
    }

In the browser, we can view our CFC page dump output to verify that the global CFFILE scope has been clobbered, despite CFFILE being on the disabledScopesForArgumentCollection security list filter.

scopes.png

This primitive is apparently enough to establish unauthenticated remote code execution, as indicated by the 9.1 CVSS score of previous mass assignment vulnerabilities affecting global scope, such as CVE-2023-44350. However, I was not able to identify a default configuration technique to weaponize global scope control for RCE via deserialization (or any other means), and I wasn’t able to find anyone else that had published one. If anyone is familiar with a technique to do so, please let me know or create an assessment with the details!

Indicated source as
Indicated source as
  • Other: Rapid7 MDR has observed successful exploitation of this vulnerability in customer environments
Indicated source as
  • Other: Rapid7 MDR has observed successful exploitation of this vulnerability in customer environments
Indicated source as
  • Other: Rapid7 MDR has observed successful exploitation of this vulnerability in customer environments