An unauthenticated API that decides where parents log in
A lookup service that turns an organization's login code into its portal URL had no authentication at all, writes included. Anyone could read every organization's code and backoffice URL, and change where a code sends people using only the organization's name.
Some products give each customer organization a short code. A parent types the code into the app, the app asks a lookup service which portal belongs to that code, and the parent is sent there to log in. This one is about that lookup service. Every endpoint on it answered without authentication, including the two that write.
The API
Production still served its Swagger UI, so the API described itself:
{
"openapi": "3.0.4",
"info": { "title": "[service]", "version": "1.0" },
"paths": {
"/discovery/get-environment": { "get": {} },
"/discovery/generate-unique-code": { "get": {} },
"/discovery/save-environment": { "post": {}, "patch": {} }
}
}Three routes, one of them taking both POST and PATCH. None of them asked for a token.
Reading the directory
GET /discovery/get-environment?name=test HTTP/1.1{"success": true, "errors": [],
"data": {"name": "test", "code": "12073" ,
"url": "https://[backoffice-host]/",
"type": "[product]", "mediaService": ""}}Lookup by ?code= worked the same way in reverse. So the service told anyone which organizations use the product, the code each one gives its parents, and for some of them the URL of a staff-only backoffice. Those codes work like shared secrets: an organization hands its code to its own parents and nobody else, and here they were all in one place.
Some codes were five digits. There was no rate limit, 12 requests in 2.3 seconds all came back 200. The error messages also told apart “invalid code”, “no environment with that name” and “an environment with that name already exists”, so names and codes could be confirmed instead of guessed.
To size it I queried 909 names taken from hostnames visible in passive DNS. 50 were live records: 45 real organizations, plus 9 backoffice URLs, 5 production and 4 test.
Writing to the directory
Creating an environment took one request:
POST /discovery/save-environment HTTP/1.1
Content-Type: application/json
{"name": "CVD-PoC-Report"}It came back with a new code and a fresh subdomain on the platform’s own domain. Then the one that matters. PATCH takes a name and a URL, and the name is all it checks:
PATCH /discovery/save-environment HTTP/1.1
Content-Type: application/json
{"name": "CVD-PoC-Report", "url": "https://phishing-example.attacker.test/login" }No current code, no proof of ownership, no auth header. Reading the record back returned my URL, so anything that resolved that code afterwards, the parent app included, was sent to it.
I only did this to the environment I had created. No real organization’s record was changed, and I listed my test records in the report so they could be removed.
Impact
Overwrite one real organization’s record and every parent who enters its code during onboarding lands on a host you control, where a copy of the login page collects their password. Behind that login are children’s schedules, photos and messages. The redirect stays until someone notices.
Root cause and fix
The service had no authentication layer, and both lookups and writes were keyed only on values the client sends. What I recommended:
- Put the whole service behind authentication, with a service token for app-to-service calls, so browsers and phones never call it directly.
- Take Swagger off production.
- Limit create and update to authenticated admin flows, and require the current code plus out-of-band confirmation before a URL changes.
- Use long random codes and rate-limit lookups.
Disclosure
This went through a coordinated disclosure program. The vendor and product aren’t named here, and paths and field values are changed. Each request has the same structure as the original.