Eigentlich wollte ich einzig mal das SSL-Deployment auf meinem Server etwas aufräumen und in diesem Zuge auch den Blog mal vernünftig auf SSL ziehen, aber aus der einfachen Aufgabe, 19 Zertifikate auf die passenden Subdomains zu heften wurde dann doch eine etwas umfangreicher Aufgabe. Nicht etwa wegen der Vielzahl an Subdomains (jede Subdomain musste für sowohl IPv4 als auch IPv6 konfiguriert werden), sondern wegen einem kleinen nervigen Bug in der kaputten Krypto. Ursache war dabei aber nicht etwa, dass er nicht verschlüsselt hätte, oder das falsche Zertifikat gezeigt hätte – nunja, damit rechnet man ja noch. Der Fehler war viel subtiler: Beim Aufruf von sub.domain.tld lieferte der Server das richtige Zertifikat, aber antwortete mit der Website für domain.tld. Also: Happy debugging!
Nun ist das ja nichts Neues, dass die meiste Krypto kaputt ist, aber von der Subtilität des Fehlers ist der durchaus bemerkenswert – Nahezu ein Fall „wie konnte das jemals funktionieren!“ Aber kurz zur verwendeten Konfig zum Mitmeißeln. Gegeben seien 2 Domains, die auf sowohl IPv4 als auch IPv6 verfügbar gemacht werden sollen:
NameVirtualHost 127.0.0.1:443
NameVirtualHost [::1]:443
NameVirtualHost [::2]:443
<VirtualHost 127.0.0.1:443>
ServerAdmin webmaster@domain.tld
DocumentRoot /var/www/virtual/domain.tld/htdocs
ServerName domain.tld
ServerAlias www.domain.tld domain.tld *.domain.tld
GnuTLSEnable on
GnuTLSCertificateFile /etc/ssl/public/domain.tld.crt
GnuTLSKeyFile /etc/ssl/private/domain.tld.key
GnuTLSPriorities SECURE:!MD5:!ANON-DH:-VERS-SSL3.0:+COMP-DEFLATE:-AES-128-CBC:-CAMELLIA-128-CBC:-3DES-CBC:-ARCFOUR-40
Header add Strict-Transport-Security "max-age=15768000"
</VirtualHost>
<VirtualHost 127.0.0.1:443>
ServerAdmin webmaster@domain.tld
DocumentRoot /var/www/virtual/domain.tld/sub/htdocs
ServerName sub.domain.tld
ServerAlias www.sub.domain.tld sub.domain.tld *.sub.domain.tld
GnuTLSEnable on
GnuTLSCertificateFile /etc/ssl/public/sub.domain.tld.crt
GnuTLSKeyFile /etc/ssl/private/sub.domain.tld.key
GnuTLSPriorities SECURE:!MD5:!ANON-DH:-VERS-SSL3.0:+COMP-DEFLATE:-AES-128-CBC:-CAMELLIA-128-CBC:-3DES-CBC:-ARCFOUR-40
Header add Strict-Transport-Security "max-age=15768000"
</VirtualHost>
<VirtualHost [::1]:443>
ServerAdmin webmaster@domain.tld
DocumentRoot /var/www/virtual/domain.tld/htdocs
ServerName domain.tld
ServerAlias www.domain.tld domain.tld *.domain.tld
GnuTLSEnable on
GnuTLSCertificateFile /etc/ssl/public/domain.tld.crt
GnuTLSKeyFile /etc/ssl/private/domain.tld.key
GnuTLSPriorities SECURE:!MD5:!ANON-DH:-VERS-SSL3.0:+COMP-DEFLATE:-AES-128-CBC:-CAMELLIA-128-CBC:-3DES-CBC:-ARCFOUR-40
Header add Strict-Transport-Security "max-age=15768000"
</VirtualHost>
<VirtualHost [::2]:443>
ServerAdmin webmaster@domain.tld
DocumentRoot /var/www/virtual/domain.tld/sub/htdocs
ServerName sub.domain.tld
ServerAlias www.sub.domain.tld sub.domain.tld *.sub.domain.tld
GnuTLSEnable on
GnuTLSCertificateFile /etc/ssl/public/sub.domain.tld.crt
GnuTLSKeyFile /etc/ssl/private/sub.domain.tld.key
GnuTLSPriorities SECURE:!MD5:!ANON-DH:-VERS-SSL3.0:+COMP-DEFLATE:-AES-128-CBC:-CAMELLIA-128-CBC:-3DES-CBC:-ARCFOUR-40
Header add Strict-Transport-Security "max-age=15768000"
</VirtualHost>
Soweit ja reines Copy&Paste. Also Config testen und Server neustarten:
apachectl configtest
apachectl restart
Und das ernüchternde Ergebnis: SSL tut zwar, sogar mit den richtigen Zertifikaten, nur wer auf http://sub.domain.tld/ die Daten für sub.domain.tld erwartet liegt etwas falsch: Denn diese erhält man nur wenn man die gleiche Konfig OHNE SSL nutzen würde.
Dass es an dieser Stelle wahrscheinlich zu einem Problem mit den Aliasnamen kam, dachte ich mir zu diesem Zeitpunkt schon fast, da ich aber für IPv6 jeder Subdomain in der Config eine eigene IP vergebe, kommentierte ich den Wildcard erstmal nur für IPv4 aus. Dies änderte an dem Phänomen jedoch wenig und so bat ich einen Bekannten um seinen Rat. Er schaute auf die Konfig, schaute noch einmal hin und meinte: irgendwas ist hier broken, die Config stimmt.
Also schaute ich mal eben in den Quelltext von mod_gnutls, was ich für den Krypto-Krams verwende und bisher auch wenig Probleme mit hatte: denn bisher funktionierte SNI hier deutlich besser als mit mod_ssl, was mangels brauchbaren Supports von eben SNI mal eben weggeschmissen wurde – und nicht nur, weil OpenSSL das beste Beispiel für Doku = Kot ist. Da mod_gnutls direkt als Paket für die verwendete Distribution vorlag, ging es somit im Code weiter:
cd /usr/src
sudo apt-src install libapache2-mod-gnutls
cd mod-gnutls-0.5.10
$EDITOR src/*.c
Glücklicherweise umfasst der Code für mod_gnutls lediglich 5 Dateien, was den Überblick recht schnell ermöglichte. Nach etwas Einarbeitung stieß ich auf die Fnktion mgs_find_sni_server, welche in gnutls_hook.c definiert wird. Ein Blick auf deren Implementierung verwies wiederum auf ap_vhost_iterate_given_conn, welche in der Apache Portable Runtime dafür zuständig ist, über die verfügbaren VHosts zu iterieren. Hierfür ruft ap_vhost_iterate_given_conn einem vom Aufrufer übergebenen Callback auf, der für jeden VHost einmalig aufgerufen wird. Liefert der Callback einen Wert ungleich 0, so bricht ap_vhost_iterate_given_conn ab und gibt den Rückgabewert des letzten Callback-Aufrufes zurück. Soweit ganz einfach – aber falsch.
Schauen wir mal in den Code – ich zitiere mal:
#if USING_2_1_RECENT
typedef struct {
mgs_handle_t *ctxt;
mgs_srvconf_rec *sc;
const char *sni_name;
} vhost_cb_rec;
static int vhost_cb(void *baton, conn_rec * conn, server_rec * s)
{
mgs_srvconf_rec *tsc;
vhost_cb_rec *x = baton;
_gnutls_log(debug_log_fp, "%s: %d\n", __func__, __LINE__);
tsc = (mgs_srvconf_rec *) ap_get_module_config(s->module_config,
&gnutls_module);
if (tsc->enabled != GNUTLS_ENABLED_TRUE || tsc->cert_cn == NULL) {
return 0;
}
/* The CN can contain a * -- this will match those too. */
if (ap_strcasecmp_match(x->sni_name, tsc->cert_cn) == 0) {
/* found a match */
#if MOD_GNUTLS_DEBUG
ap_log_error(APLOG_MARK, APLOG_DEBUG, 0,
x->ctxt->c->base_server,
"GnuTLS: Virtual Host CB: "
"'%s' == '%s'", tsc->cert_cn, x->sni_name);
#endif
/* Because we actually change the server used here, we need to reset
* things like ClientVerify.
*/
x->sc = tsc;
/* Shit. Crap. Dammit. We *really* should rehandshake here, as our
* certificate structure *should* change when the server changes.
* acccckkkkkk.
*/
return 1;
} else {
#if MOD_GNUTLS_DEBUG
ap_log_error(APLOG_MARK, APLOG_DEBUG, 0,
x->ctxt->c->base_server,
"GnuTLS: Virtual Host CB: "
"'%s' != '%s'", tsc->cert_cn, x->sni_name);
#endif
}
return 0;
}
#endif
mgs_srvconf_rec *mgs_find_sni_server(gnutls_session_t session)
{
int rv;
unsigned int sni_type;
size_t data_len = MAX_HOST_LEN;
char sni_name[MAX_HOST_LEN];
mgs_handle_t *ctxt;
#if USING_2_1_RECENT
vhost_cb_rec cbx;
#else
server_rec *s;
mgs_srvconf_rec *tsc;
#endif
if (session == NULL)
return NULL;
_gnutls_log(debug_log_fp, "%s: %d\n", __func__, __LINE__);
ctxt = gnutls_transport_get_ptr(session);
rv = gnutls_server_name_get(ctxt->session, sni_name,
&data_len, &sni_type, 0);
if (rv != 0) {
return NULL;
}
if (sni_type != GNUTLS_NAME_DNS) {
ap_log_error(APLOG_MARK, APLOG_CRIT, 0,
ctxt->c->base_server,
"GnuTLS: Unknown type '%d' for SNI: "
"'%s'", sni_type, sni_name);
return NULL;
}
/**
* Code in the Core already sets up the c->base_server as the base
* for this IP/Port combo. Trust that the core did the 'right' thing.
*/
#if USING_2_1_RECENT
cbx.ctxt = ctxt;
cbx.sc = NULL;
cbx.sni_name = sni_name;
rv = ap_vhost_iterate_given_conn(ctxt->c, vhost_cb, &cbx);
if (rv == 1) {
return cbx.sc;
}
#else
for (s = ap_server_conf; s; s = s->next) {
tsc =
(mgs_srvconf_rec *)
ap_get_module_config(s->module_config, &gnutls_module);
if (tsc->enabled != GNUTLS_ENABLED_TRUE) {
continue;
}
#if MOD_GNUTLS_DEBUG
ap_log_error(APLOG_MARK, APLOG_DEBUG, 0,
ctxt->c->base_server,
"GnuTLS: sni-x509 cn: %s/%d pk: %s s: 0x%08X s->n: 0x%08X sc: 0x%08X",
tsc->cert_cn, rv,
gnutls_pk_algorithm_get_name
(gnutls_x509_privkey_get_pk_algorithm
(ctxt->sc->privkey_x509)), (unsigned int) s,
(unsigned int) s->next, (unsigned int) tsc);
#endif
/* The CN can contain a * -- this will match those too. */
if (ap_strcasecmp_match(sni_name, tsc->cert_cn) == 0) {
#if MOD_GNUTLS_DEBUG
ap_log_error(APLOG_MARK, APLOG_DEBUG, 0,
ctxt->c->base_server,
"GnuTLS: Virtual Host: "
"'%s' == '%s'", tsc->cert_cn,
sni_name);
#endif
return tsc;
}
}
#endif
return NULL;
}
Bei genauerer Betrachtung liegen hier 2 Fallstricke, die mod_gnutls mit Bravur beide gerissen hat!
Nummer 1 ist das eigentliche ServerName-Fehlverhalten, was sich darin begründet, dass ein beliebiger passender Wildcard ausreicht, damit der Callback behauptet, den richtigen VHost gefunden zu haben.
Nummer 2 ist etwas subtiler, aber eigentlich noch deutlich bösartiger: Die Implementierung der Callback-Routine ignoriert die IP-Adresse auf der der VHost aufgespannt ist, was dazu führt, dass ein Alias für www.ipv6-only.tld plötzlich auch auf IPv4 akzeptiert wird, OBWOHL dieser dort nirgends konfiguriert ist.
Wärend Nummer 1 nur für falschen Content sorgt, was an sich schon ärgerlich genug ist, sorgt Nummer 2 dafür, dass unter Umständen eine nur für das Intranet gedachte Seite plötzlich frei im Internet verfügbar wird, wenn man nur deren Domain kennt. AUTSCH!
Der Workaround vorerst ist damit: Keine sich überschneidenden ServerAlias-Direktiven, wenn ein unspezifischer Alias eine Subdomain überdecken könnte. Damit lässt sich Problem 1 bereits recht gut umschiffen, was derzeit auch ausreicht, Problem 2 bleibt aber bestehen.
Beide Fehler habe ich Upstream reported und werd mir die Tage einmal anschauen, inwiefern man diese ohne allzu große Umbauarbeiten beheben kann. Soweit ich die Situation im Code einschätze, sollte sich hier eine recht einfach Lösung finden lassen.
Dennoch zeigt dieses Beispiel wieder einmal schön, wie kaputt unsere täglich eingesetzte Krypto ist.