TL;DR: The front end was genuinely clean: no analytics, no pixels, no third-party fonts, zero cookies before consent, blocked dotfiles, HTTPS only. The CMS behind it lived on its own subdomain — and that host had no clickjacking protection on the admin login, a staging twin without the basic auth that protected the staging front end, an API that returned records the interface deliberately hides, forms whose only validation ran in the browser, and a mail domain with SPF hardfail but no DMARC and no DKIM. Nothing here is a remote code execution. Together it is a targeted phishing kit: named employees, direct dials, mobile numbers, a login page that can be framed on the real origin, and a domain whose name can be forged with no policy to stop it.
The scenario
A mid-sized supplier asked me to look at its website. It sells to clinics and care facilities, so its customers are procurement departments, technical managers and architects — people who receive quotes, invoices and project documents by mail and who are trained to check the sender.
The public site is a pre-rendered JavaScript build served as static files behind a normal web server. A separate application on a subdomain holds the content and the admin interface. Two more hostnames appeared in the certificate register: a staging front end and a staging version of that application.
I looked at the front end first. Then I stopped looking at the front end, because everything interesting was on the other host.
This is a pattern essay, not a case report. There is no country, no product, no hostname, no version, no date and no count from the engagement in it. The mechanics are unchanged; the target is not recoverable.
What the front end does right
It is worth saying this clearly, because the rest of the article is about what went wrong:
- No third-party requests except the consent tool. No tag manager, no analytics, no advertising pixels, no map embed, no video embed, no web fonts from a CDN. Fonts are self-hosted. The only external origin on the page is the CMP itself.
- Zero cookies before consent. Measured in a fresh browser profile: home, blog and contact page set nothing at all. Not a consent banner that "blocks" things — there was nothing to block.
- HTTPS everywhere. HTTP answers with a permanent redirect on every host, HSTS includes subdomains, the certificate chain is valid, TLS 1.0 and 1.1 are refused.
- Boring hardening that works.
nosniff, a referrer policy, TRACE rejected, dotfiles and environment files return 403, no directory listings, no source maps, no framework debug pages, no stack traces. - A small API. Three read endpoints and two form endpoints. No user list, no admin endpoint, no half-finished internal routes.
That is better than most sites I look at. It is also why the rest matters: someone cared about this front end. The same care did not reach the host next door.
The second host is the real front door
The CMS lived on api. — a different origin, a different stack, a different set of headers. That is a normal architecture: static site for speed, application for editing. The problem is that teams review the website and stop at the website. The application is not part of "the website" in anyone's checklist.
What the application host was missing, on both production and staging:
- No
X-Frame-Options, noframe-ancestors. The admin login page could be embedded in any page on the internet. A framed login on the real origin, with the real certificate and the real URL bar, is the most convincing credential phishing page there is — and it does not need a lookalike domain. - No Content-Security-Policy. On an SPA admin with a session cookie, that removes the last mitigation for any injected script.
- A version string in the login page. Not a vulnerability by itself; useful for choosing which public issue to try first.
The login itself was CSRF-protected — six token-less POSTs were rejected — and the session cookie was Secure, HttpOnly, SameSite=Lax. Someone did the framework defaults. They just never added the two headers that stop a browser from putting the page in a frame.
The staging twin nobody protects
The staging front end was behind HTTP basic auth. A curious visitor got a password prompt and moved on.
The staging application had no such protection. Same login page, same API, same version string, same framework — reachable by anyone who reads the certificate register. Its robots.txt did not disallow anything either.
This is the most common split I find in mid-sized companies: the environment that gets a password is the one with a nice URL and a visible page, and the environment that holds the database, the admin session and the real attack surface does not. Staging is also where credentials are weakest — reused passwords, test accounts, a database restored from production "just for a moment" — and where nobody watches the logs.
An unprotected staging backend is not a data breach. It is a free training ground for the attack on production.
The API does not know about your filters
The contact page was fed by a public endpoint that returned staff names, roles, direct dials and mobile numbers. That is a deliberate publishing decision: sales wants reachable people. Fine.
Two records were flagged showInContacts: false. The interface honoured the flag and did not render them. The API returned them anyway — both in the list and through the single-record route — including a seeded placeholder with a test address, a one-line phone number and alt text that literally said "alternative text German".
No customer data leaked. What leaked was the assumption: the team believed a boolean in the database was an access control. It was a display setting. Every field you return is published; every filter that runs in the browser is a suggestion.
This is the same pattern as a media library where unpublished images stay downloadable, or a shop API that lists drafts. The object is public as soon as an endpoint can name it.
Forms that trust the browser
The application form accepted a CV and attachments. The client code was careful: allowed extensions, a size limit per file, a total limit, a honeypot field, a privacy checkbox. All of it ran in the browser, and all of it is optional for anyone who posts directly.
What the endpoint looked like from outside:
- No CSRF token. The API is stateless and accepted the POST without one.
Access-Control-Allow-Origin: *. Any website could post to it. There are no credentials to steal, so this is not "CORS with credentials" — it is cross-site write access to a business inbox.- Files as base64 inside JSON, in the shape
{Name, Type, Content}. The browser reads each file, strips the data-URL prefix and sends the rest as text. Type and name come from the client. Validation, if it exists, has to decode and re-check everything server-side — and the client-side size limit is exactly the kind of rule that gets skipped in that path. 500 Server Erroron an empty body, not422. A validation-first design returns field errors. A server error means the request reached code that expected data — and that each malformed request writes an exception into the log.
I did not upload a file. A live upload writes into the client's system, and the mandate was non-intrusive. So the honest finding is the design, not a proven bypass: the only checks I could verify were the ones in the browser, and the endpoint accepted unauthenticated cross-origin posts.
For a company that hires, a fake application with an attachment is a delivery vehicle that arrives through the one inbox where opening files is part of the job.
Mail is three records, and two were missing
SPF was correct and hard: -all, with the mail provider and a signature service authorised. That blocks envelope spoofing.
Then:
- no DMARC record — so no policy and no reporting;
- no DKIM selector — nothing signs the domain;
- no MTA-STS, no TLS reporting, no CAA.
A forged From: with an attacker-controlled envelope passes SPF for that envelope domain. Without DMARC there is no alignment check, so nothing tells the receiving server that the visible sender is a lie. The receiving side sees a plausible mail from the supplier's real domain, with the supplier's real logo, addressed to a named person whose direct dial is on the website.
That is the money path: a quote correction, an invoice, a change of bank details. Not a technical exploit — a business process attack. And in a supplier relationship where documents move daily, the mail is the product.
Meanwhile: the runtime became the target
While this engagement was being written up, the German BSI published an advisory that belongs in this story, because it lands exactly on the host type described above: the second host that nobody reviews.
On 21 October 2026 the PHP project released emergency patches for two remote code execution flaws in the PHP engine itself — CVE-2026-109618 (out-of-memory corruption) and CVE-2026-109619 (stale global state / use-after-free), both CVSS v4.0 9.2, both exploitable without authentication. Fixed releases: PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35; older branches receive no official fix. The BSI advisory is BITS-H no. 2026-297981-1132 (version 1.1, 21 October 2026, criticality 3/Orange, TLP:CLEAR).
Two details make it more than a patch note:
- It fires before your code runs. The demonstrated path is Apache with
mod_php, where the vulnerable sequence happens during request startup — the application never gets a chance to validate, log or rate-limit anything. - It was exploited before it was public. An exploitation attempt was observed four days before disclosure, and attempts rose sharply after the patch release.
For this pattern the lesson is uncomfortable: a static front end can be perfect, and the PHP runtime on the CMS host can still be the weakest component in the estate. The host that only exists to edit content is the one that executes the interpreter on every request — including the login page, the API and the form endpoints from the previous sections.
The same logic applies to every CMS in the estate: the version of the runtime is a property of the host, not of the repository, and nobody owns it unless somebody is assigned to it.
The certificate register is the map
None of the hostnames above were found by guessing. They were in public certificate logs — the same logs that exist to make TLS auditable. Group the entries by name and the estate appears: production, staging, API, an old alias. Structure first, then targets.
The chain
Findings
| Severity | Finding | Evidence |
|---|---|---|
| HOCH | Mail domain with SPF hardfail but no DMARC and no DKIM — a forged From is deliverable and nobody reports it | Authoritative DNS: no _dmarc, eight DKIM selectors empty, no MTA-STS, no CAA |
| MITTEL | The staging twin of the admin/API is reachable without the basic auth that protects the staging front end | 401 on the staging front end, 302 → /login on the staging API |
| MITTEL | The admin login cannot be framed-protected: no X-Frame-Options, no CSP frame-ancestors | Response headers on production and staging |
| MITTEL | No CSP and no Permissions-Policy on the marketing site or the API host; CMS content itself is clean | Header capture; 40 records checked, 0 script tags, 0 inline handlers, 0 embeds |
| MITTEL | The public API returns records the interface deliberately hides — in the list and through the single-record route | Contacts endpoint vs. single-record request for a flagged placeholder |
| MITTEL | Form endpoints accept unauthenticated cross-origin posts, validate only in the browser, and answer 500 instead of 422 | Access-Control-Allow-Origin: *; empty-body POST; client bundle |
| MITTEL | Privacy policy names a hoster that is not the network serving the site; CMP and mail processor unnamed | Policy text vs. RIPE whois of the public IP |
| NIEDRIG | The only third-party script (consent tool) loads without an integrity hash | DOM inventory: zero integrity attributes |
| NIEDRIG | Canonical and sitemap point at the apex, which 301s to www; one published article missing from the sitemap | Apex redirect; sitemap diff |
| NIEDRIG | Staging API indexable; no security.txt; no CAA record | Empty Disallow; 404 on /.well-known/; DNS |
| GOOD | Front end: zero third-party requests except the CMP, zero cookies before consent, self-hosted fonts, HTTPS-only with HSTS, dotfiles and environment files 403, no source maps; admin session cookie Secure/HttpOnly/SameSite=Lax, login CSRF-protected, admin API 401 | Fresh-profile capture, path probes, header capture |
How to see it yourself
Everything above is visible from public data:
# the estate, from public certificate logs
curl -s "https://crt.sh/?q=%25.example.tld&output=json" | jq -r '.[].name_value' | sort -u
# the front door versus the second host
curl -sI https://www.example.tld/ | grep -iE 'strict-transport|content-security|x-frame'
curl -sI https://api.example.tld/ | grep -iE 'strict-transport|content-security|x-frame'
# the staging twin: one host protected, the other not
curl -s -o /dev/null -w '%{http_code}\n' https://stage.example.tld/
curl -s -o /dev/null -w '%{http_code}\n' https://api.stage.example.tld/
# the org chart the website publishes
curl -s https://api.example.tld/api/contacts | jq '.[] | {name, email, phone, mobile, showInContacts}'
# mail policy
dig +short TXT _dmarc.example.tld
dig +short TXT selector1._domainkey.example.tld
Two rules for reading the result:
- Compare hosts. A site can be excellent and its API host mediocre. Review the estate, not the page.
- Check what the endpoint returns, not what the page renders.
showInContacts,published,visible,status— every one of those fields is a UI concept until a server-side query enforces it.
Detection: what would have caught this
- Asset inventory that includes subdomains and environments. Every host in the certificate register with an owner, an environment label and a control set.
api.stagewould have shown up as "staging, no basic auth". - Header baseline per host, not per site. One policy for the marketing host and the admin host, checked automatically. Missing
frame-ancestorson a login page is a one-line assertion. - Mail policy as a monitored record. DMARC, DKIM and SPF checked weekly; an alert when a record disappears (they often disappear during a mail migration).
- An API contract test that asserts filters. "Records with
showInContacts=falsemust not appear in/contacts" is a test, not a code review. - Form endpoints in the negative-test suite. Empty body must return 422; a cross-origin POST must be rejected or rate-limited; an oversized attachment must be refused before decoding.
- Runtime inventory per host. Not per repository — per host, including the CMS and the API subdomain. The interpreter that parses every request is the component your application code cannot protect.
What the defender should take away
- Review the estate, not the website. The CMS host, the API host and the staging twin are part of the perimeter even when they are not linked in the footer.
- Put
frame-ancestorson every login page. It costs one header and removes an entire phishing genre. - Give staging the same network control as production, or take it offline. A basic-auth prompt on the pretty host and an open door on the data host is the worst combination.
- Enforce visibility in the query. A boolean that only the front end respects is documentation, not authorisation.
- Validate on the server, and prove it. Return 422, cap the body before decoding, sniff the decoded file, drop the base64-in-JSON transport.
- Publish DMARC and sign your mail. SPF alone tells receivers which servers may send; DMARC tells them what to do when the visible sender lies.
- Treat the staff directory as a targeting list. Publishing direct dials is a sales decision; publishing them for a domain that can be spoofed is a risk decision.
Closing
The website was not the problem. The website was the part everyone had already fixed — no trackers, no cookies, no third-party scripts, a clean header set and a valid certificate.
The problem was the application behind it: a login page that could be framed, a staging twin that could be browsed, an API that published what the interface hid, forms that trusted the browser, and a mail domain that could be forged with no policy to object. None of that is exotic. All of it is routine, and all of it is enough to build a convincing message addressed to a named person.
The front end was clean. The perimeter was not.
Once an attacker gains access, they can perform the following actions:
All identifiers removed — sector detail, country, scale, hostnames, versions and dates are deliberately generic, and no figures from the engagement appear here. No client systems were accessed beyond public read requests, no credentials were used, no file was uploaded and no exploitation was performed.