Skip to main content
RunBook Academy

← All break/fix scenarios in VyOS

advancedvyos-mgmt~45 min

Router Credentials Compromised

Reported symptoms

  • The nightly configuration-drift job raised a ticket: three commits landed on edge1 between 03:04 and 03:15, inside a declared change freeze, with no change record and nobody from the on-call rota working
  • All three archive entries are attributed to user `vyos`, the shared account the automation also uses, so the first theory on the bridge was a stray pipeline run that nobody had scheduled
  • The central syslog collector shows a gap for edge1 that begins at 03:15 and is still open; the logging team say their ingest never stopped and point at the router
  • The management chain has produced no default-deny log entries since roughly 03:04, which the NOC opened as a separate ticket titled "firewall logging broken on edge1"
  • `show log auth.log` shows no unexpected SSH authentication anywhere in the window — the on-call engineer checked this first, found it clean, and downgraded the ticket to P4
  • The router is forwarding normally: both eBGP sessions up, no customer reports, every dashboard green, which is the second reason the ticket was downgraded
  • A read-only monitoring account has been failing its SSH check intermittently since 03:20, and the monitoring team have already attributed that to their own collector restart the same evening

Evidence

  • · `show configuration commit` — three entries at 03:04:11, 03:09:47 and 03:15:02, all with user `vyos`, and a source address that matches no operator workstation and no automation host in the estate inventory
  • · `compare 3`, `compare 2` and `compare 1` — three small diffs, each of which looks defensible on its own and only reads as a sequence when the three are laid out together
  • · The oldest of the three adds `set firewall ipv4 name MGMT-LOCAL rule 5 action accept` with a single source address, at a rule number below the existing logging drop rule
  • · The middle one adds a second entry under `set system login user vyos authentication public-keys`, with an identifier styled to resemble the operator key already there
  • · The newest one is a `delete system syslog host` for the central collector, timestamped to the same second the collector-side gap begins
  • · `show log auth.log` across the window — clean, with no SSH authentication from any address outside the operator allow-list
  • · `show log | match vyos-http-api` — POST /configure entries at each of the three commit times, from that same source address, authenticated as key id `ci-runner-legacy`, each returning 200
  • · `show configuration commands | match "service https"` — the listener is on its default address rather than the management VRF, and two key ids exist: `ansible-prod` and `ci-runner-legacy`
  • · The asset inventory records the CI runner that owned `ci-runner-legacy` as decommissioned fourteen months ago; nothing has consumed that key since
  • · `show firewall` — the MGMT-LOCAL default-deny counter is flat since 03:04 while the new rule 5 counter is advancing
Diagnosis and resolutionclick to reveal

Root cause

An API key created fourteen months ago for a CI runner, and never deleted when that runner was decommissioned, was still valid; and the HTTPS service was still on its default listener rather than bound to the management VRF. Either fact alone is survivable. Together they are a route into the configuration tree from anywhere that can reach the router's public address, held open by a credential whose distribution nobody controls any more — that key string lived in the runner's environment and in a repository that was archived rather than purged. Whoever holds it used the `/config` endpoint to push three small changes through the same commit engine an operator's `configure` session uses, which is why the configuration archive recorded all three and why the router's forwarding behaviour is otherwise completely normal. The three changes are one coherent sequence rather than three faults: an accept rule inserted below the management chain's logging drop rule so that this source stops being recorded, a second public key added to the shared `vyos` account so that access survives the API key being revoked, and the removal of the remote syslog host so that further evidence stops leaving the box. The clean `auth.log` contradicts none of it. The HTTP API is not SSH and does not write to that file; a negative result there was read as the absence of access when it was only the absence of one kind of access.

Remediation

Decide containment before touching anything, and record the decision. Isolating edge1 is a customer-visible outage; keeping it in service while you work means accepting that an unknown party may still hold a path onto it. Holding is a legitimate answer here — the router carries production traffic and the intruder has so far only manipulated the management plane — but it is a decision with a cost, so it needs a named owner and an end time rather than being what happens because nobody said otherwise. Once that is settled, understand why rollback is not the remediation. `rollback 3; compare; commit` reverses the three changes and feels like closure, but the credential that let them in was created long before the oldest surviving archive entry, so every configuration you can roll back to still contains it. Revoke first, explicitly: `delete service https api keys id ci-runner-legacy`, `delete` the unrecognised public key from the `vyos` account, then commit — using `commit-confirm` if you are working over the same path you are changing. Then restore the evidence channel with the syslog host and put the management chain's rule ordering back. Only after that is it worth moving the listener onto the management VRF and adding a source restriction, because those are hardening rather than containment and they do nothing about a key someone already holds. Finally, be honest about the limit: with remote logging off since 03:15 and a bounded local archive, you cannot prove from this router alone what happened after that point. If the exposure window cannot be bounded from off-box evidence, the correct outcome is a rebuild from a known good configuration, not a clean-up in place.

Verification

Verify from somewhere other than the router wherever the check allows it, because a box you no longer fully trust is a poor witness about its own state. The collector is the strongest single check: edge1's messages must be arriving again and the gap must have a definite end, confirmed at the collector rather than by reading the router's own configuration. Confirm the revoked key is actually dead by presenting it and getting 401 rather than by observing that it is absent from the configuration. Confirm `show system login user vyos authentication public-keys` lists only keys whose owners you can name, and get that list confirmed by the owners rather than by recognising the comment field, which is attacker-controlled text. Confirm the management chain is recording again by watching the default-deny counter advance under a deliberate probe from a disallowed source, not by reading the rule order back. Then widen the scope: diff every archive entry, not only the three you already know about, because the three you found are the ones that produced symptoms and there is no reason to assume they are the only ones. Finally, record what you could not verify. If the window after 03:15 has no off-box evidence covering it, that is a finding to write down, not a gap to fill with the assumption that nothing else happened.

Prevention

The two controls that would have prevented this are unglamorous and cheap. One credential per consumer, each with a named owner and an expiry, so that decommissioning a system is what deletes its key rather than something a checklist has to remember; a key with no living owner is exactly the key nobody rotates. And a listener bound to the management VRF or to localhost, so that possession of a leaked key is not sufficient on its own — the holder also has to be on the management network. Stack a source restriction on top and the key becomes one factor among several rather than the whole door. Beyond that, make the estate capable of noticing. Nothing was watching commits; this was found because a monitoring check unrelated to the incident flapped and made somebody open the router. A commit outside a change window is a high-signal, low-volume alert, and the archive listing already carries the timestamp, the user and the source address needed to raise it. Export syslog to two collectors so that deleting one host does not create a silent gap, and alert on the absence of messages rather than only on their content — a router that goes quiet is telling you something. Retire the shared `vyos` account for interactive use in favour of named accounts, so the user column in the archive answers a question instead of raising one. Raise `system config-management commit-revisions` above the default of twenty on any router that matters, since the archive is your incident timeline and the default retention is measured in changes rather than in days. And reconcile the credential inventory against the asset inventory on a schedule: the question that closes this class of incident is not "is this key strong" but "who owns the thing that uses it, and does that thing still exist".

Reported symptoms

edge1 is a VyOS border router with two eBGP sessions, a management VRF, and an HTTP API that a nightly Ansible compliance run uses to read its configuration. The estate is in a declared change freeze for the quarter close.

At 09:20 on Tuesday the configuration-drift job raised a ticket. Three commits landed on edge1 overnight — 03:04, 03:09 and 03:15 — against no change record, with nobody from the on-call rota working. All three are attributed to user vyos, which is the shared account the automation also authenticates as, so the opening theory on the bridge was a pipeline that had run on a schedule somebody had forgotten about.

Three more tickets were already open, each filed by a different team as its own problem:

  • The central syslog collector has a gap for edge1 beginning at 03:15 and still open. The logging team have checked their ingest, found it healthy for every other source, and pushed the ticket back with “router-side or WAN”.
  • The management chain has logged no default-deny hits since about 03:04. The NOC raised it as “firewall logging broken on edge1” and it is sitting in a queue.
  • A read-only monitoring account’s SSH check has been flapping since 03:20. The monitoring team have already attributed that to their own collector restart that evening.

The on-call engineer’s first move was the obvious one: look for unexpected logins. show log auth.log across the whole window is clean. No SSH authentication from anything outside the operator allow-list, successful or failed. Combined with the fact that the router is forwarding perfectly — both sessions up, no customer reports, dashboards green — the ticket was downgraded to P4 and left for the morning.

Evidence provided

Read-only / Safethe user column is useless here; the source-address column is not
vyos@edge1:~$ show configuration commit
18  2026-08-18T03:04:11+01:00   vyos   192.0.2.77    config.20260818-030411.boot
19  2026-08-18T03:09:47+01:00   vyos   192.0.2.77    config.20260818-030947.boot
20  2026-08-18T03:15:02+01:00   vyos   192.0.2.77    config.20260818-031502.boot

Illustrative output

Read-only / Safenothing between 22:31 and 06:02 — the result that downgraded the ticket
vyos@edge1:~$ show log auth.log
Aug 17 22:31:08 edge1 sshd[4412]: Accepted publickey for vyos from 203.0.113.50 port 51122 ssh2
Aug 18 06:02:19 edge1 sshd[7781]: Accepted publickey for monitoring from 10.0.0.60 port 44980 ssh2

Illustrative output

Read-only / Safea different door, and a different log file
vyos@edge1:~$ show log | match vyos-http-api
Aug 18 03:04:11 edge1 vyos-http-api: INFO [api] POST /configure from 192.0.2.77 (key ci-runner-legacy, user vyos) - 200 OK
Aug 18 03:09:47 edge1 vyos-http-api: INFO [api] POST /configure from 192.0.2.77 (key ci-runner-legacy, user vyos) - 200 OK
Aug 18 03:15:02 edge1 vyos-http-api: INFO [api] POST /configure from 192.0.2.77 (key ci-runner-legacy, user vyos) - 200 OK

Illustrative output

Read-only / Safetwo keys, one consumer, and a listener that was never moved
vyos@edge1:~$ show configuration commands | match 'service https'
set service https api keys id ansible-prod key '****'
set service https api keys id ansible-prod user 'vyos'
set service https api keys id ci-runner-legacy key '****'
set service https api keys id ci-runner-legacy user 'vyos'
set service https listen-address '0.0.0.0'
set service https port '8443'

Illustrative output

The three diffs, taken with compare 3, compare 2 and compare 1, are each about two lines long:

Read-only / Saferule 5 — below the logging drop rule, which is rule 10
vyos@edge1# compare 3
+ firewall ipv4 name MGMT-LOCAL rule 5 action accept
+ firewall ipv4 name MGMT-LOCAL rule 5 source address 192.0.2.77

Illustrative output

Read-only / Safethe identifier is attacker-chosen text and is styled to be unremarkable
vyos@edge1# compare 2
+ system login user vyos authentication public-keys ops-backup type ssh-ed25519
+ system login user vyos authentication public-keys ops-backup key AAAAC3NzaC1lZDI1NTE5AAAA...

Illustrative output

Read-only / Safe03:15:02 — the same second the collector-side gap begins
vyos@edge1# compare 1
- system syslog host 203.0.113.100 facility all level info
- system syslog host 203.0.113.100 protocol tls

Illustrative output

Work the evidence before reading on

There are four open tickets and one strong negative result. Before reading on, work out how many of them describe the same event.

  1. auth.log is clean for the whole window, and three commits landed inside it. Both statements are true at once. What has to be the case for that to be possible, and what does it tell you about which log you should have read?
  2. The syslog gap begins at 03:15:02 and the third commit is timestamped 03:15:02. Is the gap a symptom of a broken collector, or is it the change itself? What would you check to tell those apart, and can you check it from this router?
  3. Rule 5 accepts. Rule 10 drops and logs. Which one fires, and what does that do to the ticket the NOC raised about broken firewall logging?
  4. Every archive entry carries a user, and the user on all three is vyos. Why does that column answer nothing here, and what would have to change about the estate for it to answer something?
  5. The three diffs total six lines and none of them touches forwarding. Why would an intruder with full configuration access make changes this small?

Then the question that decides how the rest of the incident is run: if you revert all three commits, what is the state of the access that produced them?

Root cause

1. Two survivable mistakes that are not survivable together

The first is a credential that outlived its consumer. ci-runner-legacy was created fourteen months ago for a CI runner that was decommissioned three months later. The runner is gone; the key is still in the configuration and still valid. Nobody rotated it, because rotation is a task that belongs to an owner and this key’s owner stopped existing. Its string had lived in the runner’s environment variables and in a repository that was archived rather than purged, so who holds it now is not a question anyone can answer.

The second is a listener that was never moved. service https listen-address is still on its default, so the API answers on every address the router holds, including the public ones.

On their own, each is a finding in an audit. Together they are an unauthenticated person’s route to the configuration tree, gated by a string that is no longer controlled.

2. The API and the CLI arrive at the same commit engine

This is why the evidence looks the way it does. An operator in configure and an HTTPS request to /config reach the same configuration tree and the same commit engine; the API is a different door into the same room. Everything downstream of the commit follows from that: the validators ran, the archive entry was written, the diff is inspectable with compare, and the router carried on forwarding because nothing about forwarding was touched.

It is also why the archive is the one place worth looking first on a router you suspect. Access paths write to different logs and some of those logs can be turned off, but every path that changes the configuration converges on the commit engine, and the commit engine writes an archive entry. “Who logged in” is a question with several answers in several places. “What changed, when, and from where” has one.

3. The three commits are one sequence

Read together rather than as three tickets, the changes are ordered the way an intruder orders them.

rule 5 accept from a single source, inserted below the rule that drops and logs. Firewall rules evaluate in sequence and the first match wins, so the logging rule is no longer consulted for that source. This is not a permission the intruder needed — they already had the API — it is the removal of a record. The NOC’s “firewall logging broken” ticket is this line, seen from the outside.

An extra public key on the shared vyos account. Access via SSH now exists independently of the API key, so revoking the key alone leaves the door open. The identifier ops-backup is text the intruder chose; it is designed to survive a skim of the configuration.

The syslog host deleted. From 03:15:02 the box stops reporting off-site. Every remaining record is local, which means every remaining record is under the intruder’s control. The collector-side gap the logging team pushed back on is not a collector problem and never was — it is the third commit, observed from the one place the intruder could not reach.

4. The clean auth.log

auth.log records SSH authentication. No SSH authentication happened. The file is accurate and the conclusion drawn from it was wrong, because the question being asked was “did anyone get in” and the file only answers “did anyone get in this way”.

The monitoring check flap is the one report here that is genuinely unrelated. The monitoring team’s explanation was correct the first time. It matters only because it is what made somebody open the router at all — the intrusion was found by coincidence, which is a finding about the estate rather than about the intruder.

Resolution

  1. Decide containment first and write the decision down. Pulling edge1 out of service is a customer-visible outage; leaving it in service accepts that an unknown party may still have a path onto it. Holding is defensible while the intruder has only touched the management plane, but it is a decision with a cost, so it gets a named owner and an end time rather than becoming the default because nobody said otherwise.
  2. Do not treat rollback as the remediation. rollback 3; compare; commit reverses the three changes, but the credential that admitted them was created long before the oldest surviving archive entry — every configuration you can roll back to still contains it. Rollback here removes the intruder's changes and leaves the intruder's access.
  3. Revoke the credential explicitly and by id: delete service https api keys id ci-runner-legacy. Then remove the added public key with delete system login user vyos authentication public-keys ops-backup. Commit both together, and use commit-confirm if you are working over the same path you are changing.
  4. Restore the evidence channel before anything else that is optional: put the syslog host back, and confirm at the collector — not on the router — that messages are arriving again. Until that is true, everything you do next is being recorded only where the intruder can reach it.
  5. Put the management chain back in order so that the logging drop rule is consulted again for every source, and verify the ordering by observing a drop rather than by reading the configuration.
  6. Only now do the hardening: move service https listen-address onto the management VRF address, add set service ssh source-restrict ip-address for the operator networks, and rotate ansible-prod as well, because it has been sitting on the same reachable listener as the key that was used.
  7. Widen the scope beyond the three commits you know about. Diff every archive entry you still have, not only the three that produced symptoms, and check the other routers that shared the same CI runner era for the same key id.
  8. Bound the exposure window from off-box evidence — collector history, netflow, the partner-side records — and if it cannot be bounded, stop cleaning up in place. A router whose post-03:15 history cannot be reconstructed is rebuilt from a known-good configuration, not repaired.

Verification

  1. The revoked key is dead, demonstrated rather than assumed. Present ci-runner-legacy to the API and confirm a 401. Its absence from show configuration commands says the configuration no longer contains it; only a rejected request says the router no longer accepts it.
  2. The account carries only keys with living owners. show system login user vyos authentication public-keys lists them, and each is confirmed by the person who holds the private half — not by recognising the identifier, which is text the intruder was free to choose.
  3. The collector is receiving again and the gap has a definite end. This check is performed at the collector, because it is the one piece of evidence in this incident that a party with commit rights on edge1 could not have staged.
  4. The management chain records again. Probe it deliberately from a disallowed source and watch the default-deny counter advance. Reading the rule order back from the configuration confirms your edit, not the behaviour.
  5. Every archive entry has been diffed, not only the three. The three you found are the three that produced visible symptoms, which is not a reason to believe they are all there were.
  6. The exposure window is stated, with its source. "Nothing else happened" is a claim that needs off-box evidence covering the period; if you do not have that evidence, the finding is that the window is unbounded.
  7. The decision record exists outside the incident channel: what was held, who owns it, when it is reviewed, and what was rebuilt rather than repaired.

Prevention

  • Give every credential an owner and an expiry. One key per consumer was already the pattern here and it did not help, because the control that was missing sits at the other end of the lifecycle: decommissioning the consumer must be what deletes the key. An expiry makes neglect fail closed instead of failing open.
  • Take the listener off the default address. Bind the API to the management VRF or to localhost and add a source restriction, so that holding a leaked key is necessary but not sufficient. This is the control that would have made the fourteen-month-old key harmless.
  • Alert on commits, not on people. A commit outside a change window is rare, cheap to detect and very hard for an intruder to avoid, because every path to the configuration passes through the commit engine. The archive listing already carries the timestamp, the user and the source address the alert needs.
  • Export syslog to two collectors, and alert on silence as well as on content. A single export host is a single line an intruder can delete; a router that stops reporting is telling you something, and here nobody was listening for that.
  • Retire the shared vyos account for interactive use. Named accounts turn the archive’s user column from a dead end into an answer, and they let a single person’s access be revoked without breaking the automation.
  • Raise system config-management commit-revisions above the default of twenty on any router that matters. The archive is your incident timeline and the default retention is measured in changes rather than in days, so a busy router can age its own evidence out before anyone looks.
  • Reconcile the credential inventory against the asset inventory on a schedule, and make the question “does the thing that uses this still exist” rather than “is this key strong enough”.