A Malicious DNS Server in a Public Wi-FI
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-controlis important to answer queries coming from my isolated LAN (10.13.37.0/24) - The
local-zonedirective returnsmal.attacker.com(the attacker’s domain) in lieu ofexample.com - The
forward-zoneforwards 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:

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):

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).