Reversing NEWSEQUENTIALID() Into a Predictable GUID Counter

A restaurant ordering API blanked out every guest's name, email, and phone number and trusted the order's GUID to keep them secret. It turned out the GUID was a counter, and a second endpoint gave the names back for free.

A line illustration of a yellow-on-blue bug with nodes reminiscent of a motherboard spreading out from it like legs

Technical Summary

Exploits Explained

Severity High
CVSS 8.73 (3.1)
Affected Components: An online food ordering platform (target details redacted)

An unauthenticated attacker can read the name, email, and phone number of anyone who placed an order. The order GUIDs look random but are generated by SQL Server's NEWSEQUENTIALID(), which is a sequential counter in disguise, so placing one order just before a victim and one just after traps the victim's order in a tiny, walkable range.

A second endpoint left over from account creation accepts the same order ID, checks nothing about who is asking, and returns exactly the fields the normal order read hides. The counter is global to the whole database, so one attacker position reaches every location on the platform.

It Looked Like a Random ID

Ask a developer whether they can guess a UUID sitting in a URL and they will say no, and almost every time they will be right. That confidence is why GUIDs end up guarding things they were never meant to guard. The ordering API in this story leaned on that assumption completely. Ask it for an order and it returned the totals, the items, and the store, but the guest’s first name, last name, email, and phone came back null on any order that was not mine.

GET /api/xxxxx/orders/{order-guid}

HTTP/1.1 200 OK
{
  "firstname": null,
  "lastname": null,
  "emailaddress": null,
  "contactnumber": null,
  "total": 7.69,
  "vendorname": "[REDACTED]"
}

Figure 1 – The normal order read. For an order that is not mine, the guest identity fields come back null.

Burp Suite showing a GET request to the orders endpoint with an order GUID and a 200 OK JSON response, with the guest name, email, and phone fields returning null.

Someone clearly thought about privacy here. The whole design rested on one belief: you cannot know an order ID unless it belongs to you. The order GUID was the only thing standing between an anonymous request and a real customer’s personal data. It looked like plenty.

Two Orders, Side by Side

So I placed two orders on my own test account and lined up the IDs the API handed back.

38238f82-94a4-f111-84a0-xxxxxxxxxxxx
84248f82-94a4-f111-84a0-xxxxxxxxxxxx

Figure 2 – Two orders placed seconds apart. The IDs differ only in the leading bytes; the last ten are identical.

Burp Suite showing the attacker's first guest order request, P, with fabricated guest details on the left and the new order's GUID highlighted in the JSON response on the right.
Burp Suite showing the attacker's last guest order request, Q, with fabricated guest details on the left and the new order's GUID highlighted in the JSON response on the right.

Ten of the sixteen bytes are identical, and the part that moves sits at the front while the tail stays frozen. Random values do not look like that. That shape is a fingerprint, and it belongs to SQL Server’s NEWSEQUENTIALID().

NEWSEQUENTIALID() is a thin wrapper over the Windows call UuidCreateSequential, which produces a version 1, time based UUID: a timestamp, a clock sequence, and the network card’s MAC address. The catch is byte order. Windows stores the first three fields of a GUID little endian, while the canonical UUID text form is big endian, so reversing those three groups gives you the real UUID back.

import uuid
b = uuid.UUID("38238f82-94a4-f111-84a0-021fba531baf").bytes
u = uuid.UUID(bytes=b[3::-1] + b[5:3:-1] + b[7:5:-1] + b[8:])
print(u, "v%d" % u.version)   # 828f2338-a494-11f1-84a0-021fba531baf v1

The MAC address and clock sequence are fixed for the server, and the top of the timestamp barely moves, so almost the entire value is constant. The low timestamp is the only fast moving part, and because UuidCreateSequential just increments a stored value when it is called faster than its clock ticks, on a busy server it climbs by about one per order. The identifier I was told to treat as a secret is really a global counter.

Fencing In the Victim

A counter changes the whole game, because I no longer have to guess anything out of a 128 bit space. I just have to fence the target in. My two orders reverse to 0x828f2338 and 0x828f2484, and anything created between them lands in that gap. For the test I placed one more order in the middle, a fabricated guest standing in for a victim, and its ID reversed to 0x828f23df. Subtract the two and you get 0xA7, so the victim is order number 167 in the range.

Figure 3 – The two bracket orders reverse to consecutive version 1 UUIDs. Rebuilding the counter between them yields 331 candidates; the seeded victim is number 167.

Terminal output of a script that reverses the two bracket GUIDs into version 1 UUIDs and lists 331 candidate GUIDs between them, with candidate 167 marked as the victim found by enumeration.

Figure 4 – The seeded victim order placed between the brackets, carrying a fabricated guest identity.

Burp Suite showing the seeded victim's guest order, placed between the two bracket orders, with a fabricated name, email, and phone number and the resulting order GUID highlighted in the response.

Rebuilding every candidate in that band is mechanical: hold the constant bytes still and walk the counter from my low value up to my high one. Then I replay each candidate against the order read endpoint, which cheerfully sorts them for me. It answers three ways: 200 for an order I can see, 403 for one that belongs to a different brand on the same platform, and 400 for an ID that was never issued.

Figure 5 – Replaying the range, sorted by status. 200 for real orders, 400 for IDs never issued, 403 for orders on other brands.

Burp Suite history list of GET requests to the orders endpoint sorted by status code, showing a few 200 responses at the top followed by 400 and 403 responses for the other candidate GUIDs.

That is a clean oracle. In this run the bracket was almost entirely live orders, and because the counter is global to the whole database rather than per store, the 200s came from locations I had never touched, not just my test shop. One attacker position, the whole chain.

The Endpoint That Forgot to Ask

Finding orders was only half the job, because the read endpoint still refused to show me names. But there was an older endpoint, left over from the account creation flow, that took the same order ID, checked nothing about who I was or whether the order was mine, and returned the exact four fields the first endpoint had worked so hard to hide.

POST /api/xxxxx/orders/{order-guid}/createuser
clientid: [REDACTED]

{ "password": "...", "emailOptIn": false }

HTTP/1.1 200 OK
{
  "profile": {
    "firstName": "Ahmet",
    "lastName": "Celik",
    "email": "[email protected]",
    "phone": "8085550812"
  }
}

Figure 6 – The same order ID sent to the account creation endpoint. The guest’s real name, email, and phone come back in full.

Burp Suite showing a POST request to the createuser endpoint using the victim's order GUID, and a 200 OK response that returns the guest's name, email, and phone number and an auth token.

One request, no session, no ownership check, and I was holding a stranger’s name, email address, and phone number. The same response even hands back a working session token, so the call quietly registers an account on the victim’s email address as a bonus.

The Fix

If a client can see an identifier, do not generate it with NEWSEQUENTIALID(). That function exists to make clustered index inserts fast, and it buys that speed by giving up unpredictability. It is a fine primary key and a poor public handle. Use something genuinely random for anything a client touches, such as NEWID() or a UUIDv4, and keep the sequential value inside the database where it belongs.

Then authorize every endpoint that touches an object, not just the one you expect people to use. The read path here did everything right. The bug existed because a forgotten sibling did not. No request should return guest PII without proving the caller owns that order.

The Lesson

The takeaway is not “IDOR is bad,” everyone already knows that. It is this:

Predictability and authorization are different problems, and a random looking string solves neither. This was not one clever bug. It was two ordinary ones, a predictable identifier and a missing check, that each looked minor on their own and composed into a critical, chain wide leak. When you are testing, do not stop at the endpoint that behaves. Go and find its siblings.

Rules of Engagement

All testing was against a dedicated lab store, with every order placed as cash, so no payment data was involved. Every identity was fabricated, using reserved example.com addresses and reserved phone numbers, so no real person was contacted. I only called the account creation endpoint against an order I created myself, and orders that belonged to other tenants were counted, never read. Target details are redacted.

Thanks for reading. To learn more about the global community of researchers uncovering vulnerabilities like this, check out the Synack Red Team and apply today. Be sure to follow Synack and the Synack Red Team on LinkedIn for upcoming blogs in the Exploits Explained series.

Frequently Asked Questions

What would 1,500
elite hackers find in
your stack?

The Synack Red Team and Sara AI Pentesting work side by side to test your environment and find vulnerabilities that matter.

See Synack In Action

Learn how the Synack Platform can secure your organization