~$ skillshelf
← OverTheWire Bandit

Bandit 30 → 31: the ref that isn't a file

banditlinuxgit

Fourth git level in a row off a level page that hasn’t changed a word except the username. The clone works, there’s one file in it, and the file is a taunt:

just an epmty file... muahaha

Last level was the branches you weren’t shown, so that’s the first thing to try — and it’s closed here, because this repo genuinely has one branch and one commit. The thing I needed was in the clone the whole time, in a place ls will not show you.

the goal

There is a git repository at ssh://bandit30-git@localhost/home/bandit30-git/repo via the port 2220. The password for the user bandit30-git is the same as for the user bandit30. From your local machine (not the OverTheWire machine!), clone the repository and find the password for the next level.

the approach

closing the two routes I already knew

Branches first:

git branch -a
* master
  remotes/origin/HEAD -> origin/master
  remotes/origin/master

Nothing hiding, and origin/master points at the same commit as master, so the remote isn’t ahead either. Then the history, checked properly against every ref rather than off one line of checkout output:

git log master
git log origin/master
git log origin/HEAD

Every one of them:

commit 8f2cf5b700aa0dc83b8ec69f46974e81dd023e99 (HEAD, origin/master, origin/HEAD, master)
Author: Ben Dover <noone@overthewire.org>
    initial commit of README.md

Four names, one commit, four identical answers. That’s a real elimination: the password is not reachable from any ref I had. Which is the entire level stated as a result, if you read it that way.

dead end: reading .git by hand

ls .git
config  description  HEAD  hooks  index  info  logs  objects  packed-refs  refs

Three of those look promising and none of them are. logs/HEAD is the reflog — every line is my own username and today’s timestamps, and the first entry is the clone itself. It’s a journal of what I did since cloning, so it structurally cannot hold anything the server owns. description is a gitweb leftover for naming a repo on a web listing. index is the staging area, one entry, README.md, mine.

All local scaffolding. The pattern worth naming, because it’s the wrong instinct three times over: stuck → go one directory deeper into .git → try to cat something. It finds nothing, because .git’s layout isn’t where the answer lives.

the wrong inference

ls .git/refs
heads  remotes

No tags directory — which reads as “no tags in this repo.” That inference is wrong, and it’s the trap the level is built around.

Git stores refs two ways. Loose — one small file per ref, at the path matching its name. Packed — all of them compacted into a single text file, and the loose files deleted. A fresh clone almost always arrives packed; a repo with 500 tags would otherwise mean 500 tiny files. So ls refs showing only heads and remotes says nothing about which tags exist. It says no tag has a loose file.

The same compaction had already happened one layer down:

ls .git/objects/pack
pack-2788b75082a5f7234fd80f2975c9df0a1792408a.idx
pack-2788b75082a5f7234fd80f2975c9df0a1792408a.pack
pack-2788b75082a5f7234fd80f2975c9df0a1792408a.rev

Not a single loose object either. Those three files are one archive plus its indexes — .pack holds every object, .idx maps hashes to offsets, .rev is a reverse index. There’s no command to browse them and no reason to want one: git reads that pack transparently on every command. Every git log above pulled 8f2cf5b out of it without mentioning it.

what the man page actually says

man git-clone’s DESCRIPTION only ever talks about branches. The other half is down in OPTIONS:

--tags, --no-tags
    Control whether or not tags will be cloned. ...
    By default, tags are cloned and passing --tags is thus typically a no-op

By default, tags are cloned. Nobody passed --no-tags, so whatever the server had came down with everything else.

the file that had it all along

cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
8f2cf5b700aa0dc83b8ec69f46974e81dd023e99 refs/remotes/origin/master
6a76bc87a774031428feb5cc910568293c335545 refs/tags/secret

A tag called secret, pointing at an object with nothing to do with the commit. The obvious next move fails:

cat .git/refs/tags/secret
cat: refs/tags/secret: No such file or directory

Which reads like a contradiction and is actually the confirmation. That’s what “packed” means — the loose file was deleted when the ref got compacted into packed-refs. There will never be a file at that path. refs/tags/secret is a ref name, not a filesystem path; it only looks like one because git’s naming scheme borrowed the shape.

the ambiguous hash

Worth a warning of its own. Creating junk branches while you’re guessing — including one named after the tag’s hash with the last character missing — poisons everything after it:

git cat-file -t 6a76bc87a774031428feb5cc910568293c33554
warning: refname '6a76bc87a774031428feb5cc910568293c33554' is ambiguous.
commit

commit is the wrong answer, stated confidently. The truncated hash was now also a branch name, so git resolved it as a ref pointing at the commit rather than as an abbreviated object id — and said so in a warning that’s easy to read past when there’s a plausible-looking answer underneath it.

reading the object

cat-file wants an object — a hash or a ref name, nothing else. Not a path (git cat-file -t README.md → fatal: Not a valid object name), and not both columns of the packed-refs line at once, since those are two names for the same object rather than two arguments.

git cat-file -t refs/tags/secret
blob

blob. Not a commit — which is why git log and git checkout were never going to work on it, however you spelled it. It’s a bare file’s worth of content in the object store with a tag pointing straight at it: no commit, no tree.

git cat-file -p refs/tags/secret
<redacted>

Password, plain text, one line.

the takeaway

A clone brings down tags by default, and packing hides them from ls. Two facts that stack into one trap:

  • git branch doesn’t list tags, so the reflex from the last level finds nothing
  • ls .git/refs/tags/ is empty because the tags are packed, so the filesystem agrees with the wrong conclusion

cat .git/packed-refs settles it in one command, and git show-ref or git tag would too. If a repo looks like it has one commit and nothing else, that’s a statement about the refs you asked for, not about what’s on your disk.

Four levels of the same escalating shape, and this is where it generalises:

  • the working tree isn’t everything → the history
  • one branch’s history isn’t everything → other branches
  • branches aren’t every ref → tags
  • and underneath all of it: git’s on-disk layout is not its interface

packed-refs and the .pack file are the same idea one layer apart. Git compacts its storage whenever it likes, and the directory tree is not a reliable inventory of what the repo contains.

Two smaller bricks:

git cat-file -t before -p. Objects come in four types and the porcelain commands are built for commits. Asking the type first turns “why is git log being useless” into “of course, it’s a blob” — one word that explains every failure before it.

warning: ... is ambiguous is not noise. Don’t create refs while you’re guessing, and when git says a name is ambiguous, stop and read it. A confident wrong answer is worse than an error.