Anti-fingerprinting Configuration for NGINX

by Tom Richards

Thursday, Aug 11, 2022

Fingerprinting the web.
Fingerprinting the web.

Software on the web discloses a wealth of information with little prompt. Help protect your privacy by using these anti-fingerprinting options.

An example from Shodan:

A Shodan search result
A randomly selected search result from Shodan with lots of goodies.

A quick note about Shodan, if you haven’t heard of it before:

Shodan provides a shallow search index of all network ports. Where other search engines provide a deep index of ports 80 and 443, Shodan scrapes the surface of the web to find information about all ports, not just websites.

In this particular case, we do want the information it has on ports 80 and 443. 😅️

By issuing a plain, not-encrypted HTTP request to this machine’s IP address, we now have the following information from the response headers alone:

  1. The web server software (NGINX)
  2. The web server version (1.18.0)
  3. The domain name (via HTTPS certificate)

If we cared to dig just a little deeper, we would find some extra information in the response body (omitted here for brevity):

  1. The application software (Bitwarden)
  2. The application version (2.25.1)

Shodan has indexed the other subdomains it observed at this IP address, which gives us an idea of the other software potentially hosted on this machine.

  1. Apache Guacamole
  2. Minecraft
  3. Plex

Holy nightmare, Batman! This happens when people use the default settings for these pieces of software. Who changes those?

The answer: most people do not change settings from their defaults. We call this, “the tyranny of the default”.

Our goal

By changing our NGINX configuration from the default, we will raise the bar higher than “give up all the information on the first request”. We won’t achieve perfect security by any means, but we will take a step or two in the right direction.

Let us defend ourselves against the tyranny.

Review the default settings

Let’s begin by looking at the official nginx image from Docker Hub.

  1. Run the NGINX server.

    $ docker run --rm -it -p 8080:80 nginx
    
  2. See what it has to say.

    $ curl -I localhost:8080
    HTTP/1.1 200 OK
    Server: nginx/1.23.1
    Date: Thu, 11 Aug 2022 03:28:49 GMT
    Content-Type: text/html
    Content-Length: 615
    Last-Modified: Tue, 19 Jul 2022 14:05:27 GMT
    Connection: keep-alive
    ETag: "62d6ba27-267"
    Accept-Ranges: bytes
    
  3. (Extra credit) Inspect the underlying configuration.

    $ docker run --rm -it nginx cat /etc/nginx/nginx.conf
    <output omitted for brevity>
    

I won’t show the full configuration here because we do not care about it. That said, I do recommend exploring your distribution’s out-of-the-box configuration to understand what it provides for you by default. Every distro/package/image does things in a slightly different way.

For example, the vanilla config differs from the Debian config differs from the Alpine config, and so on.

You don’t have to know all the details about every possible packaged configuration, but you should know and understand the configuration that you use.

Remove the Server header

The first step in any “NGINX Security” tutorial will tell you to use the server_tokens directive to reduce the amount of information provided in the Server HTTP response header.

It generally looks something like this:

# Reduce 'Server: nginx/1.23.1' -> 'Server: nginx'.
server_tokens off;

While this gives us a good start, we can take this one step further by eliminating the Server header entirely. By using the ngx_headers_more module from OpenResty, we can turn off any header for all responses.

# Forcibly turn off the Server header.
more_clear_headers 'Server';

The easiest way to install this module? Install the Alpine Linux distribution package for it.

Before we try out that module, we’ll take a quick detour to see how the Alpine nginx package behaves in comparison to the previous one.

First, we hand-craft our artisinal Dockerfile:

FROM alpine:3.16

RUN set -ex; \
    apk add nginx; \
    ln -sf /dev/stdout /var/log/nginx/access.log; \
    ln -sf /dev/stderr /var/log/nginx/error.log

EXPOSE 80

STOPSIGNAL SIGQUIT

CMD ["nginx", "-g", "daemon off;"]

Then we build the image and run it:

$ docker build -t my_nginx .
$ docker run --rm -it -p 8080:80 my_nginx

Repeat the request we made before, and:

$ curl -I localhost:8080
HTTP/1.1 404 Not Found
Server: nginx
Date: Thu, 11 Aug 2022 03:28:49 GMT
Content-Type: text/html
Content-Length: 146
Connection: keep-alive

Hey! Quite nice. The Alpine version already provides a good server_tokens configuration. Let’s add the Headers More module and tell it to turn off the header entirely.

# File: Dockerfile

RUN set -ex; \
-    apk add nginx; \
+    apk add nginx nginx-mod-http-headers-more; \
    ln -sf /dev/stdout /var/log/nginx/access.log; \
# File: headers.conf
more_clear_headers 'Server';

Since we want to make more than a handful of changes to our config file, let’s mount the configuration using docker-compose so we can apply it without needing to rebuild the image over and over again.

# File: docker-compose.yml
version: '3.8'
services:
  web:
    build: .
    ports:
      - "8080:80"
    volumes:
      - ./headers.conf:/etc/nginx/http.d/headers.conf:ro

Start it up:

$ docker compose up

Once again, ask for the headers:

$ curl -I localhost:8080
HTTP/1.1 404 Not Found
Date: Thu, 11 Aug 2022 03:28:49 GMT
Content-Type: text/html
Content-Length: 146
Connection: keep-alive

Success! We have eliminated the Server header from our HTTP response. Automated scanners will now have a slightly more difficult time discovering our NGINX software.

Drop direct-to-IP requests

Typically, fingerprinting requests will naively hit your server by directly using its IP address. When this happens, why should we tell it what domain names we know about? Why should we give it any response at all?

A snippet from the NGINX docs:

https://nginx.org/en/docs/http/request_processing.html

How to prevent processing requests with undefined server names

If requests without the “Host” header field should not be allowed, a server that just drops the requests can be defined:

server {
   listen      80;
   server_name "";
   return      444;
}

Some notes about this configuration:

  1. The non-standard response code of 444 tells NGINX to close the connection.
  2. NGINX server blocks have had a default server_name of "" since 2009. I think we can omit that line, don’t you? 😉️

Our new configuration:

# File: default.conf
more_clear_headers 'Server';

server {
    listen 80 default_server;

    return 444;
}
-# File: headers.conf
-more_clear_headers 'Server';
# File: docker-compose.yml
    volumes:
-      - ./headers.conf:/etc/nginx/http.d/headers.conf:ro
+      - ./default.conf:/etc/nginx/http.d/default.conf:ro

Note that we have replaced the previous headers.conf file with a new default.conf file which overrides the existing one in the image. Restart your docker compose command to pick up the new volumes configuration:

^C
$ docker compose up

Inspect the response again:

$ curl -I localhost:8080
curl: (52) Empty reply from server

This looks much better! The outside world might still know that a TCP service exists at the given port, but we do not give up any HTTP response data.

Upgrade to HTTPS

In the Shodan screenshot above, we see that the HTTPS certificate’s Common Name field discloses the domain name(s) which belong to our server’s IP address.

Let’s first upgrade our server to HTTPS. We can then address the problems which arise after having done so.

# File: Dockerfile
-EXPOSE 80
+EXPOSE 443
# File: docker-compose.yml
    ports:
-      - "8080:80"
+      - "8443:443"
# File: default.conf
server {
-    listen 80 default_server;
+    listen 443 ssl http2 default_server;

With those changes in place, we need to do a little more rebuilding for them to take effect:

$ docker compose down -v
$ docker compose up --build

Success!

nginx: [emerg] no "ssl_certificate" is defined for the "listen ... ssl" directive in /etc/nginx/http.d/default.conf:3

Ah, drat. Turns out that NGINX wants us to give it a certificate of some kind to run in HTTPS mode.

The HTTPS certificate

But wait, the HTTPS certificate already includes the domain name! If we send back a certificate as part of the HTTPS negotiation, won’t that allow the scanner to see our domain name again?

Yes. If we want to throw the scanners off our scent, we have a couple options:

  1. Send them a real certificate for a different, but entirely unrelated domain.

    This feels too cumbersome and requires setting up yet another domain (don’t we already have enough of those? 😭️).

  2. Send them a bogus self-signed certificate for a joke domain.

    This gives us a great opportunity to send over something like Common Name: timecube.com, if you feel so inclined.

What if we just responded with nothing? I present to you, the final option,

  1. Do not present a certificate at all.

    Correct. We can, in fact, send them nothing in the HTTPS negotiation.

The NGINX configuration language has a limited interpreter, which makes this challenging. For example, I expected one of these expressions to “just work”. NGINX had different idea about the situation.

# error: cannot load certificate "/etc/nginx/"
ssl_certificate "";

# error: cannot load certificate "data:"""
ssl_certificate data:"";

# warning: using uninitialized "empty" variable while SSL handshaking
# error: cannot load certificate "data:"
set $empty "";
ssl_certificate data:$empty;

Some folks have come up with a clever workaround for this whole variable business in the form of ngx_http_map_module.

# error: cannot load certificate "data:"
map "" $empty {
    default "";
}
ssl_certificate data:$empty;
ssl_certificate_key data:$empty;

Close! We eliminated the warning about the uninitialized variable, but this still leaves us with the error. What else can we do?

It turns out, we just need one magical line for this configuration to both drop the handshake, and not log errors: the ssl_reject_handshake directive. With the release of version 1.19.4 in late 2020, NGINX added this lovely configuration directive which we will now use.

Meh, but I have an outdated NGINX and it can’t use that directive!

If you have an older version, you can always upgrade! 😉️

Let’s see if the configuration from the docs gets us what we want:

# error: no "ssl_certificate" is defined
server {
    listen 443 ssl http2 default_server;
    ssl_reject_handshake on;
}

Ugh, if we try to use this sole server block from the documentation, it won’t work unless we specify other server blocks which do have valid certificates.

For completeness sake, let’s combine the forces of the empty map certificate and the ssl_reject_handshake directive just to see if we can get NGINX to close the connection with one default server block specified:

map "" $empty {
    default "";
}

server {
    listen 443 ssl http2 default_server;
    ssl_reject_handshake on;
    ssl_certificate data:$empty;
    ssl_certificate_key data:$empty;
}

Give it one more try:

$ curl -Ik https://localhost:8443
curl: (35) error:14094458:SSL routines:ssl3_read_bytes:tlsv1 unrecognized name

It works! We have interrupted the connection for HTTPS requests. Yay!

(Technically, this does return a fatal TLS unrecognized_name alert, but we’ll ignore that for now.)

Clean up error pages

Problem: Shodan issues plain HTTP requests to port 443 like a blockhead.

$ curl -v http://localhost:8443
*   Trying 127.0.0.1:8443...
* Connected to localhost (127.0.0.1) port 8443 (#0)
> GET / HTTP/1.1
> Host: localhost:8443
> User-Agent: Chrome/
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 400 Bad Request
< Date: Thu, 11 Aug 2022 03:28:49 GMT
< Content-Type: text/html
< Content-Length: 248
< Connection: close
<
<html>
<head><title>400 The plain HTTP request was sent to HTTPS port</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The plain HTTP request was sent to HTTPS port</center>
<hr><center>nginx</center>
</body>
</html>
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- Hello, sharp reader! These comments can be disabled      -->
<!-- using the msie_padding directive. However, we do not     -->
<!-- care for this because we are going to disable this       -->
<!-- error page entirely. So, we won't see it anyway. :)      -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
* Closing connection 0

Oh great, we pooped out the server name again. Thanks, NGINX. 🤦‍♂️️

Guess what? This page also has an internal status code. 😃️

When NGINX receives a plain HTTP request on an HTTPS server, the request has an internal error code of 497. NGINX allows us to customize error pages, even for internal errors, by using the error_page directive.

Generally, you would use the error_page directive to specify your custom 4xx or 5xx page, by giving it the path to your static page:

# Not what we want :(
error_page 404             /404.html;
error_page 500 502 503 504 /50x.html;

In our case, we don’t want to send any error page data. We want more drop connection magic. In this case, the “named location” strategy comes to the rescue:

error_page 497 = @empty_err;

location @empty_err {
    return 444;
}

Testing it out one more time:

$ curl -v http://localhost:8443/
*   Trying 127.0.0.1:8443...
* Connected to localhost (127.0.0.1) port 8443 (#0)
> GET / HTTP/1.1
> Host: localhost:8443
> User-Agent: curl/7.81.0
> Accept: */*
>
* Empty reply from server
* Closing connection 0
curl: (52) Empty reply from server

At last! We have achieved a reasonable default starting configuration. We close connections for HTTP, HTTPS, and HTTP->HTTPS requests by default. We provide a real response when the client specifies a valid hostname for our server.

This might fit the category of “security through obscurity”, but I consider this a much better option than, “here, please have my data”.

Putting it all together

You can find this complete example configuration and all the Docker bits on this GitHub gist.

NGINX config:

# File: default.nginxconf

map "" $empty {
    default "";
}

# Forcibly turn off the server header.
more_clear_headers 'Server';

# Default server that forcibly drops the connection
server {
    listen 443 ssl http2 default_server;

    # Don't accept the HTTPS handshake.
    ssl_reject_handshake on;
    ssl_certificate data:$empty;     # Note: Specifying certs/keys is optional for this default server block
    ssl_certificate_key data:$empty; # as long as there is at least one other server block with valid certs.

    # Disable special HTTP->HTTPS error page.
    error_page 497 = @empty_err;

    location @empty_err {
        return 444;
    }

    return 444;
}

# Real server here
server {
    listen       443 ssl http2;
    server_name  example.org;

    # ...
}

Docker things for testing:

# File: docker-compose.yml
version: '3.8'
services:
  web:
    build: .
    ports:
      - "8443:443"
    volumes:
      - ./default.nginxconf:/etc/nginx/http.d/default.conf:ro
# File: Dockerfile
FROM alpine:3.16

RUN set -ex; \
    apk add nginx nginx-mod-http-headers-more; \
    ln -sf /dev/stdout /var/log/nginx/access.log; \
    ln -sf /dev/stderr /var/log/nginx/error.log

EXPOSE 443

STOPSIGNAL SIGQUIT

CMD ["nginx", "-g", "daemon off;"]

Conclusion

Whew! Who knew it would take this much time to figure out 33 lines of NGINX config?

In this post, we made a sensible default configuration for our NGINX server which defeats some strategies for fingerprinting web servers by IP address. It does not block all fingerprinting techniques, but it provides a good starting point for doing so.

To clarify, our starting position began here:

I asked this server some trivial questions. It positively uses NGINX 1.18 and it has Bitwarden 2.25 installed. Also, it has ties to these other domain names that I will now go investigate.

And now, where we stand after raising the bar with our updated configuration:

I asked this server some trivial questions. I can confirm that it knows how to speak TCP and TLS to some degree. I will have to probe much deeper and use data from other sources to fully understand its purpose.

Future

To further improve this upon this configuration, we might do things like:

  • Add more error codes to the error_page directive to handle other unexpected errors that NGINX might display.
  • Figure out a way to more abruptly close an HTTPS connection instead of returning a TLS unrecognized_name alert.
  • Deeply explore the language and modules that comprise the NGINX configuration system. We certainly do not understand 100% of this system. Surely, other interesting features exist which we do not know about!

To see how much data other web servers offer up by default, one might conduct a similar analysis to the one above, using the following popular web servers:

  • Apache
  • Caddy
  • IIS
  • Litespeed
  • others?

Resist the tyranny, and opt-in to privacy! 👑️ 👻️