Creating a tool in Golang for blocking malicious IPs

1. Introduction

Since I started this blog, my server has been getting more attacks than i could handle. I realized I was fighting an unfair war. A war with a group of zombie machines trying every possible way to get in. I knew it was time to bring out the big guns. It was time to fight fire with fire, and machines with machines. So I picked up my favorite language GO. And made it my objective to create a tool which will automatically block these malicious IPs.

2. The plan

2.1 Collecting the malicious IPs

Before we start writing any lines of code, We have to plan first how the tool will work. Each time there is a failed attempt to authenticate through ssh. It will generate a message in the systemd journal. We can view these messages using the following command.

$ sudo journalctl -u ssh -o cat
...
pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=218.92.0.108  user=root
Failed password for root from 218.92.0.108 port 15681 ssh2
Failed password for root from 218.92.0.27 port 54186 ssh2
...

So our tool will need to attach to this journalctl command and read new lines as they are generated.

2.2 Blocking the IPs

To be able to block these ip addresses we will use the ipset. We learned about in the last article (Using ipset to efficiently block multiple IP addresses).

Let’s first create an ipset:

$ sudo ipset create badips iphash

Now we will configure an iptables rule to block any packet coming from one of the IP addresses in the ipset badips:

$ sudo iptables -t raw -I PREROUTING -m set --match-set badips src -j DROP

Now we can just add the malicious IP addresses that want to block to the badips set as follows:

$ sudo ipset add badips 1.1.1.1

To reiterate, we created a list of IP addresses called badips and we told iptables to block any packet coming from an IP address that belongs to the list. Whenever we want to block a new IP we just have to add it the list.

3. The execution

We will break our project into 4 files as follows:

├── iptables.go
├── journal.go
├── logger.go
└── main.go
3.1 journal.go

journal.go will be responsible for hooking to the systemd’s journal and parsing IPs from all ssh authentication errors. It will also maintain a count for every single IP. Once the count reaches a certain threshold, it will send it through badIPChannel to be blocked.

package main

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"net"
	"os/exec"
	"strings"
)

// journalWatcher is responsible for attaching to and reading from journalctl
// badIPChannel is used for sending IPs to be blocked
// errChannel is for unrecoverable errors
// threshold is the number of times an IP will showup before it's blocked
// badIPCount maintains a count for all IPs encountered
type journalWatcher struct {
	badIPChannel chan net.IP
	errChannel   chan error
	threshold    int
	badIPCount   map[string]int
}

// Returns a new journal watcher.
func NewJournal(badIPChannel chan net.IP,
	errChannel chan error, threshold int) *journalWatcher {
	return &journalWatcher{
		badIPChannel: badIPChannel,
		errChannel:   errChannel,
		threshold:    threshold,
		badIPCount:   map[string]int{},
	}
}

// executes the command "journalctl -f -u ssh -n 0 -o cat" and attaches to
// stdout and stderr
func (j journalWatcher) Run() {
	command := exec.Command("journalctl", "-f", "-u", "ssh", "-n", "0", "-o", "cat")
	stdout, err := command.StdoutPipe()
	if err != nil {
		j.errChannel <- err
		return
	}
	stderr, err := command.StderrPipe()
	if err != nil {
		j.errChannel <- err
		return
	}
	err = command.Start()
	if err != nil {
		j.errChannel <- err
		return
	}
	go j.StartParser(stdout)
	go j.ListenForErrors(stderr)

	err = command.Wait()
	if err != nil {
		j.errChannel <- err
		return
	}
}

// read new lines from journal and parse IP addresses
// keep a count of each time an IP address is encountered in badIPCount
// when the count reaches a threshold send the ip through badIPChannel to be blocked.
func (j journalWatcher) StartParser(stdoutPipe io.Reader) {
	scanner := bufio.NewScanner(stdoutPipe)
	for scanner.Scan() {
		if !strings.Contains(scanner.Text(), "Failed password") {
			continue
		}
		ip, err := j.parseIP(scanner.Text())
		if err != nil {
			errLogger.Println(err)
			continue
		}
		j.badIPCount[ip.String()] += 1
		if j.badIPCount[ip.String()] >= j.threshold {
			j.badIPChannel <- ip
			delete(j.badIPCount, ip.String())
		}
	}
	close(j.badIPChannel)
	j.errChannel <- fmt.Errorf("journalctl stdout pipe closed err: %w", scanner.Err())
}

// listens for errors from journalctl command
func (j journalWatcher) ListenForErrors(stderr io.Reader) {
	scanner := bufio.NewScanner(stderr)
	for scanner.Scan() {
		j.errChannel <- errors.New(scanner.Text())
		return
	}
	close(j.badIPChannel)
	j.errChannel <- fmt.Errorf("journalctl stderr pipe closed err:%w", scanner.Err())
}

// parses ip address from each line
func (j journalWatcher) parseIP(line string) (net.IP, error) {
	parts := strings.Split(line, " ")
	var ipComing = false
	for _, p := range parts {
		if ipComing {
			ip := net.ParseIP(p)
			if ip == nil {
				return nil, fmt.Errorf("failed to parse %s as an IP", p)
			}
			return ip, nil
		}
		if p == "from" {
			ipComing = true
		}
	}
	return nil, fmt.Errorf("ip not found in line '%s'", line)
}
3.2 iptables.go

This file implements the blocking functionality through iptables and ipset. It starts by checking if the necessary dependencies are available, And then it will start listening to the badIPChannel. Whenever there is a new IP to block it will just run the command to add it to ipset.

package main

import (
	"errors"
	"fmt"
	"net"
	"os/exec"
	"strings"
)

// iptablesBlocker is responsible for blocking malicious IP addresses.
type iptablesBlocker struct {
	badIPChannel chan net.IP
	errChannel   chan error
	blockedIPs   map[string]struct{}
}

// creates a new iptablesBlocker. badIPChannel will be used to for
// receiving malicious IP addresses. errChannel will be used for sending
// errors

func NewIPtablesBlocker(badIPChannel chan net.IP,
	errChannel chan error) (*iptablesBlocker, error) {

	infoLogger.Println("checking if iptables is installed...")
	cmd := exec.Command("iptables", "--version")
	_, err := cmd.CombinedOutput()
	if err != nil {
		return nil, err
	}

	infoLogger.Println("checking if ipset is installed...")
	cmd = exec.Command("ipset", "--version")
	_, err = cmd.CombinedOutput()
	if err != nil {
		return nil, err
	}

	infoLogger.Println("creating ipset badips ...")
	cmd = exec.Command("ipset", "create", "badips", "iphash")
	output, err := cmd.CombinedOutput()
	if err != nil {
		if !strings.Contains(string(output), "already exists") {
			return nil, fmt.Errorf("%w: %s", err, string(output))
		}
	}

	infoLogger.Println("creating iptables blocking rule ...")
	cmd = exec.Command("iptables", "-t", "raw", "-I", "PREROUTING", "-m", "set", "--match-set", "badips", "src", "-j", "DROP")
	output, err = cmd.CombinedOutput()
	if err != nil {
		return nil, fmt.Errorf("%w: %s", err, string(output))
	}
	return &iptablesBlocker{
		badIPChannel: badIPChannel,
		errChannel:   errChannel,
		blockedIPs:   map[string]struct{}{},
	}, nil
}

// creates a new iptablesBlocker. badIPChannel will be used to for
// receiving malicious IP addresses. errChannel will be used for sending
// errors
func (b iptablesBlocker) startBlockingIPs() {
	for ip := range b.badIPChannel {
		if _, ok := b.blockedIPs[ip.String()]; ok {
			continue
		}
		err := b.blockIP(ip)
		if err != nil {
			b.errChannel <- err
			return
		}
	}
}

// blocks an IP address by adding it to "badips" ipset.
func (b iptablesBlocker) blockIP(ip net.IP) error {
	cmd := exec.Command("ipset", "add", "badips", ip.String())
	infoLogger.Printf("blocking ip %s\n", ip.String())
	_, err := cmd.Output()
	if err != nil {
		if exitError, ok := err.(*exec.ExitError); ok {
			if strings.Contains(string(exitError.Stderr), "already added") {
				return nil
			}
			return errors.New(string(exitError.Stderr))
		}
		return err
	}
	return nil
}
3.3 logger.go

In this file we will declare the loggers that we will use to logs what’s happening with our tool.

package main

import (
	"log"
	"os"
)

var infoLogger = log.New(os.Stdout, "INFO: ", log.LstdFlags)
var errLogger = log.New(os.Stderr, "ERROR: ", log.LstdFlags)
var fatalLogger = log.New(os.Stderr, "FATAL: ", log.LstdFlags)

3.4 Bringing things together in main.go

This will be the entry point for our application. We will initialize the journal parser as well as the iptables blocker. Our application will run until it receives a non recoverable error.


package main

import (
	"net"
	"os"
)

func main() {
	badIPChannel := make(chan net.IP, 10)
	errChannel := make(chan error)
	journal := NewJournal(badIPChannel, errChannel, 3)
	iptablesBlocker, err := NewIPtablesBlocker(badIPChannel, errChannel)
	if err != nil {
		fatalLogger.Println(err)
		os.Exit(1)
	}

	go journal.Run()
	go iptablesBlocker.startBlockingIPs()

	infoLogger.Println("brutedef running...")
	// read errors from errChannel and exit when an err is received
	err = <-errChannel
	if err != nil {
		fatalLogger.Println(err)
		os.Exit(1)
	}
}

4. Deployment

4.1 Building the binary

Now that we have finished coding our application. It’s time to deploy it to the server. First things first, let’s build our application.

$ go build -ldflags "-w -s" .
$ tree
├── brutedef  #<= this is the binary that we have to deploy
├── go.mod
├── iptables.go
├── journal.go
├── logger.go
└── main.go

4.2 Creating a systemd service

Our tool needs to be running all the time so it can actually block IPs. For this reason we will create a systemd config file for it. This will make it run as a service in the background, and even if the server restarts it will start automatically.

Create a config file for the service as follows

$ sudo cat /etc/systemd/system/brutedef.service
[Unit]
# Here we provide information for our service
Description= SSH Brute Force Defence

[Service]
# Here we specify the path for the binary
ExecStart=/opt/brutedef/brutedef

[Install]
# Here we specify the runlevel where the service should start
WantedBy=multi-user.target

Now we can just start our service using the following command

$ sudo systemctl start brutedef

To check if our service is running, we can use the following command:

$ sudo systemctl status brutedef
● brutedef.service - SSH Brute Force Defence
   Loaded: loaded (/etc/systemd/system/brutedef.service; enabled; vendor preset: enabled)
   Active: active (running) since Fri 2023-08-18 10:33:03 UTC; 3min 38s ago
 Main PID: 24070 (brutedef)
    Tasks: 5 (limit: 1149)
   Memory: 5.5M
   CGroup: /system.slice/brutedef.service
           ├─24070 /opt/brutedef/brutedef
           └─24077 journalctl -f -u ssh -n 0 -o cat

Started SSH Brute Force Defence.
INFO: 2023/08/18 10:33:03 checking if iptables is installed...
INFO: 2023/08/18 10:33:03 checking if ipset is installed...
INFO: 2023/08/18 10:33:03 creating ipset badips ...
INFO: 2023/08/18 10:33:03 creating iptables blocking rule ...
INFO: 2023/08/18 10:33:03 brutedef running...
INFO: 2023/08/18 10:35:30 blocking ip 103.142.*.*

to make sure that our service starts automatically whenever the server restarts:

$ sudo systemctl enable brutedef
Created symlink /etc/systemd/system/multi-user.target.wants/brutedef.service → /etc/systemd/system/brutedef.service.

So I have been running this service for a week now. Let’s see how many IPs it blocked.

$ sudo ipset list
Name: badips
Type: hash:ip
Revision: 4
Header: family inet hashsize 1024 maxelem 65536
Size in memory: 33352
References: 3
Number of entries: 1112
Members:
43.134.*.*
117.1.*.*
81.169.*.*
14.34.*.*
129.146.*.*
...

As you can see. There are 1112 IPs in the list which is a very significant number. Let’s see how many packets in generale were blocked.

Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
 207K   13M DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0            match-set badips src

So basically we have protected our server from more than 207k potential attacks.

You can find all the files for this project on github brutedef.