maleadt     about     archive

Knocking ports from your browser

After switching my SSH server over from some random high port to tcp/22, I have been seeing loads of brute-force activity trying to gain access. Even though those attempts are futile, I don’t like the attention. Besides, who knows what vulnerabilities may crop up yet.

In search for a good protection measure, I decided to avoid exposure of critical services through a layer of port knocking. Traditionally, one relies on special network traffic (sequence of connection attempts, specific packet with encrypted payload, …) to temporarily open up additional ports. However, generating such traffic is not user friendly (ie. requires some tool or script) and it can be hard getting it through strict firewalls.

I wanted to knock ports using some protocol or traffic, which is:

My solution to this is a simple HTML/PHP form, served over password- protected HTTPS. No firewall would block such traffic, and it is both extremely user-friendly and very secure.

Implementation

The bottom part of the system is the firewall, allowing for certain ports to be opened upon an external signal. I’m using FireHOL, a lightweight iptables-based firewall with a user-friendly syntax:

interface eth0 internet not "${UNROUTABLE_IPS}"
    policy drop
    protection strong 10/sec 10

    server ssh accept with knock admin

The important part here is with knock $NAME, which will build a special chain called knock_$NAME. Adding rules to this chain will allow access to the ssh service, deleting them will revoke access (keeping established connections intact).

Next up is some HTML form allowing the user to select a set of services to unlock. The forms also provides an IP field, which can be useful when unlocking from a different address (for example, your phone).

<form action="<?=htmlentities($_SERVER['PHP_SELF']); ?>"
      method="post">
  <table>
    <tr>
      <td><label for='service[]'>Service:</label></td>
      <td><select name="service">
        <option value="admin">administrative</option>
      </select></td>
    </tr>

    <tr>
      <td><label for='source'>Source IP:</label></td>
      <td><input type="text" name="source"
                 value="<?=(isset($_POST['source'])
                            ? $_POST['source']
                            : $_SERVER['REMOTE_ADDR'])?>">
      </td>
    </tr>

    <tr>
      <td><label for='duration'>Duration:</label></td>
      <td><input type="text" name="duration"
                 value="<?=(isset($_POST['duration'])
                            ? $_POST['duration']
                            : 15)?>">
      </td>
    </tr>

    <tr>
      <td></td>
      <td><input type="submit" name="knock"
                 value="Knock"></td>
    </td>
  </table>
</form>

In my browser, this form looks like this: Screenshot of form

Within the same PHP file, we process the data submitted by the user:

if (isset($_POST['knock'])) {
  // Build command line
  $error = NULL;
  $command = ["/usr/bin/sudo", "/usr/local/sbin/knock"];
  if (!empty($_POST['source']))
    $command = 
      array_merge($command,
                  ["-s", escapeshellarg($_POST['source'])]);
  if (!empty($_POST['duration']))
    $command =
      array_merge($command,
                  ["-d", escapeshellarg($_POST['duration'])]);
  if (!empty($_POST['service']))
    array_push($command,
               escapeshellarg($_POST['service']));
  else
    $error = "Missing service...";

  // Perform call
  if (!$error) {
    $output = [];
    $rv = 0;
    exec(join(" ", $command), $output, $rv);
    if ($rv != 0)
      $error = join('', $output);
  }

  if ($error)
    echo("<p>Error: " . join('', $output) . ".</p>");
  else
    echo("<p>Service knock completed successfully.</p>");
}

Even though the form is only privately accessible, password protected and served strictly over HTTPS, we still take some basic security precautions by checking and escaping all user inputs.

As another layer of security, I do not allow the PHP script to invoke iptables directly. Instead, I have written an auxiliary knock Perl script which takes care of adding and removing rules to iptables:

use strict;
use warnings;

use POSIX;
use Getopt::Std;

# Parse user-input
my $usage = "Usage: $0 [-s SOURCE] [-d DELAY] SERVICE\n";
my %options;
getopts("s:d:",\%options) || die $usage;
my $service = shift || die "Missing service.\n$usage";
die "Invalid service.\n" unless ($service =~ m/^[a-zA-Z]+$/);
my $delay = $options{d} || 15;
die "Invalid delay.\n" unless ($delay =~ m/^[0-9]+$/);

my @flags;
if (defined $options{s}) {
        push(@flags, ("-s", $options{s}));
}
push(@flags, ("-j", "ACCEPT"));

defined (my $kid = fork) or die "Cannot fork: $!\n";
my $mode;
if ($kid) {
    $mode = "-A";
} else {
    daemonize();
    sleep($delay);
    $mode = "-D";
}

system("/sbin/iptables", $mode, "knock_$service", @flags);
exit 0;

sub daemonize {
    POSIX::setsid or die "setsid: $!";
    chdir "/";
    umask 0;
    foreach (0 .. (POSIX::sysconf (&POSIX::_SC_OPEN_MAX) || 1024)) {
        POSIX::close $_
    }
    open(STDIN, "</dev/null");
    open(STDOUT, ">/dev/null");
    open(STDERR, ">&STDOUT");
}

By using such a script, a hypothetical malicious user can only control a limited subset of iptables commands, restricted to knock_* chains. Note that we use the list-form of Perl’s system() to prevent command injection, and take care of sanitizing user input yet again.

Lastly, the webserver needs to be able to call this script with elevated permissions. Adding www-data to the sudo group (or equivalent) would not be wise, as that would allow an attacker to gain root through the webserver user. Instead, we still use sudo, but put a very specific rule in /etc/sudoers.d/knock allowing the webserver to use sudo for our script but nothing else:

www-data    ALL=NOPASSWD: /usr/local/sbin/knock

Conclusion

Port knocking is a powerful tool for hiding services, and combined with some simple scripts it is easy to use from an ordinary browser. Security is tricky though, so it is best to hide the interface from public access.