Building a High Performance Port Scanner with Golang
Side Note: If you also like async/await style programming and Ruby, you might like this.
I've written about building a port scanner several times before; and this implementation is the fastest one I've been able to create thus far. Utilizing the multi-threaded runtime and the baked-in concurrency model provided by Golang, asynchronous I/O performance is just beautiful.๐
What does that end up looking like to actually code, and how fast does it go?
๐ฆ Assembling our Package
Like any other language, we'll pull in some libraries that have encapsulated the stuff we're interested in orchestrating into a useful program.
In the world of Golang, we'll import a bunch of packages โ and at the very top of
our script, we'll declare it as our "main" package.
package main import ( "context" "fmt" "net" "os/exec" "strconv" "strings" "sync" "time" "golang.org/x/sync/semaphore" ) // more code ...
Unlike object-orientated languages, there's no concept of a class wherein we add methods inside of. Instead, we can just attach functions to any type, ultimately giving us a method.
So, let's make a type called PortScanner which will be struct type containing two
fields: ip and lock.
type PortScanner struct { ip string lock *semaphore.Weighted }
The ip will be the IP address of the host on the network we're interested in
scanning. The lock will act as a threshold that will limit the number of go routines
that will be running at any given time.
To set our lock based on the limits of the operating system, we may opt to use
the value of the ulimit command. This can be captured to be parsed using the os/exec package.
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;
we can capture the output of this command and convert it to the right type.
To abstract that whole process away of getting the value from the command-line, I decided to
put it in a function called Ulimit.
func Ulimit() int64 { out, err := exec.Command("ulimit", "-n").Output() if err != nil { panic(err) } s := strings.TrimSpace(string(out)) i, err := strconv.ParseInt(s, 10, 64) if err != nil { panic(err) } return i }
Since we're building a port scanner, we're going to need another function that can actually,
ya' know, scan the port. The net package includes a convenient method to make a connection
with an allotted timeout value.
func ScanPort(ip string, port int, timeout time.Duration) { target := fmt.Sprintf("%s:%d", ip, port) conn, err := net.DialTimeout("tcp", target, timeout) if err != nil { if strings.Contains(err.Error(), "too many open files") { time.Sleep(timeout) ScanPort(ip, port, timeout) } else { fmt.Println(port, "closed") } return } conn.Close() fmt.Println(port, "open") }
As you probably noticed, most of the ScanPort function deals with the err from net.DialTimeout. This is because, as with any port scanner, we have some
errors to juggle:
๐คน๐ปโโ๏ธ Three Essential Errors to Juggle:
connect: connection refused 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.
dial: i/o timeout is an error produced by net.DialTimeout when an operation didn't complete within the allotted timeout value. 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's perspective. We really don't want to wait around
forever to get nothing.
socket: too many open files 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.
Based on the result of the error for the connection attempt, if the attempt failed as a result of too many files being open on the system, we'll delay the execution of that port scan. Once the delay returns, we can again retry it until the connections fails because of a timeout or, better yet, a successful connection.
To use the ScanPort method for a range of ports, we'll implement a method on the PortScanner struct that will scan a given range of ports (from the first f to the last one l) along with a Duration to use as a timeout. We'll call this
function Start, as it will kick-off the actual connection attempts later on.
func (ps *PortScanner) Start(f, l int, timeout time.Duration) { wg := sync.WaitGroup{} defer wg.Wait() for port := f; port <= l; port++ { wg.Add(1) ps.lock.Acquire(context.TODO(), 1) go func(port int) { defer ps.lock.Release(1) defer wg.Done() ScanPort(ps.ip, port, timeout) }(port) } }
Limiting Go Routines for Performance
While it is be easy to just spawn off a new go routine for every connection attempt, this
isn't the most efficient way of utilizing our computer's resources. Instead, it's much better
to bound our connections attempts to a set size based on our shell's limits. The lock in our port scanner "locks down" the concurrent connection attempts, and thus wastes significantly
less time by not trying to exceed its threshold.
The Main Function
All that's left is to write our program's main function to use the code we just implemented
to scan all of the ports on our local host.
func main() { ps := &PortScanner{ ip: "127.0.0.1", lock: semaphore.NewWeighted(Ulimit()), } ps.Start(1, 65535, 500*time.Millisecond) }
๐จ Compiling the Program
Compiling our port scanner is as trivial as running the following command:
$ go build port_scanner.go
Which provides us with a binary which we can execute to perform our port scan now:
$ ./port_scanner
But, how fast does it go? Let's use the time command on unix-like systems to give
us a quick benchmark for an idea:
$ time ./port_scanner ... real 0m31.384s user 0m4.766s sys 0m7.653s
Let's do the math on that: 65535 ports / 31 seconds is about 2000 ports a second. That's incredibly fast, and 2x faster than the single-threaded Ruby version I've made before! ๐
๐ Source Code
Below is the full source code for the scanner (copy+paste friendly for testing):
package main import ( "context" "fmt" "net" "os/exec" "strconv" "strings" "sync" "time" "golang.org/x/sync/semaphore" ) type PortScanner struct { ip string lock *semaphore.Weighted } func Ulimit() int64 { out, err := exec.Command("ulimit", "-n").Output() if err != nil { panic(err) } s := strings.TrimSpace(string(out)) i, err := strconv.ParseInt(s, 10, 64) if err != nil { panic(err) } return i } func ScanPort(ip string, port int, timeout time.Duration) { target := fmt.Sprintf("%s:%d", ip, port) conn, err := net.DialTimeout("tcp", target, timeout) if err != nil { if strings.Contains(err.Error(), "too many open files") { time.Sleep(timeout) ScanPort(ip, port, timeout) } else { fmt.Println(port, "closed") } return } conn.Close() fmt.Println(port, "open") } func (ps *PortScanner) Start(f, l int, timeout time.Duration) { wg := sync.WaitGroup{} defer wg.Wait() for port := f; port <= l; port++ { wg.Add(1) ps.lock.Acquire(context.TODO(), 1) go func(port int) { defer ps.lock.Release(1) defer wg.Done() ScanPort(ps.ip, port, timeout) }(port) } } func main() { ps := &PortScanner{ ip: "127.0.0.1", lock: semaphore.NewWeighted(Ulimit()), } ps.Start(1, 65535, 500*time.Millisecond) }
๐ Conclusion
The multi-threaded runtime in Golang coupled with an asynchronous design allows for some incredible performance for a simple port scanner. Feel free to hack on it some more or let me know where it could be improved.
Until next time, that's all folks!