Avoiding TLS certificate leakage with Nginx

Published on 06 Mar 2024

TL;DR: Having a Nginx configuration file with server_name _; is not enough for it to match any not supported domain name. Always properly configure a fallback method with a self-signed certificate not leaking any personal data and make sure it is loaded first as the loading order of configuration files matters. For instance move it to /etc/nginx/sites-enabled/0_default and make sure no other server block configuration is loaded first.

Context

I recently installed a server with a wildcard certificate in order to host some services. The wildcard certificate is useful in order to only have one renewal to do for all your subdomains, but it is also useful if you have privacy concerns about your services. Indeed, each certificate request is logged for the sake of transparency and trust of certificate chain and thus it results in leaking yoursecretsubdomain.myhome.com . This is well-known from pentesters as it often leads to widening the attack surface when trying to reach into a network. Check out some of Google domains requests: https://crt.sh/?q=%25.google.com Back to the topic, I was happy running my super leet secret service (aka my IP camera) when it suddenly occurred to me: what if someone tries to reach my server without knowing my domain name at all? Answer given by curl:

$ curl -v -k https://10.10.10.10
*   Trying 10.10.10.10:443...
* Connected to 10.10.10.10 (10.10.10.10) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=*.myhome.com
*  start date: Jan  1 10:30:40 2000 GMT
*  expire date: Jan  1 10:30:40 3000 GMT
*  issuer: C=US; O=Let's Encrypt; CN=R3
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using sha256WithRSAEncryption
*   Certificate level 1: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
*   Certificate level 2: Public key type RSA (4096/152 Bits/secBits), signed using sha256WithRSAEncryption
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://10.10.10.10/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: 10.10.10.10]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.6.0]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: 10.10.10.10
> User-Agent: curl/8.6.0
> Accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
< HTTP/2 401
< server: nginx/1.22.1
< date: Wed, 06 Mar 2024 19:54:38 GMT
< content-type: text/html
< content-length: 179
< www-authenticate: Basic realm="My IP Camera"
<
<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
<hr><center>nginx/1.22.1</center>
</body>
</html>

So here we are, Nginx happily leaks my certificate. How could this happen since I have a fallback method? Stupidly enough, I store my Nginx configuration files in /etc/nginx/sites-enabled and did have a fallback method in /etc/nginx/sites-enabled/default. This particular rule that we see comes from one of the subdomain being stored in /etc/nginx/sites-enabled/abracadabra.myhome.com which is loaded before the default handler.

root@server:~# ls /etc/nginx/sites-enabled/ -l
total 16
-rw-r--r-- 1 root root 2465 Mar  5 18:34 abracdabra.myhome.com
-rw-r--r-- 1 root root  337 Mar  5 18:45 default
-rw-r--r-- 1 root root 5788 Mar  5 18:49 secret.myhome.com

Although the default configuration file matches every domain name (with a server_name _; directive), in this specific case Nginx will use the first suitable server block, which leads into leaking my certificate from the first configuration file (as well as the service running behind it).

Solution

I came up with an easy but radical solution:

# Whatever configuration you may have
#server {
#    listen 10.20.30.1:80;
#    server_name _;
#    return 301 https://$host$request_uri;
#}

# Interface exposed on the internet
server {
    listen 192.168.1.79:443 ssl http2;
    server_name _;
    ssl_certificate     /etc/nginx/blackhole_cert.pem;
    ssl_certificate_key /etc/nginx/blackhole_key.pem;
    return 444;
}

After restarting Nginx, the magic happens:

$ curl -v -k https://10.10.10.10
*   Trying 10.10.10.10:443...
* Connected to 10.10.10.10 (10.10.10.10) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / RSASSA-PSS
* ALPN: server accepted h2
* Server certificate:
*  subject: OU=No SNI provided; please fix your client.; CN=invalid2.invalid
*  start date: Mar  6 09:48:01 2024 GMT
*  expire date: Mar  4 09:48:01 2034 GMT
*  issuer: OU=No SNI provided; please fix your client.; CN=invalid2.invalid
*  SSL certificate verify result: self-signed certificate (18), continuing anyway.
*   Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://10.10.10.10/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: 10.10.10.10]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.6.0]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: 10.10.10.10
> User-Agent: curl/8.6.0
> Accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
* HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1)
* Connection #0 to host 10.10.10.10 left intact
curl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1)

Conclusion

In the end I completely failed my opsec as my wildcard certificate got leaked on Shodan (among other scanners) but hopefully this blogpost will help someone double check their proxy configuration. Always properly configure a fallback method with a self-signed certificate not leaking any personal data and make sure it is loaded first as the loading order of configuration files matters.