This is not novel research, in fact it’s a failed experiment, but I’ll use it to share my notes and set-up.

While on a job, I was asking myself:

What can an attacker do in 2026 if they control a public Wi-Fi, including DNS?

In particular - what happens if a user requests exmample.com and the attacker-controlled DNS returns a CNAME for malicious.com, where the attacker can serve a valid SSL certificate with a phishing page?

The answer is - the browser will block it and show an ‘invalid certificate’. The same happens with curl. Nothing to see here, but I enjoyed a quiet evening tinkering with OpenBSD, firewalling, Caddy and Nginx, and learned a couple of things along the way.

The Setup

  • A Windows 11 vanilla VM, in an ‘isolated’ network
  • An OpenBSD gateway acting as DHCP and DNS server
  • A VPS in the cloud

OpenBSD

Why OpenBSD? Because it’s incredibly clean, doesn’t mess up with networking (hello, NetworkManager, why do you never work as I want you to), and doesn’t require learning another firewalling paradigm and syntax every few years (hello, ipchains/iptables/netfilter).

DHCP server

OpenBSD was running udhcpd:

# vio1: isolated
subnet 10.13.37.0 netmask 255.255.255.0 {
	option domain-name-servers 10.13.37.4;
	option routers 10.13.37.4;
	range 10.13.37.100 10.13.37.200;
	# option classless-static-routes 10.128.127.0/24 10.13.37.4;
}

host win11 {
             hardware ethernet 52:54:00:xx:yy:zz;
             fixed-address 10.13.37.101;
           }
> [!IMPORTANT]
> Where I've lost some time: the option `classless-static-routes`, if enabled,
> tells Windows clients to **ignore** the "routers". So while my VM was getting
> an IP address, it wasn't getting a default gateway. 

DNS server

For DNS I used unbound.

> [!IMPORTANT]
> Take note that in OpenBSD, the config file is in `/var/unbound/etc/unbound.conf`
> and not `/etc/unbound.conf` because of chrooting. Another time sink..

The configuration file:

# $OpenBSD: unbound.conf,v 1.21 2020/10/28 11:35:58 sthen Exp $

server:
	interface: 127.0.0.1
	#interface: 127.0.0.1@5353	# listen on alternative port
	interface: vio1
	interface: ::1

	access-control: 0.0.0.0/0 refuse
	access-control: 127.0.0.0/8 allow
	access-control: 10.13.37.0/24 allow
	access-control: ::0/0 refuse
	access-control: ::1 allow

	hide-identity: yes
	hide-version: yes

	# Perform DNSSEC validation.
	#
	auto-trust-anchor-file: "/var/unbound/db/root.key"
	val-log-level: 2

	# Synthesize NXDOMAINs from DNSSEC NSEC chains.
	# https://tools.ietf.org/html/rfc8198
	#
	aggressive-nsec: yes

	local-zone: "example.com." redirect
	local-data: "example.com. IN CNAME mal.attacker.com."

forward-zone:
    name: "."                     # The dot matches all internet queries
    forward-addr: 8.8.8.8         # Google Primary DNS
    forward-addr: 8.8.4.4         # Google Secondary DNS (Optional backup)

Things to note:

  • The access-control is important to answer queries coming from my isolated LAN (10.13.37.0/24)
  • The local-zone directive returns mal.attacker.com (the attacker’s domain) in lieu of example.com
  • The forward-zone forwards the rest of the queries to Google

Packet Filter (firewall)

I’m truly in love with OpenBSD’s pf and its syntax:

#	$OpenBSD: pf.conf,v 1.55 2017/12/03 20:40:04 sthen Exp $
#
# See pf.conf(5) and /etc/examples/pf.conf

set skip on lo

block return	# block stateless traffic
pass		# establish keep-state

# probably not necessary
pass in quick on lo proto udp from any to any port 53
pass in quick on vio1 proto udp from any to any port 53

# vio1 isolated network
pass in quick on vio1 proto udp from 10.13.37.101 to any port domain keep state
pass out quick on vio0 from 10.13.37.101 to any nat-to (vio0)

I’m not 100% sure all lines are required - that was the result of some tinkering, but it worked.

Malicious HTTP server

I configured my domain attacker.com (not the real one) to answer mal.attacker.com with the IP address of my VPS in the cloud.

HTTP server: Caddy

Caddy will automatically acquire an SSL certificate.

As it turns out, Caddy refuses to serve requests with an invalid or unexpected SNI. This turned out to be a problem - the client would request a certificate for example.com, not mal.attacker.com as I was expecting. I needed my server to return any SSL request with the certificate for the malicious site.

So I had to modify the Caddyfile to serve a default SNI, but still it wasn’t working properly, got frustrated, and I tried nginx (see below). I’m putting the Caddy config here for posterity:

# file /etc/caddy/Caddyfile
{
        # If the client provides NO SNI (e.g. direct IP connection)
        default_sni mal.attacker.com
    # Adjusts settings for all HTTP/HTTPS servers Caddy spawns
    servers {
        # If the client provides an UNMATCHED/INVALID SNI string
        fallback_sni mal.attacker.com
    }
}

:80 {
	# Set this path to your site's directory.
	root * /usr/share/caddy

	# Enable the static file server.
	file_server
}


mal.attacker.com:443 {

	root * /usr/share/caddy
    file_server
    encode zstd gzip
    log {
        output file /var/log/caddy/access.log
    }
}

:443 {
	# tls {
	# }
	respond "Hello! You connected using an unmatched or missing SNI string."
}

This is what happens with a default Caddy configuration:

Viewing the site served by Caddy

The SSL error is SSL_PROTOCOL_ERROR. Notice how the user is requesting example.com even after the DNS server (unbound) returned a CNAME for mal.attacker.com.

Nginx

Since Caddy was getting on my nerves, I figured I’d try nginx.

On my VPS, I’ve acquired a certificate for mal.attacker.com with certbot:

certbot certonly -d mal.attacker.com`

Then the nginx config file:

# file /etc/nginx/sites-enabled/example.conf

# =========================================================================
# 1. THE DEFAULT FALLBACK BLOCK
# Catches raw IPs, missing SNIs, and unconfigured/mismatched domains.
# =========================================================================
server {
    # The 'default_server' flag tells Nginx to route all unmatched HTTPS here
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;

    server_name _; # A catch-all wild card hostname

    # You MUST provide a certificate here for the TLS handshake to succeed.
    # Nginx will present this fallback cert to the unrecognized client.
    ssl_certificate     /etc/letsencrypt/live/mal.attacker.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mal.attacker.com/privkey.pem;

    # Secure TLS configurations
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    # Decide what to do with mismatched/unwanted connections:
    # Option A: Close the connection immediately without a response (Highly recommended for security)
    # return 444; 

    # Option B: Alternatively, uncomment below to serve a generic error page instead
    location / {
        return 403 "Forbidden: Invalid or mismatched SNI hostname.";
    }
}

# =========================================================================
# 2. YOUR LEGITIMATE SITE BLOCK
# Catches explicit, matched domain requests.
# =========================================================================
server {
    listen 443 ssl;
    listen [::]:443 ssl;

    # Nginx routes traffic here ONLY if the client's SNI exactly matches this domain
    server_name mal.attacker.com ://mal.attacker.com;

   ssl_certificate     /etc/letsencrypt/live/mal.attacker.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mal.attacker.com/privkey.pem;

    location / {
        root /var/www/html;
        index index.html;
    }
}

No joy! The browser’s SSL libraries noticed and raised a different error (ERR_CERT_COMMON_NAME_INVALID):

Viewing the site served by Nginx

For the record, curl also returned the same result.

Conclusions

Seems like even controlling a malicious DNS server would prevent an attacker from doing too much damage. It makes sense in retrospect - for a successful attack, a HTTP redirect should take place first, from the legitimate site (example.com).

I do wonder if there’s obscure DNS settings, or other browsers, that would fall for this. But I suspect not. Either way, it was a good chance to dust off my home lab and play with a serious UNIX (OpenBSD).