SCARLETEEL and the case against trusting Terraform state
In 2023, Sysdig’s threat research team investigated a customer intrusion
they later named SCARLETEEL — an attacker who broke into a containerized
workload, escalated privileges into the AWS account behind it, and then
went looking for proprietary code and infrastructure secrets to steal
(Sysdig).
Partway through, the attacker’s tooling — Pacu and TruffleHog, both
built for exactly this kind of hunt — turned up a terraform.tfstate
file sitting in an S3 bucket the compromised role could read. Inside it,
in plain text, was an IAM access key and secret key for a second,
connected AWS account. That one file turned a single compromised
container into a foothold for lateral movement across account
boundaries. Nothing about it required a Terraform bug. The state file
did exactly what state files are designed to do: record everything
Terraform knows about the resources it manages, including the ones
that happen to be secrets.
Root cause
Terraform’s state file is a JSON snapshot of every managed resource’s
full attribute set — not just the config you wrote, but every value
returned by the provider after apply. If a random_password resource
generates a database password, that password lands in state. If an
aws_db_instance block sets a master password, that password lands in
state. If a tls_private_key resource generates a key pair, the
private key lands in state, in plain text, by default. Marking a
variable sensitive = true doesn’t change any of this — it only tells
Terraform to redact the value from CLI output and plan diffs. The
underlying JSON on disk (or in S3, or in Terraform Cloud) is untouched;
HashiCorp’s own docs are explicit that sensitive is a display
control, not encryption
(HashiCorp).
Anyone who can read the state file — whether that’s a teammate with S3
read access, a CI job with an overly broad role, or an attacker who
found the bucket — can read every secret Terraform ever touched, in
clear text, with terraform show or a plain jq.
That would be a manageable risk if state files were consistently
locked down. In practice, they aren’t. A researcher who spent three
days scanning likely S3 bucket names for .tfstate files found roughly
900 publicly exposed state files, 41 of which contained live AWS
credentials still valid enough to authenticate
(Vechron).
State files also end up in git history when someone runs terraform
init with a local backend and commits the working directory, or in
CI build artifacts, or on a departing contractor’s laptop. Every one of
those copies carries the same plaintext secrets as the original.
Blast radius
In the SCARLETEEL case, the leaked key wasn’t for the account already
compromised — it belonged to a second, connected account, which is
what turned a contained incident into a multi-account one. That’s the
pattern that makes state-file exposure worse than a typical leaked
credential: state files for anything beyond a single, narrowly scoped
stack tend to accumulate secrets for everything that stack touches —
database passwords, third-party API keys, TLS private keys, and IAM
keys for cross-account roles or CI service accounts, all bundled into
one JSON blob. A single exposed state file for, say, a shared
networking or platform stack can hand over more standing access than
any one resource’s blast radius would suggest, because the file is a
census of the whole stack’s secrets, not just one of them. And unlike a
key rotation you can trigger the moment you notice a leaked credential
in a log, a state file’s exposure window is often invisible — nothing
about a misconfigured bucket policy shows up in an application log or
a CloudTrail alarm unless you’re specifically watching for s3:GetObject
on that prefix.
Remediation
The durable fix is to stop putting secrets in state in the first
place, and Terraform 1.10 and 1.11 finally gave that a first-class
answer. Ephemeral values, introduced in Terraform 1.10, let a
value — say, a token fetched from Vault or Secrets Manager at apply
time — flow through configuration without ever being written to the
plan or state file
(HashiCorp).
Terraform 1.11 extended that to managed resources with **write-only
arguments**: resource arguments that accept a value during an
operation but are never persisted anywhere, so a generated password or
fetched secret can be passed straight to, say, an aws_db_instance
resource without landing in state at all
(HashiCorp).
Where a provider or resource doesn’t support write-only arguments yet,
the second-best option is to keep the secret out of Terraform
entirely — create it in Secrets Manager or SSM out of band and have
Terraform reference the ARN, not the value.
For the state that still exists, treat it like the credential store it
actually is: encrypt the backend at rest (S3 SSE, or Terraform Cloud’s
built-in encryption), block all public access on the bucket, and scope
the bucket policy to only the roles that actually run `terraform
plan/apply` — not a broad “read access to the account’s S3” policy
that quietly includes it. Enable state locking (this site’s own backend
uses S3’s native locking) so concurrent applies don’t corrupt state,
and versioning so a bad apply is recoverable. Never commit .tfstate
to git, and add it to .gitignore before the first terraform init,
not after. And because none of this is retroactive: any secret that
has ever appeared in a state file — even one you’ve since locked down
or migrated off of write-only arguments — should be treated as
potentially exposed and rotated, the same way you’d rotate a key found
in a public GitHub repo.
The bigger lesson
State-file exposure doesn’t get the attention of a CVE because there’s no patch to apply — it’s a design property of how Terraform has always worked, not a bug HashiCorp introduced or fixed. That’s exactly why it persists: there’s no advisory prompting a fleet-wide fix, no scanner flagging a known-vulnerable version, just a JSON file that quietly accumulates every secret a stack has ever touched until someone either locks the bucket down or an attacker finds it first. Ephemeral values and write-only arguments are the first real structural fix in Terraform’s history, but they only help the resources and providers that have adopted them — everything else still ends up in state by default, which makes the backend’s access controls the only thing standing between a misconfigured bucket policy and a SCARLETEEL-style pivot.
Join the discussion
Comments for this post live on social — reply to the thread.
Related posts
Storm-0558: When a Stolen Signing Key Forges Any Token
A single stolen token-signing key let attackers mint valid cloud identities and read email in about 25 organizations. Root cause, blast radius, and what to do about your own keys.
CircleCI Breach: Stolen Session Cookies and Your CI Secrets
One infected engineer laptop, one stolen SSO session, and every customer secret in CircleCI was in play. What failed, the blast radius, and how to make CI secrets cheap to rotate.
The chalk/debug npm Compromise: Anatomy of a Package Hijack
One phished maintainer turned 18 ubiquitous npm packages into a crypto-stealing payload in about two hours. Here is how it worked and the build-pipeline controls that blunt it.