219 lines
10 KiB
Python
Executable File
219 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Proves the host's git calls cannot be turned into code execution.
|
|
|
|
python3 tests/test_vm_git_hardening.py
|
|
|
|
WHY THIS TEST EXISTS
|
|
|
|
The project share is mounted inside the VM, so anything the guest runs
|
|
can write `.git/` - and git is designed to execute things it finds there.
|
|
vmd commits that tree on the host. Without care, "the agent finished and
|
|
vmd committed the result" means "the guest chose a program and vmd ran
|
|
it".
|
|
|
|
Every case here plants a real attack and then asserts it did not fire.
|
|
The first case is a CONTROL: it uses plain git and the attack MUST
|
|
succeed. Without that, this file would pass just as happily if git had
|
|
stopped running hooks altogether, or if the planted hook were never
|
|
executable, and it would be proving nothing at all.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "vm", "vmd"))
|
|
|
|
import gitea # noqa: E402
|
|
|
|
FAILS: list[str] = []
|
|
|
|
|
|
def check(label: str, condition: bool, detail: str = "") -> None:
|
|
if condition:
|
|
print(f"ok {label}")
|
|
else:
|
|
FAILS.append(f"{label}: {detail}")
|
|
print(f"FAIL {label}\n {detail}")
|
|
|
|
|
|
def make_repo(tmp: str, marker: str) -> tuple[str, str]:
|
|
"""A repo booby-trapped the way a hostile guest would leave it."""
|
|
repo = os.path.join(tmp, "project")
|
|
os.makedirs(os.path.join(repo, "evil"), exist_ok=True)
|
|
subprocess.run(["git", "init", "-q", "-b", "main", repo], check=True)
|
|
with open(os.path.join(repo, "file.txt"), "w") as handle:
|
|
handle.write("content\n")
|
|
|
|
hooks = os.path.join(repo, ".git", "hooks")
|
|
os.makedirs(hooks, exist_ok=True)
|
|
for path in (os.path.join(hooks, "pre-commit"), os.path.join(repo, "evil", "pre-commit")):
|
|
with open(path, "w") as handle:
|
|
handle.write(f"#!/bin/sh\necho fired >> {marker}\n")
|
|
os.chmod(path, 0o755)
|
|
|
|
# The second half of the attack: even with .git/hooks neutralised,
|
|
# repository config can point hooksPath somewhere else entirely.
|
|
subprocess.run(["git", "-C", repo, "config", "core.hooksPath",
|
|
os.path.join(repo, "evil")], check=True)
|
|
return repo, marker
|
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
marker = os.path.join(tmp, "fired")
|
|
|
|
# --- CONTROL: the attack must work against plain git ---------------
|
|
repo, _ = make_repo(tmp, marker)
|
|
subprocess.run(["git", "-C", repo, "add", "-A"], check=True,
|
|
capture_output=True)
|
|
subprocess.run(["git", "-C", repo, "-c", "user.name=t", "-c", "user.email=t@t",
|
|
"commit", "-qm", "control"], capture_output=True)
|
|
check("CONTROL: plain git runs the planted hook",
|
|
os.path.exists(marker),
|
|
"the hook did not fire, so this file proves nothing - fix the control first")
|
|
|
|
# --- the real case -------------------------------------------------
|
|
if os.path.exists(marker):
|
|
os.unlink(marker)
|
|
with open(os.path.join(repo, "file.txt"), "a") as handle:
|
|
handle.write("more\n")
|
|
|
|
sha = gitea.commit(repo, "hardened commit")
|
|
check("gitea.commit still commits", bool(sha), f"returned {sha!r}")
|
|
check("no hook fired under gitea.commit",
|
|
not os.path.exists(marker),
|
|
"a planted hook executed on the host during a commit")
|
|
|
|
# --- and the flags that make that true are actually present --------
|
|
flags = " ".join(gitea._SAFE_FLAGS)
|
|
for needed in ("core.hooksPath=/var/empty", "core.fsmonitor=false",
|
|
"protocol.ext.allow=never", "protocol.file.allow=never"):
|
|
check(f"flag present: {needed}", needed in flags, flags)
|
|
for needed in ("GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"):
|
|
check(f"env neutralised: {needed}", gitea._SAFE_ENV.get(needed) == "/dev/null",
|
|
str(gitea._SAFE_ENV))
|
|
|
|
# --- a token must never reach the tree -----------------------------
|
|
config_path = os.path.join(repo, ".git", "config")
|
|
config = open(config_path).read() if os.path.exists(config_path) else ""
|
|
check("no credential was written into .git/config",
|
|
"@" not in config.split("url =")[-1][:200] if "url =" in config else True,
|
|
config[:200])
|
|
|
|
|
|
# --- what the person is handed at the end --------------------------------
|
|
#
|
|
# Two complaints from a real session, both about the last thing the user
|
|
# sees. They were handed the repository URL when they had asked for a link
|
|
# to download the script - a page to navigate rather than a file to save.
|
|
# And the repository was labelled "Created by the owuitools VM agent for
|
|
# project 'systeminfo-ps'", which says nothing about what the project is.
|
|
|
|
work = tempfile.mkdtemp()
|
|
|
|
|
|
def run_git(*args):
|
|
subprocess.run(["git", "-C", work, *args], check=True, capture_output=True,
|
|
env={**os.environ, "GIT_CONFIG_GLOBAL": "/dev/null",
|
|
"GIT_CONFIG_SYSTEM": "/dev/null"})
|
|
|
|
|
|
run_git("init", "-q", "-b", "main")
|
|
for name in ("SystemInfo.ps1", "systeminfo.json", ".gitignore"):
|
|
open(os.path.join(work, name), "w").write("x")
|
|
os.makedirs(os.path.join(work, "sub"), exist_ok=True)
|
|
open(os.path.join(work, "sub", "deep.ps1"), "w").write("x")
|
|
open(os.path.join(work, "left-behind.txt"), "w").write("x")
|
|
run_git("add", "SystemInfo.ps1", "systeminfo.json", ".gitignore", "sub/deep.ps1")
|
|
run_git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x")
|
|
|
|
tracked = gitea.tracked_files(work)
|
|
check("a download link is only offered for a file that was committed",
|
|
"SystemInfo.ps1" in tracked and "left-behind.txt" not in tracked,
|
|
"a link to an uncommitted file is a 404 with the operator's name on it")
|
|
check("and not for the repository's own dotfiles", ".gitignore" not in tracked)
|
|
check("the file asked for comes before anything nested",
|
|
tracked.index("SystemInfo.ps1") < tracked.index("sub/deep.ps1"))
|
|
|
|
# The link handed to the user is the releases page, because a link into
|
|
# the repository does NOT download: measured, Gitea serves the raw file as
|
|
# `text/plain` with `Content-Disposition: inline`, so the browser shows the
|
|
# source. Release assets are attachments.
|
|
check("a release is built from what was committed, and is capped",
|
|
gitea.RELEASE_ASSETS_MAX > 0 and gitea.RELEASE_ASSET_MAX > 0)
|
|
check("the asset list is the committed files",
|
|
"tracked_files(repo_path" in open(
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"..", "vm", "vmd", "gitea.py"), encoding="utf-8").read(),
|
|
"an asset for an uncommitted file is a download of something that was thrown away")
|
|
|
|
gitea_src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"..", "vm", "vmd", "gitea.py"), encoding="utf-8").read()
|
|
check("a nested file keeps its path in the asset name",
|
|
"name.replace('/', '_')" in gitea_src,
|
|
"a release has no folders, so two files called the same thing would collide")
|
|
vmd_src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"..", "vm", "vmd", "vmd.py"), encoding="utf-8").read()
|
|
publish = vmd_src[vmd_src.index("def _publish"):vmd_src.index("def v_session_status")]
|
|
check("the release is published after the push, not instead of it",
|
|
publish.index("gitea.push(") < publish.index("gitea.publish_release("),
|
|
"the tag has to point at a commit the server already has")
|
|
check("a release that fails does not fail the session",
|
|
"release_error" in publish and "publish_error" in publish,
|
|
"the work is committed and pushed by then; only the download page is missing")
|
|
check("uploading an asset is multipart, as Gitea requires",
|
|
"multipart/form-data; boundary=" in gitea_src)
|
|
check("the upload boundary is not guessable from the file",
|
|
"os.urandom" in gitea_src,
|
|
"a fixed boundary appearing inside a file would corrupt the upload")
|
|
|
|
# The description is generated from the job, so it cannot become a pitch
|
|
# and cannot describe work that was never asked for.
|
|
desc = gitea.short_description(
|
|
"Goal: Write a PowerShell script that collects system information and prints it. "
|
|
"Then test both modes and report the results verbatim.", "systeminfo-ps")
|
|
check("the repository says what the project is", "PowerShell script" in desc, desc)
|
|
check("it is one line and short",
|
|
"\n" not in desc and len(desc) <= gitea.SUMMARY_CAP + 3, f"{len(desc)}: {desc}")
|
|
check("it stops at the first sentence rather than reciting the job",
|
|
"test both modes" not in desc, desc)
|
|
check("a label like 'Goal:' is not part of the description",
|
|
not desc.lower().startswith("goal"), desc)
|
|
check("a job with nothing usable still gets a description",
|
|
gitea.short_description("", "demo") != "")
|
|
|
|
# A job opens by addressing the machine and telling it to build something.
|
|
# A repository description labels the thing that was built, so neither the
|
|
# machine nor the instruction belongs in it. Measured on a real session,
|
|
# the first version read "On the Ubuntu machine, write a small Python
|
|
# script called wordcount.py that takes a filename and prints the..." -
|
|
# the instruction, verbatim and cut off.
|
|
built = gitea.short_description(
|
|
"On the Ubuntu machine, write a small Python script called wordcount.py that takes "
|
|
"a filename and prints the ten most common words with their counts. Save it in the "
|
|
"project folder and run it.", "wordcount-demo")
|
|
check("the machine is not part of the description",
|
|
"ubuntu" not in built.lower() and not built.lower().startswith("on the"), built)
|
|
check("nor is the instruction that asked for it",
|
|
not built.lower().startswith("write"), built)
|
|
check("what is left describes the thing that was built",
|
|
built.startswith("Small Python script called wordcount.py"), built)
|
|
|
|
# Only verbs that CREATE are stripped. A project whose job was to test
|
|
# something is accurately described by the word "test".
|
|
checked = gitea.short_description(
|
|
"Test the bash command find -mtime +7 -delete and report which files it removes.",
|
|
"find-test")
|
|
check("a job that was a test still says so", checked.lower().startswith("test"), checked)
|
|
|
|
|
|
print()
|
|
print("=" * 60)
|
|
if FAILS:
|
|
print(f"FAILED {len(FAILS)}")
|
|
for f in FAILS:
|
|
print(" -", f)
|
|
sys.exit(1)
|
|
print("all git hardening cases pass")
|