Skip to main content

Bithug picoCTF 2021 Solution

Chain together multiple web vulnerabilities in a custom Git hosting platform to gain admin access and retrieve the flag.

Published: April 2, 2026Updated: August 25, 2026

Description

A git hosting service. Log in and find the flag hidden in a private repository you have no direct access to.

Open the challenge URL and create an account.

bash
# Open http://mercury.picoctf.net:<PORT_FROM_INSTANCE>/ in your browser

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Understand the authorization model
    Observation
    The description puts the flag in a private repository with no direct access. So the question is how the server decides who is authorized, and whether any privileged code path can be reached from outside.
    After registering and exploring the Bithug UI, check the source or experiment with the API. The server grants admin privileges to any HTTP request that arrives from a loopback address (127.0.0.1 or ::1). That admin status bypasses every repository authorization check, so an admin can push to any repo, including the private /_/<username>.git that was created for you at signup and holds the flag. The challenge is therefore to make the server issue an HTTP request to itself from 127.0.0.1 - a classic Server-Side Request Forgery (SSRF) setup.
    Learn more

    Why localhost == admin? The app sits behind a reverse proxy configured to pass the real client IP. Spoofing the X-Forwarded-For header is blocked by the proxy. The only way to produce a request genuinely originating from 127.0.0.1 is to make the server itself send that request - which is exactly what a webhook does.

    Access control via refs/meta/config. Access to each repository is gated by an access.conf file stored in the git ref refs/meta/config. Only users listed there (or admins) can clone the repo. The exploit goal is to push a new access.conf that adds your username, and that push must arrive from localhost so the server accepts it as admin.

    For the broader SSRF technique, confirming the sink and sweeping internal ranges, see the SSRF for CTF post.

  2. Step 2Identify the SSRF vector: webhooks with template-injected URLs
    Observation
    Bithug has a webhook feature that POSTs to a user-supplied URL after each push. The server itself originates that request, so any destination sees it coming from 127.0.0.1. That is a classic SSRF entry point.
    Bithug supports repository webhooks: after a push, the server sends a POST request to the configured URL. Because the POST originates from the server process, the destination server sees the source IP as 127.0.0.1. This is the SSRF primitive. However, the webhook validation rejects URLs pointing to localhost or the loopback range, so a direct http://localhost:1823/... webhook is refused. The bypass exploits how the server processes the webhook URL through a mustache-style formatString template before making the request: template variables like {{property}} are replaced with values from a data object, and any unknown variable is replaced with an empty string. By crafting a URL where the host component is a template variable, that component is stripped at execution time even though the URL looked valid during the denylist check.
    Learn more

    Template injection trick. The webhook URL is run through formatString after it passes validation. The function replaces {{key}} with the corresponding value from a data object, and replaces unknown keys with an empty string. The URL http://{{/}}localhost:1823/_/user.git/git-receive-pack passes validation because the host field after URL parsing is {{/ (not a recognized loopback address). When formatString runs, {{/}} is unknown and becomes an empty string, yielding http://localhost:1823/_/user.git/git-receive-pack.

    Alternative: 307 redirect server. Some solvers instead pointed the webhook at an external server they controlled that immediately returned a 307 Temporary Redirect to the localhost target. The HTTP client follows the redirect, and the resulting request originates from the server (127.0.0.1). Both approaches achieve the same SSRF; the template injection path requires no external infrastructure.

  3. Step 3Craft a git packfile that adds you to access.conf
    Observation
    Access control for each repository lives in access.conf under the special ref refs/meta/config. Forging a git-receive-pack payload that pushes a new access.conf to that ref, delivered through the SSRF webhook as admin, grants clone rights.
    A git push over HTTP is a POST to /repo.git/git-receive-pack with a specific binary body (a git packfile). You need to craft one that pushes a new commit to refs/meta/config, where that commit contains a single file called access.conf listing your username. The easiest approach is to perform a real git push against a repo you own while capturing the network traffic (e.g. with tcpdump and tshark), then extract the git-receive-pack POST body from the capture and replay those bytes as the webhook body. Bithug accepts the body base64-encoded, so base64-encode the raw bytes before setting the webhook body field.
    bash
    # Build the commit that carries access.conf
    bash
    mkdir flag-access && cd flag-access && git init
    bash
    git checkout --orphan meta-config
    bash
    echo 'your_username' > access.conf
    bash
    git add access.conf && git commit -m 'grant access'
    bash
    # Capture a real push to a repo you already own on the instance
    bash
    sudo tcpdump -i any -s 0 -w push.pcap tcp port <PORT_FROM_INSTANCE> &
    bash
    git push http://<USER>:<PASS>@mercury.picoctf.net:<PORT_FROM_INSTANCE>/<USER>/scratch.git HEAD:refs/meta/config
    bash
    sudo pkill tcpdump
    bash
    # Pull the POST body of the git-receive-pack request out of the capture
    bash
    tshark -r push.pcap -Y 'http.request.uri contains "git-receive-pack"' -T fields -e http.file_data | tr -d ':' | xxd -r -p > packfile.bin
    bash
    base64 -w0 packfile.bin > packfile.b64
    What didn't work first

    Tried: Use git fast-export or git bundle instead of capturing the raw HTTP packfile body.

    git fast-export produces a stream-format dump for git fast-import, and git bundle wraps objects in a bundle header. Neither is the raw pkt-line and PACK binary that git-receive-pack expects as an HTTP POST body, so replaying either gives a malformed request that Bithug rejects or ignores. You need the exact bytes git's HTTP transport sends, captured from a real push.

    Tried: Push to refs/heads/main instead of refs/meta/config when generating the capture.

    The access control file Bithug reads lives under refs/meta/config, not in any normal branch. Pushing to refs/heads/main updates the repo history and leaves access.conf untouched, so the clone is still denied. The captured packfile has to name refs/meta/config as its destination ref.

    Learn more

    What is a git packfile? When you run git push, the client sends a binary stream: first a pkt-line listing the ref updates (old-sha new-sha refname), then a PACK file containing the objects needed to reconstruct those commits. This exact byte stream is what you need to replay as the webhook body so that the Bithug server processes it as a legitimate git push from an admin.

    refs/meta/config is a git ref that exists outside the normal branch namespace. Bithug uses it to store access configuration separate from the working history of the repository, similar to how Gerrit Code Review manages project config.

  4. Step 4Register the webhook and trigger it
    Observation
    The template variable trick gets a localhost URL past the webhook denylist, and the packfile is ready. Wire them together: register the webhook on a repo you control, then trigger it with a push.
    Go to the settings of a repository you own and add a webhook. Set the URL to the template-injected localhost address that targets your private flag repository. Set the body to the base64-encoded packfile from the previous step and the content type to application/x-git-receive-pack-request. Once the webhook is saved, push any commit to that repository. The push triggers the webhook, the server's HTTP client strips the template variable from the URL and sends the POST to localhost:1823/_/<username>.git/git-receive-pack, the request arrives as 127.0.0.1, the server grants it admin status, and the packfile is accepted - updating refs/meta/config with your access.conf.
    bash
    # Template-injected webhook URL (replace <PORT> and <USERNAME>)
    bash
    http://{{/}}localhost:<PORT_FROM_INSTANCE>/_/<USERNAME>.git/git-receive-pack
    bash
    bash
    # Trigger by pushing anything to the repo that owns the webhook
    bash
    echo trigger >> README.md
    bash
    git add README.md && git commit -m 'trigger webhook'
    bash
    git push
    What didn't work first

    Tried: Set the webhook URL directly to http://localhost:PORT/_/username.git/git-receive-pack without the template variable trick.

    Bithug validates the webhook URL before saving and rejects any host in the loopback range, so it returns an invalid-target error and never registers the hook. The template variable trick works because the parser sees the host field as a literal string during validation, which matches nothing on the denylist. Only after saving does formatString collapse it, producing the localhost URL at execution time.

    Tried: Add the X-Forwarded-For: 127.0.0.1 header to a direct clone request instead of using the webhook SSRF.

    Bithug sits behind a reverse proxy that strips or overwrites X-Forwarded-For before the application sees it, so a header you supply is ignored and the app reads your real public IP and denies admin. The only way to get admin is to make the server itself originate the connection over loopback, which is exactly what the webhook POST does.

    Learn more

    Why does this work at the wire level? The webhook HTTP client on the server makes a POST to http://localhost:1823/_/username.git/git-receive-pack. The TCP connection originates from the server process itself, so the loopback stack sets the source IP to 127.0.0.1. The Bithug request handler checks the source IP, sees a loopback address, and promotes the request to admin. The packfile body is then processed with full admin privileges, bypassing the normal access.conf check that would reject a regular user.

  5. Step 5Clone the flag repository and read the flag
    Observation
    The webhook push has updated refs/meta/config with your username in access.conf. Your credentials now satisfy the authorization check, so an ordinary git clone of the private repository should work.
    With your username now listed in the access.conf of /_/<username>.git, you can clone that repository using your normal credentials. The README.md inside contains the flag.
    bash
    git clone http://<YOUR_USER>:<YOUR_PASS>@mercury.picoctf.net:<PORT_FROM_INSTANCE>/_/<YOUR_USER>.git
    bash
    cat <YOUR_USER>/README.md

    The README.md contains the flag: picoCTF{good_job_at_gitting_good}.

Interactive tools
  • URL Encoder / DecoderEncode and decode URL-encoded (percent-encoded) strings. Useful for web exploitation challenges involving query parameters, form data, and HTTP headers.
  • Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.
  • Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.

Flag

Reveal flag

picoCTF{good_job_at_gitting_good}

Bithug chains SSRF with git packfile injection: a webhook URL crafted with a mustache-style template variable that is stripped at execution time bypasses the localhost denylist, allowing the server to POST a git-receive-pack payload to itself as admin, which updates refs/meta/config to grant access to the private flag repository.

Key takeaway

Server-Side Request Forgery tricks a server into making HTTP requests on the attacker's behalf, reaching internal services firewalled off from the outside. Because the request comes from the server, the destination sees a trusted source such as 127.0.0.1 and grants privileges it would never give an external caller. Denylists are fragile here, because URL parsers, template engines, and open redirects all offer ways to smuggle a blocked hostname past validation. The reliable fix is an allowlist of permitted destinations plus strict URL normalization before any outbound request.

Related reading

Useful tools for Web Exploitation

Where to go next