CyberNugget

L44 · Case study: the Optus (2022) and T-Mobile (2023) API breaches

Module 6: API Security · ~20 min · Lesson 44 of 71

🎯 What you'll learn

  • How a forgotten API on a dormant domain exposed about 9.5 million Optus customers
  • Why an incrementing ID turned one access-control bug into a bulk scrape
  • How T-Mobile's attacker drained one API for six weeks before anyone noticed
  • How both failures map to Module 6 (L39–L43) and to your own routes

🔥 Why this matters

In September 2022, about a third of Australia's population learned that their name, date of birth and phone number, and in millions of cases a passport or licence number, had been taken from their phone company. Four months later, T-Mobile told the SEC that someone had pulled data on 37 million accounts through one API. No malware, no zero-day: just the API, asked over and over.

🏢 The companies and the setup

Optus is Australia's second-largest telecom company, owned by Singapore's Singtel. Like any telco, it holds identity documents: Australian rules require ID checks, for example for prepaid mobile services. T-Mobile US is one of the three big US carriers, with over 100 million customers.

The key piece at Optus was a dormant domain: an older, internet-facing host that still served APIs reaching customer data, long after anyone needed it.

A note on sources. Optus never published a full technical account. Below, [ACMA] and [OAIC] mark allegations by Australia's communications and privacy regulators in their Federal Court cases: these are claims in pending cases, not court findings. [report] marks journalists' and the attacker's accounts from 2022. [8-K] marks T-Mobile's SEC filing.

⏱ Attack timeline

Optus: the attack path

 ATTACKER (internet)
   │ 1. finds an API on a dormant but still
   │    internet-facing Optus domain            [ACMA]
   ▼
 TARGET DOMAIN API
   │ 2. its access controls had been ineffective
   │    since a coding error in Sept 2018       [ACMA]
   │    "no authentication needed"            [report]
   ▼
 CUSTOMER RECORDS, ONE PER contactId
   │ 3. the ID goes up by one per customer, so a
   │    script asks for N, N+1, N+2, ...       [report]
   ▼
 ~9.5 MILLION CURRENT AND FORMER CUSTOMERS
   │ 4. name, DOB, phone, email; ~3.2M addresses;
   │    ~2.5M passport or licence numbers      [ACMA*]
   ▼
 EXTORTION
     5. posts 10,000 records and demands US$1M in
        Monero; days later deletes the post and
        claims the data was destroyed         [report]

* Figures moved: early reports said "up to 9.8 million", regulators now say about 9.5 million, and Optus's 2022 count of ID numbers (about 2.1 million) is lower than ACMA's. The hostname and "no authentication" come from the attacker and early reporting (disputed); ACMA describes a coding error that made access controls ineffective, which Optus has acknowledged.

The dates

 2018 Sep ── coding error breaks access controls on
      │      the main domain and the target domain  [ACMA]
 2021 Aug ── Optus finds and fixes it on the main
      │      domain; the dormant one stays broken   [ACMA]
 2022 Sep ── data taken; Optus discloses on 22 Sep;
      │      extortion post appears, then vanishes
 2023     ── court rejects Optus's claim that the
      │      Deloitte review was privileged       [report]
 2024 May ── ACMA sues: one breach per active customer,
      │      about 3.6M, up to A$250,000 each     [ACMA]
 2025 Aug ── OAIC sues for "serious interference" with
      │      ~9.5M people's privacy, Oct 2019 to
      │      Sep 2022                              [OAIC]
 2026     ── at the time of writing, no penalty judgment
             found in either case (check for updates)

The 2021 detail is the one to remember. Optus found the bug and fixed it, on one domain. The same broken control stayed live on a host nobody was watching.

Optus first called the attack "sophisticated"; the minister called it "quite a basic hack" [report]. ACMA's account fits "basic".

T-Mobile: six weeks on one API

 2022 Nov 25 ── attacker begins pulling data through
      │         a single API "without authorization" [8-K]
      │         (how they got access: not disclosed)
      │   ~6 weeks, ~37M current prepaid and postpaid
      │   accounts (many without the full data set)
 2023 Jan 5  ── T-Mobile detects it; access cut within
      │         about a day                          [8-K]
 2023 Jan 19 ── 8-K filed; customers notified

The API returned name, billing address, email, phone, date of birth, account number, and plan details. T-Mobile said no payment cards, Social Security numbers, government IDs or passwords were exposed [8-K]. Whether the API needed authentication, and whether IDs were guessable, was never made public; treat claims either way as unverified. It wasn't T-Mobile's first: a 2021 breach had exposed data, including Social Security numbers, on tens of millions of people. In 2024 the FCC announced a $31.5 million settlement covering its 2021–2023 incidents, half of it a required security investment [report].

🔍 What went wrong

Failure Optus T-Mobile Earlier lesson
Forgotten API still live Dormant domain never decommissioned [ACMA] Not disclosed L39 API9 improper inventory; L38
Fixed one copy, not the class Fixed on main domain in 2021 only [ACMA] – L34 the same bug in /v1 and /v2
Access control broken Coding error; reportedly no auth at all Abused "without authorization" L21, L40 BOLA, L43
Enumerable IDs Incrementing contactId [report] Unknown L34 IDs as a seatbelt, L20 randomness
No limits or alerting on volume Millions of records pulled Six weeks unnoticed L42 rate limits, L39 API4
Returned and kept too much ID document numbers; former customers DOB, account numbers L41 excessive data exposure
Never threat modeled "Who can call this host?" – L05 (it named Optus as the example)

In L03's terms, there was no foothold and no privilege escalation. The API was the data.

🛡 What would have stopped it

  • An inventory with owners. No owner, no traffic: the dormant domain gets switched off (L39).
  • Fix the class, not the instance. In 2021, a search for every place that control ran would have found the target domain.
  • Deny by default at the edge. A gateway rule that every route needs an auth plugin, with an explicit public list, and a CI test that calls each route without credentials (L43).
  • Object-level checks in the service: "is this record yours?" (L40). This is the real fix.
  • Random IDs, so a missed check can't be scraped in order (second deep dive). A seatbelt only.
  • Alerts on distinct objects per client. 10,000 different IDs in an hour is a scrape (first deep dive, L42).
  • Return and retain less. An API that never returns licence numbers can't leak them.

🧪 Worked example: the contact API, before and after

The shape of the bug, in Node and MongoDB (made-up code, not Optus's):

// contacts.js (vulnerable), on a host nobody remembers
app.get("/api/contacts/:id", async (req, res) => {
  const c = await db.collection("contacts")
    .findOne({ contactId: Number(req.params.id) }); // 1, 2, 3...
  if (!c) return res.sendStatus(404);
  res.json(c);          // whole document: DOB, address, ID number
});

Three problems: no caller identity, a countable ID, and the whole document returned. The fix, in the service itself:

// contacts.js (fixed)
app.get("/api/contacts/:id", requireToken("contacts:read"),
  async (req, res) => {
    const c = await db.collection("contacts").findOne(
      { publicId: req.params.id,       // random, not 1, 2, 3
        customerId: req.auth.sub },    // must be the caller's
      { projection: { _id: 0, name: 1, email: 1, phone: 1 } });
    if (!c) return res.sendStatus(404); // same for "not yours"
    res.json(c);                        // only what the screen needs
  });

requireToken verifies signature, expiry, audience and scope (L25), even if Kong did too (L43).

🧰 Lessons for your own work

  1. List your team's gateway routes and hosts (Kong routes, in the examples): owner, auth plugin, last real traffic. No owner or no traffic means removal, not a backlog item.
  2. When you fix an access-control bug, grep for its siblings: other versions, other hosts, batch endpoints, internal copies.
  3. Query by the caller's identity. In MongoDB, put the owner field from the verified token in the filter, so "not yours" and "doesn't exist" are the same 404.
  4. Count distinct objects per client on a dashboard, and return the minimum: if the screen doesn't show a document number, the API doesn't send it.

🛠 Try it (optional, ~5 min)

Build a deliberately bad API on your own machine and scrape it. Save as ~/l44-try/contacts_api.py:

# A deliberately bad API, for localhost only.
import json, re
from http.server import BaseHTTPRequestHandler, HTTPServer

DB = {str(1000 + i): {"id": 1000 + i, "name": f"Test {i}"}
      for i in range(20)}

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        m = re.fullmatch(r"/api/contacts/(\d+)", self.path)
        rec = DB.get(m.group(1)) if m else None   # no auth!
        self.send_response(200 if rec else 404)
        self.end_headers()
        self.wfile.write(json.dumps(rec or {}).encode())

HTTPServer(("127.0.0.1", 8044), H).serve_forever()
cd ~/l44-try && python3 contacts_api.py 2> access.log &
for id in $(seq 998 1022); do
  curl -s -o /dev/null -w "%{http_code} " \
    http://127.0.0.1:8044/api/contacts/$id
done; echo
# 404 404 200 200 200 ... 200 404 404 404   (20 × 200)
kill %1; tail -3 access.log

Twenty records in under a second. access.log shows one client, consecutive IDs, a steady rhythm: the pattern the first deep dive turns into an alert. Delete ~/l44-try when done.

🧠 Key takeaways

  • Optus: a forgotten API, a broken access control and countable IDs exposed about 9.5 million people.
  • The bug was fixed on the main domain in 2021 and left on the dormant one [ACMA]: fix the class.
  • T-Mobile: one API drained for six weeks; detection, not prevention, ended it.
  • The real fix is an object-level check in the service; random IDs and gateway rules are layers.

📖 Words to know

Term Meaning
Enumeration Walking through possible IDs (N, N+1, ...) to collect every record an API will return
Dormant domain A host that is no longer used but is still reachable from the internet
Improper inventory (API9) Not knowing which APIs, versions and hosts you expose, so some go unguarded
ACMA Australian Communications and Media Authority, the telecom regulator
OAIC Office of the Australian Information Commissioner, the privacy regulator
Form 8-K A US SEC filing that public companies use to report material events, such as a breach
Data minimization Collecting, returning and keeping only the data a purpose needs

⏭ Next time

Module 6 is done. Module 7 opens with L45: the shared responsibility model, and which half of the security job is yours when you build on a cloud such as AWS.

Deep dive 1

Spotting enumeration in gateway logs

T-Mobile's attacker worked for about six weeks. Optus's pulled millions of records. Both left a trail in access logs before the data showed up for sale. Gateway logs, such as Kong's, show the same pattern.

The signature of a scrape

 NORMAL CUSTOMER APP              SCRAPER
 ────────────────────────         ─────────────────────────────
 same 1–3 IDs, over and over      a new ID on almost every call
 IDs scattered (their own)        IDs consecutive: N, N+1, N+2
 bursts around logins             steady rhythm, day and night
 almost no 404s                   404s where IDs are missing
 one consumer, many IPs (mobile)  one token or IP, or many IPs
                                  each taking a slice

The single best signal is distinct objects per client per time window. Rate limits count calls; a scraper calling politely stays under them. Counting different IDs catches it.

🧪 Worked example: finding it with jq

Kong's http-log plugin sends one JSON document per request. Here's a made-up slice, flattened to four fields (real entries nest these under request, response and so on), saved as flat.jsonl; t is seconds:

{"t":10,"ip":"198.51.100.7","uri":"/api/contacts/881201","status":200}
{"t":14,"ip":"203.0.113.20","uri":"/api/contacts/552041","status":200}
{"t":19,"ip":"198.51.100.7","uri":"/api/contacts/881202","status":200}
{"t":23,"ip":"198.51.100.7","uri":"/api/contacts/881203","status":404}
{"t":28,"ip":"198.51.100.7","uri":"/api/contacts/881204","status":200}
{"t":30,"ip":"203.0.113.20","uri":"/api/contacts/552041","status":200}
{"t":33,"ip":"198.51.100.7","uri":"/api/contacts/881205","status":200}
{"t":37,"ip":"192.0.2.44","uri":"/api/contacts/710009","status":200}
{"t":38,"ip":"198.51.100.7","uri":"/api/contacts/881206","status":200}
{"t":43,"ip":"198.51.100.7","uri":"/api/contacts/881207","status":200}

For each client, count calls, distinct IDs, and how many IDs are exactly one more than the last:

jq -s -c '
  group_by(.ip)
  | map({ip: .[0].ip, calls: length,
         ids: (map(.uri | capture("(?<n>[0-9]+)$").n | tonumber)
               | unique)})
  | map(. + {distinct: (.ids | length),
             steps_of_1: ([.ids as $a | range(1; $a | length)
                           | select($a[.] - $a[. - 1] == 1)]
                          | length)})
  | map(select(.distinct >= 5 and .steps_of_1 >= 4) | del(.ids))
' flat.jsonl
# [{"ip":"198.51.100.7","calls":7,"distinct":7,"steps_of_1":6}]

203.0.113.20 read the same record twice: a normal user. 198.51.100.7 read seven different, consecutive records in 33 seconds, including one that didn't exist. That's enumeration.

Making it a real alert

  • Group by every identity you have: consumer, token subject, client IP, and device ID. A careful attacker spreads across IPs, so also alert on the route as a whole: "distinct IDs read per minute" against its normal baseline.
  • Run it continuously. Ship gateway logs to your log platform and schedule the query; a daily report of "top 10 clients by distinct objects read" would have shown T-Mobile's six-week pattern on day one.
  • Watch 404 ratios. Guessing produces misses. A client with 30% 404s on a lookup route is probing.
  • Mind the logs themselves. Request URIs can hold IDs that are personal data; apply your retention and access rules to gateway logs too.

🧠 Extra takeaways

  • Count distinct objects per client, not just calls; polite scrapers pass rate limits.
  • Consecutive IDs, steady timing and scattered 404s are the fingerprint of enumeration.
  • Group by consumer, token and route as well as IP, because scrapers rotate addresses.
  • A scheduled report is cheap; six weeks of silence is expensive.
Deep dive 2

Guessable IDs in a typical stack: MongoDB ObjectIds and time-ordered UUIDs

Optus's contactId went up by one. A MongoDB _id doesn't, so it's safe to expose, right? Not quite: it's far more guessable than it looks.

What's inside an ObjectId

A MongoDB ObjectId is 12 bytes, usually generated by the driver in your service:

 672d1a4b    9f3c2e81d4      1a2b3c
 ────────    ──────────      ──────
 4 bytes     5 bytes         3 bytes
 seconds     random, fixed   counter, +1 per new ID
 since 1970  per process     (starts at a random value)

So two documents inserted by the same pod a moment apart have IDs that differ only in the counter and maybe the timestamp. Anyone who sees one ID knows when it was created, which process made it, and roughly what its neighbours look like.

🧪 Worked example: from one ID to its neighbours

from datetime import datetime, timezone

mine = "672d1a4b9f3c2e81d41a2b3c"   # an ID you were given

def parts(oid):
    ts = int(oid[:8], 16)           # 4 bytes: seconds
    rnd = oid[8:18]                 # 5 bytes: per process
    ctr = int(oid[18:], 16)         # 3 bytes: counter
    return ts, rnd, ctr

ts, rnd, ctr = parts(mine)
print("created:", datetime.fromtimestamp(ts, timezone.utc))
print("process:", rnd, " counter:", ctr)

# neighbours: same process, next few inserts, same or next second
for dt in (0, 1):
    for dc in (1, 2, 3):
        print(f"{ts + dt:08x}{rnd}{(ctr + dc) % 2**24:06x}")

Output (Python 3, standard library only):

created: 2024-11-07 19:51:39+00:00
process: 9f3c2e81d4  counter: 1715004
672d1a4b9f3c2e81d41a2b3d
672d1a4b9f3c2e81d41a2b3e
672d1a4b9f3c2e81d41a2b3f
672d1a4c9f3c2e81d41a2b3d
672d1a4c9f3c2e81d41a2b3e
672d1a4c9f3c2e81d41a2b3f

One of those is very likely the next customer the same pod created. On a busy cluster the search space is bigger (more pods, more inserts per second), but it's thousands of guesses, not 2^96. Bug bounty write-ups describe IDOR bugs found exactly this way. Run this only on IDs you made up or created yourself.

Choosing IDs

ID type Guessable from a neighbour? Leaks Good for
Sequential integer Trivially Count of customers, sign-up order Internal keys only
MongoDB ObjectId Often, within one process Creation time, process Internal _id
UUIDv7 (RFC 9562) Not practically, but some implementations use a counter, leaving fewer random bits Creation time (ms) Sortable DB keys
UUIDv4 No (122 random bits, L20) Nothing Public IDs
Random token (secrets.token_urlsafe(16)) No Nothing Public IDs, short links

(Python 3.14's uuid.uuid7(), for example, fills 42 of the random bits with a counter, leaving 32 random bits between neighbours. Details differ between libraries.)

A common pattern: keep _id as an ObjectId for index locality, and add a separate random publicId with a unique index for anything in a URL. Whatever you choose, it's a seatbelt. The check is still "is this record yours?" (L34, L40).

🧠 Extra takeaways

  • ObjectIds hold a timestamp, a per-process value and a counter, so neighbours are guessable.
  • UUIDv7 and ObjectIds leak creation time; avoid them where that's sensitive.
  • Expose a random public ID (UUIDv4 or a random token) and keep sortable IDs internal.
  • No ID scheme replaces the ownership check in the query.

The quiz for this lesson and the Tests section need JavaScript.