Skip to main content
RunBook Academy

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

intermediatekey-compromise~40 min

A static deployment token is being used from an address that is not yours

Reported symptoms

  • The morning review of denied requests at 09:20 UTC shows a burst of 214 denials in ninety seconds, all carrying the same token accessor
  • The denials are timestamped 03:41 UTC, a period in which the CI system that owns the token ran no jobs at all
  • The remote address on every one of those records is 203.0.113.47, which is outside the CI egress range and outside every range the organisation owns
  • Five requests from the same address at 03:44 UTC were allowed, and the paths they read are production deployment credentials
  • The CI pipelines are all green and nothing has failed, which is the reason nobody looked earlier
  • The token has no expiry, was created fourteen months ago during the secret-manager migration, and its creator has left the organisation

Evidence

  • · The audit log contains 214 response records at 03:41 UTC for one accessor with policy_results allowed false, each on a different path under kv/data
  • · The same accessor has five records at 03:44 UTC with policy_results allowed true, naming the exact paths that were read
  • · Every one of those 219 records carries remote_address 203.0.113.47; the CI egress range is 198.51.100.0/24
  • · A grouping of the whole audit log by remote address for that accessor shows 41,882 requests from the CI range over fourteen months and 219 from the unexpected address in one three-minute window
  • · The same grouping run across every other accessor returns no requests at all from 203.0.113.47, so the exposure is one credential and not the platform
  • · A token lookup on the accessor returns ttl 0, an empty expire_time and the policy set deploy-read and default
  • · The audit records show the secret values HMACed, so the log establishes which paths were read and cannot establish what the values were
  • · A search of the configuration-management repository, the CI secret store and the internal wiki finds the same token value in four places
Diagnosis and resolutionclick to reveal

Root cause

Two defects, and the second one is the reason this is a three-minute window rather than a three-minute incident. The first is the credential itself. It is a bearer token with no expiry and no binding to a caller, which means possession is the entire authentication decision: anyone holding those bytes is the CI system as far as the secret manager is concerned, from anywhere, forever. It was created during a migration fourteen months ago as a temporary measure, and it was distributed by being copied, into the CI secret store, into two configuration-management variable files and into a wiki page written to help the next person do the migration. Its blast radius is therefore not the CI system. It is the union of every system it was pasted into, every backup of those systems, every laptop that cloned that repository and every reader of that wiki page. That is what made compromise possible and it is what makes the leak vector genuinely hard to establish after the fact. The second defect is that the audit log was written faithfully and read by nobody. Use from an unexpected address at an hour when the owning system was idle is about as clear a signal as an audit log can produce, and it sat unexamined for five and a half hours. It was ultimately noticed only because the attacker enumerated paths and tripped the policy 214 times; a quieter attacker who read only what the policy allowed would have generated five successful records that looked exactly like ordinary work.

Remediation

Preserve the audit record before changing anything. Copy the relevant period to storage the incident team controls and that the platform cannot overwrite. This takes seconds, it is the only complete account of what happened, and every subsequent step alters the system that produced it. Then revoke, and revoke rather than plan to rotate. Revoking a token by its accessor is immediate and effective here, because the verifier is your own server, the check happens on every request, and there is no cache anywhere that can honour the old credential; this is a genuinely different situation from revoking a public TLS certificate and it is worth being conscious of the difference. Do not leave the credential live to observe the intruder unless an accountable owner has made that decision explicitly and time-boxed it. Next, establish usage from the audit log: the allowed records name every path that was read, and each of those secrets is now compromised in its own right and joins the rotation queue. Then establish distribution, which is the step teams skip. Enumerate every place the token value was written and remove it from all of them, including repository history and the wiki. A rotation that leaves a copy behind has rotated nothing. Issue the replacement in a different shape: bounded lifetime, bound to a caller, obtained by the workload at run time rather than pasted by a human.

Verification

The audit log is the independent channel, and it is the one to use, because it is written by the server rather than reported by the client. After revocation it must contain zero records for the old accessor with a successful result, and any further attempt must appear as a refusal. Prove that actively rather than by waiting: replay the old credential from a host you control and confirm the request is refused, then confirm that refusal appears in the log where you expect it. Confirm the CI pipelines run green with the new credential, and confirm from the audit log that the new accessor appears only from the CI egress range, because a replacement that is already being used from somewhere unexpected has inherited the original defect. For each secret named in the allowed records, confirm rotation by reading its version metadata rather than by asking the owning team whether they did it. Confirm the old value no longer appears anywhere: run the search across the configuration repository including its history, the CI secret store and the wiki, and record the result. Finally, confirm the new detection works by making a request from an address outside the expected range and waiting for the alert, rather than by reviewing the rule and agreeing with it.

Prevention

No static credential without a lifetime. A token that never expires converts every future copy of itself into a permanent liability, and the copies are made by well-intentioned people solving real problems at the time. Bind the credential to a caller so that possession alone is not sufficient: a source address range, a workload identity, or an authentication method that requires the caller to prove something it cannot paste into a wiki. Make issuance the only distribution path, so the workload fetches its credential at start-up and no human ever holds the value; this removes the four-location problem at its source rather than policing it. Alert on the shape of use, not only on its success: a burst of denials from one accessor is an enumeration signature and should page within minutes, and use from outside the expected source range should page regardless of whether it succeeded. Read the audit log, or automate something that does, because an audit log nobody reads is storage rather than a control, and this incident was detected by a human doing a weekly review five and a half hours late. Keep an inventory of every long-lived credential with a named owner and an expiry date, and treat an entry with no expiry as an open finding rather than a fact. And record, at issuance time, every system a credential will be distributed to, so the rotation runbook does not have to reconstruct that list under pressure.

Reported symptoms

The secret manager has been in production for fourteen months. It holds about nine hundred secrets across forty applications, and the CI system reads a small subset of them to deploy. Nothing has gone wrong with it. That is the context for what follows, because nothing is going wrong now either, in the sense that everything still works.

At 09:20 UTC an engineer runs the weekly review of denied requests. It is a fifteen-minute job that usually produces two or three misconfigured applications. This morning it produces something else:

  • 214 denials in ninety seconds, all carrying the same token accessor, all on different paths.
  • Timestamped 03:41 UTC, in a window when the CI system that owns that token ran no jobs at all.
  • Sourced from 203.0.113.47. The CI egress range is 198.51.100.0/24. This address is not in any range the organisation owns.
  • Five more records from the same address three minutes later, and those five were allowed. The paths they read are production deployment credentials.
  • Every pipeline is green. Nothing has failed, nothing has alerted, and no user has noticed anything at all.

The token was created fourteen months ago during the migration onto the secret manager, as a temporary bridge. It has no expiry. The engineer who created it left the organisation in March.

The incident is declared at 09:31 UTC, five hours and fifty minutes after the last unexpected request.

Evidence provided

The audit log is the whole investigation, and it is worth looking at a single record in full before counting anything, because its shape determines what questions can be answered.

Here is one of the 214 denials.

{"time":"2026-08-26T03:41:52.318204117Z","type":"response",
 "auth":{"client_token":"hmac-sha256:9f2c41d0ab77...","accessor":"hmac-sha256:c47e1b90d2f5...",
         "policies":["deploy-read","default"],"policy_results":{"allowed":false}},
 "request":{"operation":"read","mount_point":"kv/","mount_type":"kv",
            "path":"kv/data/platform/signing-ca","remote_address":"203.0.113.47"},
 "response":{"data":{"error":"hmac-sha256:b0a0f532..."}},
 "error":"1 error occurred:\n\t* permission denied\n\n"}

And here is one of the five that succeeded.

{"time":"2026-08-26T03:44:06.771930044Z","type":"response",
 "auth":{"client_token":"hmac-sha256:9f2c41d0ab77...","accessor":"hmac-sha256:c47e1b90d2f5...",
         "policies":["deploy-read","default"],"policy_results":{"allowed":true}},
 "request":{"operation":"read","mount_point":"kv/","mount_type":"kv",
            "path":"kv/data/deploy/registry-push","remote_address":"203.0.113.47"},
 "response":{"data":{"data":"hmac-sha256:5d1c88ae..."}}}

Read those two together and the boundaries of the investigation are already set. The log records which credential, from where, to what path, and whether the policy allowed it. It does not record the secret value: that field is HMACed, as is the token itself. So the log can tell you exactly what was read and can never tell you what the reader learned, which is why the exposure list has to be built from paths and then reasoned about.

Counting is the next step, and the count is the shape of the attack. The accessor is stored as an HMAC digest, so the mapping from a log record back to a token is done by hashing the candidate accessors with the audit device key rather than by reading a value out of the log.

Read-only / Safefourteen months of ordinary work, and one three-minute window that is not
$ jq -r 'select(.auth.accessor == "hmac-sha256:c47e1b90d2f5...") | [.request.remote_address, (.auth.policy_results.allowed|tostring)] | @tsv' /openbao/audit/audit.log | sort | uniq -c
  41882 198.51.100.19   true
  214 203.0.113.47    false
    5 203.0.113.47    true

Illustrative output

The same grouping across every other accessor is the negative result that bounds the incident.

Read-only / Safeone accessor, not the platform: this is a credential incident, not a breach of the manager
$ jq -r 'select(.request.remote_address == "203.0.113.47") | .auth.accessor' /openbao/audit/audit.log | sort -u | wc -l
1

Illustrative output

The credential itself explains why three minutes was enough.

Read-only / Safettl 0 and a null expire_time: this credential was never going to stop working
$ bao token lookup -accessor -format=json c47e1b90d2f5
{
"data": {
  "accessor": "c47e1b90d2f5",
  "creation_time": 1750843245,
  "expire_time": null,
  "policies": ["default", "deploy-read"],
  "renewable": false,
  "ttl": 0
}
}

Illustrative output

Then the question that decides how much work the remediation is: where does this value live? Read the value from the incident store rather than typing it, so that it does not land in a shell history or in the process table.

Read-only / Safetwo files in the current tree, before anyone has looked at history
$ grep -rIl -F -f /run/incident/token-value.txt /srv/config-management
/srv/config-management/group_vars/all/secrets.yml
/srv/config-management/group_vars/ci/deploy.yml

Illustrative output

Read-only / Safethe same search across history, which is the number that decides the scope
$ TOKEN=$(cat /run/incident/token-value.txt)
git -C /srv/config-management log --all --oneline -S "$TOKEN" | wc -l

Adding the CI secret store entry and the wiki page written during the migration, the value is present in four places that anybody has found so far. Nobody in the incident channel is confident that is the complete list, and that discomfort is the most important output of this step.

Work the evidence before reading on

Two questions in this incident have clean answers from the evidence and two do not, and separating them is most of the skill.

  1. The log records 214 denials followed by 5 successes from the same address. What behaviour produces that pattern, and what does it tell you about what the holder knew when they started?
  2. The token value and the secret values are HMACed in every record. Which incident questions can the audit log answer, and which can it never answer no matter how much of it you read?
  3. The negative result shows one accessor from that address and no others. What hypothesis does that eliminate, and what does it deliberately not eliminate?
  4. The token has ttl 0 and a null expiry. If it had carried a one-hour lifetime with automatic renewal, which parts of this incident would still have happened?

Before continuing: the log establishes that the credential was used from an address you do not own. State precisely what it does not establish, and name where that answer would have to come from.

Root cause

Possession was the entire authentication decision

The credential is a bearer token. There is nothing else to it. It carries no binding to a source address, no binding to a workload identity, and no expiry, so the secret manager’s authentication decision reduces to a single question: do you have the bytes.

Anyone holding those bytes was the CI system, from anywhere, for as long as the token existed, which was going to be forever. That is not a subtle design flaw. It is the property the token was created for during the migration, when the priority was getting a pipeline working and the plan was to replace it later.

Distribution by copying made the blast radius unknowable

A credential that is fetched has one location. A credential that is pasted has as many locations as people found convenient.

This one is in the CI secret store, in two configuration-management variable files, and in a wiki page written to help the next engineer perform the same migration. That is four locations before anyone has looked at repository history, at backups of those repositories, at the laptops that cloned them, at the CI job logs from before masking was configured, or at the container images built from those variable files.

This is the defect that makes the leak vector genuinely hard to determine. The audit log is definitive about use and silent about acquisition, and with four or more copies in circulation there is no single place to look. It is also the defect that makes the remediation large: revoking the token takes one command, and finding every copy takes days.

The audit log was written and never read

Use of a CI credential at 03:41 UTC, from an address in no range the organisation owns, on a night the CI system ran nothing, is close to the clearest signal an audit log is capable of producing. It sat there for five hours and fifty minutes.

There was no alert on source address, no alert on denial rate, and no alert on use outside the owning system’s schedule. The log was enabled correctly, written correctly, retained correctly, and consulted weekly by a human doing a manual review. That is a control in the sense that it eventually worked, and it is not a control in any sense that bounds an incident.

The detail worth sitting with is that the review only worked because the holder was noisy. Two hundred and fourteen denials in ninety seconds is a path enumeration and it is impossible to miss in a report of denials. A holder who read only the five paths the policy allowed would have produced five successful records indistinguishable in shape from ordinary work, and this incident would still be running.

Resolution

  1. Preserve the audit record before you change anything. Copy the incident window, with generous margins on both sides, to storage the incident team controls and the platform cannot rotate or overwrite. Record a hash of the copy. This takes seconds and everything that follows modifies the system that produced it.
  2. Revoke the credential by its accessor, and revoke rather than schedule a rotation. Revocation is immediate and complete here because the verifier is your own server, it checks on every request, and nothing caches an authorisation decision. That is a materially stronger position than revoking a public TLS certificate, where the client may never check at all, and it is worth knowing which of the two situations you are in.
  3. Confirm the revocation took effect by replaying the old credential from a host you control and observing the refusal. A revocation you have not tested is a revocation you are assuming.
  4. Build the exposure list from the allowed records. Every path in a record with a successful policy result names a secret that the holder can read, so every one of those secrets is compromised in its own right and enters its own rotation, on its own timeline, with its own owner. Do not narrow this list on the basis that a value was probably not used.
  5. Build the distribution list, and expect it to be the long part. Search the configuration repository including its full history, the CI secret store, the wiki, job logs from before secret masking was configured, and any container image built from those variable files. Remove the value from every one of them.
  6. Issue the replacement in a different shape rather than pasting a new value into the same four places. It must have a bounded lifetime, must be bound to something the caller cannot copy, such as its source range or its workload identity, and must be obtained by the pipeline at run time rather than held by a human.
  7. Review the policy the token carried while it is fresh in everyone's mind. It was written fourteen months ago for a migration and has been the effective definition of the CI system authority ever since. The paths it permits are the paths this incident exposed.
  8. Add the detection that was missing: an alert on a burst of denials from one accessor, and an alert on use of any credential from outside the source range recorded for it.
  9. Write the timeline down while the log is open, including the detection delay of five hours and fifty minutes. That number is the finding, not a detail of it.

Verification

  1. Query the preserved and live audit logs for the old accessor after the revocation timestamp. There must be zero records with a successful policy result. This is the server independently reporting on itself and it is stronger than any client-side confirmation.
  2. Replay the old credential deliberately from a controlled host and confirm the request is refused, then confirm that refusal appears in the audit log where you expect it. This proves two things at once: the credential is dead, and the log is still recording.
  3. Confirm the CI pipelines run green on the replacement credential, end to end, including a deployment. A pipeline that passes its unit stage proves nothing about credential access.
  4. Query the audit log for the new accessor and confirm it appears only from the CI egress range. A replacement already being used from somewhere unexpected has inherited the defect it was meant to fix.
  5. For every secret in the exposure list, confirm rotation by reading its version metadata in the secret manager rather than by asking the owning team. Metadata is evidence; a message in a channel is a claim.
  6. Re-run the distribution search across the configuration repository and its history, the CI secret store and the wiki, and record the result as a deliverable. A value found in history is still a value in every clone.
  7. Make a request from an address outside the expected source range on purpose and confirm the new detection alerts within its window, to the rota that will be carrying the pager next month.

Prevention

  • No static credential without a lifetime. A credential that never expires turns every copy ever made of it into a permanent liability, and the copies are made by careful people solving real problems under deadline. A lifetime bounds that automatically and without anyone remembering.
  • Bind the credential to something the holder cannot paste. A source range, a workload identity, a signed assertion the caller must produce. If possession alone is sufficient, then the security of the credential is the security of the least careful place it was ever written down.
  • Make issuance the only distribution path. A workload that fetches its credential at start-up has one copy, held in memory, for a bounded time. It removes the four-location problem instead of policing it.
  • Alert on the shape of use, not only on failure. A burst of denials from one accessor is an enumeration signature. Use from outside a credential recorded source range should page whether or not it succeeded, because the successful ones are the dangerous ones.
  • Read the audit log, or build something that reads it. An audit log nobody reads is storage. This incident was found by a weekly manual review, which is a control that works on a timescale of days when the exposure happens on a timescale of minutes.
  • Keep an inventory of long-lived credentials with owners and expiry dates. Treat an entry with no expiry as an open finding rather than a documented fact, and review it when the owner leaves rather than fourteen months later.
  • Record the intended distribution at issuance time. The rotation runbook should read a list, not reconstruct one under pressure, and the act of writing that list down at creation is often what stops the third and fourth copy from being made.