JSTGTECH
← Back to blog

SCARLETEEL and the case against trusting Terraform state

5 min read

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.

Related posts