Stable shell for zsh
One of the first things you need to master in order to become a successful HTB pwn3r and CTF pro is how to upgrade your reverse shell into an interactive terminal.
tl;dr
A reverse shell isn't a real terminal. Ctrl+C kills your session instead of the process you meant to interrupt, tab completion does nothing, arrow keys print garbage, and sudo refuses to run. Fix this by allocating a pseudo-terminal (PTY) on the target and syncing your local terminal's state with it. On zsh targets, one of the steps has to go in as a single line, or it breaks.
What makes a shell dumb
Popping a reverse shell with netcat gets you a raw pipe to /bin/sh, not a terminal. The process on the other end has no tty attached — it's reading from a socket. That has real consequences:
No job control. Ctrl+Z is supposed to suspend the current job; instead it suspends your netcat listener, and the remote process keeps running with no way to bring it back to the foreground.
No signal handling worth the name. Ctrl+C doesn't interrupt the remote command — it can kill the whole shell, because the signal has nowhere sane to go.
No line editing. Arrow keys and backspace don't edit your input; they print raw escape sequences (^[[A) into the command line, because there's no terminal driver interpreting them.
No sudo. Most sudo configurations refuse to run without a controlling terminal — you'll see sudo: no tty present and no askpass program specified — which matters a lot when privilege escalation is the whole point of the exercise.
Why it matters
You might think this is a comfort issue — line editing and tab completion, nice to have but not essential. It isn't. Job control means you can't background a long-running job and keep working. Broken signal handling means one Ctrl+C can drop your entire shell and cost you the foothold. And a missing TTY blocks sudo, su, and any full-screen tool — vim, top, less — that expects to draw to a real terminal. On a CTF box or an engagement, that's not friction, that's the difference between finishing the box and starting over.
The actual fix: allocate a real PTY
A shell only becomes stable once the operating system treats it as a real terminal — a pseudo-terminal, allocated on the target and wired up to yours. Everything past that point — colored prompts, aliases, a nice .bashrc — is cosmetic and irrelevant to whether the shell works.
The standard way to get there is Python's pty module:
python -c 'import pty; pty.spawn("/bin/bash")'
pty.spawn opens a pseudo-terminal pair on the target and runs /bin/bash attached to the slave end. The shell now has a real controlling terminal — job control and signal handling on the remote side start working immediately.
That's only half the fix. Your local terminal still doesn't know about it, so it keeps intercepting Ctrl+Z, Ctrl+C, and arrow keys before they ever reach the remote shell. Background the session, put your local terminal into raw mode, and bring the session back:
stty raw -echo; fg
stty raw -echo tells your local terminal to stop processing control characters and stop echoing input locally — from this point on, keystrokes pass straight through instead of being intercepted. fg brings the backgrounded netcat session back to the foreground.
Here's the catch: on a zsh target, this has to be typed as one line, not two commands run in sequence. Run them separately and zsh's own job control can reorder the backgrounding and foregrounding, and you lose the session instead of upgrading it.
Once you're back, finish the setup so full-screen tools render correctly:
stty rows <rows> cols <cols>
export TERM=xterm-256color
Get your terminal's actual dimensions beforehand with stty size or stty -a locally. If the cursor looks pushed to the left after fg, run reset to reinitialize the terminal.
When Python isn't there
Python is common but not guaranteed — minimal containers and stripped-down images may not have it. The fallback is script, which allocates a PTY without needing an interpreter:
SHELL=/bin/bash script -q /dev/null
Same idea, different tool: script normally records a terminal session to a file, but pointing its output at /dev/null and forcing the shell gets you a PTY as a side effect. You still need the same stty raw -echo; fg step afterward — script gives you the terminal, not the local-side sync.
socat can do the whole thing in one shot if it's installed on both ends, but that's a bigger ask than Python or script being present, and it's rarely there by default on a target you don't control.
Limitations
None of this survives a shell restart — if the connection drops, you're back to a dumb shell and have to redo the upgrade. Background jobs started before you upgraded don't retroactively get proper signal handling; if you need that, restart them after. And matching TERM matters more than it looks: get it wrong and full-screen tools will misrender even though the PTY itself is working fine.
Where this leaves you
Stability isn't a feature list — it's a PTY on the target and terminal state kept in sync between both ends. Once you understand what stty raw -echo; fg and pty.spawn are actually doing to the terminal, you're not memorizing a one-liner. You can reach for script when Python's missing, or diagnose why a shell still won't take sudo after you thought you'd upgraded it — because you know what "stable" actually requires.