Einen Portscan mit PHP durchführen

Unsere Windows-Webserver sind mit einer Firewall auf dem Windows selbst gesichert, sowie mit einer Firewall beim Hoster.

Diese Firewall blockiert die meisten Ports aus dem Internet (nur Port 21, 80 und 443 sind offen) und lässt alle Ports von unserem Firmennetzwerk durch.

Soweit die Theorie.

In der Praxis war es schon einmal so, dass die Firewall beim Provider „ausgefallen“ war und wir es nur zufällig gemerkt haben.

Also muss eine Lösung her, die das automatisiert, regelmäßig überprüft.

Ein einfacher Job in der Windows-Aufgabenplanung in unserem Firmennetzwerk scheidet aus, weil von unserem Firmennetzwerk aus ja per Definition alle Ports offen sind.

Also habe ich folgende „Architektur“ gebaut:

  • Ein PHP-Portscan-Skript, das auf einem beliebigen externen Hoster läuft und JSON zurückgibt.
  • Ein CS-Script, das auf einem beliebigen Windows-Server läuft und das PHP-Skript per URL aufruft.

Das CS-Script wird täglich als geplante Aufgabe von einem CMD-Skript aus aufgerufen.

So erreiche ich indirekt einen Portscan von extern.

Mein CS-Script sendet am Ende eine Erfolgs- oder Fehler-E-Mail-Nachricht, so dass ich immer weiß, ob noch alles passt.

Nachfolgend die Skripte.

Portscan.php

<?php
error_reporting(E_ALL);

$hosts = array("www.example.com", "www.example.org", "www.example.net");
$tcpPorts = array(21, 22, 23, 25, 53, 79, 80, 110, 115, 135, 139, 143, 194, 389, 443, 445, 465, 1433, 1723, 3306, 3389, 5632, 5900, 6112, 8080);

$timeoutSeconds = 0.5;

$result = array();

foreach($hosts as $host)
{
	foreach($tcpPorts as $port)
	{
		$fp = fsockopen($host, $port, $errno, $errstr, $timeoutSeconds);
		if (!$fp) {
			$item = array(
				'host' => $host,
				'port' => $port,
				'open' => false,
				'message' => "$errstr ($errno)"
				);

			array_push($result, $item);
		} else {
			$item = array(
				'host' => $host,
				'port' => $port,
				'open' => true,
				'message' => ''
				);

			array_push($result, $item);

			fclose($fp);
		}	
	}
}

SendToBrowserAsJson($result);

// ----------------

function SendToBrowserAsJson($obj)
{
	$raw = json_encode($obj/*, JSON_PRETTY_PRINT*/);
	$le = json_last_error();
	if( $le>0 )
	{
		if($le==JSON_ERROR_UTF8)
		{
			$raw = json_encode(Utf8ize($obj)/*, JSON_PRETTY_PRINT*/);
			$le = json_last_error();
		}

		if( $le>0 )
		{
			$leReadable = translateJsonLastError($le);
			$msg = "JSON last error: $le ($leReadable).";

			error_log($msg);
			error_log($obj);

			throw new Exception($msg);
		}
	}

	ob_clean();
	header('Content-type: application/json');
	echo($raw);
}

function Utf8ize($d)
{
	if (is_array($d))
		foreach ($d as $k => $v)
			$d[$k] = Utf8ize($v);

	elseif(is_object($d))
		foreach ($d as $k => $v)
			$d->$k = Utf8ize($v);

	else
		return utf8_encode($d);

	return $d;
}

function translateJsonLastError($le)
{
	switch($le)
	{
		case JSON_ERROR_NONE:
			return "JSON_ERROR_NONE - Kein Fehler aufgetreten.";
		case JSON_ERROR_DEPTH:
			return "JSON_ERROR_DEPTH - 	Die maximale Stacktiefe wurde überschritten.";
		case JSON_ERROR_STATE_MISMATCH:
			return "JSON_ERROR_STATE_MISMATCH - Ungültiges oder missgestaltetes JSON.";
		case JSON_ERROR_CTRL_CHAR:
			return "JSON_ERROR_CTRL_CHAR - Steuerzeichenfehler, möglicherweise unkorrekt kodiert.";
		case JSON_ERROR_SYNTAX:
			return "JSON_ERROR_SYNTAX - Syntaxfehler.";
		case JSON_ERROR_UTF8:
			return "JSON_ERROR_UTF8 - Missgestaltete UTF-8-Zeichen, möglicherweise fehlerhaft kodiert.";
		case JSON_ERROR_RECURSION:
			return "JSON_ERROR_RECURSION - Eine oder mehrere rekursive Referenzen im zu kodierenden Wert.";
		case JSON_ERROR_INF_OR_NAN:
			return "JSON_ERROR_INF_OR_NAN - Eine oder mehrere NAN- oder INF-Werte im zu kodierenden Wert.";
		case JSON_ERROR_UNSUPPORTED_TYPE:
			return "JSON_ERROR_UNSUPPORTED_TYPE - Ein Wert eines Typs, der nicht kodiert werden kann, wurde übergeben.";
		default:
			return "Unbekannt $le";
	}
}

?>

Portscan.cs

//css_ref System.Core
//css_ref Microsoft.CSharp

//css_host /platform:x86;

//css_nuget Newtonsoft.Json

using System;
using System.Linq;
using System.Collections;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.IO;
using System.Net.Mail;
using CSScriptLibrary;
using System.Reflection;
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;

public static class Processor
{
    private sealed class ValidServerInfo
    {
        public string Host { get; set; }
        public int[] AllowedPorts { get; set; }
    }

    private sealed class InvalidValidServerInfo
    {
        public string Host { get; set; }
        public int[] ForbiddenOpenPorts { get; set; }
    }

    private sealed class JsonElement
    {
        public string Host { get; set; }
        public int Port { get; set; }
        public bool Open { get; set; }
        public string Message { get; set; }
    }

    #region Prüfungen.
    // ----------------------------------------------------------------------

    // Hier alle Prüfungen der Ordner durchführen.
    private static void doChecks()
    {
        var validServerInfos = new ValidServerInfo[]
        {
            new ValidServerInfo
            {
                Host = "www.example.com",
                AllowedPorts = new [] { 21, 80, 443 }
            },
            new ValidServerInfo
            {
                Host = "www.example.org",
                AllowedPorts = new [] { 21, 80, 443 }
            },
            new ValidServerInfo
            {
                Host = "www.example.net",
                AllowedPorts = new [] { 21, 80, 443 }
            }
        };

        // --

        string contents;

        log("Starte Portscan via Hilfs-URL '{0}'.", PortScanUrl);

        var sw = new Stopwatch();
        sw.Start();
        using (var wc = new System.Net.WebClient()) contents = wc.DownloadString(PortScanUrl);
        sw.Stop();

        log("Starte Portscan via Hilfs-URL '{0}' beendet. Dauerte {1}. Ergebnis-JSON:", PortScanUrl, sw.Elapsed);
        log(contents);

        var scanResultItems = JsonConvert.DeserializeObject<JsonElement[]>(contents);

        // --

        var forbiddenInfos = new List<InvalidValidServerInfo>();

        foreach (var validServerInfo in validServerInfos)
        {
            var openPorts = scanResultItems
                .Where(i => i.Host == validServerInfo.Host &&
                            i.Open)
                .Select(i => i.Port)
                .ToArray();

            log("Offene Ports bei Host '{0}' sind '{1}'.", validServerInfo.Host, string.Join(", ", openPorts));

            var invalidOpenPorts = openPorts
                .Where(p => !validServerInfo.AllowedPorts.Any(v => v == p))
                .ToArray();

            if (invalidOpenPorts.Any())
            {
                forbiddenInfos.Add(new InvalidValidServerInfo
                {
                    Host = validServerInfo.Host,
                    ForbiddenOpenPorts = invalidOpenPorts
                });
            }
        }

        // --

        if (forbiddenInfos.Any())
        {
            log("Ja, es sind unerwartete Ports geöffnet. Sende Fehler-E-Mail.");
            sendErrorEMail(forbiddenInfos.ToArray());
        }
        else
        {
            log("Nein, es sind nur die erwarteten Ports geöffnet. Sende Erfolgs-E-Mail.");
            sendSuccessEMail(validServerInfos.ToArray());
        }
    }

    // ----------------------------------------------------------------------
    #endregion

    #region Konfiguration.
    // ----------------------------------------------------------------------

    private const string LogFilePath = "C:\\LogFiles\\Portscan.log";

    private const string PortScanUrl = "http://my-external-server.com/portscan.php";

    // Wer im Fehlerfall die E-Mail-Nachricht bekommt.
    private const string EMailReceiver = "server@example.org";

    // ----------------------------------------------------------------------
    #endregion

    #region Hauptteil.
    // ----------------------------------------------------------------------

    public static void Main(string[] args)
    {
        var startDate = DateTime.Now;
        log("Started script '{0}' at {1}.", scriptFileName, startDate);

        try
        {
            doChecks();
        }
        catch (Exception x)
        {
            log("Error occurred:");
            log(x.Message);
            log(x.StackTrace);

            throw;
        }
        finally
        {
            var delta = DateTime.Now - startDate;
            log("Finished script '{0}' started at {1}, took {2}.", scriptFileName, DateTime.Now, formateTimeSpan(delta));

            Process.GetCurrentProcess().Kill();
        }
    }

    // ----------------------------------------------------------------------
    #endregion

    #region E-Mail-Versand.
    // ----------------------------------------------------------------------

    private static void sendErrorEMail(InvalidValidServerInfo[] invalidInfos)
    {
        var subject = "[Fehler] ⛔ Unzulässige Ports sind geöffnet";
        var body =
            "Unzulässig geöffnete Ports:    \r\n" +
            "\r\n" +
            "#HOSTSANDPORTS#    \r\n" +
            "\r\n" +
            "\r\n";

        subject = replacePlaceholders(subject, invalidInfos);
        body = replacePlaceholders(body, invalidInfos);

        log("----");
        log("Sende E-Mail.");
        log("Betreff: " + subject);
        log("Nachricht: " + body);
        log("----");

        // --

        // add from,to mailaddresses
        var from = new MailAddress("server@example.org", "Webserver");
        var sender = new MailAddress("server@example.org", "Webserver");
        var to = new MailAddress(EMailReceiver);
        var myMail = new MailMessage(from, to);

        // add ReplyTo
        var replyto = new MailAddress(EMailReceiver);
        myMail.ReplyToList.Add(replyto);
        myMail.Sender = sender;

        // set subject and encoding
        myMail.Subject = subject;
        myMail.SubjectEncoding = System.Text.Encoding.UTF8;

        // set body-message and encoding
        myMail.Body = body;
        myMail.BodyEncoding = System.Text.Encoding.UTF8;

        var client = new SmtpClient("mail.example.org");
        client.Send(myMail);
    }

    private static void sendSuccessEMail(ValidServerInfo[] invalidInfos)
    {
        var subject = "[Erfolg] ✅ Nur zulässige Ports sind geöffnet";
        var body =
            "Zulässig geöffnete Ports:    \r\n" +
            "\r\n" +
            "#HOSTSANDPORTS#    \r\n" +
            "\r\n" +
            "\r\n";

        subject = replacePlaceholders(subject, invalidInfos);
        body = replacePlaceholders(body, invalidInfos);

        log("----");
        log("Sende E-Mail.");
        log("Betreff: " + subject);
        log("Nachricht: " + body);
        log("----");

        // --

        // add from,to mailaddresses
        var from = new MailAddress("server@example.org", "Webserver");
        var sender = new MailAddress("server@example.org", "Webserver");
        var to = new MailAddress(EMailReceiver);
        var myMail = new MailMessage(from, to);

        // add ReplyTo
        var replyto = new MailAddress(EMailReceiver);
        myMail.ReplyToList.Add(replyto);
        myMail.Sender = sender;

        // set subject and encoding
        myMail.Subject = subject;
        myMail.SubjectEncoding = System.Text.Encoding.UTF8;

        // set body-message and encoding
        myMail.Body = body;
        myMail.BodyEncoding = System.Text.Encoding.UTF8;

        var client = new SmtpClient("mail.example.org");
        client.Send(myMail);
    }

    private static string replacePlaceholders(string text, InvalidValidServerInfo[] invalidInfos)
    {
        var hostsAndPorts = invalidInfos.Select(i => string.Format("• {0} → {1}", i.Host, string.Join(", ", i.ForbiddenOpenPorts))).ToArray();

        text = text.Replace("#SERVER#", Environment.MachineName);
        text = text.Replace("#HOSTSANDPORTS#", string.Join(Environment.NewLine, hostsAndPorts));

        return text;
    }

    private static string replacePlaceholders(string text, ValidServerInfo[] invalidInfos)
    {
        var hostsAndPorts = invalidInfos.Select(i => string.Format("• {0} → {1}", i.Host, string.Join(", ", i.AllowedPorts))).ToArray();

        text = text.Replace("#SERVER#", Environment.MachineName);
        text = text.Replace("#HOSTSANDPORTS#", string.Join(Environment.NewLine, hostsAndPorts));

        return text;
    }

    // ----------------------------------------------------------------------
    #endregion

    #region Helper.
    // ----------------------------------------------------------------------

    private static string _scriptFile = System.Reflection.Assembly.GetExecutingAssembly().GetCustomAttribute<System.Reflection.AssemblyDescriptionAttribute>().Description;
    private static string scriptFilePath { get { return _scriptFile; } }
    private static string scriptFolderPath { get { return Path.GetDirectoryName(_scriptFile).TrimEnd('\\'); } }
    private static string scriptFileName { get { return Path.GetFileName(_scriptFile); } }

    private static void log()
    {
        log(string.Empty);
    }

    private static void log(string text)
    {
        log(text, new object[] { });
    }

    private static bool _isFirstLog = true;

    private static void log(string text, params object[] args)
    {
        try
        {
            Console.WriteLine(text, args);

            // --

            var filePath = LogFilePath;

            if (_isFirstLog && File.Exists(filePath)) File.Delete(filePath);
            _isFirstLog = false;

            if (!string.IsNullOrEmpty(text))
            {
                var msg = string.Format(@"[{0}] {1}" + Environment.NewLine,
                    DateTime.Now,
                    string.Format(text, args));

                File.AppendAllText(filePath, msg);
            }
        }
        catch (Exception)
        {
            // Logging soll _niemals_ was kaputt machen können.
        }
    }

    private static void log(object o)
    {
        log(o == null ? string.Empty : o.ToString());
    }

    // See http://stackoverflow.com/questions/11/how-do-i-calculate-relative-time.
    private static string formateTimeSpan(
        TimeSpan ts)
    {
        const int SECOND = 1;
        const int MINUTE = 60 * SECOND;
        const int HOUR = 60 * MINUTE;
        const int DAY = 24 * HOUR;
        const int MONTH = 30 * DAY;

        double delta = ts.TotalSeconds;

        if (delta < 1 * MINUTE)
        {
            return ts.Seconds == 1 ? "one second ago" : ts.Seconds + " seconds ago";
        }
        if (delta < 2 * MINUTE)
        {
            return "a minute ago";
        }
        if (delta < 45 * MINUTE)
        {
            return ts.Minutes + " minutes ago";
        }
        if (delta < 90 * MINUTE)
        {
            return "an hour ago";
        }
        if (delta < 24 * HOUR)
        {
            return ts.Hours + " hours ago";
        }
        if (delta < 48 * HOUR)
        {
            return "yesterday";
        }
        if (delta < 30 * DAY)
        {
            return ts.Days + " days ago";
        }
        if (delta < 12 * MONTH)
        {
            int months = Convert.ToInt32(Math.Floor((double)ts.Days / 30));
            return months <= 1 ? "one month ago" : months + " months ago";
        }
        else
        {
            int years = Convert.ToInt32(Math.Floor((double)ts.Days / 365));
            return years <= 1 ? "one year ago" : years + " years ago";
        }
    }

    // ----------------------------------------------------------------------
    #endregion
}

Portscan.cmd

PUSHD 
CD /d %~dp0 
SET CSSCRIPT_DIR=C:\Program Files\cs-script
"C:\Program Files\cs-script\cscs.exe" /dbg "%~dp0\portscan.cs"
POPD 

EXIT /B 0

Die Skripte sind teilweise anonymisiert, ggf. helfen sie ja dem einen oder anderen etwas weiter.

Mögliche Verbesserungen

Als Verbesserung könnte z. B. noch implementiert werden:

  • Das PHP-Skript bekommt per URL-Parameter den Server und die zulässigen Ports übergeben (dann bitte auch einen API-Key als Minimal-Sicherheit)