diff --git a/MANIFEST b/MANIFEST index f775c29e4..acb7a5f6d 100644 --- a/MANIFEST +++ b/MANIFEST @@ -79,6 +79,7 @@ include/compat/sha2.h include/compat/stdbool.h include/log_server.pb-c.h include/protobuf-c/protobuf-c.h +lib/iolog/hostcheck.h include/sudo_compat.h include/sudo_conf.h include/sudo_debug.h @@ -95,6 +96,7 @@ include/sudo_rand.h include/sudo_util.h install-sh lib/iolog/Makefile.in +lib/iolog/hostcheck.c lib/iolog/iolog_fileio.c lib/iolog/iolog_path.c lib/iolog/iolog_util.c diff --git a/include/hostcheck.h b/include/hostcheck.h new file mode 100644 index 000000000..f77e528b2 --- /dev/null +++ b/include/hostcheck.h @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2020 Laszlo Orban + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#ifndef SUDO_HOSTCHECK_H +#define SUDO_HOSTCHECK_H + +#if defined(HAVE_OPENSSL) + +# include +# include "sudo_compat.h" + +typedef enum { + MatchFound, + MatchNotFound, + NoSANPresent, + MalformedCertificate, + Error +} HostnameValidationResult; + +__dso_public HostnameValidationResult +validate_hostname(const X509 *cert, const char *hostname, const char *ipaddr, int resolve); + +#endif /* HAVE_OPENSSL */ + +#endif /* SUDO_HOSTCHECK_H */ \ No newline at end of file diff --git a/lib/iolog/Makefile.in b/lib/iolog/Makefile.in index 94d060791..787903225 100644 --- a/lib/iolog/Makefile.in +++ b/lib/iolog/Makefile.in @@ -82,7 +82,7 @@ DEVEL = @DEVEL@ SHELL = @SHELL@ -LIBIOLOG_OBJS = iolog_fileio.lo iolog_path.lo iolog_util.lo +LIBIOLOG_OBJS = iolog_fileio.lo iolog_path.lo iolog_util.lo hostcheck.lo IOBJS = $(LIBIOLOG_OBJS:.lo=.i) @@ -201,6 +201,14 @@ check_iolog_util.i: $(srcdir)/regress/iolog_util/check_iolog_util.c \ $(CC) -E -o $@ $(CPPFLAGS) $< check_iolog_util.plog: check_iolog_util.i rm -f $@; pvs-studio --cfg $(PVS_CFG) --sourcetree-root $(top_srcdir) --skip-cl-exe yes --source-file $(srcdir)/regress/iolog_util/check_iolog_util.c --i-file $< --output-file $@ +hostcheck.lo: $(srcdir)/hostcheck.c $(incdir)/hostcheck.h \ + $(incdir)/sudo_compat.h $(top_builddir)/config.h + $(LIBTOOL) $(LTFLAGS) --mode=compile $(CC) -c -o $@ $(CPPFLAGS) $(CFLAGS) $(ASAN_CFLAGS) $(PIE_CFLAGS) $(SSP_CFLAGS) $(srcdir)/hostcheck.c +hostcheck.i: $(srcdir)/hostcheck.c $(incdir)/hostcheck.h \ + $(incdir)/sudo_compat.h $(top_builddir)/config.h + $(CC) -E -o $@ $(CPPFLAGS) $< +hostcheck.plog: hostcheck.i + rm -f $@; pvs-studio --cfg $(PVS_CFG) --sourcetree-root $(top_srcdir) --skip-cl-exe yes --source-file $(srcdir)/hostcheck.c --i-file $< --output-file $@ iolog_fileio.lo: $(srcdir)/iolog_fileio.c $(incdir)/compat/stdbool.h \ $(incdir)/sudo_compat.h $(incdir)/sudo_conf.h \ $(incdir)/sudo_debug.h $(incdir)/sudo_event.h \ diff --git a/lib/iolog/hostcheck.c b/lib/iolog/hostcheck.c new file mode 100644 index 000000000..b7fcb2a57 --- /dev/null +++ b/lib/iolog/hostcheck.c @@ -0,0 +1,361 @@ +/* + * Copyright (c) 2020 Laszlo Orban + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include "config.h" + +#if defined(HAVE_OPENSSL) +# include +# include +# include +# include +# include + +# include "hostcheck.h" + +/** + * @brief Checks if given hostname resolves to the given IP address. + * + * @param hostname hostname to be resolved + * @param ipaddr ip address to be checked + * + * @return 1 if hostname resolves to the given IP address + * 0 otherwise + */ +static int +forward_lookup_match(const char *hostname, const char *ipaddr) +{ + int ret = 0; + struct addrinfo *res = NULL, *p; + void *addr; + struct sockaddr_in *ipv4; +#if defined(HAVE_STRUCT_IN6_ADDR) + struct sockaddr_in6 *ipv6; + char ipstr[INET6_ADDRSTRLEN]; +#else + char ipstr[INET_ADDRSTRLEN]; +#endif + + if (getaddrinfo(hostname, NULL, NULL, &res) != 0) { + goto exit; + } + + for(p = res ;p != NULL; p = p->ai_next) { + if(p->ai_family == AF_INET) { + ipv4 = (struct sockaddr_in *)p->ai_addr; + addr = &(ipv4->sin_addr); +#if defined(HAVE_STRUCT_IN6_ADDR) + } else if(p->ai_family == AF_INET6) { + ipv6 = (struct sockaddr_in6 *)p->ai_addr; + addr = &(ipv6->sin6_addr); +#endif + } else { + goto exit; + } + + if (inet_ntop(p->ai_family, addr, ipstr, sizeof(ipstr)) != 0) { + if (strcmp(ipaddr, ipstr) == 0) { + ret = 1; + break; + } + } + } + +exit: + if (res != NULL) { + freeaddrinfo(res); + } + return ret; +} + +/** + * @brief Compares the given hostname with a DNS entry in a certificate. + * + * The certificate DNS name can contain wildcards in the left-most label. + * A wildcard can match only one label. + * Accepted names: + * - foo.bar.example.com + * - *.example.com + * - *.bar.example.com + * + * @param hostname peer's name + * @param certname_asn1 hostname in the certificate + * + * @return MatchFound + * MatchNotFound + */ +static HostnameValidationResult +validate_name(const char *hostname, ASN1_STRING *certname_asn1) { + char *certname_s = (char *) ASN1_STRING_get0_data(certname_asn1); + int certname_len = ASN1_STRING_length(certname_asn1); + int hostname_len = strlen(hostname); + + /* remove last '.' from hostname if exists */ + if (hostname_len != 0 && hostname[hostname_len - 1] == '.') { + --hostname_len; + } + + /* skip the first label if wildcard */ + if (certname_len > 2 && certname_s[0] == '*' && certname_s[1] == '.') { + if (hostname_len != 0) { + do { + --hostname_len; + if (*hostname++ == '.') { + break; + } + } while (hostname_len != 0); + } + certname_s += 2; + certname_len -= 2; + } + /* Compare expected hostname with the DNS name */ + if (certname_len != hostname_len) { + return MatchNotFound; + } + if (strncasecmp(hostname, certname_s, hostname_len) != 0) { + return MatchNotFound; + } + + return MatchFound; +} + +/** + * @brief Matches a hostname with the cert's CN. + * + * @param hostname peer's name + * on client side: it is the name where the client is connected to + * on server side, it is in fact an IP address of the remote client + * @param ipaddr peer's IP address + * @param cert peer's X509 certificate + * @param resolve if the value is not 0, the function checks that the value of the CN + * resolves to the given ipaddr or not. + * + * @return MatchFound + * MatchNotFound + * MalformedCertificate + * Error + */ +static HostnameValidationResult +matches_common_name(const char *hostname, const char *ipaddr, const X509 *cert, int resolve) +{ + X509_NAME_ENTRY *common_name_entry = NULL; + ASN1_STRING *common_name_asn1 = NULL; + int common_name_loc = -1; + + /* Find the position of the CN field in the Subject field of the certificate */ + common_name_loc = X509_NAME_get_index_by_NID(X509_get_subject_name((X509 *) cert), NID_commonName, -1); + if (common_name_loc < 0) { + return Error; + } + + /* Extract the CN field */ + common_name_entry = X509_NAME_get_entry(X509_get_subject_name((X509 *) cert), common_name_loc); + if (common_name_entry == NULL) { + return Error; + } + + /* Convert the CN field to a C string */ + common_name_asn1 = X509_NAME_ENTRY_get_data(common_name_entry); + if (common_name_asn1 == NULL) { + return Error; + } + const unsigned char *common_name_str = ASN1_STRING_get0_data(common_name_asn1); + + /* Make sure there isn't an embedded NUL character in the CN */ + if (memchr(common_name_str, '\0', ASN1_STRING_length(common_name_asn1)) != NULL) { + return MalformedCertificate; + } + + /* Compare expected hostname with the CN */ + if (validate_name(hostname, common_name_asn1) == MatchFound) { + return MatchFound; + } + + int common_name_length = ASN1_STRING_length(common_name_asn1); + unsigned char *nullterm_common_name = malloc(common_name_length + 1); + + if (nullterm_common_name == NULL) { + return Error; + } + + memcpy(nullterm_common_name, common_name_str, common_name_length); + nullterm_common_name[common_name_length] = '\0'; + + + /* check if hostname in the CN field resolves to the given ip address */ + if (resolve && forward_lookup_match(nullterm_common_name, ipaddr)) { + free(nullterm_common_name); + return MatchFound; + } + + free(nullterm_common_name); + return MatchNotFound; +} + +/** + * @brief Matches a hostname or ipaddr with the cert's corresponding SAN field. + * + * SAN can have different fields. For hostname matching, the GEN_DNS field is used, + * for IP address matching, the GEN_IPADD field is used. + * Since SAN is an X503 v3 extension, it can happen that the cert does + * not contain SAN at all. + * + * @param hostname remote peer's name + * on client side: it is the name where the client is connected to + * on server side, it is in fact an IP address of the remote client + * @param ipaddr remote peer's IP address + * @param cert peer's X509 certificate + * @param resolve if the value is not 0, the function checks that the value of the + * SAN GEN_DNS resolves to the given ipaddr or not. + * + * @return MatchFound + * MatchNotFound + * NoSANPresent + * MalformedCertificate + * Error + */ +static HostnameValidationResult +matches_subject_alternative_name(const char *hostname, const char *ipaddr, const X509 *cert, int resolve) +{ + HostnameValidationResult result = MatchNotFound; + int i; + int san_names_nb = -1; + STACK_OF(GENERAL_NAME) *san_names = NULL; + + /* Try to extract the names within the SAN extension from the certificate */ + san_names = X509_get_ext_d2i((X509 *) cert, NID_subject_alt_name, NULL, NULL); + if (san_names == NULL) { + return NoSANPresent; + } + san_names_nb = sk_GENERAL_NAME_num(san_names); + + /* Check each name within the extension */ + for (i=0; itype == GEN_DNS) { + const unsigned char *dns_name = ASN1_STRING_get0_data(current_name->d.dNSName); + + /* Make sure there isn't an embedded NUL character in the DNS name */ + if (memchr(dns_name, '\0', ASN1_STRING_length(current_name->d.dNSName)) != NULL) { + result = MalformedCertificate; + break; + } else { + /* Compare expected hostname with the DNS name */ + if (validate_name(hostname, current_name->d.dNSName) == MatchFound) { + result = MatchFound; + break; + } + + int dns_name_length = ASN1_STRING_length(current_name->d.dNSName); + unsigned char *nullterm_dns_name = malloc(dns_name_length + 1); + + if (nullterm_dns_name == NULL) { + return Error; + } + + memcpy(nullterm_dns_name, dns_name, dns_name_length); + nullterm_dns_name[dns_name_length] = '\0'; + + if (resolve && forward_lookup_match(nullterm_dns_name, ipaddr)) { + free(nullterm_dns_name); + result = MatchFound; + break; + } + free(nullterm_dns_name); + } + } else if (current_name->type == GEN_IPADD) { + const unsigned char *san_ip = ASN1_STRING_get0_data(current_name->d.iPAddress); +#if defined(HAVE_STRUCT_IN6_ADDR) + char san_ip_str[INET6_ADDRSTRLEN]; +#else + char san_ip_str[INET_ADDRSTRLEN]; +#endif + + /* IPV4 address */ + if(current_name->d.iPAddress->length == 4) { + if (inet_ntop(AF_INET, san_ip, san_ip_str, INET_ADDRSTRLEN) == NULL) { + result = MalformedCertificate; + break; + } +#if defined(HAVE_STRUCT_IN6_ADDR) + /* IPV6 address */ + } else if (current_name->d.iPAddress->length == 16) { + if (inet_ntop(AF_INET6, san_ip, san_ip_str, INET6_ADDRSTRLEN) == NULL) { + result = MalformedCertificate; + break; + } +# endif + } else { + result = MalformedCertificate; + break; + } + + if (strcasecmp(ipaddr, san_ip_str) == 0) { + result = MatchFound; + break; + } + } + } + sk_GENERAL_NAME_pop_free(san_names, GENERAL_NAME_free); + + return result; +} + +/** + * @brief Do hostname/IP validation on the given X509 certificate. + * + * According to RFC 6125 section 6.4.4, first the certificate's SAN field + * has to be checked. If there is no SAN field, the certificate's CN field + * has to be checked. + * + * @param cert X509 certificate + * @param hostname remote peer's name + * on client side: it is the name where the client is connected to + * on server side, it is in fact an IP address of the remote client + * @param ipaddr remote peer's IP address + * @param resolve if the value is not 0, the function checks that the value of the + * SAN GEN_DNS or the value of CN resolves to the given ipaddr or not. + * + * @return MatchFound + * MatchNotFound + * MalformedCertificate + * Error + */ +HostnameValidationResult +validate_hostname(const X509 *cert, const char *hostname, const char *ipaddr, int resolve) +{ + HostnameValidationResult res = MatchFound; + + /* hostname can be also an ip address, if client connects + * to ip instead of FQDN + */ + if((ipaddr == NULL) || (cert == NULL)) { + return Error; + } + + /* check SAN first if exists */ + res = matches_subject_alternative_name(hostname, ipaddr, cert, resolve); + + /* According to RFC 6125 section 6.4.4, check CN only, + * if no SAN name was provided + */ + if (res == NoSANPresent) { + res = matches_common_name(hostname, ipaddr, cert, resolve); + } + + return res; +} +#endif /* HAVE_OPENSSL */ \ No newline at end of file