luigi1809 / webfilter-ng

Transparent HTTP/HTTPS/TLS web filter
GNU Lesser General Public License v3.0
36 stars 11 forks source link

Encrypted SNI #4

Open mrbluecoat opened 4 years ago

mrbluecoat commented 4 years ago

Any options for dealing with encrypted SNI? https://blog.cloudflare.com/encrypted-sni/

luigi1809 commented 4 years ago

@mrbluecoat thanks for your report Encrypted SNI can not be decrypted by Man-in-the-middle. I changed the code to drop the connection when encrypted SNI is detected (TLS extension 65486). Things gonna change as Encrypted SNI standard is a draft.

Tested with firefox 70. Cloudfare DoH + ESNI via about:config :

Test page : https://cloudflare.com/cdn-cgi/trace

Unfortunately, firefox does not fallback to unencrypted SNI and the connection is dropped.

luigi1809 commented 4 years ago

https://github.com/luigi1809/webfilter-ng/commit/d7d5fcfdbd7f05ebd945ea76e93759fb3067fe9c

luigi1809 commented 4 years ago

I wait for encrypted SNI to be a standard to support encrypted SNI in a better way

mrbluecoat commented 4 years ago

Thanks for the update. Dropping Cloudflare traffic for hosts using ESNI is a tolerable stopgap but is there any long-term solution for monitoring home/work traffic with ESNI? Perhaps client cert approach like mitmproxy?

luigi1809 commented 4 years ago

key to encrypt SNI is provided by DNS.

In Cloudflare current implementation of the draft RFC, the key is obtained by requesting a DNS TXT entry of the root domain name prefixed by _esni.

dig _esni.cloudflare.com TXT

; <<>> DiG 9.10.3-P4-Debian <<>> _esni.cloudflare.com TXT
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 42475
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 13, ADDITIONAL: 27

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;_esni.cloudflare.com.      IN  TXT

;; ANSWER SECTION:
_esni.cloudflare.com.   3600    IN  TXT "/wFF7do4ACQAHQAgWcWTew6M6glGwqxZ2wjf7xpok65Xu9FkJsrrHCJ+G1MAAhMBAQQAAAAAXc5pEAAAAABd1lIQAAA="

Current draft says that a ESNI DNS type is used instead of TXT.

Since ESNI key is provided by DNS, if you control the DNS, you can deny client from receiving the SNI key so that the browser does not encrypt the SNI

luigi1809 commented 4 years ago

It seems bind has no option to block DNS request type but unbound has https://serverfault.com/questions/744613/block-any-request-in-bind

It's worth looking at unbound

mrbluecoat commented 4 years ago

Very promising! Thanks for your research, I really appreciate it. I'm working on an embedded system with only 512MB of memory so I wonder if unbound or knot-resolver would be lightweight enough. I'll look into them and FTL/dnsmasq: https://github.com/pi-hole/FTL/blob/master/dnsmasq_interface.c#L47

mrbluecoat commented 4 years ago

I'm not an iptables expert so I'm not sure if a variation of this would help: https://serverfault.com/a/843805

luigi1809 commented 4 years ago

I would not use iptables for filtering DNS type query. I presume it would take too much ressource.

Filtering by the server should be more performant. To be tested with unbound

policy.add(function (req, query)
    if query.stype == kres.type.ANY then
            return policy.DROP
elseif query.stype == kres.type.ESNI then
            return policy.DROP
    end
end)

If you have a procedure to install unbound, please share it

mrbluecoat commented 4 years ago

Using https://docs.pi-hole.net/guides/unbound/ as a guide:

sudo apt install -y unbound dnsutils
wget -O root.hints https://www.internic.net/domain/named.root && sudo mv root.hints /var/lib/unbound/

cat >> /etc/unbound/unbound.conf.d/custom.conf <<EOL
server:
    logfile: "/var/log/unbound/unbound.log"
    verbosity: 3
    port: 53
    do-ip4: yes
    do-ip6: no
    do-udp: yes
    do-tcp: yes
    root-hints: "/var/lib/unbound/root.hints"
    harden-glue: yes
    harden-dnssec-stripped: yes
    use-caps-for-id: no
    edns-buffer-size: 1472
    prefetch: yes
    num-threads: 1
    so-rcvbuf: 1m
    private-address: 192.168.0.0/16
    private-address: 169.254.0.0/16
    private-address: 172.16.0.0/12
    private-address: 10.0.0.0/8
    private-address: fd00::/8
    private-address: fe80::/10
EOL

sudo service unbound start

dig sigfail.verteiltesysteme.net @127.0.0.1 -p 53 | grep SERVFAIL
dig sigok.verteiltesysteme.net @127.0.0.1 -p 53 | grep NOERROR
mrbluecoat commented 4 years ago

I haven't found an example to block type ESNI with unbound (the policy example above is for Knot). Another potential option is to clone https://coredns.io/plugins/any/ for ESNI.

mrbluecoat commented 4 years ago

Another relevant CoreDNS reference: https://coredns.io/plugins/rewrite/

mrbluecoat commented 4 years ago

I'm slowly making progress on this. I've been able to install and configure Knot-Resolver to confirm the proposed approach above works.

Install the latest Knot-Resolver:

wget https://secure.nic.cz/files/knot-resolver/knot-resolver-release.deb
dpkg -i knot-resolver-release.deb
apt update
apt install -y knot-resolver knot-dnsutils lua-cqueues

Configure Knot-Resolver:

cat > /etc/knot-resolver/kresd.conf <<EOF
-- Knot DNS Resolver configuration in Lua
verbose(true)

-- Enable modules
modules = {
  'policy',
  'view',
  'hints',
  'serve_stale < cache',
  'workarounds < iterate',
  'stats',
  'predict',
  'prefill'
}

-- Disable IPv6
net.ipv6 = false

-- Switch to unprivileged user --
user('knot-resolver','knot-resolver')

-- Set the size of the cache to 150 MB
cache.size = 150 * MB

-- Accept all requests from these subnets
view:addr('127.0.0.1/8', function (req, qry) return policy.PASS end)
view:addr('10.0.0.0/8', function (req, qry) return policy.PASS end)
view:addr('172.16.0.0/12', function (req, qry) return policy.PASS end)
view:addr('169.254.0.0/16', function (req, qry) return policy.PASS end)
view:addr('192.168.0.1/16', function (req, qry) return policy.PASS end)

-- Drop everything that hasn't matched
view:addr('0.0.0.0/0', function (req, qry) return policy.DROP end)

-- Prevent ESNI
policy.add(policy.pattern(policy.DENY, '\5_esni'))
policy.add(function (req, query)
  if query.stype == kres.type.ANY then
    return policy.DROP
  elseif query.stype == kres.type.ESNI then
    return policy.DROP
  end
end)

-- DNSSEC validation enabled by default in v4+ (no config needed)

-- Root hints
hints.root_file = '/usr/share/dns/root.hints'

-- Daily refresh
prefill.config({
  ['.'] = {
    url = 'https://www.internic.net/domain/root.zone',
    ca_file = '/etc/ssl/certs/ca-certificates.crt',
    interval = 86400  -- seconds
  }
})

-- Forward queries to CleanBrowsing via DNS-over-TLS (DoT)
policy.add(policy.all(policy.TLS_FORWARD({
  {'185.228.168.168', hostname='family-filter-dns.cleanbrowsing.org'},
  {'185.228.169.168', hostname='family-filter-dns.cleanbrowsing.org'}
})))

-- Prefetch learning (20-minute blocks over 24 hours)
predict.config({ window = 20, period = 72 })
EOF

Start Knot-Resolver on a couple CPU cores: systemctl enable --now kresd@{1..2}.service

Run basic tests:

kdig google.com | grep -q NOERROR && echo DNS test 1/2: PASS || echo DNS test 1/2: FAIL
kdig blah.google.com | grep -q NXDOMAIN && echo DNS test 2/2: PASS || echo DNS test 2/2: FAIL
kdig sigok.verteiltesysteme.net +dnssec | grep -q NOERROR && echo DNSSEC test 1/2: PASS || echo DNS test 1/2: FAIL
kdig sigfail.verteiltesysteme.net +dnssec | grep -q SERVFAIL && echo DNSSEC test 2/2: PASS || echo DNS test 2/2: FAIL
kdig -d @185.228.168.168 +tls-ca +tls-host=family-filter-dns.cleanbrowsing.org example.com | grep -q trusted && echo DoT test: PASS || echo DoT test: FAIL

Prerequisites for ESNI test: compile ESNI-enabled OpenSSL and curl

Run ESNI test:

cd $HOME/code/curl
./curl-esni https://www.cloudflare.com/cdn-cgi/trace 2> /dev/null | grep -q sni=plaintext && echo ESNI test: PASS || echo ESNI test: FAIL
mrbluecoat commented 4 years ago

P.S. I confirmed your Firefox testing. It only works as desired if network.trr.mode is set to 0 or 5

0: Off by default 1: Firefox will choose based on which is faster 2: TRR preferred, fall back to DNS on failure 3: TRR only, no DNS fallback 5: TRR completely disabled

Example bypass:

network.security.esni.enabled true
network.trr.mode 3
network.trr.bootstrapAddress 104.16.249.249

Further testing will be needed to see if we can IP spoof the common public DNS endpoint IPs via #6 to force DNS resolution via our local knot-resolver install that blocks ESNI. Not a perfect solution since the list of those IPs will be constantly growing but it's a start at least and will capture most DNS circumnavigation attempts.

luigi1809 commented 4 years ago

Filtering TLS packet with ESNI field is not a good approach in long term because there will be no fallback to plaintext SNI in case connection with ESNI does not work. It would result in fitering legitimate website, which we do not want to.

I think the best approach would be to sync with the local DNS to get the ESNI entry in DNS request in addition to A/AAAA entries that associate domain name to IP address. Keep in cache couple of IP address / computed ESNI for domain name so that we can take the decision whether to filter ESNI in middle as we do currently with plaintext SNI

mrbluecoat commented 4 years ago

But if the browser bypasses DNS entirely (like the Firefox example above) how will syncing with the local DNS server help us?

luigi1809 commented 4 years ago

We should maintain a blacklist of DoH DNS so that browser fallbacks to the DNS set in OS by DHCP

mrbluecoat commented 4 years ago

I see. Something like https://github.com/bambenek/block-doh

mrbluecoat commented 4 years ago

Another list: https://github.com/curl/curl/wiki/DNS-over-HTTPS