March 2, 2019

Locating ssh hackers


Have you ever read an article and thought: I want to build this?

Well that happened to me while reading Geolocating SSH Hackers In Real-Time, so I decided to build it.

I am into Kubernetes these days so I decided that I would host the showcase on my Kubernetes lab environment:

  • I run a Proxmox server with 64 cores and 256 GB of RAM, that is reachable over ssh from the internet (pub/priv-key login only). This is perfect for implementing this showcase as and username/password auth attempt fails anyway
  • My production Kubernetes cluster has two ingress controllers and one of them is reachable only internally: this is perfect to expose Grafana

The end result (TLDR;)

The final architecture looks like this:

   | (4) Grafana Dasboard |
   | (3)    InfluxDB      |
   | (2) Log Collector  |
    --------------------    Kubernetes
             |              -----------
   | (1)     syslogd     |    Host OS
  1. syslogd forwards all log entries related to failed login attempts to (2), passing the IP as parameter
  2. The login collector gets called each time a login attempt fails. It resolves the IP address into coordinates and stored the login attempt with the timestamp and its location in InfluxDB
  3. InfluxDB persists the login attempts and exposes a datasource to Grafana
  4. The Grafana dashboard draws the data from InfluxDB and shows the attempts to hack the server: failed login attempts

The final result from an end-user perspective looks like this:

Grafana Dashboard

How I got there

We will proceed in a different order from top to bottom so that we can test the chain when syslogd’s forwarding is enabled. All details as well as the source code can be found here



The setup of Influxdb is pretty straight forward using helm, based on a slightly modified values.yml.

helm install --name influxdb -f ./values/influxdb/values.yaml stable/influxdb --namespace influxdb


The influxdb user is admin and you can retrieve the auto-generated password with: kubectl -n influxdb get secret influxdb-influxdb-auth -o jsonpath="{.data.influxdb-password}" | base64 -d

Create the database

We will use the influxdb cli to connect and create the database login-attempts.

First, retrieve the password:

export PASSWORD=$(kubectl -n influxdb get secret influxdb-influxdb-auth -o jsonpath="{.data.influxdb-password}" | base64 -d)

Then forward the local port 8086 to the influxdb pod:

kubectl port-forward --namespace influxdb $(kubectl get pods --namespace influxdb -l app=influxdb-influxdb -o jsonpath='{ .items[0] }') 8086:8086

connect to InfluxDB:

influx -host -port 8086 -username admin -password $PASSWORD

and finally create the database:

create database "login-attempts" with duration 30d



Here again we will use helm:

helm install --name grafana -f ./values/grafana/values.yaml stable/grafana --namespace grafana

and retrieve the password (the username is admin):

kubectl get secret --namespace grafana grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo

To expose grafana to the network we need an ingress and there are two ways to deploy that kubernetes object:

  1. let helm create the ingress by setting ingress.enabled to true in values/grafana/values.yaml
  2. Deploy an own ingress: this is the way I prefer to do things because I use Traefik as an ingress controller on my Kubernetes cluster, that takes care of generating Let’s Encrypt certificates automatically for any new ingress. Aside from that I did not want to expose Grafana to the internet so I added a whitelist-source-range definition to the ingress to make sure that Grafana only accepts incoming traffic coming from my local network.

To create the ingress using Traefik:

  1. Edit the hostname in manifests/grafana/ingress.yaml
  2. Deploy the ingress: kubectl -n grafana apply -f manifests/grafana/ingress.yaml

Add Worldmap Panel

Next we will add the Worldmap panel to Grafana.

  1. connect to the grafana pod: kubectl -n grafana exec -it $(kubectl get pods --namespace grafana -l app=grafana -o jsonpath='{.items[0]}') /bin/bash
  2. Install the panel: grafana-cli plugins install grafana-worldmap-panel
  3. Force the pod to restart: kubectl -n grafana delete pod $(kubectl get pods --namespace grafana -l app=grafana -o jsonpath='{.items[0]}')
  4. Wait for grafana to restart and verify that the panel is available: click “Add Dashboard” and check that the “Worldmap Panel” is available

Add InfluxDB datasourcenfluxdb

  1. Go to “Configuration - Datasources” and select “Add Datasource”
  2. Select “InfluxDB”
  3. Name the datasource InfluxDB
  4. Add the cluster internal URL: http://influxdb-influxdb.influxdb.svc:8086
  5. Enter the database name: _internal (the default database)
  6. Add admin and his password as credentials
  7. Hit save and test: you should see a positive test-result

The log collector

Create an account

The tcp listener uses to resolve IPs to geohashes.

1. Register a free account at 1. Grab your API key and add it to config.json

Update 2019-03-31: I reached’s quota of free api-calls far too soon so I moved to the free (for non-commercial use)

Build and deploy

The log receiver is a tcp socket listener written in nodejs that will get called from rsyslogd.

We will deploy it as container to Kubernetes, and expose it through a nodePort (we can not use an ingress as the communication will happen over tcp, not http):

  1. Build the container: docker build -t <your-dockerhub-name>/logincollector:<tag>
  2. Push the container: docker push <your-dockerhub-name>/logincollector:<tag>
  3. Change the deployment in manifests/logincollector/deployment.yaml according to your docker image
  4. Create the namespace: kubectl create ns logincollector
  5. Deploy: kubectl -n logincollector apply -f manifests/logincollector/

You should see the following output:

deployment.apps "collector-deployment" created
service "collector-svc" created

Now get the details of the service: kubectl -n logincollector get svc

That should display something like this:

NAME            TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)          AGE
collector-svc   NodePort   <none>        8080:31829/TCP   40s

Where 31829 is the node port that is exposed on each cluster node. We will need this port to configure the log shipping in the rsyslogd config.


Find out the log pattern we are looking for

On the node running sshd check the result of less /var/log/auth.log | grep ssh. The output may look like this:

Feb 20 11:59:55 pve sshd[31596]: Invalid user netflow from port 48854
Feb 20 11:59:55 pve sshd[31596]: Invalid user netflow from port 48854
Feb 20 12:01:36 pve sshd[31816]: Disconnected from port 33903 [preauth]

Your log may look different so you may have to adapt the patterns. What we are looking for here is: Invalid user netflow from port 48854, so a less /var/log/auth.log | grep ssh | grep " Invalid user" will show the list of login attempts we are looking for.

Create the rsysdlog rules

Edit /etc/rsyslog.d/50-default.conf and add:

#  Default rules for rsyslog.
#                       For more information see rsyslog.conf(5) and /etc/rsyslog.conf

# First some standard log files.  Log by facility.

if $programname == 'sshd' then {
   if $msg startswith ' Invalid user' then {
      # Transform and forward data!
      action(type="omfwd" queue.type="LinkedList" action.resumeRetryCount="-1" queue.size="10000" queue.saveonshutdown="on" target="" port="31829" protocol="tcp" template="ip-json")
#   stop

auth,authpriv.*                 /var/log/auth.log
*.*;auth,authpriv.none          -/var/log/syslog
#cron.*                         /var/log/cron.log
#daemon.*                       -/var/log/daemon.log
kern.*                          -/var/log/kern.log
#lpr.*                          -/var/log/lpr.log
mail.*                          -/var/log/mail.log
#user.*                         -/var/log/user.log

What this script does is pretty straight forward:

  • if $programname == 'sshd' then { filters for log entries coming from sshd
  • if $msg startswith ' Invalid user' then { describes the entries we are lookging for (see 4.1.)
  • action(type="omfwd" target="<your-kubernetes-node-ip-goes-here>" port="<your-nodeport-goes-here>" protocol="tcp" template="ip-json") uses the omfwd forwarder to send log entries as tcp packets (see protocol) to the IP defined in target and to the port defined in port
  • additional options to the action make sure that no message is lost
  • template="ip-json" describes the format of the tcp packet that we are going to define in the next step

Create the message template

Still on the node running sshd, edit /etc/rsyslog.d/50-default.conf and add:

# We handle "Invalid user q from port 10664"
template(name="ip-json" type="string" string="{\"username\":\"%msg:R,ERE,1,DFLT:^ Invalid.*user ([a-zA-Z]*).* ([0-9][0-9]*[0-9]*.[0-9][0-9]*[0-9]*.[0-9][0-9]*[0-9]*.[0-9][0-9]*[0-9]*).* port ([0-9]*)--end%\",\"ip\":\"%msg:R,ERE,2,DFLT:^ Invalid.*user ([a-zA-Z]*).* ([0-9][0-9]*[0-9]*.[0-9][0-9]*[0-9]*.[0-9][0-9]*[0-9]*.[0-9][0-9]*[0-9]*).* port ([0-9]*)--end%\",\"port\":\"%msg:R,ERE,3,DFLT:^ Invalid.*user ([a-zA-Z]*).* ([0-9][0-9]*[0-9]*.[0-9][0-9]*[0-9]*.[0-9][0-9]*[0-9]*.[0-9][0-9]*[0-9]*).* port ([0-9]*)--end%\"}")

Please note that the template needs to be consistent with the pattern we have identified.

Start forwarding

To start forwarding we now need to restart syslogd: /etc/init.d/rsyslog restart, and check that everything works well: /etc/init.d/rsyslog status

Verify that data flows to Influxdb

Check the socket listener logs

To check the logs execute: kubectl -n logincollector logs -l app=collectorapp

If packets are received from syslogd and stored to the database you should see something like:

Received data: {"username":"b","ip":"","port":"33614"}

Intruder added
Received data: {"username":"teamspeak","ip":"","port":"34994"}

Intruder added
Received data: {"username":"admin","ip":"","port":"62561"}

Intruder added
Received data: {"username":"sftp","ip":"","port":"53614"}

Check the database

  1. Logon to Influxdb and check if the data gets persisted: select * from "login-attempts"..geossh

The result should look like:

name: geossh
time                geohash             ip                      port    username        value
1550665352142176076     u09tvnqgx 33614   b                   1
1550665718062144620     wx4g0kz6e 11142   b                   1

Setup the dashboard

Finally we will setup the dashboard in Grafana:

  1. Create a new dashboard names “Login Attempts”
  2. Add the World panel and edit it
  3. Go to [General] and edit the title: “Failed Login Attempts”
  4. Go to [Metric] and add “InfluxDB” as datasource
  5. Edit the query to be SELECT "value" AS "metric" FROM "login-attempts".."geossh" WHERE $timeFilter GROUP BY "geohash"
  6. Set the format to Table
  7. Go to [Worldmap] and edit the visual options to suit your needs (defaults are fine as well)
  8. In “Map data options” set “Location data” to geohash, “Geohash field” to geohash, “Metric field” to metric

Et voila!

Content licensed under CC BY 4.0