I do AppSec triage for a living — mostly reading through Bugcrowd submissions
and reasoning about whether a reported issue is actually exploitable, and how
badly. So when I pick weekend HTB boxes to work through, I'm not really
looking for the hardest exploit dev challenge I can find. I'm looking for
boxes that exercise the same instincts I need on a Tuesday afternoon at work:
reading an unfamiliar application's surface area, noticing what's not
supposed to be reachable, and following a thread of small mistakes to their
logical end.
This weekend's box (an easy-rated Linux machine on HTB) turned out to be a
genuinely great case study for that, so I wanted to write up what I actually
learned rather than just "I did the box."
Recon taught me more than the target's services
The initial port scan only turned up two ports — SSH and a webserver — and
the webserver redirected to a hostname I hadn't seen before. That alone is a
small but useful reminder: a redirect to an unfamiliar hostname is a signal to
go add that hostname to your resolver and actually look at what's there,
not just note "HTTP open" and move on.
Once I was looking at the real site, a vhost fuzzing pass (ffuf, with the
default/junk response size filtered out) turned up two more subdomains that
weren't linked from the main site anywhere. One turned out to be a self-hosted
git server, the other an internal business application. Neither would have
been found by just clicking around the visible site. Attack surface and
visible surface are not the same thing, and I don't think I'll ever stop
being reminded of that.
The finding I'll actually remember: secrets survive "deletion"
The git server had a public repo containing infrastructure config for the
business app — a Docker Compose file and an environment file. The environment
file, as it currently existed, had its password field blanked out. Good
hygiene, right?
Except git log -p on that file showed an earlier commit where the real
value was still there in plaintext. Someone had added the credential, realized
the mistake, and "fixed" it by blanking the field in a new commit — without
realizing (or without rotating the credential either way) that the value was
still permanently readable by anyone who cloned the repo and looked at its
history.
This is, I think, the most broadly useful lesson from the whole exercise:
a secret that ever touches a commit is compromised the moment that commit
is pushed anywhere with more than one set of eyes on it. Blanking it in a
later commit does nothing for anyone who can run git log. The only real fix
is treating the credential as burned and rotating it — full history rewrites
are a nice-to-have at best, not a substitute.
I now have git log -p --all | grep -iE "password|secret|key|token" as a
near-automatic step any time I get read access to a git repo during recon,
lab or otherwise.
Credential reuse, twice over
That leaked password worked directly against the admin login of the business
application, once paired with an email address I found from an entirely
different piece of recon — a "careers" page had a job posting listing a
hiring manager's contact email. Worth remembering that OSINT on a target
isn't only infrastructure enumeration; sometimes it's just reading the
website like a human would and noting who's named where.
The application itself happened to be running a version with a recently
disclosed, authenticated remote code execution vulnerability, reachable
through a file-attachment feature. The validation preventing arbitrary file
uploads was purely client-side (extension checking in the browser), so the
actual exploitation was: rename the payload to an allowed extension, upload
it, then use an intercepting proxy to rename it back to something executable
before the request actually reached the server. I've now seen this exact
pattern — client-side-only extension validation — in more than one context,
both in labs and in real bug reports I've triaged. It's essentially
decorative security if there's no matching server-side check.
Once I had code execution as the web server's low-privilege user, a second
config file on the box (this time the CMS's own database configuration) had
yet another password — different from the git-leaked one — which worked
directly for SSH access as a real system user. Two independent instances of
password reuse across service boundaries, in a single chain. It's a small
thing to say "don't reuse passwords across services," but seeing it exploited
twice back to back in one box is a much better teacher than the advice on its
own ever is.
The privilege escalation: why os.path.join() isn't a safety net
This is the part I most wanted to write about, because it's a genuinely
transferable code-review lesson, not just a CTF trick.
Enumerating scheduled jobs on the box turned up a systemd timer running a
Python script as root every minute. The script's purpose was mundane:
sync files out of specially-flagged "template" git repositories into a
staging directory, presumably so they could be used elsewhere on the system.
The vulnerable logic looked, in spirit, like this:
for filepath in files_from_git_ls_tree(repo):
target = os.path.join(staging_dir, filepath)
os.makedirs(os.path.dirname(target), exist_ok=True)
write_file(target, get_blob_contents(repo, filepath))
The bug is that filepath comes straight from git ls-tree output with zero
validation, and os.path.join() will happily honor ../ sequences in its
second argument — it does not sanitize traversal, it just joins path
components. If filepath were something like
../../../../root/.ssh/authorized_keys, the resulting target resolves
outside staging_dir entirely.
The catch is that Git's own CLI won't normally let you create a tracked file
whose path contains .. — that protection lives in the porcelain commands
(git add, git commit), not in the underlying object format itself. So the
actual technique was to build the raw git objects by hand: hash and
zlib-compress a blob and a sequence of tree objects directly into
.git/objects, chain them into a tree structure whose path components
included the traversal sequence, wrap it in a commit object, and point a
branch ref directly at that commit — bypassing the CLI's safety checks
entirely because I was never asking the CLI to validate a .. path; I was
constructing the low-level objects that represent one.
Once pushed to a repository flagged as a template, the vulnerable sync script
picked it up, joined its untrusted path onto the staging directory with no
validation, and wrote a file straight through to a sensitive location outside
the intended directory — running as root, which meant the write itself
carried root privileges.
The generalizable takeaway: path-joining functions across virtually every
language (os.path.join in Python, path.join in Node, Path.Combine in
.NET) treat .. as a normal path component and resolve it — they are not
sanitization functions, and none of them will stop a traversal on their own.
Any time a path component originates from data you don't fully control — a
filename from an upload, a path from a tarball, a path from a git tree,
anything — the resulting joined path needs to be canonicalized (e.g.
os.path.realpath()) and explicitly checked to confirm it's still inside the
intended base directory before it's touched. I now treat this as one of the
first things I look for when reviewing file-handling code in triage, right
alongside the more commonly-flagged injection points.
Closing thought
Nothing about this box required novel exploit development. Every step was a
recognizable category of mistake: a secret lingering in history, a password
reused where it shouldn't have been, client-side-only validation, and an
unsanitized path join. That's exactly why I think easy-to-medium HTB boxes are
worth doing deliberately rather than skipping to the hard ones — the value
isn't in the difficulty of any single step, it's in seeing how ordinary
mistakes chain together into full compromise, which is precisely the shape
most real-world findings take.
Tools used: nmap, ffuf, curl, Burp Suite, git, a small custom Python
script for raw git object construction, ssh, nc.
Top comments (1)
The "attack surface isn't visible surface" lesson is the one I'd underline too, and there's a recon move that pairs nicely with your ffuf vhost pass: Certificate Transparency logs. crt.sh against the apex domain (plus the SAN list on the box's own TLS cert) will often just hand you internal-looking hostnames — a self-hosted git server, a staging app — that a wordlist would never guess, and you get them without sending a single request to the target. CT gives you the names that were ever certed; ffuf gives you the ones that resolve but were never issued a cert. Run both and the overlap is usually small.
On the upload bypass, the reason the proxy rename works is worth stating as a rule: the filename, the extension, and the Content-Type header all travel inside the request, so all three are attacker-authored. "Validate the upload" is already the wrong frame because there's nothing trustworthy in the request to validate against — the only ground truth is the byte content, and even that only matters relative to where the file lands. Re-derive the type from magic bytes, store it outside anything the app will later execute or feed to a parser, and it stops being an RCE primitive no matter what the extension claims.