diff --git a/scripts/base/protocols/ssl/files.zeek b/scripts/base/protocols/ssl/files.zeek index b1ae3bc428..ed8ea10c96 100644 --- a/scripts/base/protocols/ssl/files.zeek +++ b/scripts/base/protocols/ssl/files.zeek @@ -6,10 +6,10 @@ module SSL; export { - ## Set this to false to remove the server certificate subject and + ## Set this to true to includd the server certificate subject and ## issuer from the SSL log file. This information is still available ## in x509.log. - const log_include_server_certificate_subject_issuer = T &redef; + const log_include_server_certificate_subject_issuer = F &redef; ## Set this to true to include the client certificate subject ## and issuer in the SSL logfile. This information is rarely present @@ -47,6 +47,11 @@ export { ## client. client_issuer: string &log &optional; + ## Set to true if the hostname sent in the SNI matches the certificate. + ## Set to false if they do not match. Unset if the client did not send + ## an SNI. + sni_matches_cert: bool &log &optional; + ## Current number of certificates seen from either side. Used ## to create file handles. server_depth: count &default=0; @@ -108,7 +113,7 @@ event zeek_init() &priority=5 if ( ! log_include_server_certificate_subject_issuer ) { add ssl_filter$exclude["subject"]; - add ssl_filter$exclude["isser"]; + add ssl_filter$exclude["issuer"]; } if ( ! log_include_client_certificate_subject_issuer ) { @@ -168,6 +173,14 @@ hook ssl_finishing(c: connection) &priority=20 if ( c$ssl?$cert_chain && |c$ssl$cert_chain| > 0 && c$ssl$cert_chain[0]?$x509 ) { + if ( c$ssl?$server_name ) + { + if ( x509_check_cert_hostname(c$ssl$cert_chain[0]$x509$handle, c$ssl$server_name) != "" ) + c$ssl$sni_matches_cert = T; + else + c$ssl$sni_matches_cert = F; + } + c$ssl$subject = c$ssl$cert_chain[0]$x509$certificate$subject; c$ssl$issuer = c$ssl$cert_chain[0]$x509$certificate$issuer; } diff --git a/src/Val.cc b/src/Val.cc index f93e5beb88..44099fa034 100644 --- a/src/Val.cc +++ b/src/Val.cc @@ -986,6 +986,12 @@ string StringVal::ToStdString() const return string((char*)bs->Bytes(), bs->Len()); } +string_view StringVal::ToStdStringView() const + { + auto* bs = AsString(); + return string_view((char*)bs->Bytes(), bs->Len()); + } + StringVal* StringVal::ToUpper() { string_val->ToUpper(); diff --git a/src/Val.h b/src/Val.h index 979460cefa..f8cbe59928 100644 --- a/src/Val.h +++ b/src/Val.h @@ -539,6 +539,7 @@ public: // { return AsString()->ExpandedString(format); } std::string ToStdString() const; + std::string_view ToStdStringView() const; StringVal* ToUpper(); const String* Get() const { return string_val; } diff --git a/src/file_analysis/analyzer/x509/X509.cc b/src/file_analysis/analyzer/x509/X509.cc index 055cc0c434..293c4a76b5 100644 --- a/src/file_analysis/analyzer/x509/X509.cc +++ b/src/file_analysis/analyzer/x509/X509.cc @@ -361,12 +361,13 @@ void X509::ParseSAN(X509_EXTENSION* ext) continue; } + auto len = ASN1_STRING_length(gen->d.ia5); #if ( OPENSSL_VERSION_NUMBER < 0x10100000L ) || defined(LIBRESSL_VERSION_NUMBER) const char* name = (const char*) ASN1_STRING_data(gen->d.ia5); #else const char* name = (const char*) ASN1_STRING_get0_data(gen->d.ia5); #endif - auto bs = make_intrusive(name); + auto bs = make_intrusive(len, name); switch ( gen->type ) { diff --git a/src/file_analysis/analyzer/x509/functions.bif b/src/file_analysis/analyzer/x509/functions.bif index 847962850f..af4f971099 100644 --- a/src/file_analysis/analyzer/x509/functions.bif +++ b/src/file_analysis/analyzer/x509/functions.bif @@ -134,6 +134,54 @@ const EVP_MD* hash_to_evp(int hash) } } +// Check a given hostname against a name given in a cert (SAN, CN) and +// return if they match. +bool check_hostname(std::string_view hostname, std::string_view certname) + { + // let's start with the easy one + if ( hostname == certname ) + return true; + + // ok, now there is still the chance that it is a wildcard cert. + // We go according to RFC6128 here: + // * wildcards are allowed in the leftmost label + // * wildcards are only compared against the leftmost label + // * the wildcard character may not be the only part of the label (so abc* is ok) + // * we don't accept wildcards in anything lower than the 3rd level, so *.a.top + // Certificates that use something else cannot legitimately be issued and this + // seems to match other implementations. + + // first - let's see if the certname contains a wildcard character. + auto wildpos = certname.find('*'); + if ( wildpos == std::string::npos ) + return false; + + // then let's see if certname contains at least two dots, for three levels of domains + auto firstpos = certname.find('.'); + if ( firstpos == std::string::npos || certname.find('.', firstpos+1) == std::string::npos) + return false; + + // let's see if the wildcard is directly before the first label separator + if ( wildpos + 1 != firstpos ) + return false; + + // ok, we have chances. Let's see if the hostname portions match + auto host_firstpos = hostname.find('.'); + if ( host_firstpos == std::string::npos ) + return false; + + if ( hostname.substr(host_firstpos) != certname.substr(firstpos) ) + return false; + + // ok, the hostnames match and we have a wildcard. Let's see if the characters + // before the wildcard do match. If they do - yup, it is a match. If they don't, + // it is not. + if ( wildpos && hostname.substr(0, wildpos) != certname.substr(0, wildpos) ) + return false; + + return true; + } + %%} ## Parses a certificate into an X509::Certificate structure. @@ -924,3 +972,125 @@ function x509_set_certificate_cache_hit_callback%(f: string_any_file_hook%) : bo return zeek::val_mgr->True(); %} + +## This function checks a hostname against the name given in a certificate subject/SAN, including +## our interpretation of RFC6128 wildcard expansions. This specifically means that wildcards are +## only allowed in the leftmost label, wildcards only span one label, the wildcard has to be the +## last character before the label-separator, but additional characters are allowed before it, and +## the wildcard has to be at least at the third level (so *.a.b). +## +## hostname: Hostname to test +## +## certname: Name given in the CN/SAN of a certificate; wildcards will be expanded +## +## Returns: True if the hostname matches. +## +## .. zeek:see:: x509_check_cert_hostname +function x509_check_hostname%(hostname: string, certname: string%): bool + %{ + if ( check_hostname(hostname->ToStdStringView(), certname->ToStdStringView()) ) + return zeek::val_mgr->True(); + + return zeek::val_mgr->False(); + %} + +## This function checks if a hostname matches one of the hostnames given in the certificate. +## +## For our matching we adhere to RFC6128 for the labels (see :zeek:id:`x509_check_hostname`). +## Furthermore we adhere to RFC2818 and check only the names given in the SAN, if a SAN is present, +## ignoring CNs in the Subject. If no SAN is present, we will use the last CN in the subject +## for our tests. +## +## cert: The X509 certificate opaque handle. +## +## hostname: Hostname to check +## +## Returns: empty string if the hostname does not match; matched name (which can contain wildcards) +## if it did. +## +## .. zeek:see:: x509_check_hostname +function x509_check_cert_hostname%(cert_opaque: opaque of x509, hostname: string%): string + %{ + auto* cert_handle = (zeek::file_analysis::detail::X509Val *) cert_opaque; + std::string_view hostview = hostname->ToStdStringView(); + + X509* cert = cert_handle->GetCertificate(); + if ( ! cert ) + { + zeek::emit_builtin_error(zeek::util::fmt("No certificate in opaque")); + return zeek::make_intrusive(""); + } + + // According to RFC5280 (4.2.1.6) and RFC2818 (3.1), if the SAN is present, the subject + // of the certificate is ignored. Let's start by looking at the SAN. + auto sanpos = X509_get_ext_by_NID(cert, NID_subject_alt_name, -1); + if ( sanpos > -1 ) + { + auto* ex = X509_get_ext(cert, sanpos); + if ( ! ex ) + { + zeek::emit_builtin_error(zeek::util::fmt("Could not get SAN from cert")); + return zeek::make_intrusive(""); + } + + auto *altname = reinterpret_cast(X509V3_EXT_d2i(ex)); + if ( ! altname ) + { + zeek::emit_builtin_error(zeek::util::fmt("Could not get names from SAN ext")); + return zeek::make_intrusive(""); + } + + auto num_names = sk_GENERAL_NAME_num(altname); + for ( int i = 0; i < num_names; i++ ) + { + auto *gen = sk_GENERAL_NAME_value(altname, i); + assert(gen); + + if ( gen->type != GEN_DNS ) + continue; + + if ( ASN1_STRING_type(gen->d.ia5) != V_ASN1_IA5STRING ) + continue; + + std::size_t len = ASN1_STRING_length(gen->d.ia5); +#if ( OPENSSL_VERSION_NUMBER < 0x10100000L ) || defined(LIBRESSL_VERSION_NUMBER) + auto* name = reinterpret_cast(ASN1_STRING_data(gen->d.ia5)); +#else + auto* name = reinterpret_cast(ASN1_STRING_get0_data(gen->d.ia5)); +#endif + std::string_view nameview {name, len}; + if ( check_hostname(hostview, nameview) ) + return zeek::make_intrusive(len, name); + } + } + else + { + // ok, we have to get the last CN from the Subject. Let's do that. + auto* subject = X509_get_subject_name(cert); + if ( ! subject ) + { + zeek::emit_builtin_error(zeek::util::fmt("Could not get certificate subject")); + return zeek::make_intrusive(""); + } + + int lastpos = -1; + int found_nid = -1; + while ( ( lastpos = X509_NAME_get_index_by_NID(subject, NID_commonName, lastpos) ) >= 0 ) + found_nid = lastpos; + + // found CN + if ( found_nid >= 0 ) + { + char buf[2048]; + BIO *bio = BIO_new(BIO_s_mem()); + ASN1_STRING_print(bio, X509_NAME_ENTRY_get_data(X509_NAME_get_entry(subject, found_nid))); + size_t len = BIO_gets(bio, buf, sizeof(buf)); + BIO_free(bio); + std::string_view cn {buf, len}; + if ( check_hostname(hostview, cn) ) + return zeek::make_intrusive(len, buf); + } + } + + return zeek::make_intrusive(""); + %} diff --git a/testing/btest/Baseline/bifs.x509_check_hostname/.stdout b/testing/btest/Baseline/bifs.x509_check_hostname/.stdout new file mode 100644 index 0000000000..0848f293ae --- /dev/null +++ b/testing/btest/Baseline/bifs.x509_check_hostname/.stdout @@ -0,0 +1,19 @@ +### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63. +hi, www.zeek.org, F +ww.zeek.org, www.zeek.org, F +www.zeek.org, www.zeek.org, T +www.zeek.org, *, F +www.zeek.org, zeek.org, F +www.zeek.org, *.zeek.org, T +www.zeek.org, a*.zeek.org, F +www.zeek.org, ww*.zeek.org, T +www.zeek.org, wa*.zeek.org, F +www.zeek.org, ww*.leek.com, F +www.zeek.org, *.*.com, F +, , T +www.zeek.org\x00testing, *.zeek.org, F +zeek.org, zeek.org, T +zeek.org, *.org, F +a.b.zeek.org, *.b.zeek.org, T +a.b.zeek.org, *.zeek.org, F +a.b.zeek.org, *.a.zeek.org, F diff --git a/testing/btest/Baseline/scripts.base.files.x509.x509_check_cert_hostname/.stdout b/testing/btest/Baseline/scripts.base.files.x509.x509_check_cert_hostname/.stdout new file mode 100644 index 0000000000..e99c734510 --- /dev/null +++ b/testing/btest/Baseline/scripts.base.files.x509.x509_check_cert_hostname/.stdout @@ -0,0 +1,41 @@ +### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63. +CN=*.google.com,O=Google Inc,L=Mountain View,ST=California,C=US, www.google.com, *.google.com +CN=*.google.com,O=Google Inc,L=Mountain View,ST=California,C=US, www.zeek.org, +CN=*.google.com,O=Google Inc,L=Mountain View,ST=California,C=US, hello.android.com, *.android.com +CN=*.google.com,O=Google Inc,L=Mountain View,ST=California,C=US, g.co, g.co +CN=*.google.com,O=Google Inc,L=Mountain View,ST=California,C=US, Google Internet Authority G2, +CN=Google Internet Authority G2,O=Google Inc,C=US, www.google.com, +CN=Google Internet Authority G2,O=Google Inc,C=US, www.zeek.org, +CN=Google Internet Authority G2,O=Google Inc,C=US, hello.android.com, +CN=Google Internet Authority G2,O=Google Inc,C=US, g.co, +CN=Google Internet Authority G2,O=Google Inc,C=US, Google Internet Authority G2, Google Internet Authority G2 +CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US, www.google.com, +CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US, www.zeek.org, +CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US, hello.android.com, +CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US, g.co, +CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US, Google Internet Authority G2, +CN=*.google.com,O=Google Inc,L=Mountain View,ST=California,C=US, www.google.com, *.google.com +CN=*.google.com,O=Google Inc,L=Mountain View,ST=California,C=US, www.zeek.org, +CN=*.google.com,O=Google Inc,L=Mountain View,ST=California,C=US, hello.android.com, *.android.com +CN=*.google.com,O=Google Inc,L=Mountain View,ST=California,C=US, g.co, g.co +CN=*.google.com,O=Google Inc,L=Mountain View,ST=California,C=US, Google Internet Authority G2, +CN=Google Internet Authority G2,O=Google Inc,C=US, www.google.com, +CN=Google Internet Authority G2,O=Google Inc,C=US, www.zeek.org, +CN=Google Internet Authority G2,O=Google Inc,C=US, hello.android.com, +CN=Google Internet Authority G2,O=Google Inc,C=US, g.co, +CN=Google Internet Authority G2,O=Google Inc,C=US, Google Internet Authority G2, Google Internet Authority G2 +CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US, www.google.com, +CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US, www.zeek.org, +CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US, hello.android.com, +CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US, g.co, +CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US, Google Internet Authority G2, +CN=Bro, Bro, +CN=Bro, Broo, +CN=Bro, www.zeek.org, +CN=Bro, 9566.alt.helloIamADomain.example, 9566.alt.helloIamADomain.example +CN=WIN2K8R2.awakecoding.ath.cx, WIN2K8R2.awakecoding.ath.cx, WIN2K8R2.awakecoding.ath.cx +CN=WIN2K8R2.awakecoding.ath.cx, awakecoding.ath.cx, +CN=WIN2K8R2.awakecoding.ath.cx, www.zeek.org, +CN=WIN2K8R2.awakecoding.ath.cx, WIN2K8R2.awakecoding.ath.cx, WIN2K8R2.awakecoding.ath.cx +CN=WIN2K8R2.awakecoding.ath.cx, awakecoding.ath.cx, +CN=WIN2K8R2.awakecoding.ath.cx, www.zeek.org, diff --git a/testing/btest/bifs/x509_check_hostname.zeek b/testing/btest/bifs/x509_check_hostname.zeek new file mode 100644 index 0000000000..7094426d2c --- /dev/null +++ b/testing/btest/bifs/x509_check_hostname.zeek @@ -0,0 +1,31 @@ +# Test the x509_check_hostname bif. + +# @TEST-EXEC: zeek -b %INPUT +# @TEST-EXEC: btest-diff .stdout + +function check_it(host: string, cert: string) + { + print host, cert, x509_check_hostname(host, cert); + } + +event zeek_init() + { + check_it("hi", "www.zeek.org"); + check_it("ww.zeek.org", "www.zeek.org"); + check_it("www.zeek.org", "www.zeek.org"); + check_it("www.zeek.org", "*"); + check_it("www.zeek.org", "zeek.org"); + check_it("www.zeek.org", "*.zeek.org"); + check_it("www.zeek.org", "a*.zeek.org"); + check_it("www.zeek.org", "ww*.zeek.org"); + check_it("www.zeek.org", "wa*.zeek.org"); + check_it("www.zeek.org", "ww*.leek.com"); + check_it("www.zeek.org", "*.*.com"); + check_it("", ""); + check_it("www.zeek.org\x00testing", "*.zeek.org"); + check_it("zeek.org", "zeek.org"); + check_it("zeek.org", "*.org"); + check_it("a.b.zeek.org", "*.b.zeek.org"); + check_it("a.b.zeek.org", "*.zeek.org"); + check_it("a.b.zeek.org", "*.a.zeek.org"); + } diff --git a/testing/btest/scripts/base/files/x509/x509_check_cert_hostname.zeek b/testing/btest/scripts/base/files/x509/x509_check_cert_hostname.zeek new file mode 100644 index 0000000000..3d4cbeecbf --- /dev/null +++ b/testing/btest/scripts/base/files/x509/x509_check_cert_hostname.zeek @@ -0,0 +1,55 @@ +# Test that certificate event caching works as expected. + +# @TEST-EXEC: zeek -b -r $TRACES/tls/google-duplicate.trace common.zeek google-duplicate.zeek +# @TEST-EXEC: cat $TRACES/tls/tls-fragmented-handshake.pcap.gz | gunzip | zeek -b -r - common.zeek fragmented.zeek +# @TEST-EXEC: zeek -b -r $TRACES/rdp/rdp-to-ssl.pcap common.zeek rdp.zeek +# @TEST-EXEC: btest-diff .stdout + +@TEST-START-FILE common.zeek + +@load base/protocols/ssl + +function test_it(cert_ref: opaque of x509, name: string, subject: string) + { + print subject, name, x509_check_cert_hostname(cert_ref, name); + } + +@TEST-END-FILE + +@TEST-START-FILE google-duplicate.zeek + +event x509_certificate(f: fa_file, cert_ref: opaque of x509, cert: X509::Certificate) + { + test_it(cert_ref, "www.google.com", cert$subject); + test_it(cert_ref, "www.zeek.org", cert$subject); + test_it(cert_ref, "hello.android.com", cert$subject); + test_it(cert_ref, "g.co", cert$subject); + test_it(cert_ref, "Google Internet Authority G2", cert$subject); + } + +@TEST-END-FILE + +@TEST-START-FILE fragmented.zeek + +event x509_certificate(f: fa_file, cert_ref: opaque of x509, cert: X509::Certificate) + { + test_it(cert_ref, "Bro", cert$subject); + test_it(cert_ref, "Broo", cert$subject); + test_it(cert_ref, "www.zeek.org", cert$subject); + test_it(cert_ref, "9566.alt.helloIamADomain.example", cert$subject); + } + +@TEST-END-FILE + +@TEST-START-FILE rdp.zeek + +@load base/protocols/rdp + +event x509_certificate(f: fa_file, cert_ref: opaque of x509, cert: X509::Certificate) + { + test_it(cert_ref, "WIN2K8R2.awakecoding.ath.cx", cert$subject); + test_it(cert_ref, "awakecoding.ath.cx", cert$subject); + test_it(cert_ref, "www.zeek.org", cert$subject); + } + +@TEST-END-FILE