Illustration: a patch verification, four log layers and a rotation that never ran

This report is anonymised. It describes a real incident without host names, addresses or customer data. The focus is not the vulnerability but the chain of evidence: what do you check when you must decide within hours whether a system has been compromised — and how easily can the result be misread?

Starting point

Within one day the PHP project published two critical, unauthenticated vulnerabilities (CVE-2026-109618 and CVE-2026-109619, CVSS 9.2 each, jointly called “PHP2Shell”). Affected are PHP installations serving web requests. The host in question ran a reverse proxy, a Nextcloud instance on PHP-FPM and a number of static sites. The operator's question was simple:

Are we affected? Is the patch active? And do the logs show anyone exploiting the flaw?

Three questions, three very different evidence problems. That was the difficulty.

Step 1 — prove exposure instead of assuming it

The first reflex (“we run PHP, so we are affected”) is too coarse. What matters is the SAPI: only installations that process web requests are exposed. The proof was correspondingly concrete:

Step 2 — patch verification: package and runtime

An installed security update is not yet an active patch. Between “the package is on disk” and “the running process uses it” sits a restart of the FPM master. So it was checked twice:

CheckResultWhat it proves
Package version8.5.4-0ubuntu1.5 (Ubuntu fix, USN-8948-1/-2)The fix is installed
Installation time2026-10-22 06:12 (automatic updates)When the fix arrived
FPM restart2026-10-22 06:13, master active ever sinceThe fix runs
File integritypackage verification without deviationNo tampered binaries

These four lines are the actual patch proof. Everything else is interpretation of logs.

Step 3 — four log layers, four different questions

A single log cannot prove an incident. Each layer answers a different question — and has its own blind spot:

LayerAnswersBlind spot
Proxy access logWhich requests arrived, with which status and body size?No content; rotation limits lookback
Application log (Nextcloud)What did the application do internally — errors, warnings, logins?At info level only events, no access records
FPM logWorker crashes, memory exhaustion, fatal errorsPHP level only, no HTTP context
Journald / kernelOOM kills, segfaults, core dumpsNo application semantics

In addition: Fail2ban (SSH brute force) and the package manager (patch timing). Only the combination carries a statement — and only if the blind spots are named.

The false finding — “the application writes no log”

The search for the application log covered the usual directories (/srv, /var, /home, limited depth) and found nothing. The conclusion was obvious: there is no log. It was wrong.

Two things had been overlooked:

  1. The data directory lay outside the search space. The instance did not store its data in the program directory but on a separate volume. The search was thorough — just not there.
  2. The logger had a fallback. If the configured log file did not exist and its directory was not writable, the application wrote to a substitute file in the data directory. That is correct behaviour — but it hides a misconfiguration.

The actual defect sat one level deeper: the rotation job reads the log path from the configuration. Because that path did not exist, it compared the size of a non-existent file — and never rotated. Result: a single file of 438 MB instead of hourly rotation.

flowchart TD C["Configuration:
logfile = program-dir/data"] --> L{"File exists?"} L -->|no| F["Logger uses fallback:
data-dir/nextcloud.log"] L -->|yes| W["Logger writes configured file"] C --> R["Rotation job reads the same
configured path"] R --> N{"File there?"} N -->|no| X["No rotation
(file grows unbounded)"] F --> X

The lesson is uncomfortable because it concerns our own work: “not found” is not a finding as long as the search space is undocumented. A negative finding needs the same care as a positive one — including a statement of where one did not look.

The second false finding — the firewall

An admin port was “reachable only inside the LAN”. The check found no port-specific allow and noted: no allow present, so closed from outside. That was too short as well.

In fact a blanket rule for the entire LAN subnet existed, permitting every port. So the port was open inside the LAN — protection came from network topology, not from the port rule. The result (“not reachable from the internet”) was right, the reasoning was not. And a wrong reasoning is the beginning of the next wrong decision.

Rule: read a firewall as a whole — default policy, blanket allows, order. A glance at “is a port allow missing?” is not enough.

What the logs actually showed

After correcting the log path, the evaluation became possible — and the picture was inconspicuous:

The only “hot” hit was a scanner sending four roughly 1 MB requests to the orphaned Adminer site — exactly the pattern an exploitation attempt produces. All four ended with 502 because the PHP socket did not exist. The attack ran against a dead backend. That too is a result: the most dangerous finding proved the effectiveness of an (unintended) hardening.

The limit of proof — and why evidence must be secured

Two retention rules limited the statement:

  1. Proxy logs: 5 × 10 MB rotation — about ten days of lookback. The observation window was fully covered, nothing more.
  2. Application log: rotation keeps exactly one previous file. On the next run the evaluation window would have been overwritten.

Therefore the evaluation window was secured before the next rotation as a separate archive: compressed, owner root, mode 640 (the file contains user names and IP addresses), with a documented checksum. Evidence that cannot be reproduced is of little value in an audit — a checksum turns a file into a verifiable statement.

flowchart LR A["Live log
(one rotated file)"] --> B["Window extract
2026-10-15 … 2026-10-25"] B --> C["gzip archive
root:root 640"] C --> D["SHA-256 in the report"] D --> E["Reproduction:
gunzip + wc -l"]

What worked well

What we will do differently next time

  1. Document the search space before searching. Write “not found” only when the paths and depths searched are named.
  2. Attach a counter-check to every config statement. Log path: not only read it, but verify the file exists, grows and rotates.
  3. Test rotation actively (exceed the size artificially, prove rotation in the log) — do not assume it runs.
  4. Read the firewall as a whole (default policy, blanket subnet rules, order).
  5. Dismantle orphaned sites immediately. A dead PHP site is not a risk, but a permanent noise source in the log and a blind spot in the assessment.
  6. Secure evidence before rotation — with owner, mode and checksum.
  7. Correct false findings, do not smooth them over. The report contains the correction including date and cause. A silently repaired false finding destroys trust more than the mistake itself.

Transferability

The incident itself was unspectacular: patched, not exploited, no notification required. What was valuable is the evidence apparatus: exposure via inventory, patch via package and runtime, exploitation via several log layers, assessment with explicit limits of proof, evidence with checksum — and a report with a change history that also contains our own misjudgement. This structure can be versioned in Git and still be verified years later.

Sources