Sunday 30 June 2013

Web socket security issue: risk assessment & findings

G'day:
Yesterday I engaged in some unrepentant shock tactics, writing an article entitled "Security warning: stop using ColdFusion web sockets right now". This warning arose from my initial investigations into an apparent significant security hole in web sockets, as reported by Henry Ho on Stack Overflow. I have checked into things more thoroughly, and here's the details of my findings.

Firstly, I have considered how responsible I am being by publishing this material. But I have concluded my readership is far less than Stack Overflow's, so the vulnerability is already public. Plus I think it would be helpful for people to know what they're up against. Plus - unabashedly - I hope the "publicity" will encourage Adobe to deal with this ASAP.

Threat summary

OK, so what's the story? Basically ColdFusion 10's web sockets have a couple of significant security failings, and some other unhelpful quirky behaviour as well.

Web sockets partially ignores method access level modifiers

Web sockets should only be able to access methods with their access level set to remote. However one can also run public methods remotely, via web sockets. I have verified that neither package nor private methods are exposed in this way, so it almost seems like Adobe haven't done this by accident, but have wilfully created this security hole.

Web sockets partially ignores whether a CFC is supposed to be web-accessible

Web sockets should only be able to make remote calls to web-accessible CFCs. This is so obvious it should go without saying. However web socket calls can be made to non-web-accessible CFCs, if they have a ColdFusion mapping to them. Note I said ColdFusion mapping, not web server mapping. A ColdFusion mapping should play no part in whether a file is web accessible.

These two by themselves represent a significant breach in ColdFusion's code security, I think. There are also some vagaries of how web socket calls are made which make mitigating this situation more difficult.

Web sockets requests bypass Application.cfc

One potential solution is to use onCfcRequest() to check the access of the method being called. However it seems Application.cfc is completely ignored when a web socket request is made. onRequestStart() is not fired, nor is onCfcRequest(). So we cannot leverage either of those hooks to protect ourselves from this.

Web sockets do not properly understand ColdFusion's security roles

One possible work-around is to put security roles onto all web-accessible and CF-mapped CFC methods (!! which could be a significant undertaking). This will actually work given web socket requests just fail on any method call for which the method has roles requirements. Even if the user is authorised to run the method, web sockets just "doesn't get it".

Threat detail

Baseline

Consider this very contrived web sockets demonstration:

<script type="text/javascript"> 
    function displayResponse(message){ 
        var messageArea = document.getElementById("messageArea"); 
        var serverStatusMessage = ColdFusion.JSON.encode(message); 
        messageArea.innerHTML += serverStatusMessage + "<br >"; 
    } 

    function startPolling(){ 
        telemetrySocket.invoke("ServerTelemetry", "getStatus"); 
    }

    function clearMessage(){ 
        document.getElementById("messageArea").innerHTML = ""; 
    }
</script> 
<cfwebsocket name="telemetrySocket" onmessage="displayResponse"> 
<button onclick="startPolling()">Poll Server</button>
<button onclick="clearMessage()">Clear Messages</button>
<div id="messageArea"></div>

The intent here is that the web socket calls getStatus() in ServerTelemetry.cfc, which is thus:

// ServerTelemetry.cfc
component {

    remote string function getStatus(){
        var t1 = false;
        thread action="run" name="t1" {
            for (var i=1; i <= 10; i++){
                sleep(2000);
                var load = randRange(1,100);
                if (load <= 25){
                    var status = "NOMINAL";
                } else if (load <= 90){
                    var status = "ACCEPTABLE";
                } else{
                    var status = "CRITICAL";
                }
                wsSendMessage("Server load is #status# at #now()#");
            }            
        }
        return "Server status polling initiated";
    }
    
    
    public string function nonRemoteMethod(){
        return "*** nonRemoteMethod() called from ServerTelemetry.cfc";
    }

}

getStatus() emulates server-status feedback, by firing a thread off which randomly declares the server status as being nominal, acceptable or critical. Notice how getStatus() is remote, so all good. This should work. And it does. Here's typical output, once I click "Start Polling":

Poll Server Clear Messages
{"clientid":1053820853,"ns":"coldfusion.websocket.channels","reqType":"welcome","code":0,"type":"response","msg":"ok"}
{"clientid":1053820853,"ns":"coldfusion.websocket.channels","data":"Server status polling initiated","reqType":"invoke","code":0,"type":"response"}
{"ns":"coldfusion.websocket.channels","data":"Server load is NOMINAL at {ts '2013-06-30 11:30:34'}","type":"data"}
{"ns":"coldfusion.websocket.channels","data":"Server load is NOMINAL at {ts '2013-06-30 11:30:36'}","type":"data"}
{"ns":"coldfusion.websocket.channels","data":"Server load is NOMINAL at {ts '2013-06-30 11:30:38'}","type":"data"}
{"ns":"coldfusion.websocket.channels","data":"Server load is ACCEPTABLE at {ts '2013-06-30 11:30:40'}","type":"data"}
{"ns":"coldfusion.websocket.channels","data":"Server load is CRITICAL at {ts '2013-06-30 11:30:42'}","type":"data"}
{"ns":"coldfusion.websocket.channels","data":"Server load is ACCEPTABLE at {ts '2013-06-30 11:30:44'}","type":"data"}
{"ns":"coldfusion.websocket.channels","data":"Server load is ACCEPTABLE at {ts '2013-06-30 11:30:46'}","type":"data"}
{"ns":"coldfusion.websocket.channels","data":"Server load is ACCEPTABLE at {ts '2013-06-30 11:30:48'}","type":"data"}
{"ns":"coldfusion.websocket.channels","data":"Server load is ACCEPTABLE at {ts '2013-06-30 11:30:50'}","type":"data"}
{"ns":"coldfusion.websocket.channels","data":"Server load is ACCEPTABLE at {ts '2013-06-30 11:30:52'}","type":"data"}

Fine.

However. The web socket call is made by client-side Javascript (to state the obvious). The legitimate code is this:

telemetrySocket.invoke("ServerTelemetry", "getStatus"); 

However I can open up Chrome's dev tools and change that to be this:

telemetrySocket.invoke("ServerTelemetry", "nonRemoteMethod"); 

And this will also run:

{"clientid":1053820853,"ns":"coldfusion.websocket.channels","data":"*** nonRemoteMethod() called from ServerTelemetry.cfc","reqType":"invoke","code":0,"type":"response"}

Notice how nonRemoteMethod() does not - as its name suggests - have remote access. It only allows public access, which means it should only be callable from CFML code on the ColdFusion server itself. So there's no way that a call from the client should be able to execute this code.

Other web-browsable CFCs

That's "fine". However the next thing I tried is to create another CFC adjacent to ServerTelemetry:

component {
    
    remote string function remoteMethod(){
        return "*** remoteMethod() called from Adjacent.cfc";
    }
    
    public string function publicMethod(){
        return "*** publicMethod() called from Adjacent.cfc";
    }
    
    package string function packageMethod(){
        return "*** packageMethod() called from Adjacent.cfc";
    }
    
    private string function privateMethod(){
        return "*** privateMethod() called from Adjacent.cfc";
    }

}

Note that there's a method of each access level: remote, public, package and private. I can further "hack" via my Chrome devtools console, thus:

telemetrySocket.invoke("Adjacent", "publicMethod");

And this "works" (in that it runs without error):
{"clientid":1053820853,"ns":"coldfusion.websocket.channels","data":"*** publicMethod() called from Adjacent.cfc","reqType":"invoke","code":0,"type":"response"}

So public methods from other CFCs are also accessible. If I run a package method, I get this:

{"clientid":1053820853,"ns":"coldfusion.websocket.channels","reqType":"invoke","code":4001,"type":"response","msg":"An error occurred while invoking the function packageMethod of cfc Adjacent. Verify if the function syntax is correct."}

If CF was actually doing its job here, the message ought to be:

Invalid method called

The method packagemethod does not exists or is inaccessible remotely


Because that would indicate it's actually checking the method accessibility, and reporting the situation properly. NB: that's the error if I browse to Adjacent.cfc?method=packageMethod in my browser.

For completeness, when I try to run Adjacent.cfc's publicMethod(), I get the same error (which is correct). And for privateMethod() I get this:


Element privateMethod is undefined in a Java object of type class coldfusion.runtime.TemplateProxy.

I get a similar error if I try to run privateMethod() via the web socket:

{"clientid":1053820853,"ns":"coldfusion.websocket.channels","reqType":"invoke","code":4001,"type":"response","msg":"The method privateMethod was not found in component C:\\apps\\adobe\\ColdFusion\\10\\second\\wwwroot\\htdocs\\simplified\\Adjacent.cfc."}

All web-accessible directories

Next I tried creating another subdirectory elsewhere in the web root, and they were all accessible via dotted-path notation, eg:

telemetrySocket.invoke("differentWebExposedDirectory.InAnotherDir", "publicMethod");

I was able to call that method too.

Non-web accessible CFCs

As with any well-configured ColdFusion application, only the files I intend my users to browse to are in the web server's doc root. This means the bulk of my CFML files are not in the doc root, and therefore cannot be accessed from the outside world. Everyone should be configuring their CF apps like this. So in my CF instances ColdFusion root dir, I have these directories:

C:\apps\adobe\ColdFusion\10\second\wwwroot\
This is the ColdFusion root. ColdFusion has access to any files within this directory.

C:\apps\adobe\ColdFusion\10\second\wwwroot\htdocs\
This is the website. This is the directory that Apache considers its doc root. It will not serve any files from outwith that directory. Only files within that directory should be accessible from the outside world.

Within the CF root, I have created the following file, C:\apps\adobe\ColdFusion\10\second\wwwroot\notWebBrowsable\NotBrowsable.cfc:

component {
    
    public string function publicMethod(){
        return "*** publicMethod() called from NotBrowsable.cfc";
    }

}

There is no URL to this file, so it is not externally accessible. The only way to access publicMethod() is via CFML code, eg:

o = createObject("notWebBrowsable.NotBrowsable");
writeOutput(o.publicMethod());

Fortunately, a web socket request cannot access that method either:

{"clientid":1053820853,"ns":"coldfusion.websocket.channels","reqType":"invoke","code":4001,"type":"response","msg":"Could not find the ColdFusion component or interface notWebBrowsable.NotBrowsable."}


But.

If I happen to have a ColdFusion mapping to that directory - as will often be the case when one has tird party apps installed, or some simply wants to have a homogeneous entry point to one's own application - then suddenly a web socket request can access that method:

telemetrySocket.invoke("notWebBrowsable.NotBrowsable", "publicMethod");

Yields:
{"clientid":1053820853,"ns":"coldfusion.websocket.channels","data":"*** publicMethod() called from NotBrowsable.cfc","reqType":"invoke","code":0,"type":"response"}

(ie: that's the method running correctly).

The ought to be no correlation whatsoever between ColdFusion mappings and web accessibility.

Secure Roles

I have created this file:

// /differentWebExposedDirectory/SecuredViaRoles.cfc
component {
    
    remote string function remoteMethod() roles="secured" {
        return "*** remoteMethod() called from notWebBrowsable.SecuredViaRoles";
    }
    
    public string function publicMethod() roles="secured" {
        return "*** publicMethod() called from notWebBrowsable.SecuredViaRoles";
    }
    
    public string function unsecuredPublicMethod() {
        return "*** unsecuredPublicMethod() called from notWebBrowsable.SecuredViaRoles";
    }
    
    remote string function unsecuredRemoteMethod() {
        return "*** unsecuredRemoteMethod() called from notWebBrowsable.SecuredViaRoles";
    }

}

So this file is within the website, so is web-browsable. If I try to browse to remoteMethod() via a URL, eg:

http://cf10second.local/differentWebExposedDirectory/SecuredViaRoles.cfc?method=remoteMethod

Then I get this:
The current user is not authorized to invoke this method.

Good. I have hastily knocked together some login/logout files, thus:

<!--- login.cfm --->
<cflogin>
    <cfloginuser name="validUser" password="validUserPwd" roles="secured">
    Logged In
</cflogin>


<!--- logout.cfm --->
<cflogout>
Logged out

If I login and re-hit that URL, the method runs:
*** remoteMethod() called from differentWebExposedDirectory.SecuredViaRoles

OK, so that demonstrates that remote method calls via URL will indeed pay attention to CF's security roles. I only verified this because I've never used them before, so wasn't sure.

The only good news in all this is that when I now try to hit that remoteMethod() via a web socket call:
telemetrySocket.invoke("differentWebExposedDirectory.SecuredViaRoles", "remoteMethod");

I get an error:
{"clientid":1053820853,"ns":"coldfusion.websocket.channels","reqType":"invoke","code":4001,"type":"response","msg":"An error occurred while invoking the function remoteMethod of cfc differentWebExposedDirectory.SecuredViaRoles. Verify if the function syntax is correct."}

Now I say it's "good" because the method didn't run. It's not actually "good" in this case, because that method should have run, because my current user has been authorised. But the web socket call is not passing this info along. Or it simply has no idea how to handle user roles, as that error is not really correct, is it? It should say "The current user is not authorized to invoke this method." like when I otherwise call that method without role authorisation.

But what it means is if I put a role - any role - on methods I need to not have accessible via a web socket, they won't run. So that's kind of good. -ish. Not really. On the other hand it also means that for any CFML code to use them, the current user does need to be authorised. So that's a bit of a pyrrhic victory there.

Application.cfc

I thought I could use onCfcRequest() to check a method's access before it was executed, as I detailed in an earlier article about onCfcRequest(). This article points out that onCfcRequest() comes with its own security risks if not handled properly, but if handled properly, it should sort this issue out.  I have this Application.cfc running during all this testing:

// Application.cfc
component {

    this.name = "websocketSecurity01";
    
    public void function onApplicationStart(){
        writeLog(file=this.name, text="onApplicationStart() called");
    }
    
    public void function onRequestStart(){
        writeLog(file=this.name, text="onRequestStart() called");
    }
    
    public void function onRequest(){
        writeLog(file=this.name, text="onRequest() called for #arguments[1]#");
        include arguments[1];
    }
    
    public any function onCfcRequest(required string cfc, required string method, required struct args){
        writeLog(file=this.name, text="onCfcRequest() called for #arguments.cfc#.#arguments.method#");

        var o = createObject(arguments.cfc);
        var metadata = getMetadata(o[method]); 
        
        if (structKeyExists(metadata, "access") && metadata.access == "remote"){
            writeLog(file=this.name, text="onCfcRequest() executed #arguments.cfc#.#arguments.method#");
            return invoke(o, method, args);
        }else{
            writeLog(file=this.name, text="onCfcRequest() blocked #arguments.cfc#.#arguments.method#");
            throw(type="InvalidMethodException", message="Invalid method called", detail="The method #method# does not exists or is inaccessible remotely");
        }
    }

}

The key bit is in onCfcRequest(), where I check what the access level of the incoming method is, and only run it if it's explicitly remote. Otherwise I just error out. Cool.

Well it would be cool if web socket requests actually invoked any event handlers in Application.cfc, which they do not. So that's not much chop.

Conclusion

As people have indicated, to be able to exploit this one needs to know the names of the CFCs and the methods. This might seem like a barrier, but what about all these "convention of configuration" frameworks out there? Especially ones like Coldbox who actively suggest installing the whole app in the web root! I think the CFC & method names could be inferred from the URL sometimes (obviously there are ways to mitigate this). Also there are off-the-shelf applications (or: "out of the Github") applications out there too, so people will know what some of the CFCs and methods we're using will be. I did not suggest this basically turns one's application inside out and spills its innards out for all to see. But it is a serious flippin' security cock-up with web sockets. I think we can all agree on this. And how seriously you individually take it: up to you. But at least you know the vector and the risks now.

It's gotta be fixed. And I'd also quite like to know "what the hell were you thinking?" with a lot of this behaviour. I'll settle for the former; I don't expect to hear about the latter.

If nothing else, I had a play around with web sockets, which is something. Plus the security system that <cflogin> and method roles offer in CF, which I had never used before.

But it's a nice sunny day outside, so there are better things to do than all this.

Righto.

--
Adam