Skip to main content
RunBook Academy

← All break/fix scenarios in Secrets, PKI & Certificates

advancedssh-trust~40 min

Remote host identification has changed on two hosts, and only one was rebuilt

Reported symptoms

  • At 08:14 UTC the deploy pipeline failed at its first SSH step against web-04.lab.example with a changed host identification warning and exited non-zero.
  • A second and unrelated name, bastion-2.lab.example, produced the identical warning eleven minutes later, which the incident channel immediately read as a fleet-wide event.
  • A change record showed web-04 had been rebuilt at 22:10 the previous night, so the first hypothesis was that both warnings were harmless rebuild noise.
  • The nightly backup job reported success throughout the night and the morning, so no alert fired from that path and nobody looked at it.
  • Interactive logins to every other host in the estate worked normally, and the SSH service on both named hosts was reachable and answering promptly.
  • An unrelated ticket saying agent forwarding had stopped working from the backup host had been open for three weeks with no owner.

Evidence

  • · ssh to web-04.lab.example printed the changed identification banner, naming the offending ED25519 key at line 14 of the deploy account known_hosts file and printing the fingerprint the host now sends.
  • · ssh to bastion-2.lab.example printed the same banner naming line 9, with a completely different fingerprint, which rules out a single shared cause such as a rewritten known_hosts file.
  • · The provisioning inventory records the host key fingerprint captured at first boot of every build; the web-04 record written at 22:14 on 25 August matches the fingerprint web-04 is sending now.
  • · The bastion-2 inventory record was written on 3 February and has not been updated since, and it does not match what bastion-2 is sending now.
  • · The fingerprint that bastion-2 is now sending appears in the same inventory against build-07.lab.example, a continuous integration worker with a much lower trust level.
  • · The generated internal zone file maps bastion-2.lab.example to 198.51.100.24, which is the address the inventory holds for build-07.lab.example.
  • · The backup job wrapper sets StrictHostKeyChecking to no, and its transcript from 22:11 on 25 August carries the client notices that password authentication, agent forwarding and port forwarding were disabled.
  • · No change record, package upgrade, reboot or console event exists for bastion-2 in the last six months, so a legitimate host key regeneration on that host is ruled out.
Diagnosis and resolutionclick to reveal

Root cause

Two independent defects arrived on the same night and produced one shared symptom. The first is a templating fault in the job that regenerates the internal DNS zone from the provisioning database. When build-07 was rebuilt at 22:02 the job wrote build-07 address into the record for bastion-2.lab.example, so every client that resolved that name from 22:08 onwards was directed at a continuous integration worker. The host key warning was therefore not a rebuild at all. It was the correct and only warning that the name no longer pointed at the machine it used to point at. The second defect is that the estate had no authenticated host identity. Host keys were established by trust on first use and recorded nowhere an operator would think to look, so the question every engineer asked in the channel, is this the same machine, had no answer that could be obtained from the connection itself. The provisioning inventory did hold the answer, but it was a reporting artefact rather than a trust anchor, and nothing in the runbook pointed at it. The rebuild of web-04 was genuine and benign. It became an outage only because the pipeline had no way to accept a legitimate new key safely, and it became a security incident only because the backup job had been configured months earlier to stop asking.

Remediation

Do not touch known_hosts first. The known_hosts entry is the only surviving record of what the name used to be, and removing it destroys the evidence that separates a rebuild from a redirection. Start by freezing further connections to both names: pause the deploy pipeline and disable the backup job timer so that no further credential is offered to an unidentified peer. Classify each host separately against the provisioning inventory, which is an out of band channel that the network cannot influence. Where the fingerprint the host is sending matches a first boot record with a plausible build time, as it does for web-04, the change is a rebuild. Replace that one known_hosts line using the fingerprint from the inventory record, never by copying whatever the live connection just offered. Where the fingerprint does not match any record for that name, as with bastion-2, treat the identity as unresolved: do not connect, reach the real host through the console or serial path, and hand the case to whoever owns security response. Only then fix the zone template and regenerate. Do not reach for StrictHostKeyChecking set to no, and do not leave the backup job configured that way. On a changed key that setting does not merely skip a prompt: it silently disables password authentication, keyboard interactive authentication, agent forwarding, X11, port and tunnel forwarding, and UpdateHostKeys, which is exactly why the agent forwarding ticket existed. Finish by rotating every credential the backup job could have presented to build-07 between 22:11 and the freeze.

Verification

Prove the fix from a channel that the compromised name cannot influence. Read the running host key fingerprint of the real bastion-2 from its console or serial session, using ssh-keygen with the minus l flag against the host key file on disk, and compare it by eye with the inventory record and with the known_hosts line you kept. Resolve the name again from a second resolver and confirm it now returns the address the inventory holds for bastion-2 rather than the one it holds for build-07. Reconnect to web-04 with the client in verbose mode and confirm the session establishes with no banner and no prompt, which demonstrates that the replaced entry matches rather than that the warning was suppressed. Run the deploy pipeline against a single host and confirm it completes rather than skipping the SSH step. Re-enable the backup job with strict checking restored and confirm that it now fails loudly against a host whose key you have deliberately not recorded, because a job that cannot fail on a changed key cannot report one. Finally, confirm the rotated credentials are live by authenticating with the new material and confirming the old material is refused.

Prevention

Give hosts an identity that survives a rebuild. An SSH host certificate authority replaces per host trust on first use with one trusted CA key distributed through a known_hosts line carrying the cert-authority marker, after which a rebuilt host presents a freshly issued certificate and no client sees a warning at all. Issue host certificates with a bounded validity, thirteen weeks in this estate, with renewal attempted at nine weeks, a warning at three weeks remaining and a page at one week. Record the first boot fingerprint of every build into the inventory automatically and make that record the documented source of truth in the runbook, not a report nobody reads. Add a continuous integration check that fails any merge introducing StrictHostKeyChecking set to no or off in a script, playbook or unit file, and allow accept-new only inside a named bootstrap path with a comment explaining the exposure window. Make the zone generation job diff its output against the previous run and refuse to publish when an existing name changes address without a matching change record. Alert on any edit to a shared known_hosts file. Review the estate quarterly for names whose address has moved without a build record.

Reported symptoms

The estate is two hundred and forty Linux hosts across three racks, reached over SSH by a deploy pipeline, a nightly backup job and about fifteen engineers. Host keys have always been accepted on first contact and never thought about again. The last time anybody edited a known_hosts file by hand was during a rack migration two years ago.

At 08:14 UTC the deploy pipeline stopped at its first step. The incident channel filled up faster than the facts did:

  • 08:14, the pipeline logs the changed host identification banner for web-04.lab.example and exits non-zero before any change is made.
  • 08:17, an engineer notes that web-04 was rebuilt at 22:10 the previous night and proposes clearing the entry and moving on.
  • 08:25, a second engineer tries bastion-2.lab.example by hand and gets the same banner, at which point the working theory becomes a fleet-wide known_hosts corruption.
  • 08:31, somebody points out that the backup job has been reporting success all night, which is offered as proof that SSH is fine.
  • 08:39, a third engineer starts writing a loop that would have run ssh-keygen -R across every name in the inventory.

That loop was stopped one minute before it ran. What nobody had yet noticed was that only one of the two hosts had been rebuilt, that the backup job had not been fine since 22:11 the previous evening, and that the three week old ticket about agent forwarding no longer working was the same incident seen from a different angle.

Evidence provided

Read-only / SafeDeploy runner, 08:14 UTC: the pipeline refuses to proceed and prints the key it was offered
$ ssh -o BatchMode=yes deploy@web-04.lab.example true
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
The fingerprint for the ED25519 key sent by the remote host is
SHA256:/hOjaMxlXaTaWekzkFCIxR4PRU/c7eLkooB565Y7X6I
Please contact your system administrator.
Add correct host key in /home/deploy/.ssh/known_hosts to get rid of this message.
Offending ED25519 key in /home/deploy/.ssh/known_hosts:14
Host key for web-04.lab.example has changed and you have requested strict checking.
Host key verification failed.

Illustrative output

Read-only / SafeEngineer laptop, 08:25 UTC: the same banner, a different line and a different key
$ ssh -o BatchMode=yes deploy@bastion-2.lab.example true
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
The fingerprint for the ED25519 key sent by the remote host is
SHA256:9Qk2rTNvXcE0aLpYd7sBHf4uMwZ1jRoK6bViGtA3nXQ
Please contact your system administrator.
Add correct host key in /home/deploy/.ssh/known_hosts to get rid of this message.
Offending ED25519 key in /home/deploy/.ssh/known_hosts:9
Host key for bastion-2.lab.example has changed and you have requested strict checking.
Host key verification failed.

Illustrative output

Note what the banner does and does not say. It names the offending line so you can find it, and it tells you to add the correct host key. It does not tell you which key is correct, and upstream OpenSSH does not offer to remove the entry for you. If your terminal showed a helpful command to delete the line, that hint came from a distribution patch, not from the software that just refused the connection.

The provisioning system captures the host key fingerprint of every build at first boot and files it against the build record. Nobody in the channel knew this existed.

# inventory export: host_key_provenance, 2026-08-26T08:44Z
name                    address          first_boot            ed25519_fingerprint
build-07.lab.example    198.51.100.24    2026-08-25T22:06Z     SHA256:9Qk2rTNvXcE0aLpYd7sBHf4uMwZ1jRoK6bViGtA3nXQ
web-04.lab.example      198.51.100.31    2026-08-25T22:14Z     SHA256:/hOjaMxlXaTaWekzkFCIxR4PRU/c7eLkooB565Y7X6I
bastion-2.lab.example   198.51.100.11    2026-02-03T09:52Z     SHA256:6x39cg8OAp7PZgisHreCLRO7g0vp6s0VZIw+DcTgKtE

Two rows settle the incident and one row is the incident. The web-04 fingerprint recorded four minutes after its rebuild is character for character the key that web-04 is sending now. The bastion-2 record is six months old, has no build since, and does not match what the name is answering with. The key the name is answering with belongs to build-07.

The internal zone is generated from the same provisioning database by a nightly job.

; /var/named/generated/lab.example.zone  (serial 2026082504)
build-07        IN  A   198.51.100.24
bastion-2       IN  A   198.51.100.24
web-04          IN  A   198.51.100.31
Read-only / SafeAny client, from a second resolver: confirm the name resolution independently of the zone file on disk
$ getent hosts bastion-2.lab.example build-07.lab.example

The backup job never saw a banner because it was told not to care.

# /etc/backup/ssh.conf, unchanged since 2026-04-02
Host *.lab.example
    User backup
    StrictHostKeyChecking no
    ForwardAgent yes
# /var/log/backup/run-20260825.log, first connection after the zone regeneration
2026-08-25T22:11:07Z connecting bastion-2.lab.example
2026-08-25T22:11:07Z ssh: Password authentication is disabled to avoid man-in-the-middle attacks.
2026-08-25T22:11:07Z ssh: Keyboard-interactive authentication is disabled to avoid man-in-the-middle attacks.
2026-08-25T22:11:07Z ssh: Agent forwarding is disabled to avoid man-in-the-middle attacks.
2026-08-25T22:11:07Z ssh: Port forwarding is disabled to avoid man-in-the-middle attacks.
2026-08-25T22:11:08Z transfer started
2026-08-25T22:11:44Z run complete, exit 0

Work the evidence before reading on

The interesting thing here is not that two hosts changed their keys. It is that the two changes have nothing in common except the message they produced, and that the message is identical whether the cause is a rebuild you authorised or a name that now points somewhere else.

  1. The banner for web-04 prints a fingerprint. Where else in the evidence does that exact string appear, and what does the timestamp next to it tell you about whether the change was authorised?
  2. The banner for bastion-2 prints a different fingerprint. Search the inventory export for it. What is it attached to?
  3. Two names in the generated zone share one address. Which of the two is wrong, and how do you tell from the inventory rather than by guessing?
  4. The backup log shows four notices at 22:11 and then a successful transfer. What did the backup account authenticate to, and what did it offer while it was doing so?

Before continuing: for each of the two hosts, say whether the key it is presenting is the right key, and name the single line of evidence that settles it. If you cannot, you must not edit known_hosts yet.

Root cause

The zone regeneration wrote one host address under another host name

build-07 was rebuilt at 22:02. The job that regenerates lab.example from the provisioning database ran at 22:08 and emitted bastion-2 with 198.51.100.24, the address belonging to build-07. From that moment every client resolving bastion-2.lab.example connected to a continuous integration worker. The changed host key warning at 08:25 was therefore completely correct and had nothing to do with any rebuild. The fingerprint in that banner, SHA256:9Qk2rTNvXcE0aLpYd7sBHf4uMwZ1jRoK6bViGtA3nXQ, is the build-07 row in the inventory export, recorded at 22:06 that same evening. Deleting line 9 of known_hosts would have replaced a true warning with a permanent silent misdirection.

The rebuild of web-04 was genuine, and the estate could not say so

web-04 really was rebuilt at 22:10, and its first boot fingerprint recorded at 22:14 is the key it is presenting now. That is a benign change. The defect is that nothing in the connection path could establish it. Trust on first use records what a host said the first time anybody asked, which answers the question is this the same key as last time but never the question is this the right key. The inventory held the answer all along, and the runbook did not mention it, so five engineers spent twenty-five minutes debating a fact that was already written down.

The backup job had been told to accept exactly this

StrictHostKeyChecking no did not skip a prompt. It let the backup account authenticate to build-07 believing it was bastion-2, which is why the client emitted four separate notices at 22:11 saying which authentication methods it was withdrawing. Those notices are the whole content of the three week old agent forwarding ticket. The backup account carries a key that reaches every host in the estate, and it offered that key, with agent forwarding requested, to a build worker that any pipeline author can run code on.

Resolution

  1. Freeze the exposure before you diagnose anything. Pause the deploy pipeline and stop the backup timer so that no further connection offers a credential to a peer you have not identified. The pipeline has already failed safe; the backup job has not, and it will run again on schedule.
  2. Copy the affected known_hosts files aside, with cp -a, before any tool writes to them. Note the line numbers the banners named, 14 for web-04 and 9 for bastion-2, and record the fingerprint each banner printed. This is your only record of the prior state.
  3. Classify each host independently against the provisioning inventory, which is an out of band channel the network cannot influence. A fingerprint that matches a first boot record with a build time you can corroborate is a rebuild. A fingerprint that matches no record for that name is an unresolved identity, whatever the name suggests.
  4. For web-04, whose presented key matches the record written at 22:14, replace only line 14. Remove the stale entry with ssh-keygen -R web-04.lab.example and append the recorded key from the inventory. Take the key material from the provisioning record, never by scanning the live host, because scanning proves only that something on the network is willing to answer.
  5. For bastion-2, do not connect and do not edit line 9. Reach the real machine at 198.51.100.11 through the console or serial path and confirm which host is actually running there. Open a security case: a privileged account authenticated to a build worker under another name for ten hours.
  6. Fix the zone generation template so the name field and the address field are read from the same inventory row, regenerate the zone, bump the serial and confirm that bastion-2 and build-07 no longer share an address. Until this lands, every client that resolves the name is still being misdirected.
  7. Rotate every credential the backup account could have presented between 22:11 on 25 August and the freeze. That is the backup SSH key itself, anything reachable through the forwarded agent, and any token the transfer script read from its environment. Treat them as disclosed, because a pipeline author on build-07 could have read them.
  8. Restore strict checking in the backup wrapper. Remove StrictHostKeyChecking no outright rather than lowering it, and do not substitute accept-new here: this job connects to hosts it has met before, so first contact handling is not what it needs. Where genuine first contact automation exists elsewhere, accept-new is the correct setting and must be documented with the window it leaves open.

Verification

  1. Read the running host key of the real bastion-2 from its console session with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub and compare it by eye with the inventory record and with the copy of line 9 you preserved. This reads the key from the machine itself rather than from anything the network offered.
  2. Resolve both names from a resolver other than the one the incident used, and confirm each returns the address its own inventory row holds. Two names returning one address is the defect; one name returning its own address is the fix.
  3. Reconnect to web-04 with ssh -v and confirm the session establishes with no banner and no prompt. A silent success proves the entry matches; a suppressed warning would not, which is why nothing in this procedure disables checking.
  4. Run the deploy pipeline against a single host and confirm it completes the SSH step rather than skipping it. Check the exit status, not just the absence of a red line in the log.
  5. Re-enable the backup job with strict checking restored, point a test run at a host whose key you have deliberately not recorded, and confirm it fails. A job that cannot fail on a changed host key can never report one, so proving the failure path is part of proving the fix.
  6. Authenticate with each rotated credential and confirm the superseded material is refused. Rotation that was never tested against the old value is an assumption, not a control.
  7. Confirm the agent forwarding ticket is resolved by the same change, and close it against this incident rather than separately. If it still reproduces, the wrapper configuration was not the only place the setting appeared.

Prevention

  • Give hosts an identity that survives a rebuild. An SSH host certificate authority replaces per host trust on first use with one CA key distributed as a known_hosts line carrying the @cert-authority marker. A rebuilt host presents a freshly issued certificate and no client sees a warning, because the client is checking a signature rather than remembering a key.
  • Bound the certificate, then alert on it. Issue host certificates with a thirteen week validity, renew at nine weeks, warn at three weeks remaining and page at one week. A certificate authority with no expiry policy is a long lived key with extra steps.
  • Make the provenance record authoritative, not incidental. Capture the first boot fingerprint automatically, store it beside the build record, and name that record in the runbook as the thing an operator consults before editing known_hosts.
  • Fail the merge, not the incident. Add a continuous integration check that rejects StrictHostKeyChecking no or off in any script, playbook or unit file, and permit accept-new only in a named bootstrap path with a comment stating the exposure window.
  • Diff generated infrastructure before publishing it. The zone job should compare its output against the previous run and refuse to publish when an existing name changes address without a matching change record. This one control would have stopped the incident at 22:08.
  • Watch the trust files themselves. Alert on any edit to a shared known_hosts file, and review quarterly for names whose address has moved with no build behind it.