Building a High-Performance Asynchronous Port Scanner with Ruby
To see just how fast, let's try building an asynchronous port scanner; meaning a port scanner that can look for many open ports at a time. To qualify as "fast", at least for the sake of this blog post, means it should be able to scan hundreds of ports a second.
Just how do we do that?
π§ Single-Threaded Design
The first option you might jump to in order to increase performance in languages like Ruby or Python is usually multi-threading. This isn't the absolute worst choice but quickly becomes a bottle-neck in and of itself due to a global lock these languages enforce on their runtime ensuring only one thread runs at a time, even if you have many.
Effectively, this means your "multi-threaded" programs are really more like a confusing single-threaded program β sometimes actually performing worse than their truly single-threaded variants. If this is the first time you've heard this, it can be kind'of tricky to really understand.
The main advantage of using multi-threading is providing a means to deal with real world situations like network or disk I/O (input/output) operational latency. So, programmers have come up with abstractions to deal with this. With multi-threading APIs in Ruby or Python, we have an abstraction as a programmer to splice up our work into time segments. On I/O blocking operations, the language will decide that another thread can run, usually providing a speed boost for things like varying network connection speeds. This doesn't come cheap though.
Threads will often end up "fighting" each-other for control of the runtime causing CPU context-switching which eats up your performance. This can be a really annoying thing to wrap your head around and provide a rather erratic runtime behavior that is a pain to debug or code complex interactions.
To avoid the headache of orchestrating multiple threads, we will be building a single-threaded port scanner. But, like in a multi-threaded program, we still want to be able to weave together tasks asynchronously.
If a program can be broken up into a series of tasks that can be executed independently, it can be considered asynchronous. It is a property of how a program can be executed, not what it does. βSamuel Williams
β‘οΈ Modern Async Support for Ruby
For sometime now, Samuel Williams and friends have been working away on bringing modern, asynchronous and timeout-capable I/O for Ruby to the masses under the Socketry project.
Side Note: You can read more about it here.
Using async-await will
provide us with a clean API to describe asynchronous tasks. To ensure our network connections
are asynchronous, we will use async-io. Using async Gems within the Socketry project provides a plethora of options to build non-blocking I/O programs with great performance. The underlying design of async is based on a Reactor Pattern for concurrency backed by Fibers.
Side Note: Another project you might want to check out is Falcon, a high performance asynchronous multi-process web-server built on async libraries.
π©π½βπ» Implementing the Port Scanner with Async
To get started, let's write the first part of the script to require the async libraries we want to use.
require "async/await" require "async/io" require "async/semaphore" # more code ...
Now, let's create a PortScanner class, including the libraries within it to
provide clean async semantics and create our #initialize method:
# previous code ... class PortScanner include Async::Await include Async::IO def initialize(host: "127.0.0.1", ports: (1..1024)) @host = host @ports = ports @semaphore = Async::Semaphore.new(`ulimit -n`.to_i) end # more code... end
By default, if no host: argument is given when creating a new instance of our
scanner, our localhost will be the target to scan. If not given any Range/Array/Enumerable for ports:, then ports 1...1024 will be used, very similar to nmap.
The @semaphore will be used to lock down the number of scans going on at a time to whatever the max number of
available file descriptors are on the system. For every open connection, there's an open file on
the system, and we want to limit this to what ulimit tells us that
is, at least specifically on unix-like operating systems including macOS or Linux.
Note: ulimit is a builtin shell command used
to manage various resource restrictions. The -n flag will reveal the maximum
number of open files allowed; #to_i on the output from that command in the code
will turn it into a Integer.
We cache this result and use it to set the maximum number of open connections we'll try at a
time, since there's no reason to push past that threshold.
Now, let's implement a method which can scan a single port in our PortScanner class:
β οΈ This is the code to scan a single port. Moreover, this is will be the most complicated part of the code because of all the things we need to juggle, which I'll explain in a bit more detail in just a moment.
# inside of PortScanner, under initialize ... def scan_port(port, timeout: 0.5) timeout(timeout) do Async::IO::Endpoint.tcp(@host, port).connect do |peer| peer.close puts "#{port} open" end end rescue Errno::ECONNREFUSED, Async::TimeoutError puts "#{port} closed" rescue Errno::EMFILE sleep timeout retry end # more code...
π€Ήπ»β Three Essential Errors to Juggle:
Errno::ECONNREFUSED is an error that will occur when a port is not open to
connect to. This is a pretty typical response for most ports, and this response that is
enforced with most REJECT firewall rules.
Async::TimeoutError is an error produced by timeout(timeout) when an operation didn't complete within the allotted timeout: value
(default of 0.5). This may happen for various reasons including a DROP firewall rule where the target doesn't explicitly tell you a port isn't open from the scanner'
perspective. We really don't want to wait around forever to get nothing.
Errno::EMFILE is an error that will occur when we try to connect to a port,
but we have way too many connections (files, really) already open and need to schedule the
execution to happen again, but just a little later, continuously delaying its execution until
it's able to be processed.
To break down what's happening, the method #scan_port will attempt to connect to
the target on the given port β I know, that's the obvious part.
If any error occurs β and that's going to happenβ we can rescue that method and do something based on the cause of the error, right inside of the method itself.
π€ Why do we do this again?
During a port scan, especially for very large ranges, say thousands, or tens of thousands, our
scanner may try to open up a connection, but β due the the limitations of our operating system
β we may not be able to. We could be browsing Twitter or GitHub while our scanner runs super large batches
after-all. Remember ulimit and the @semaphore from earlier inside of #initialize?
Keeping track of the appropriate errors helps prevent missing open ports on larger scan ranges and allows us to handle things according to the error. In certain situations, multi-threaded port scanners can end up missing open ports simply because the scanner is counting any and all errors as a "closed port" when it probably should be retrying the ports that error'd out because of the operating system's limitations. This goes for basically every port scanning implementation I've created and/or used.
In order to scan all the ports the PortScanner class is initialized with, let's
implement a #start method which will iterate over each port and calls the #scan_port method we just created. But, this time we'll use the async keyword in-front of the
method definition.
# inside of PortScanner, under scan_port ... async def start(timeout: 0.5) @ports.map do |port| @semaphore.async do scan_port(port, timeout: timeout) end end.collect(&:result) end
Within the #start method, each port in the range that the PortScanner is initialized with will be scanned, running asynchronously β and resource bound to the operating
system's limitations with the @semaphore. The #collect(&:result) near the very end of the method will allow us to collect the result
of each port scan attempt.
βοΈ Using the Scanner
To use the port scanning API we just created could look something just like the following:
# previous code ... PortScanner.new(host: "127.0.0.1", ports: (1..1024)).start
There aren't any open ports to test with? Using nc, it's pretty easy to create some open ports. The following will setup a TCP listener on your localhost on port 1024:
$ nc -v -l -p 1024
Now, let's run our port scanner, using grep to filter for the open ports:
$ ruby async_port_scanner.rb | grep "open" 1024 open
π₯Bam! Just like that, we have a port scanner! How fast does it go? Using time on Unix-like operating systems can help us get an idea:
$ time ruby async_port_scanner.rb | grep "open" 1024 open real 0m2.345s user 0m0.531s sys 0m0.130s
That's roughly 2β3 seconds to scan all those ports (1024 of them) on a single
thread! What if we wanted to scan all the ports (1β¦65535), how long would that
take?
real 1m12.693s user 1m5.933s sys 0m5.157s
So, if I can do the calculation right: 65535 ports/(1min + 12secs = 72secs) would
be roughly ~910 ports a second. That's pretty fast! π
π Source Code
Below is the full source code for the scanner (copy+paste friendly for testing):
π€40 lines of asynchronous port scanning awesomeness!
require "async/await" require "async/io" require "async/semaphore" class PortScanner include Async::Await include Async::IO def initialize(host: "127.0.0.1", ports: (1..1024)) @host = host @ports = ports @semaphore = Async::Semaphore.new(`ulimit -n`.to_i) end def scan_port(port, timeout: 0.5) timeout(timeout) do Async::IO::Endpoint.tcp(@host, port).connect do |peer| peer.close puts "#{port} open" end end rescue Errno::ECONNREFUSED, Async::TimeoutError puts "#{port} closed" rescue Errno::EMFILE sleep timeout retry end async def start(timeout: 0.5) @ports.map do |port| @semaphore.async do scan_port(port, timeout: timeout) end end.collect(&:result) end end PortScanner.new(host: "127.0.0.1", ports: (1..1024)).start
π Conclusion
Building a high performance, asynchronous port scanner with Ruby is actually pretty awesome, and very possible! By embracing single threaded performance backed by Fibers provides really interesting opportunities, and great performance for I/O-bound operations (like a port scanner) that should only get better over time.
If you like what you see, be sure to give the Async/Socketry gem(s) a star πon GitHub β or, make a pull request to make them better! If you see where I did something wrong, want to improve the implementation, or have any questions, feel to chat on Gitter!
Until next time, that's all folks!