Data Exposure and ServiceNow: The Elephant in the ITSM Room

This research is written and discovered by Aaron Costello (Twitter @ConspiracyProof). Daniel Miessler has had absolutely no part in the research nor this article. His sole link to the research is taking statements from this very article and reposting them on Twitter. Please provide proper accreditation if choosing to share.

Background and Introduction

The purpose of this tutorial is to share my knowledge of how a built-in capability within ServiceNow could potentially be leveraged to extract data from records as an unauthenticated user. This is extremely similar to my piece on Salesforce Data Exposure, in that the vector being discussed in this article is out-of-the-box (OOB) functionality that exists by default within all versions of ServiceNow. The good news, I have been observing this vector since 2021 and have yet to find evidence of exploitation in the wild. The bad news, it has likely existed since 2015 which is the creation date of the component, and this long predates the inception of my surveillance.

It is entirely improper for both myself, and any individuals reading this article, to claim that this is a vulnerability or 0day. Because of the open-source nature of the offending component, it is recorded quite clearly that the underlying JS code which powers the component was modified by at least one individual from ServiceNow on 3rd March 2023 to make it less dangerous. Additionally, through this method one can only retrieve data that they are explicitly authorised to see based on access controls.

Lastly, there are dangerously incorrect assumptions being made about the security of large-scale SaaS platforms that have any concept of public access to data. Whether the basis of these assumptions are from providing blind trust in vendors, or SaaS practitioners rooted in the ego of their own expertise, or something else, I don’t know. But it is dangerous, and it should stop. It is my one and only ask of you all to approach SaaS products that have the capability to make resources public with utmost skepticism and care. It is my knowledge, not a guess, that near-identical vectors exist across other popular SaaS applications, not only ServiceNow and Salesforce.

What is ServiceNow?

ServiceNow, unlike some of it’s competitors, is both considered to be a Platform-as-a-Service (PaaS) and Software-as-a-Service (SaaS). It is considered PaaS because it provides a platform for creating custom applications, and SaaS because it offers a suite of ready-to-use software applications accessible over the internet ‘as a service’. As multi-faceted as it is, there are clear popular use cases.

  • IT Service Management (ITSM): The core use-case is ITSM, where it helps organizations manage and automate IT services, incidents, problems, changes, and service requests.

  • IT Operations Management (ITOM): Managing and optimizing IT infrastructure and operations, including network, cloud, and server management.

  • IT Asset Management (ITAM): Organizations can efficiently track and manage their IT assets, ensuring compliance and cost optimization.

  • Service Desk and Customer Support: It serves as a platform for managing service desks and customer support, enabling efficient incident resolution and support ticket handling.

  • Employee Self-Service: The self-service portal empowers employees to request services, access information, and solve common issues without the need for direct IT support.

These are just to name a few. Knowing these use-cases is important, as they will give you an idea as to the kind of data that ServiceNow is typically used to store and process. They are just the tip of the iceberg when it comes to a platform as large as this, and if you cannot tell already, a potential gold-mine of data for hackers.

Technical Analysis

ServiceNow’s widgets are an incredibly powerful but often overlooked component of the base platform, serving as an API for the Service Portal. They are open-source, written in JS, and are equally as powerful as Scripted REST APIs. It’s likely that their perceived limitation as small components of the Service Portal has allowed them to largely go unnoticed as a potential concern. Even more so, their access control is not governed by ACLs. As a result cloud practitioners who are searching for exposed endpoints will always miss them when performing routine checks for public ACLs on non-record components. Instead, their access control is dictated by fields on the individual widget record itself.

It’s important to note that I have yet to discover any ServiceNow documentation that mentions the fact they have placed a public endpoint for possible data disclosure as an OOB component of the core platform, even though, as you will shortly see, they are aware of it and its potential for security risks.

The widget in scope for this particular piece is known plainly as Simple List. It can be found by navigating to Service Portal > Widgets. It has a UUID (sys_id) of 5b255672cb03020000f8d856634c9c28. Its function is simple, to return record data that is readable by the caller when provided a table and field as input. If you find the widget’s record, and open it, you will see the below:

Simple List with default RBAC settings

The first thing that should catch your eye is that it’s set to Public without any roles defined. This is by default.

Within this record is also the Server Script and Client Script. The purpose of the Server Script is to provide backend functionality, and this is normally called by the Client Script which is what our browsers interact with. However, in the case of this widget, the Server Script reads directly from request parameters so it may be queried directly.

Before I dig in to the meat of the Server Script, you will be seeing small snippets of very human-readable JS code. So yes, I am aware that this portion of the article is very discriminatory against those on LinkedIn with ‘PhD x CISSP x Threat Modelling Guru x SBOM empath’ in their LinkedIn titles, but ChatGPT can explain it for them. Anyway, let me show you what are the most relevant pieces of the Server Script code.

LINE 7: options.table = $sp.getParameter('t') || options.table;

Takes in the value of GET parameter t using $sp.getParameter, a member of the GlideSPScriptable class. A really interesting thing here is the alternative value if the GET parameter t is not provided. In most cases widgets do not accept input directly from a HTTP request. Instead data is passed to widgets via the Client Script or Widget Instances through the options global variable as you see above. This knowledge may come in handy for you later.

LINE 11: if (!gs.getSession().isLoggedIn() && !new SNCACLUtil().hasPublicAccess(options.table)) {
LINE 12:		gs.warn("Deny access to table which is not public: " + options.table);
LINE 13:		data.isValid = false;
LINE 14:		return;
LINE 15:	}

Remember I mentioned that ServiceNow made an addition to this code on 3rd March 2023? This was it. In effect, it checks if the table explicitly has the public role. If it does not, access is denied. Within ServiceNow, resources that rely on ACLs for access control can cause a resource to be public through several ways. We know that one must satisfy the Role, Condition, and Scripted parts of an ACL. If public is not defined as a role on the ACL, an unauthenticated user might still pass the condition or scripted parts and thus be granted access. Even more likely is the ACL is entirely empty of a defined Role, Condition, or Script; allowing an unauthenticated user access to the resource.

Taking the above statement into account, can you find an OOB ACLs that prior to the date of this addition, were exposing sensitive PII information? If you can’t, I’ll be telling you later on in the article.

LINE 17: var gr = new GlideRecordSecure(options.table); // does ACL checking for us

Above we are priming for an permission-respecting call to the DB using the table provided from the earlier GET request, which is really what shifts the majority of data exposures through this widget from a vulnerability to a misconfiguration.

LINE 30: options.display_field = $sp.getParameter('f') || options.display_field;
LINE 31: if (!options.display_field || !grTemp.isValidField(options.display_field))
LINE 32:		options.display_field = gr.getDisplayName();

Similarly to how the t value was fetched for the table, the f GET parameter’s value is fetched for the field. If not provided, the script will automatically select whatever the table’s display column is.

LINE 34: if (input && input.filterText)
LINE 35:		gr.addEncodedQuery(options.display_field + "LIKE" + input.filterText)

Similarly to option, input is another global variable but takes inputs from POST data. In this case, the caller can provide a parameter filterText with a value that is a valid encoded filter, which will allow filtering on the returned result set using a LIKE operation. This is really good when testing against kb_knowledge table’s text field, as I have commonly found passwords and tokens by filtering for them in the article’s content.

LINE 38: options.secondary_fields = options.secondary_fields || "";
LINE 39: options.secondary_fields = options.secondary_fields.split(",");

Previously on line (30), I showed how to specify a single field to return from the query. I also previously discussed that the options global variable is often not controllable by the user since it does not take information from a HTTP request. However, there does exist other public proxy-like widgets that allow an unauthenticated user to call this widget and control the values of the options global, granting the caller the ability to specify additional fields. Think of it as a fun exercise to figure it out!

LINE 56: data.maxCount = 500;
LINE 57: gr.setLimit(data.maxCount);
LINE 58: gr.query();
         ...
LINE 64: while (gR.next()){
            ...

Prior to eventually querying the DB, a limit of 500 maximum returned records can be returned. After the query is made, it will begin iterating over the results set.

LINE 68: var record = {};
         ...
LINE 79: record.sys_id = gr.getValue('sys_id');

An empty Object is instantiated for each record, with the first key-pair assigned to it being the sys_id (UUID) of the record.

LINE 87: if (options.display_field)
LINE 88:	record.display_field = getField(gr, options.display_field);
LINE 89:
LINE 90: record.secondary_fields = [];
LINE 91: options.secondary_fields.forEach(function(f) {
LINE 92:	record.secondary_fields.push(getField(gr, f));
LINE 93:  });

The value provided to the f GET parameter, or if not provided, the calculated display field name, is passed to the getField function. This function returns both the value and also the display value that the record contains in that field. The difference between the value and the display value is straightforward. In the event that the options.displayfield is of a ‘reference field’ type, then the value will be the reference ID (sys_id) and the display value will be the resolved readable value that is normally shown within the ServiceNow UI.

This is the same function called for each of the secondary_fields, if provided.

LINE 108: data.list.push(record);

Lastly, the record object is added to the data global variable which is responsible for returning data to the client.

Example Payload

Apart from the parameters discussed in the previous section, there are two things worth mentioning when crafting the payload.

Firstly, even though unauthenticated, one must populate various session related headers. To obtain these session values unauthenticated, navigate to the ServiceNow login page.

  • The first required header, Cookie, will be populated for you automatically. You can simply copy it and its values from your request.

  • The second is requirement is to craft an X-UserToken header. This can be populated by taking the value of the g_ck JS variable from the login page HTTP response body.

It’s important to understand that in some cases, organizations may be using SSO and instead of a ServiceNow login page you will receive a 201 redirect to the IdP. When this happens, refer to your HTTP proxy and you will see that the redirect occurred from ServiceNow’s oauth_redirect.do page. You may take both the cookies from the request to this page, and the g_ck value in the response, in order to obtain / craft a valid Cookie and X-UserToken header.

Secondly, you will notice that the POST method must be used even in the absence of any POST data. Even if one chooses not to filter the query results using the filterText POST parameter, the request must still use the POST method and a Content-Type value of application/json.

This example request is querying the incident table, and does not provide an f parameter value. So, the widget will default to retrieving the display field for the table which is number. For the purposes for the PoC, I have set the public role on the ACL.

POST /api/now/sp/widget/widget-simple-list?t=incident HTTP/1.1
Host: example.service-now.com
Cookie: glide_user_route=glide.2a12d7af3d7d455e312f7e86b22564e7; glide_node_id_for_js=634d231b1c48aefac83fc3383d156040cc484385b028f4b4edcea4c8e3d996c1; BIGipServerpool_ven04337=363f9ef47179748e8b0ebda002c7c371; JSESSIONID=D9D761781699C065ECE575DDF5363A00; __CJ_g_startTime=%221697203063167%22
X-UserToken:d4e3deea1bf1bd1008e154e4604bcb1fe636d0a2e7f6380e8b9f79a037a543fe8fb59dba
Content-Type: application/json
Accept: application/json
Connection: close

It’s extremely easy to determine a response that contains results by doing two things:

  1. Ensure that the result object is not empty

  2. Ensure that result.data.count > 0

If result is just an empty object, you’ve malformed the request somehow. If result.data.count is 0, then you do not have permission to read any records from within this table. Below, a successful response with a single record returned:

...
"data":{
         "viewAllMsg":"View all",
         "isValid":true,
         "count":1,
         "list":[
            {
               "sys_id":"<example sys_id>",
               "className":"incident",
               "display_field":{
                  "display_value":"INC0010021",
                  "label":"Number",
                  "type":"string",
                  "value":"INC0010021"
               },
               "secondary_fields":[
                  {
                     "display_value":null,
                     "value":null
                  }
               ]
            },
         ],
         ...
}
...

In some cases, you might see that display_field.display_value is null. Don’t panic, this just means that the f value provided isn’t a field you have read permissions for. In reality, this null return value is a blessing . It’s telling us ‘Hey there’s an exposed field here, it’s just not this one!’. So, which one is it?

Well no one is expected to memorise every field name in the schema, and luckily you don’t have to. Simply do the following:

  1. Login to your own ServiceNow instance

  2. In the Application Navigator search, enter Tables & Columns, select it

  3. CTRL+F your table name. Select it from the list.

  4. On the right-hand side, you’ll see every field within the table. Click on the individual field labels to see their actual name. Simply iterate over the most interesting ones using the f parameter to see if they’re exposed

Testing En Masse

In order to reliably test this at scale against a large number of tables, one could export a list of all tables and fields from the ServiceNow dictionary table and use them as key-pairs in requests. The only downside to this is you’ll get about 125k results of mostly rubbish.

Instead, for a quick and meaningful test, I would suggest getting a short list of meaningful (but admittedly lazily filtered) tables.

If you’re performing a blackbox test and don’t have the exact schema of the site you’re testing against, you will unfortunately be missing out on custom tables or tables specific to any installed ServiceNow applications. On the plus side, the list of tables that I provided are sure to exist on every platform. With this list alone, you can still get incredibly good results against any site you’re authorized to test against.

Identifying Exploitation Attempts

Through analysis of the Transaction Log, one can investigate potential attempts at exploiting both the specific widget within this article and other public widgets by an unauthenticated threat actor. I’ve found the easiest way to do so is by creating a report, below I have outlined the steps on how to do so.

  1. By default, ServiceNow does not allow reporting on the syslog_transaction table. Enter sys_properties.list in the Application Navigator and hit Enter. Search for glide.ui.permitted_tables and open the record. Append syslog_transaction to the value and click Update.

  2. Navigate to Reports > Create New. In the Data sub-menu, enter the information as follows:

    • Report name: <select a name for the report>

    • Source type: Table

    • Table: Transaction Log Entry (syslog_transaction)

  3. Select the Configure sub-menu. Click Choose columns. Select the columns you wish to see, personally I would choose the following:

    • Created

    • Output Length (can assist identifying successful exploitation based on response size)

    • URL

    • IP Address

  4. Click the filter icon, as seen in the below screenshot and add the following filters:

    • URL starts with /api/now/sp/widget/widget-simple-list

      • OR

    • URL starts with /api/now/sp/widget/5b255672cb03020000f8d856634c9c28

    • AND

    • Created at or after <select look-back period>

      • AND

    • Created by is guest

  5. It’s worth noting that the rotation of the syslog_transaction table is around 8 weeks, so the look-back period may be quite limited. Click the Save button to save the report for reuse. Click Run.

The results will appear on the page. The PDF export will truncate the results significantly to 1000 rows maximum. It is recommended instead to export to CSV. To do so, right-click any of the report column headers and select Export > CSV.

Exporting the report as CSV

Mitigation & Remediation

Whilst the only true remediation for this issue is to address the underlying access controls that cause information to be public, there are several temporary mitigations that can bide your organization time whilst the aforementioned access control issues are being addressed.

Inbound IP Address Restriction

Implementing IP restrictions for inbound traffic will entirely prevent public data exposure, as anyone attempting to access any platform resource from outside of a defined IP whitelist will be unable to do so. Those which do not have any legitimate external facing content should already be looking to ensure all inbound traffic is coming from a trusted network, and this article provides you another reason to pursue a corporate VPN or similar. On the other hand, those who do have intentionally public resources, such as a knowledgebase, are recommended to opt out of this one.

For implementation details and more information about IP restrictions, refer to the official documentation.

Disable Public Widgets

Widgets as a specific vector for retrieving public data while unauthenticated can be entirely prevented by unchecking the public flag within a widget’s record. Prior to removing public access, ensure that any widget that is regularly used by employees of your organization has the proper roles set on the widget record that will allow them to continue to access the widget during regular business usage. It is trivial to adjust the report filters within the Identifying Exploitation Attempts section to look for legitimate usage which can assist with deciding which roles to secure the widget with.

Making widgets non-public is an effective measure, but don’t forget to take into account that a widget may also be used by external integrations or external login-capable users also.

Secure ACLs with a Role / Explicit Roles Plugin

Within the Technical Analysis, I alluded to the fact that it’s not only ACLs / User Criteria with the public role that are at risk, but also empty ACLs. The widget used for the PoC in this article cannot retrieve data that is not explicitly assigned the public role, but other vectors I know of can. The easiest way to secure these ACLs without adjusting the ACL conditions or the ACL script is to assign a role not possessed by the ‘guest’ user to each ACL. Implementation is easy, simply create a role and mass-assign it to every user (except ‘guest’), then subsequently assign it to every ACL that you wish to be available to all authenticated users. If you do not wish to do this yourself, you can install the Explicit Roles plugin.

Extensive prep should be made prior to turning this plugin on, as it can break functionality if implemented hastily and incorrectly. In effect, it will assign the snc_internal role to all existing users (except guest) and assign the snc_internal role to all ACLs without a role. This still means you must validate ACLs that contain the public role, and remove it if necessary. Additionally, be careful with this since any ‘external integration’ users will also receive the role. If this occurs, re-assign these integrations the snc_external role and work with the integration vendor on ensuring its proper functionality.

A Hat Tip

As I’ve insinuated within this article, this is not the only vector in my arsenal. In fact, I even made ServiceNow aware of it 173 days ago. This vector functions the exact same as the widget used to prior to ServiceNow’s ‘explicitly public’ mitigation they introduced to it earlier this year.

In other words, if you’ve got any ACLs that allow for public access through not only the public role, but also via conditions, a scripted ACL check, or even a blank ACL, you are still to this day leaking data. Unfortunately, the sys_user table which contains every user on the platform has an OOB blank ACL for the name field, therefore satisfying ServiceNow’s definition of ‘public’. In other words, the table’s not leaking data through the widget discussed in this article, but it still is via a separate vector.

Therefore, anyone who has not modified that ACL directly or indirectly (such as through installing the Explicit Roles plugin), is leaking the full names of all internal users, which is PII. Fortunately, my moral compass is pointing north and the thought of most instances leaking PII by default is irking me without doing something about it. So this is a polite nudge to add the little piece of code below to the scripted ACL part of the read ACL sys_user.name.

gs.getUserName() != "guest";

Conclusion

Honestly, I think this entire article illustrated the point I was making pretty well in the introduction. The fact that this widget disaster is known by the vendor, as proven this year by their modifications, yet has existed since 2015 without any publicly facing documentation, is appalling. So I’ll parrot what I said in my introduction. Stop blindly trusting vendors, take security of your data into your own hands by look intensely where you’re storing it.

Seriously, think about the fact that these SaaS products give you the ability to publicly expose data through legitimate means. Such as a public knowledge base / shop / site, give some thought as to how that data is appearing on the page. What APIs are being used? If you ever even see the concept of public mentioned anywhere on a SaaS platform, through a permission, role, module, plugin, or otherwise, you should be raising your eyebrows as to how that’s implemented.

The shared responsibility model becomes broken when there is not clear honesty between the vendor and the customer. This is not the last article of its kind, I have many more that I am eager to publish. Hopefully this article will inspire organisations to take their SaaS security seriously, by employing SaaS security experts to routinely review not only customer-made configurations but also the vendor’s architecture of the product and their implementation of an RBAC model.

Thank you for bearing with me throughout this piece. If you have read this article and decided to implement a process to validate your ServiceNow RBAC controls along with some mitigatory techniques against the vectors I have discussed, then you deserve to consider yourself a ‘slay baddy’.

Next
Next

Salesforce Lightning - Tinting the Windows