Protect your Linux Server with a Port Knocker and UFW

Protect your Linux Server with a Port Knocker and UFW

I was running the SSH server on an internet facing linux machine for a while (the one you are reading this post on...) on the standard port and was, as to be expected, bombarded with bogus login requests.

SSH login requests on an internet facing server

To protect the server, I decided to first move ssh to a different port and then hide it behind a port knocker.

The port knocker is very simple and works in the following way.

  • Your ssh port is by default denied by the firewall.
  • If 3 predefined TCP ports get "knocked" in sequence within a short period of time (a simple request by telnet, nc or ssh), the port knocker daemon will:
    • Enable a firewall rule that allows ssh traffic to the port predefined from the IP that "knocked".
    • Wait a short time
    • Disable the firewall rule again.

Instead of a firewall, you can also use iptables to enable/disable traffic, but I like the clean approach that ufw allows.

The following steps show how to set things up on Ubuntu 22.04 Server but should be very similar on other distros.

Installing the UFW firewall

Setting up the UFW firewall is really easy. Install it using:

sudo apt install ufw

Now, when running

sudo ufw status

You will see that the status is inactive.

The default firewall policy works well for servers and desktops. Close all ports by default and only open required TCP or UDP ports.

sudo ufw default allow outgoing
sudo ufw default deny incoming

Now start opening the ports you need to be accessible in the following way.

sudo ufw allow 80/tcp comment 'Allow HTTP'
sudo ufw allow 443/tcp comment 'Allow SSL'
sudo ufw allow 1122/udp comment 'Allow custom UDP port'

Now you can enable UFW with

sudo ufw enable

and query it's status with

sudo ufw status

You can also get the status list numbered with

sudo ufw status numbered

which will output something like

[1] 80/tcp                     ALLOW IN    Anywhere                   # Allow HTTP
[2] 443/tcp                    ALLOW IN    Anywhere                   # Allow HTTPS
[3] 80/tcp (v6)                ALLOW IN    Anywhere (v6)              # Allow HTTP
[4] 443/tcp (v6)               ALLOW IN    Anywhere (v6)              # Allow HTTPS

You can then easily remove rules with their number

sudo ufw delete 1

Installing a Port Knocker daemon

Next, you need a port knocker daemon. I recommend trying knockd, which is very simple to install and configure. Note that simple port knockers may be vulnerable because a sniffer may recover the port sequence that was used. More complex types use an encrypted string as a knock which is more secure.

To install knockd, run

sudo apt install knockd

Now edit the configuration file /etc/knockd.conf. Here is an example that uses ports 7000, 8000 and 9000, has a 5 second timeout for a knock, will enable port 22 (ssh) for the IP that knocked and after 10 seconds, disable it again.

[options]
        UseSyslog

[SSH]
        sequence    = 7000,8000,9000
        seq_timeout = 5
        command     = ufw allow from %IP% to any port 22
        tcpflags    = syn
        cmd_timeout = 10
        stop_command = ufw delete allow from %IP% to any port 22

The beauty is that if you log in to the server using SSH after knocking, you will not be disconnected when UFW blocks the port again. Only new connections can't be established anymore then.

Also configure the file /etc/default/knockd in the following way so it starts on init and only listens on the internet facing network adapter (in this case eth0)

################################################ 
# 
# knockd's default file, for generic sys config 
# 
################################################ 

# control if we start knockd at init or not 
# 1 = start 
# anything else = don't start 
START_KNOCKD=1 

# command line options 
KNOCKD_OPTS="-i eth0" 

You now need to add the knock ports to UFW:

sudo ufw allow 7000/tcp comment 'Allow knockd on 7000'
sudo ufw allow 8000/tcp comment 'Allow knockd on 8000'
sudo ufw allow 9000/tcp comment 'Allow knockd on 9000'

You don't add 22/tcp to UFW, knockd will manage that for you.

Now start the Knocker daemon using

sudo systemctl knockd enable
sudo systemctl knockd start

Knock Client

To initiate a knock, you can install a knock client. Knockd has a client "knock" already installed.

On MacOS for example, you can install it using:

brew install knock

Alternatively, you can use the following Python script which I have adapted from this repo.

#!/usr/bin/env python

import argparse
import socket
import sys
import time

parser = argparse.ArgumentParser()

parser.add_argument('host', metavar='HOST', type=str,
                    help='Hostname to knock at')
parser.add_argument('ports', metavar='PORT', type=int, nargs='+',
                    help='Port(s) to use, in order specified')
parser.add_argument('-t', '--timeout', type=int,
                    help='Timeout for connection attempt (seconds), default 10')
parser.add_argument('-v', '--verbose', action="store_true",
                    help='Show detailed information')
parser.add_argument('-w', '--wait', type=float,
                    help='Time to wait between knocks (seconds), default 1.0')

parser.set_defaults(timeout=2)
parser.set_defaults(wait=0.5)

args = parser.parse_args()

CONN_REFUSED_MESSAGES = ["connection refused", "no connection could be made because the target machine actively refused it"]
TCP_IP = args.host
TIMEOUT = args.timeout
VERBOSE = args.verbose
WAIT = args.wait

ports_failed = []

if len(args.ports) > 1 and VERBOSE:
    format_wait = str(WAIT).rstrip('0').rstrip('.')
    sys.stdout.write("Waiting %ss between each connection attempt\n" % format_wait)
    sys.stdout.flush()

for i, TCP_PORT in enumerate(args.ports):

    if VERBOSE:
        sys.stdout.write("Knocking on port ")
        sys.stdout.write('{0: <10}'.format(str(TCP_PORT) + '...'))
        sys.stdout.flush()

    sock_msg, sock_ok = None, True

    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(TIMEOUT)
        s.connect((TCP_IP, TCP_PORT))
        sock_msg = "open"
        s.close()

    # timeouts are knocks, too
    except socket.timeout:
        sock_msg = "no answer"

    except socket.error as e:
        if any(substring in str(e).lower() for substring in CONN_REFUSED_MESSAGES):
            sock_ok = True
        else:
            ports_failed.append(TCP_PORT)
            sock_msg = e
            sock_ok = False

    if VERBOSE:
        if sock_ok:
            sys.stdout.write("OK")
        else:
            sys.stdout.write("FAILED")
        if sock_msg:
            sys.stdout.write(f" ({sock_msg})")
        sys.stdout.write("\n")
        sys.stdout.flush()

    if i+1 < len(args.ports):
        time.sleep(WAIT)

if len(ports_failed):
    s_ports = ", ".join([str(p) for p in ports_failed])
    sys.stdout.write(f"\nFailed ports: {s_ports}")
    sys.stdout.flush()
    sys.exit(1)

sys.stdout.flush()
sys.exit(0)

On linux or Mac OS, just name the file knack , make it executable and put it somewhere in the path. You can then just call it using

knack -v server.domain.com 7000 8000 9000

On Windows, this works as well. Name the file knack.py and put it in your path. Now add .PY to the existing PATHEXT environment variable and associate the .py file extension with C:\Windows\py.exe, the Python launcher.

You can now also call it with the same command as on linux / Mac OS.

knack -v server.domain.com 7000 8000 9000

Thanks for setting me on this path, Skylar! 😸♥️