commit 9313c75efaeda3aa4a971a20749aa5e402a2ca4a
Author: Isis Lovecruft <isis(a)patternsinthevoid.net>
Date: Thu Apr 19 05:29:08 2012 -0700
Added 0x20 query/response check and authoritative nameserver SOA serial number check.
---
TODO | 2 +
ooni-probe.conf | 3 +
tests/captiveportal.py | 194 ++++++++++++++++++++++++++++++++++++++----------
3 files changed, 161 insertions(+), 38 deletions(-)
diff --git a/TODO b/TODO
index 9ecae4b..8cc5b90 100644
--- a/TODO
+++ b/TODO
@@ -18,6 +18,8 @@ effort: low, skill: medium
* All the captive portal detection tests (old/ooni/http.py
and old/ooni/dnsooni.py)
effort: low, skill: low
+ - The captiveportal test is a port of the old code, plus
+ a couple new methods for testing.
* All the DNS censorship detection tests (old/ooni/dnsooni.py)
effort: medium, skill: medium
diff --git a/ooni-probe.conf b/ooni-probe.conf
index 7a456d8..bae6910 100644
--- a/ooni-probe.conf
+++ b/ooni-probe.conf
@@ -58,6 +58,9 @@ do_captive_portal_vendor_tests = true
# Enable DNS-based vendor tests for captive portals:
do_captive_portal_vendor_dns_tests = true
+# Enable checking of DNS requests for tampering:
+check_dns_requests = true
+
### traceroute testing related config parameters
# This is the list of ips to traceroute to
diff --git a/tests/captiveportal.py b/tests/captiveportal.py
index 8703c76..ffa86c4 100644
--- a/tests/captiveportal.py
+++ b/tests/captiveportal.py
@@ -12,6 +12,7 @@
"""
import base64
import os
+import random
import re
import string
import urllib2
@@ -51,9 +52,6 @@ class CaptivePortal(Test):
"""
Compares content and status codes of HTTP responses, and attempts
to determine if content has been altered.
-
- TODO: compare headers, compare 0x20 dns requests with authoritative
- server answers.
"""
def __init__(self, ooni, name=__plugoo__):
Test.__init__(self, ooni, name)
@@ -135,42 +133,43 @@ class CaptivePortal(Test):
def dns_resolve(self, hostname, nameserver=None):
"""
- Resolves hostname though nameserver ns to its corresponding
- address(es). If ns is not given, use local DNS resolver.
+ Resolves hostname(s) though nameserver to corresponding
+ address(es). hostname may be either a single hostname string,
+ or a list of strings. If nameserver is not given, use local
+ DNS resolver, and if that fails try using 8.8.8.8.
"""
log = self.logger
+ if isinstance(hostname, str):
+ hostname = [hostname]
+
if nameserver is not None:
res = resolver.Resolver(configure=False)
res.nameservers = [nameserver]
else:
res = resolver.Resolver()
- # This is gross and needs to be cleaned up, but it
- # was the best way I could find to handle all the
- # exceptions properly.
- try:
- answer = res.query(hostname)
- response = []
- for addr in answer:
- response.append(addr.address)
- return response
- except resolver.NoNameservers as nns:
- res.nameservers = ['8.8.8.8']
+ response = []
+ answer = None
+
+ for hn in hostname:
try:
- answer = res.query(hostname)
- response = []
- for addr in answer:
- response.append(addr.address)
- return response
- except resolver.NXDOMAIN as nx:
- log.info("DNS resolution for %s returned NXDOMAIN" % hostname)
- response = ['NXDOMAIN']
- return response
- except resolver.NXDOMAIN as nx:
- log.info("DNS resolution for %s returned NXDOMAIN" % hostname)
- response = ['NXDOMAIN']
- return response
+ answer = res.query(hn)
+ except resolver.NoNameservers:
+ res.nameservers = ['8.8.8.8']
+ try:
+ answer = res.query(hn)
+ except resolver.NXDOMAIN:
+ log.info("DNS resolution for %s returned NXDOMAIN" % hn)
+ response.append('NXDOMAIN')
+ except resolver.NXDOMAIN:
+ log.info("DNS resolution for %s returned NXDOMAIN" % hn)
+ response.append('NXDOMAIN')
+ finally:
+ if answer:
+ for addr in answer:
+ response.append(addr.address)
+ return response
def dns_resolve_match(self, experiment_hostname, control_address):
"""
@@ -186,13 +185,118 @@ class CaptivePortal(Test):
if len(set(experiment_address) & set([control_address])) > 0:
return True, experiment_address
else:
- log.info("DNS comparison of control '%s' does not match " \
- "experiment response '%s'" % control_address, address)
+ log.info("DNS comparison of control '%s' does not" % control_address)
+ log.info("match experiment response '%s'" % experiment_address)
return False, experiment_address
else:
log.debug("dns_resolve() for %s failed" % experiment_hostname)
return None, experiment_address
+ def get_auth_nameservers(self, hostname):
+ """
+ Many CPs set a nameserver to be used. Let's query that
+ nameserver for the authoritative nameservers of hostname.
+
+ The equivalent of:
+ $ dig +short NS ooni.nu
+ """
+ res = resolver.Resolver()
+ answer = res.query(hostname, 'NS')
+ auth_nameservers = []
+ for auth in answer:
+ auth_nameservers.append(auth.to_text())
+ return auth_nameservers
+
+ def hostname_to_0x20(self, hostname):
+ """
+ MaKEs yOur HOsTnaME lOoK LiKE THis.
+
+ For more information, see:
+ D. Dagon, et. al. "Increased DNS Forgery Resistance
+ Through 0x20-Bit Encoding". Proc. CSS, 2008.
+ """
+ hostname_0x20 = ''
+ for char in hostname:
+ l33t = random.choice(['caps', 'nocaps'])
+ if l33t == 'caps':
+ hostname_0x20 = hostname_0x20 + char.capitalize()
+ else:
+ hostname_0x20 = hostname_0x20 + char.lower()
+ return hostname_0x20
+
+ def check_0x20_to_auth_ns(self, hostname, sample_size=None):
+ """
+ Resolve a 0x20 DNS request for hostname over hostname's
+ authoritative nameserver(s), and check to make sure that
+ the capitalization in the 0x20 request matches that of the
+ response. Also, check the serial numbers of the SOA (Start
+ of Authority) records on the authoritative nameservers to
+ make sure that they match.
+
+ If sample_size is given, a random sample equal to that number
+ of authoritative nameservers will be queried; default is 5.
+ """
+ log = self.logger
+ log.info("")
+ log.info("Testing random capitalization of DNS queries...")
+ log.info("Testing that Start of Authority serial numbers match...")
+
+ auth_nameservers = self.get_auth_nameservers(hostname)
+
+ if sample_size is None:
+ sample_size = 5
+ resolved_auth_ns = random.sample(self.dns_resolve(auth_nameservers),
+ sample_size)
+
+ querynames = []
+ answernames = []
+ serials = []
+
+ # Even when gevent monkey patching is on, the requests here
+ # are sent without being 0x20'd, so we need to 0x20 them.
+ hostname = self.hostname_to_0x20(hostname)
+
+ for auth_ns in resolved_auth_ns:
+ res = resolver.Resolver(configure=False)
+ res.nameservers = [auth_ns]
+ try:
+ answer = res.query(hostname, 'SOA')
+ except resolver.Timeout:
+ continue
+ querynames.append(answer.qname.to_text())
+ answernames.append(answer.rrset.name.to_text())
+ for soa in answer:
+ serials.append(str(soa.serial))
+
+ if len(set(querynames).intersection(answernames)) == 1:
+ log.info("Capitalization in DNS queries and responses match.")
+ name_match = True
+ else:
+ log.info("The random capitalization '%s' used in" % hostname)
+ log.info("DNS queries to that hostname's authoritative")
+ log.info("nameservers does not match the capitalization in")
+ log.info("the response.")
+ name_match = False
+
+ if len(set(serials)) == 1:
+ log.info("Start of Authority serial numbers all match.")
+ serial_match = True
+ else:
+ log.info("Some SOA serial numbers did not match the rest!")
+ serial_match = False
+
+ ret = name_match, serial_match, querynames, answernames, serials
+
+ if name_match and serial_match:
+ log.info("Your DNS queries do not appear to be tampered.")
+ return ret
+ elif name_match or serial_match:
+ log.info("Something is tampering with your DNS queries.")
+ return ret
+ elif not name_match and not serial_match:
+ log.info("Your DNS queries are definitely being tampered with.")
+ return ret
+
def get_random_url_safe_string(self, length):
"""
Returns a random url-safe string of specified length, where
@@ -497,17 +601,26 @@ def run(ooni):
"""
Runs the CaptivePortal(Test).
- If do_captive_portal_vendor_tests is set to true, then vendor
- specific captive portal tests will be run.
+ CONFIG OPTIONS
+ --------------
+
+ If "do_captive_portal_vendor_tests" is set to "true", then vendor
+ specific captive portal HTTP-based tests will be run.
+
+ If "do_captive_portal_dns_tests" is set to "true", then vendor
+ specific captive portal DNS-based tests will be run.
- If captive_portal = filename.txt, then user-specified tests
+ If "check_dns_requests" is set to "true", then Ooni-probe will
+ attempt to check that your DNS requests are not being tampered with
+ by a captive portal.
+
+ If "captive_portal" = "yourfilename.txt", then user-specified tests
will be run.
- Either vendor tests or user-defined tests can be run, or both.
+ Any combination of the above tests can be run.
"""
config = ooni.config
log = ooni.logger
- tally = ooni.tally
assets = []
if (os.path.isfile(os.path.join(config.main.assetdir,
@@ -517,8 +630,7 @@ def run(ooni):
captiveportal = CaptivePortal(ooni)
log.info("Starting captive portal test...")
- captiveportal.run(assets, {'index': 1, 'tally': tally.count,
- 'tally_marks': tally.marks})
+ captiveportal.run(assets, {'index': 1})
if config.tests.do_captive_portal_vendor_tests:
log.info("")
@@ -530,4 +642,10 @@ def run(ooni):
log.info("Running vendor DNS-based tests...")
captiveportal.run_vendor_dns_tests()
+ if config.tests.check_dns_requests:
+ log.info("")
+ log.info("Checking that DNS requests are not being tampered...")
+ captiveportal.check_0x20_to_auth_ns('ooni.nu')
+
+ log.info("")
log.info("Captive portal test finished!")