WordPress mit TLS Client Authentication absichern

27.04.2015 yahe administration code legacy security wordpress

Vor einiger Zeit hatte ich einmal gezeigt, wie man WordPress gegen eine größere Menge von Zero-Day-Exploits absichern kann, indem man verschiedene Datenbanknutzer einführt. Dadurch verwenden normale Webseitenbesucher einen Datenbanknutzer mit geringeren Privilegien und Nutzer des Backends einen Datenbanknutzer mit höheren Privilegien. Bei Softwarefehlern, die schreibenden Zugriff auf die Datenbank ermöglichen, können so Schreibversuche unterbunden werden.

Leider ist den Entwicklern von WordPress nicht sonderlich daran gelegen, die Datenbank so zu strukturieren, dass bestimmte Datenbankzugriffe für gewisse Nutzer nicht notwendig sind. So wurde vor einiger Zeit ein neues Session-Management eingeführt, das es zwar ermöglicht, Sitzungen auf anderen Rechnern zu beenden, dafür jedoch Daten in eine Tabelle schreibt, in der auch wichtige Konfigurationsinformationen abgelegt sind. Das führte zu Problemen bei der Trennung der Datenbanknutzer. Ich überlegte deshalb, wie ich die Trennung der Datenbanknutzer beibehalten und gleichzeitig flexibler bestimmen kann, wann ein höher privilegierter Datenbanknutzer benötigt wird.

Ebenfalls vor einiger Zeit hatte ich einmal gezeigt, wie man in NGINX TLS-Clientzertifikate einsetzen kann. Meine Idee war nun, die Steigerung der Privilegien innerhalb der WordPress-Datenbank an die Nutzung von TLS-Clientzertifikaten zu koppeln. So hat man eine Art Zwei-Faktor-Authentisierung: Mit seinem Zertifikat schaltet man die Nutzerrechte in der Datenbank frei und mit seinem Passwort loggt man sich im Backend ein.

Mein erster Versuch war, mit optionalen Clientzertifikaten zu arbeiten. Der Server fragt die Clientauthentisierung beim Browser an. Wenn der Browser kein Clientzertifikat zurückliefert, wird trotzdem mit der Seitendarstellung mit geringeren Priviliegen fortgefahren. Allerdings stellte sich heraus, dass der Safari-Browser bei jedem einzelnen Seitenaufruf nach dem Clientzertifikat fragt, sobald man eines hinterlegt. Eigentlich sendet der Server das Zertifikat der CA mit, von der das passende Clientzertifikat ausgestellt sein muss. Browser können diese Information nutzen, um die Clientzertifikate herauszufiltern, die nicht verwendet werden können. Doch leider sieht das Apple anders und so fragt der Safari den Nutzer ständig nach dem zu verwendenden Clientzertifikat, selbst, wenn es kein passendes gibt. Also musste eine nutzerverträglichere Lösung her.

In der neuen Lösung ruft man eine separate Seite auf, für die man zwingend eine Clientauthentisierung durchführen muss. Diese überprüft die Authentizität des Zertifikats und leitet dann auf einen Teil der eigentlichen, zu schützenden Seite weiter. Dieser Teil startet die Session, in der hinterlegt wird, dass die höheren Privilegien genutzt werden dürfen. Der Aufruf dieses Teils ist durch ein zeit- und passwortbasiertes Challenge-Response-Verfahren geschützt. Das spannende an diesem Ansatz ist, dass es prizipiell auch dafür geeignet ist, ein Single-Sign-On-Portal für verschiedenste Anwendungen aufzubauen. Kern der Anwendung ist die Datei "auth.php", in der sich die grundlegende Logik befindet:

<?php
  define("SUCCESS_CONTENT", "SUCCESS"); // defines what text should be in TLS_SUCCESS

  define("CHALLENGE_ALGO",   "sha256"); // defines the algorithm used for response HMAC
  define("CHALLENGE_LENGTH", 32);       // defines the length of the random challenge

  define("PARAM_START",   "?"); // should be static
  define("PARAM_VALUE",   "="); // should be static
  define("PARAM_COMBINE", "&"); // should be static

  define("CHALLENGE_PARAM", "challenge"); // name of challenge GET parameter
  define("RESPONSE_PARAM",  "response");  // name of response GET parameter

  define("DN_PARAM",      "TLS_DN");      // name of DN SERVER parameter
  define("SUCCESS_PARAM", "TLS_SUCCESS"); // name of success SERVER parameter

  define("LOGINSESSION_LOGGEDIN", "loginsession_loggedin");

  function getChallenge() {
    return openssl_random_pseudo_bytes(CHALLENGE_LENGTH);
  }

  function getResponse($challenge, $previous = false) {
    // prepare a delta time value for cases where we missed the variance window
    $delta = ($previous) ? CHALLENGE_VARIANCE : 0;

    // use the current time as a replay prevention mechanism
    $time_challenge = dechex((int)((time()-$delta) / CHALLENGE_VARIANCE));

    return hash_hmac(CHALLENGE_ALGO, $challenge.$time_challenge, CHALLENGE_KEY);
  }

  function check_GET() {
    $result = false;

    // check if the parameters are set
    if (isset($_GET[CHALLENGE_PARAM]) && isset($_GET[RESPONSE_PARAM])) {
      // convert challenge parameter to binary
      $challenge = hex2bin($_GET[CHALLENGE_PARAM]);
      // check if the challenge parameter was a hex
      if (false !== $challenge) {
        // calculate response from challenge parameter
        $response = getResponse($challenge, false);
        // check if response calculation was possible
        if (false !== $response) {
          // check if the calculated response matches the response parameter
          $result = (0 === strcasecmp($response, $_GET[RESPONSE_PARAM]));
        }

        // maybe we just passed the time variance window, test the previous one
        if (!$result) {
          // calculate response from challenge parameter
          $response = getResponse($challenge, true);
          // check if response calculation was possible
          if (false !== $response) {
            // check if the calculated response matches the response parameter
            $result = (0 === strcasecmp($response, $_GET[RESPONSE_PARAM]));
          }
        }
      }
    }

    return $result;
  }

  function check_SERVER() {
    $result = false;

    // check if the necessary TLS server values have been set
    if ((isset($_SERVER[DN_PARAM])) && (isset($_SERVER[SUCCESS_PARAM]))) {
      // check if the TLS server values contained the required content
      $result = ((0 === strcasecmp(DN_CONTENT,      $_SERVER[DN_PARAM])) &&
                 (0 === strcasecmp(SUCCESS_CONTENT, $_SERVER[SUCCESS_PARAM])));
    }

    return $result;
  }

  function createSession() {
    // open a session
    session_start();

    // session should exist now
    if (PHP_SESSION_ACTIVE === session_status()) {
      // prevent session fixation
      session_regenerate_id(true);

      $_SESSION[LOGINSESSION_LOGGEDIN] = true;
    }
  }

  function destroySession() {
    // check if there actually is a session to destroy
    if ((isset($_COOKIE[session_name()])) || (isset($_GET[session_name()]))) {
      // open the existing session
      session_start();

      // session should be open now
      if (PHP_SESSION_ACTIVE === session_status()) {
        // remove loggedin value from session
        if (isset($_SESSION[LOGINSESSION_LOGGEDIN])) {
          unset($_SESSION[LOGINSESSION_LOGGEDIN]);
        }

        // check if the session is empty
        if (0 === count($_SESSION)) {
          // destroy the session if it is
          session_unset();
          session_destroy();

          // unset the session cookie
          if (isset($_COOKIE[session_name()])) {
            $params = session_get_cookie_params();
            setcookie(session_name(), "", time()-3600, $params["path"], $params["domain"], $params["secure"], $params["httponly"]);
          }
        } else {
          // prevent session fixation
          session_regenerate_id(true);
        }
      }
    }
  }

  function doRedirect($target) {
    header("HTTP/1.1 302 Moved Temporarily");
    header("Location: ".$target);
  }

  function triggerChallengeResponse() {
    // generate new challenge
    $challenge = getChallenge();
    // check if challenge generation worked
    if (false !== $challenge) {
      // calculate response from challenge
      $response = getResponse($challenge, false);
      // check if response calucation worked
      if (false !== $response) {
        // convert challenge from binary to hex
        $challenge = bin2hex($challenge);

        // redirect to challenge-response
        doRedirect(PARAM_PATH.PARAM_START.CHALLENGE_PARAM.PARAM_VALUE.$challenge.PARAM_COMBINE.RESPONSE_PARAM.PARAM_VALUE.$response);
      }
    }
  }
?>

Die eigentliche Aufgabe, das Clientzertifikat zu prüfen, zur eigentlichen Webseite zu redirecten und dort die Session zu starten, übernimmt die Datei "logon.php". Diese gibt es zwei Mal, einmal auf dem Server, der die TLS-Clientauthentisierung übernimmt und einmal auf dem Server, auf dem WordPress installiert ist. Die in der Datei enthaltene Konfiguration sollte auf beiden Servern identisch sein:

<?php
  define("DN_CONTENT", "/cn=example.com"); // defines what text should be in TLS_DN

  define("CHALLENGE_VARIANCE", 300); // defines how long the challenge will be usable
  define("CHALLENGE_KEY",      "V3RY 5TR0NG P455W0RD"); // defines the password used for the response HMAC

  define("PARAM_PATH", "https://example.com/auth/logon.php"); // redirect path

  define("FINAL_PATH", "https://example.com/wp-login.php");

  require_once(dirname(__FILE__)."/auth.php");

  if (check_GET()) {
    createSession();
    print("<a href=\"".FINAL_PATH."\">Logon</a>");
  } else {
    if (check_SERVER()) {
      triggerChallengeResponse();
      exit();
    }
  }
?>

Es kann auch sinnvoll sein, die erhaltenen Privilegien wieder abzugeben, indem man die Session beendet. Diese Aufgabe übernimmt die Datei "logoff.php", die ebenfalls auf beiden Servern vorhanden und dieselbe Konfiguration enthalten sollte:

<?php
  define("DN_CONTENT", "/cn=example.com"); // defines what text should be in TLS_DN

  define("CHALLENGE_VARIANCE", 300); // defines how long the challenge will be usable
  define("CHALLENGE_KEY",      "V3RY 5TR0NG P455W0RD"); // defines the password used for the response HMAC

  define("PARAM_PATH", "https://example.com/auth/logoff.php"); // redirect path

  require_once(dirname(__FILE__)."/auth.php");

  if (check_GET()) {
    destroySession();
    print("logoff");
  } else {
    if (check_SERVER()) {
      triggerChallengeResponse();
      exit();
    }
  }
?>

In der eigentlichen Anwendung kann dann anhand der Session zwischen den Datenbanknutzern unterschieden werden. Im Falle von WordPress habe ich das in der Konfigurationsdatei "wp-config.php" getan:

$loginsession_evaluated = false;
// open the session if there is one
if ((isset($_COOKIE[session_name()])) || (isset($_GET[session_name()]))) {
  session_start();

  if ((isset($_SESSION["loginsession_loggedin"])) && ($_SESSION["loginsession_loggedin"])) {
    $loginsession_evaluated = true;
  }
}

// select user dependent on loginsession
if (($loginsession_evaluated) || (defined("DOING_CRON"))) {
  define("DB_USER",     "<ADMIN USER>");
  define("DB_PASSWORD", "<ADMIN PASSWORD>");
} else {
  define("DB_USER",     "<GUEST USER>");
  define("DB_PASSWORD", "<GUEST PASSWORD>");
}

Da das von der Theorie her vielleicht alles ein bisschen trocken ist, habe ich den Ablauf eines vollständigen Logins als Screencapture aufgenommen und bei Youtube hochgeladenen. Vielleicht wird dadurch klarer, wie die einzelnen Schritte ineinander greifen.

Ich hoffe, dieser kleiner Exkurs bietet dem ein oder anderen eine neue Lösungsmöglichkeit für die Absicherung von Nutzerzugängen. Die gezeigte Implementation habe ich, soweit wie möglich, generisch gehalten. Durch das Challenge-Reponse-Verfahren ist sie beispielsweise auch als Grundlage für Inter-Domain-Single-Sign-On geeignet und durch die Verwendung von einfachen Sessions sollte sie zudem softwareunabhängig einsetzbar sein.


Search

Categories

administration (45)
arduino (12)
calcpw (3)
code (38)
hardware (20)
java (2)
legacy (113)
linux (31)
publicity (8)
raspberry (3)
review (2)
security (65)
thoughts (22)
update (11)
windows (17)
wordpress (19)