Guide

Drupal settings.php or services.yml was modified — who changed it and when

You logged in this morning and something is off. Maybe Drupal is suddenly running in a different database. Maybe the trusted host check is bypassed.

1. Problem

You logged in this morning and something is off. Maybe Drupal is suddenly running in a different database. Maybe the trusted host check is bypassed. Maybe nothing visible — just a git status on the deployed code that shows sites/default/settings.php modified, and you did not deploy last night. You SSH in, run stat sites/default/settings.php, and the change time is 03:42 — a window where nobody on your team was working.

Your search history reads "drupal settings.php changed who modified", "drupal services.yml unexpected modification", and "drupal file integrity monitoring". Every result is either a generic "harden your Drupal install" checklist or a paid-product page. None of them tell you what actually changed in your file, when it happened relative to authenticated admin sessions or a config import, or whether the change is the cause or the consequence of something worse.

The Drupal admin UI surfaces nothing. /admin/reports/status is green. The hosting panel shows nothing. Drupal core does not hash its own bootstrap files at runtime. This issue surfaces as a file.integrity_changed signal — a deterministic, out-of-process hash diff — and if nothing was emitting that signal, the modification is invisible until a customer-reported symptom drags it into view.

2. Impact

sites/default/settings.php and sites/default/services.yml are the two most security-critical files on a Drupal install. Together they contain:

  • The database credentials (databases['default']['default'])
  • The hash salt that protects every login session
  • The trusted host patterns that prevent host-header injection
  • Reverse-proxy and SSL termination assumptions
  • The config_sync_directory path
  • Custom service overrides — including HTTP middleware that executes on every request
  • Feature flags like $config['system.logging']['error_level'] and $settings['update_free_access']

An attacker who can write to either file can do any of the following without leaving a trace in Drupal's own logs:

  • Swap databases['default'] to a logging proxy and exfiltrate every login credential
  • Replace the hash salt to invalidate all sessions and force a controlled password-reset flow
  • Add a $settings['trusted_host_patterns'] entry pointing at their own domain to enable host-header phishing
  • Edit services.yml to register a malicious HTTP middleware that runs before any access check
  • Set $settings['update_free_access'] = TRUE; to expose update.php to the internet

Compromise of these files is not a "broken module" event. It is a foothold. By the time visitors see the symptom — login failures, redirects, the wrong site loading — the attacker has had hours of unobserved access. The cost is measured in lost revenue, blacklisted SEO, regulator notifications under GDPR Article 33 (72-hour breach window), and the slow horror of explaining to a client that their checkout sent card numbers to a server in another country for nine days.

The reason this drags on is not that the change is hidden. It is that nothing was watching the file.

3. Why It’s Hard to Spot

When sites/default/settings.php, sites/default/services.yml, .htaccess, index.php, update.php, or robots.txt is modified, the Logystera Drupal agent emits a file.integrity_changed signal. The agent runs on Drupal cron (typically every 15 minutes to 1 hour, configurable in /admin/config/system/logystera). On each pass it computes an MD5 hash of every file in the watched-paths set and compares against the last known-good baseline stored in Drupal state under the logystera.integrity_hashes key.

A diff produces one signal per changed file with the payload:

event_type: file.integrity_changed
payload:
  file: settings.php           # or: services.yml, .htaccess, index.php, update.php, robots.txt
  previous_hash: 9f86d081884c7d65...
  current_hash:  e3b0c44298fc1c14...
labels:
  category: security

That is the mechanism. Not "Drupal flagged something." Not "your scanner reported a problem." A file.integrity_changed event is a deterministic statement: the bytes of this file at this path changed between scan T and scan T+1, and here are the hashes to prove it.

Rule drupal_critical_file_modified (Definition::Rule id 434) fires on this signal with threshold = 1 — a single integrity event is enough to alert. The 1-hour suppress window (suppress: 3600s) is deliberate: a real deploy that legitimately rewrites settings.php typically also rewrites services.yml, sometimes .htaccess, and may touch index.php. Without suppression, one deploy would page you four times in two minutes. With it, the first event opens the alert, and subsequent integrity events on the same entity in the same hour ride the same incident — letting the first responder correlate the cluster instead of fighting four separate notifications.

4. Cause

Drupal core has no native file integrity monitoring. Status Report does not hash settings.php. The Update module does not flag a modified bootstrap file. The dblog module records what Drupal sees — config saves, user logins, watchdog warnings — but a file rewritten by an attacker with shell access never enters Drupal's request lifecycle, so nothing logs it.

Uptime monitors check that a URL returns 200. A site with an injected service middleware returns 200 perfectly. A trusted-host bypass that only fires for a specific Host header returns 200 to your monitor.

Security modules — Security Review, Paranoia, hacked! — scan on schedule and surface results in the Drupal admin UI. The attacker, who now has admin via the new salt, can turn the scanner off. Worse, those modules run under Drupal, after settings.php has loaded — a malicious middleware registered in services.yml can short-circuit the scanner before it runs.

File modification time is unreliable: any attacker with shell access runs touch -t to backdate the file. Drupal's Configuration Management system tracks YAML config in sites/default/files/config_sync/ — but settings.php and services.yml are not config-management-tracked. They sit outside the export/import flow on purpose, which is exactly why they are the highest-leverage targets.

The only evidence of compromise is a few hundred bytes of difference in two files nobody opens. The only way to notice in time is for something outside the Drupal process to be hashing them on a schedule and shouting when the hash changes. That is the gap file.integrity_changed exists to close.

5. Solution

5.1 Diagnose (logs first)

Diagnosis starts from the file.integrity_changed signal and works outward. The signal tells you the file changed and what the old/new hashes are. The diagnostic job is to (a) confirm what changed, (b) pin the change time, and (c) decide whether it correlates with a legitimate deploy or with an unattributed admin session.

Step 1: Capture the current state and diff against a known-good baseline.

# Anchor the current state for forensics before doing anything else
sha256sum /var/www/drupal/web/sites/default/settings.php \
          /var/www/drupal/web/sites/default/services.yml \
          /var/www/drupal/web/.htaccess

# Diff against the last deployed version from the artifact
diff /var/backups/drupal/settings.php /var/www/drupal/web/sites/default/settings.php
diff /var/backups/drupal/services.yml /var/www/drupal/web/sites/default/services.yml

The hash you compute here is what the agent reports as current_hash in the next file.integrity_changed payload. If you have the signal stream, you already have both previous_hash and current_hash.

Step 2: Pin the actual change time.

# mtime is forgeable with `touch -t`, ctime is not
stat -c '%Y %y mtime  %n' /var/www/drupal/web/sites/default/settings.php
stat -c '%Z %z ctime  %n' /var/www/drupal/web/sites/default/settings.php

# Filesystem audit log if auditd is configured
ausearch -f /var/www/drupal/web/sites/default/settings.php --start today

stat -c '%Z' (inode change time) cannot be reset by touch -t without chown/chmod tricks. If file.integrity_changed event timestamp matches ctime within seconds, you have a high-confidence change moment. That moment is the anchor for every subsequent correlation — this is the time-correlation step the rest of the diagnosis hangs off.

Step 3: Correlate the change time with a legitimate deploy.

This is the single most important step. A legitimate deploy will produce a burst of drupal_deployment_events_total increments (the agent emits deployment.start / deployment.end events when drush deploy, drush updatedb, or a CI runner triggers the deploy hooks). A suspicious mutation produces file.integrity_changed without the surrounding deployment burst.

# Did a deploy run within ±5 minutes of the change?
grep "deployment\." /var/log/drupal/logystera-agent.log \
  | awk '$0 ~ "2026-04-26 03:3[7-9]|03:4[0-7]"'

# Cross-check the CI runner log on the deploy host
journalctl -u gitlab-runner --since "2026-04-26 03:30" --until "2026-04-26 03:50"

If the change time falls inside a deployment.start / deployment.end window with a matching CI run, this is almost certainly a legitimate deploy — go to step 5 to verify the diff content. If there is no drupal_deployment_events_total increment in the window, treat it as suspicious and continue to step 4.

Step 4: Correlate with admin sessions and config saves.

# Admin login sessions around the change time (Apache/nginx)
grep -E "POST /(user/login|user/[0-9]+/edit)" /var/log/nginx/access.log \
  | awk '$4 >= "[26/Apr/2026:03:35" && $4 <= "[26/Apr/2026:03:50"'

# Config saves recorded by Drupal — these increment drupal_config_save_total
drush sql:query "SELECT uid, type, message, timestamp \
  FROM watchdog \
  WHERE timestamp BETWEEN UNIX_TIMESTAMP('2026-04-26 03:35') \
                      AND UNIX_TIMESTAMP('2026-04-26 03:50') \
  ORDER BY timestamp;"

# Any module enable/disable in that window — surfaces as drupal_module_changes_total
drush sql:query "SELECT name, status FROM key_value \
  WHERE collection='system.schema' \
  ORDER BY name;" | head -40

Each successful authenticated POST to a Drupal admin route emits an auth.attempt with outcome=success. Each config.save increments drupal_config_save_total. Each module enable/disable produces drupal_module_changes_total. A file.integrity_changed event with no correlated admin session, no drupal_config_save_total activity, and no drupal_deployment_events_total window means the attacker bypassed the Drupal request path entirely — FTP, hosting panel, supply-chain build, or a compromised deploy key.

Step 5: Look at what actually changed.

# Common backdoor patterns in settings.php
grep -nE "eval\(|base64_decode|gzinflate|str_rot13|create_function|assert\(" \
  /var/www/drupal/web/sites/default/settings.php

# Anything appended after the standard Drupal closing comment is suspicious
sed -n '/Custom configuration/,$p' /var/www/drupal/web/sites/default/settings.php

# In services.yml: services with class names you do not recognise
grep -A2 "class: " /var/www/drupal/web/sites/default/services.yml \
  | grep -vE "Drupal\\\\(Core|Component)\\\\|^--$"

The signal told you bytes changed. These commands tell you what bytes. The combination — change time + correlation context + diff content — is the case file.

5.2 Root Causes

(see root causes inline in 5.3 Fix)

5.3 Fix

Each root cause maps to a distinct combination of file.integrity_changed plus or minus its supporting signals. Walk down the list until the pattern matches.

Cause 1: Legitimate deploy (most common, often misread as an incident). CI pushed a new settings.php to update a memcache host, config_sync path, or trusted-host pattern.

  • Signal pattern: file.integrity_changed for settings.php (and often services.yml) inside a window bracketed by drupal_deployment_events_total increments. Paired with drupal_module_changes_total if drush updatedb ran.
  • In logs: CI runner log shows drush deploy; Drupal dblog shows config imports.

Cause 2: Compromised admin account with shell access. The attacker phished a credential, pivoted to SSH (often via a hosting panel's in-browser shell), and edited the file directly.

  • Signal pattern: file.integrity_changed with no drupal_deployment_events_total window, but auth.attempt success from an unfamiliar IP/country immediately preceding the change.
  • In logs: A successful /user/login POST in nginx access log within ±2 minutes, followed by no further Drupal-side activity.

Cause 3: Vulnerable contrib module with arbitrary file write. A module endpoint accepts a path parameter without access checks (think recent SA-CONTRIB advisories).

  • Signal pattern: file.integrity_changed with no preceding auth.attempt success but anomalous POSTs to a module-specific route at the same second. Often paired with drupal_module_changes_total if a malicious module was enabled by the same vector.
  • In logs: A burst of POSTs to one specific module route, then the integrity event, then quiet.

Cause 4: FTP/SFTP/hosting panel breach. The attacker bypassed Drupal entirely.

  • Signal pattern: file.integrity_changed with no correlated auth.attempt, no drupal_config_save_total, no PHP request log entry at the change time. ctime jumps but no HTTP traffic touched the directory.
  • In logs: /var/log/auth.log shows an SFTP session; nginx access log shows nothing on Drupal in the window.

Cause 5: Compromised CI/CD pipeline. Your own tooling pushed a poisoned settings.php.

  • Signal pattern: file.integrity_changed correlated with a legitimate drupal_deployment_events_total window — but the diff content is malicious. The hardest case: correlation says "deploy", content says "compromise".
  • In logs: Pipeline logs show a successful run; the introducing commit has no associated issue or comes from a deploy key not previously used for that branch.

Action sequence (apply after pattern-matching above): Restoring the file is the easy part. Closing the door is the hard part. Work the steps below in order, and only stop when you can map the change to one of the five causes above with evidence.

  1. Snapshot first. Copy settings.php, services.yml, and .htaccess to /tmp/forensics-$(date +%s)/. If your insurer needs the artifact, you cannot recreate it after restore.
  2. Restore from the deployment artifact, not from a Drupal admin tool. The CI bucket or git archive of the deploy commit is authoritative. Do not trust the running filesystem.
  3. Rotate the hash salt ($settings['hash_salt']) to invalidate every session, and rotate the database password — if the attacker had the old credentials, they have them offline now.
  4. Verify trusted-host patterns and remove any you do not recognise.
  5. drush cache:rebuild to clear cached service-container state — services.yml changes are cached in cache/container/.
  6. Re-baseline integrity hashes at /admin/config/system/logystera (clears the logystera.integrity_hashes state key so the next scan establishes a clean reference).
  7. Audit user__roles for accounts created in the past 30 days and key_value collection system.schema for unfamiliar module names.
  8. If cause #2 or #3: rotate every admin password, enable TFA, verify $settings['update_free_access'] = FALSE;, and disable in-Drupal file editing.
  9. If cause #5: rotate the deploy key, invalidate every CI runner token, and audit pipeline commit history for a poisoned merge.

5.4 Verify

The signal that should stop appearing is file.integrity_changed for settings.php and services.yml outside of approved deploy windows.

Healthy baseline: on a quiet site, file.integrity_changed should fire zero times per week. On a site with active development, expect 1–3 events per deploy windowsettings.php, services.yml, occasionally .htaccess — clustered tightly inside a drupal_deployment_events_total window. Anything outside that pattern is suspicious. Specifically, an integrity event with no surrounding deployment burst within ±5 minutes is the threshold for "this is not normal" — and it is exactly the condition that rule 434 fires on (threshold=1, suppress=3600s).

Timeframe: if no new file.integrity_changed events appear for settings.php or services.yml in the next 72 hours (outside of approved deploys), and no new admin accounts appear in users_field_data over the same window, the immediate compromise is closed. If a new event fires unattributed inside that window, the attacker still has access — go back to §5.2 and assume a different cause from the one you picked first.

# Re-hash on the schedule the agent uses — should match between runs
sha256sum /var/www/drupal/web/sites/default/settings.php
# Wait one cron cycle. Run again. Record the value somewhere off the host.
sha256sum /var/www/drupal/web/sites/default/settings.php

# No backdoor patterns in the restored file
grep -cE "eval\(|base64_decode|gzinflate|create_function" \
  /var/www/drupal/web/sites/default/settings.php
# Expected output: 0

A "the site loads" test is not verification. The compromise loaded the site too.

6. How to Catch This Early

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as file.integrity_changed.

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as file.integrity_changed.

Everything you just did manually — diff the file against a known-good copy, pin ctime, walk the deploy log to see if a CI run bracketed the change, cross-reference admin sessions, look for matching drupal_config_save_total and drupal_module_changes_total activity — Logystera does automatically. The same file.integrity_changed signal you just searched for is detected, charted, and alerted in real time, and the surrounding deployment correlation is built into the rule's evaluation context.

1. The signal in the dashboard.

!Logystera dashboard — file.integrity_changed over time

file.integrity_changed on settings.php at 03:42, with no corresponding drupal_deployment_events_total window — the absence of the deploy correlation is the alert condition.

2. The alert that fires.

!Logystera alert — Drupal critical file modified outside deploy window

Critical alert fires within one cron cycle of the integrity event; the 1-hour suppress window groups subsequent services.yml and .htaccess changes from the same compromise into the same incident instead of paging four separate alerts.

3. Why this matters.

The fix is simple once you know the problem. The hard part is knowing it happened at all. Drupal does not watch its own bootstrap files. In-Drupal scanners can be silenced by the very compromise you want to detect. Logystera turns this kind of failure from a customer-reported emergency into a one-cron-cycle notification with the file path, both hashes, and the deployment-correlation context that tells you whether it was your CI runner or someone else.

7. Related Silent Failures

Other compromises in the same file.integrity_changed and drupal_config_save_total cluster, ordered by signal proximity:

  • Drupal .htaccess was modified — silent SEO-spam redirects in Drupal — a file.integrity_changed event for .htaccess is the most common companion to a settings.php injection.
  • Unknown admin role appeared in Drupal — detecting drupal_role_changes_total outside of config_import — when the attacker's foothold is a role grant rather than a file edit.
  • Drupal module silently enabled — tracing drupal_module_changes_total without a deploy window — the contrib-module attack vector, when the payload is a malicious module rather than a config file edit.
  • Drupal config import without CI correlation — diagnosing drupal_config_save_total bursts outside of drupal_deployment_events_total — the CMI-based attack path, where the attacker imports a YAML bundle that grants themselves access.
  • Drupal update.php exposed to the internet — detecting update_free_access flips via file.integrity_changed — the specific settings.php injection that turns the update endpoint into a remote-code-execution channel.

See what's actually happening in your Drupal system

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.