[tor-commits] [ooni-probe/master] Added 0x20 query/response check and authoritative nameserver SOA serial number check.

art at torproject.org art at torproject.org
Mon Jul 9 14:39:04 UTC 2012


commit 9313c75efaeda3aa4a971a20749aa5e402a2ca4a
Author: Isis Lovecruft <isis at 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!")





More information about the tor-commits mailing list