[tor-commits] [bridgedb/develop] Don't burn active probing-resistant PTs.

phw at torproject.org phw at torproject.org
Tue Jun 4 16:36:31 UTC 2019


commit efff3eb11dc62044ab2d64cf1a078ec913f7ae7c
Author: Philipp Winter <phw at nymity.ch>
Date:   Wed Apr 10 17:31:43 2019 -0700

    Don't burn active probing-resistant PTs.
    
    The GFW used to block bridges by IP address:port but a while ago, it
    started to block bridges by IP address instead.  This means that if a
    bridge runs obfs4 (which is active probing-resistant) and obfs3 (which
    is not active probing-resistant), and BridgeDB happens to hand out the
    bridge's obfs3 line, the GFW would manage to probe the bridge, and block
    the entire IP address, *including* obfs4.
    
    In this patch, we deal with the GFW's update by not handing out a
    bridge's probe-able protocols if it also runs an active
    probing-resistant protocol such as obfs4 or scramblesuit.
    
    The patch adds a new configuration option, PROBING_RESISTANT_TRANSPORTS,
    which must contain a list of active probing-resistant protocols.
    
    This fixes bug 28655: <https://bugs.torproject.org/28655>
---
 .test.requirements.txt                          |  2 +-
 .travis.requirements.txt                        |  2 +-
 CHANGELOG                                       |  8 ++++++
 bridgedb.conf                                   |  8 ++++++
 bridgedb/bridgerequest.py                       |  7 +++++
 bridgedb/bridges.py                             | 21 +++++++++++++++
 bridgedb/filters.py                             | 34 ++++++++++++++++++++++++
 bridgedb/main.py                                |  8 ++++++
 bridgedb/runner.py                              |  2 +-
 bridgedb/test/test_distributors_moat_request.py |  3 ++-
 bridgedb/test/test_filters.py                   | 35 +++++++++++++++++++++++++
 bridgedb/test/test_https_distributor.py         | 31 ++++++++++++++++++++++
 bridgedb/test/test_main.py                      |  1 +
 bridgedb/test/util.py                           | 17 +++++++++++-
 doc/HACKING.md                                  |  2 +-
 scripts/setup-tests                             |  2 +-
 16 files changed, 176 insertions(+), 7 deletions(-)

diff --git a/.test.requirements.txt b/.test.requirements.txt
index 6a85c00..c84fb58 100644
--- a/.test.requirements.txt
+++ b/.test.requirements.txt
@@ -6,7 +6,7 @@
 #     $ make coverage
 #
 coverage==4.2
-git+https://git.torproject.org/user/isis/leekspin.git@bad0bed11a9018f65555b3c6998b26e2cb06f5b5#egg=leekspin-2.2.0.dev1-py2.7
+git+https://git.torproject.org/user/phw/leekspin.git@d34c804cd0f01af5206833e62c0dedec8565b235#egg=leekspin
 mechanize==0.2.5
 pep8==1.5.7
 # pylint must be pinned until pylint bug #203 is fixed. See
diff --git a/.travis.requirements.txt b/.travis.requirements.txt
index e05e643..2d56b79 100644
--- a/.travis.requirements.txt
+++ b/.travis.requirements.txt
@@ -15,7 +15,7 @@
 #------------------------------------------------------------------------------
 coverage==4.2
 coveralls==1.2.0
-git+https://git.torproject.org/user/isis/leekspin.git@bad0bed11a9018f65555b3c6998b26e2cb06f5b5#egg=leekspin-2.2.0.dev1-py2.7
+git+https://git.torproject.org/user/phw/leekspin.git@d34c804cd0f01af5206833e62c0dedec8565b235#egg=leekspin
 mechanize==0.2.5
 sure==1.2.2
 Babel==0.9.6
diff --git a/CHANGELOG b/CHANGELOG
index 9ffeac1..f45f56f 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,11 @@
+Changes in version 0.6.X - YYYY-MM-DD
+
+        * FIXES #28655 https://bugs.torproject.org/28655
+        When a bridge supports an active probing-resistant transport, it should
+        not give out flavors that are vulnerable to active probing.  For
+        example, if a bridge supports obfs4 and obfs3, it should only give out
+        obfs4.
+
 Changes in version 0.6.3 - 2018-01-23
 
         * FIXES #24432 https://bugs.torproject.org/24432
diff --git a/bridgedb.conf b/bridgedb.conf
index 7700917..73b65b8 100644
--- a/bridgedb.conf
+++ b/bridgedb.conf
@@ -282,6 +282,14 @@ SUPPORTED_TRANSPORTS = {
     'fte': True,
 }
 
+# PROBING_RESISTANT_TRANSPORTS is a list of transports that are resistant to
+# active probing attacks as pioneered by China's GFW.  If a bridge supports any
+# of the following transports, only these transports are distributed, and no
+# others.  Here's why: If we have a bridge that supports both obfs3 and obfs4,
+# we don't want to hand out its obfs3 line to users because this may get the
+# bridge probed and its IP address blocked, which also blocks the obfs4 PT.
+PROBING_RESISTANT_TRANSPORTS = ['scramblesuit', 'obfs4']
+
 # DEFAULT_TRANSPORT is a string. It should be the PT methodname of the
 # transport which is selected by default (e.g. in the webserver dropdown
 # menu).
diff --git a/bridgedb/bridgerequest.py b/bridgedb/bridgerequest.py
index d5af05f..bf64069 100644
--- a/bridgedb/bridgerequest.py
+++ b/bridgedb/bridgerequest.py
@@ -28,6 +28,7 @@ from bridgedb.crypto import getHMACFunc
 from bridgedb.filters import byIPv
 from bridgedb.filters import byNotBlockedIn
 from bridgedb.filters import byTransport
+from bridgedb.filters import byProbingResistance
 
 
 class IRequestBridges(Interface):
@@ -238,6 +239,12 @@ class BridgeRequestBase(object):
         msg = ("Adding a filter to %s for %s for IPv%d"
                % (self.__class__.__name__, self.client, self.ipVersion))
 
+        # If this bridge runs any active probing-resistant PTs, we should
+        # *only* hand out its active probing-resistant PTs.  Otherwise, a
+        # non-resistant PT would get this bridge scanned and blocked:
+        # <https://bugs.torproject.org/28655>
+        self.addFilter(byProbingResistance(pt, self.ipVersion))
+
         if self.notBlockedIn:
             for country in self.notBlockedIn:
                 logging.info("%s %s bridges not blocked in %s..." %
diff --git a/bridgedb/bridges.py b/bridgedb/bridges.py
index 8b4bc1b..cf90b3b 100644
--- a/bridgedb/bridges.py
+++ b/bridgedb/bridges.py
@@ -365,6 +365,9 @@ class PluggableTransport(BridgeAddressBase):
             {'password': 'NEQGQYLUMUQGK5TFOJ4XI2DJNZTS4LRO'}
     """
 
+    # A list of PT names that are resistant to active probing attacks.
+    probing_resistant_transports = []
+
     def __init__(self, fingerprint=None, methodname=None,
                  address=None, port=None, arguments=None):
         """Create a ``PluggableTransport`` describing a PT running on a bridge.
@@ -529,6 +532,17 @@ class PluggableTransport(BridgeAddressBase):
             except (AttributeError, TypeError):
                 raise TypeError("methodname must be a str or unicode")
 
+    def isProbingResistant(self):
+        """Reveal if this pluggable transport is active probing-resistant.
+
+        :rtype: bool
+        :returns: ``True`` if this pluggable transport is resistant to active
+            probing attacks, ``False`` otherwise.
+        """
+
+        return self.methodname in PluggableTransport.probing_resistant_transports
+
+
     def getTransportLine(self, includeFingerprint=True, bridgePrefix=False):
         """Get a Bridge Line for this :class:`PluggableTransport`.
 
@@ -1031,6 +1045,13 @@ class Bridge(BridgeBackwardsCompatibility):
 
         return prefix + fingerprint + separator + nickname
 
+    def hasProbingResistantPT(self):
+        # We want to know if this bridge runs any active probing-resistant PTs
+        # because if so, we should *only* hand out its active probing-resistant
+        # PTs.  Otherwise, a non-resistant PT would get this bridge scanned and
+        # blocked: <https://bugs.torproject.org/28655>
+        return any([t.isProbingResistant() for t in self.transports])
+
     def _checkServerDescriptor(self, descriptor):
         # If we're parsing the server-descriptor, require a networkstatus
         # document:
diff --git a/bridgedb/filters.py b/bridgedb/filters.py
index cac587b..f268c83 100644
--- a/bridgedb/filters.py
+++ b/bridgedb/filters.py
@@ -127,6 +127,40 @@ def byIPv(ipVersion=None):
 byIPv4 = byIPv(4)
 byIPv6 = byIPv(6)
 
+def byProbingResistance(methodname=None, ipVersion=None):
+    """Return ``True`` if the bridge can be given out safely without
+    jeopardizing a probing-resistant transport that runs on the same bridge.
+
+    :param str methodname: A Pluggable Transport
+        :data:`~bridgedb.bridges.PluggableTransport.methodname`.
+    :param int ipVersion: Either ``4`` or ``6``. The IP version that the
+        ``Bridge``'s ``PluggableTransport``
+        :attr:`address <bridgedb.bridges.PluggableTransport.address>` should
+        have.
+    :rtype: callable
+    :returns: A filter function for :class:`Bridges <bridgedb.bridges.Bridge>`.
+    """
+
+    if ipVersion not in (4, 6):
+        ipVersion = 4
+
+    methodname = "vanilla" if methodname is None else methodname.lower()
+    name = "by-probing-resistance-%s ipv%d" % (methodname, ipVersion)
+
+    try:
+        return _cache[name]
+    except KeyError:
+        def _byProbingResistance(bridge):
+            if bridge.hasProbingResistantPT():
+                return methodname in ('scramblesuit', 'obfs4')
+            return True
+
+        setattr(_byProbingResistance, "description", "probing_resistance")
+        _byProbingResistance.__name__ = "byProbingResistance(%s,%s)" % (methodname, ipVersion)
+        _byProbingResistance.name = name
+        _cache[name] = _byProbingResistance
+        return _byProbingResistance
+
 def byTransport(methodname=None, ipVersion=None):
     """Returns a filter function for a :class:`~bridgedb.bridges.Bridge`.
 
diff --git a/bridgedb/main.py b/bridgedb/main.py
index 586ed83..71ae48a 100644
--- a/bridgedb/main.py
+++ b/bridgedb/main.py
@@ -331,6 +331,14 @@ def run(options, reactor=reactor):
             pidfile.write("%s\n" % os.getpid())
             pidfile.flush()
 
+    # Let our pluggable transport class know what transports are resistant to
+    # active probing.  We need to know because we shouldn't hand out a
+    # probing-vulnerable transport on a bridge that supports a
+    # probing-resistant transport.  See
+    # <https://bugs.torproject.org/28655> for details.
+    from bridgedb.bridges import PluggableTransport
+    PluggableTransport.probing_resistant_transports = config.PROBING_RESISTANT_TRANSPORTS
+
     from bridgedb import persistent
 
     state = persistent.State(config=config)
diff --git a/bridgedb/runner.py b/bridgedb/runner.py
index 35865e5..b1a21d2 100644
--- a/bridgedb/runner.py
+++ b/bridgedb/runner.py
@@ -90,7 +90,7 @@ def generateDescriptors(count=None, rundir=None):
         descriptors, used to calculate the OR fingerprints, and sign the
         descriptors, among other things.
 
-    .. _Leekspin: https://gitweb.torproject.org/user/isis/leekspin.git
+    .. _Leekspin: https://gitweb.torproject.org/user/phw/leekspin.git
 
     :param integer count: Number of mocked bridges to generate descriptor
         for. (default: 3)
diff --git a/bridgedb/test/test_distributors_moat_request.py b/bridgedb/test/test_distributors_moat_request.py
index ed7f493..3c6ff51 100644
--- a/bridgedb/test/test_distributors_moat_request.py
+++ b/bridgedb/test/test_distributors_moat_request.py
@@ -32,7 +32,8 @@ class MoatBridgeRequest(unittest.TestCase):
 
         self.assertItemsEqual(['byTransportNotBlockedIn(None,us,4)',
                                'byTransportNotBlockedIn(None,ir,4)',
-                               'byTransportNotBlockedIn(None,sy,4)'],
+                               'byTransportNotBlockedIn(None,sy,4)',
+                               'byProbingResistance(vanilla,4)'],
                               [x.__name__ for x in self.bridgeRequest.filters])
 
     def test_withoutBlockInCountry_not_a_valid_country_code(self):
diff --git a/bridgedb/test/test_filters.py b/bridgedb/test/test_filters.py
index 3b9efcb..f43124c 100644
--- a/bridgedb/test/test_filters.py
+++ b/bridgedb/test/test_filters.py
@@ -37,6 +37,8 @@ class FiltersTests(unittest.TestCase):
 
         self.hmac = getHMACFunc('plasma')
 
+        PluggableTransport.probing_resistant_transports = ['scramblesuit', 'obfs4']
+
     def addIPv4VoltronPT(self):
         pt = PluggableTransport('a' * 40, 'voltron', '1.1.1.1', 1111, {})
         self.bridge.transports.append(pt)
@@ -301,6 +303,39 @@ class FiltersTests(unittest.TestCase):
         filtre = filters.byNotBlockedIn('cn', methodname='obfs3')
         self.assertFalse(filtre(self.bridge))
 
+    def test_byProbingResistance(self):
+        """A bridge with probing-resistant transports must not hand out its
+        non-probing-resistant transports.
+        """
+
+        scramblesuitArgs = {'password': 'NEQGQYLUMUQGK5TFOJ4XI2DJNZTS4LRO'}
+        obfs4Args = {'cert': 'UXj/cWm0qolGrROYpkl0UyD/7PEhzkoZkZXrOpjRKwImvkpQZwmF0nSzBXfyfbT9afBZEw',
+                     'iat-mode': '1'}
+
+        obfs2 = PluggableTransport('a' * 40, 'obfs2', '1.1.1.1', 1111, {})
+        scramblesuit = PluggableTransport('a' * 40, 'scramblesuit', '1.1.1.1',
+                                          1111, scramblesuitArgs)
+        obfs4 = PluggableTransport('a' * 40, 'obfs4', '1.1.1.1', 111,
+                                   obfs4Args)
+        self.bridge.transports.append(obfs2)
+        self.bridge.transports.append(scramblesuit)
+        self.bridge.transports.append(obfs4)
+
+        filtre = filters.byProbingResistance(methodname='obfs2', ipVersion=4)
+        self.assertFalse(filtre(self.bridge))
+
+        filtre = filters.byProbingResistance(methodname="vanilla", ipVersion=4)
+        self.assertFalse(filtre(self.bridge))
+
+        filtre = filters.byProbingResistance(ipVersion=4)
+        self.assertFalse(filtre(self.bridge))
+
+        filtre = filters.byProbingResistance(methodname='scramblesuit', ipVersion=4)
+        self.assertTrue(filtre(self.bridge))
+
+        filtre = filters.byProbingResistance(methodname='obfs4', ipVersion=4)
+        self.assertTrue(filtre(self.bridge))
+
     def test_byNotBlockedIn_ipv5(self):
         """Calling byNotBlockedIn([…], ipVersion=5) should default to IPv4."""
         self.bridge.setBlockedIn('ru')
diff --git a/bridgedb/test/test_https_distributor.py b/bridgedb/test/test_https_distributor.py
index 791a940..c25c598 100644
--- a/bridgedb/test/test_https_distributor.py
+++ b/bridgedb/test/test_https_distributor.py
@@ -19,6 +19,7 @@ import random
 
 from twisted.trial import unittest
 
+from bridgedb.bridges import PluggableTransport
 from bridgedb.Bridges import BridgeRing
 from bridgedb.Bridges import BridgeRingParameters
 from bridgedb.filters import byIPv4
@@ -43,6 +44,7 @@ class HTTPSDistributorTests(unittest.TestCase):
     def setUp(self):
         self.key = 'aQpeOFIj8q20s98awfoiq23rpOIjFaqpEWFoij1X'
         self.bridges = BRIDGES
+        PluggableTransport.probing_resistant_transports = ['scramblesuit', 'obfs4']
 
     def tearDown(self):
         """Reset all bridge blocks in between test method runs."""
@@ -181,6 +183,35 @@ class HTTPSDistributorTests(unittest.TestCase):
             b = dist.getBridges(clientRequest2, 1)
             self.assertEqual(len(b), 3)
 
+    def test_HTTPSDistributor_getBridges_probing_vulnerable(self):
+        dist = distributor.HTTPSDistributor(1, self.key)
+        bridges = self.bridges[:]
+        [dist.insert(bridge) for bridge in bridges]
+
+        def requestTransports(bridges, transport, vulnerable):
+            for _ in range(len(bridges)):
+                request = HTTPSBridgeRequest(addClientCountryCode=False)
+                request.client = randomValidIPv4String()
+                request.isValid(True)
+                if transport is not None:
+                    request.transports.append(transport)
+                request.generateFilters()
+
+                obtained_bridges = dist.getBridges(request, 1)
+                for bridge in obtained_bridges:
+                    if vulnerable:
+                        self.assertFalse(bridge.hasProbingResistantPT())
+                        for t in bridge.transports:
+                            self.assertTrue(t.methodname != 'obfs4' and
+                                            t.methodname != 'scramblesuit')
+                    else:
+                        self.assertTrue(bridge.hasProbingResistantPT())
+
+        requestTransports(bridges, None, True)
+        requestTransports(bridges, 'obfs2', True)
+        requestTransports(bridges, 'obfs3', True)
+        requestTransports(bridges, 'obfs4', False)
+
     def test_HTTPSDistributor_getBridges_with_some_blocked_bridges(self):
         dist = distributor.HTTPSDistributor(1, self.key)
         bridges = self.bridges[:]
diff --git a/bridgedb/test/test_main.py b/bridgedb/test/test_main.py
index b6eae2b..52d98a2 100644
--- a/bridgedb/test/test_main.py
+++ b/bridgedb/test/test_main.py
@@ -403,6 +403,7 @@ BRIDGE_PURPOSE = "bridge"
 TASKS = {'GET_TOR_EXIT_LIST': 3 * 60 * 60,}
 SERVER_PUBLIC_FQDN = 'bridges.torproject.org'
 SERVER_PUBLIC_EXTERNAL_IP = '38.229.72.19'
+PROBING_RESISTANT_TRANSPORTS = ['scramblesuit', 'obfs4']
 HTTPS_DIST = True
 HTTPS_BIND_IP = None
 HTTPS_PORT = None
diff --git a/bridgedb/test/util.py b/bridgedb/test/util.py
index 042c92d..9fc16b8 100644
--- a/bridgedb/test/util.py
+++ b/bridgedb/test/util.py
@@ -196,13 +196,28 @@ def generateFakeBridges(n=500):
         addrs = [(randomValidIPv6(), randomHighPort(), 6)]
         fpr = "".join(random.choice('abcdef0123456789') for _ in xrange(40))
 
-        # We only support the ones without PT args, because they're easier to fake.
         supported = ["obfs2", "obfs3", "fte"]
         transports = []
         for j, method in zip(range(1, len(supported) + 1), supported):
             pt = PluggableTransport(fpr, method, addr, port - j, {})
             transports.append(pt)
 
+        # Every tenth bridge supports obfs4.
+        if i % 10 == 0:
+            obfs4Args = {'iat-mode': '1',
+                         'node-id': '2a79f14120945873482b7823caabe2fcde848722',
+                         'public-key': '0a5b046d07f6f971b7776de682f57c5b9cdc8fa060db7ef59de82e721c8098f4'}
+            pt = PluggableTransport(fpr, "obfs4", addr, port - j,
+                                    obfs4Args)
+            transports.append(pt)
+
+        # Every fifteenth bridge supports scramblesuit.
+        if i % 15 == 0:
+            scramblesuitArgs = {'password': 'NEQGQYLUMUQGK5TFOJ4XI2DJNZTS4LRO'}
+            pt = PluggableTransport(fpr, "scramblesuit", addr, port - j,
+                                    scramblesuitArgs)
+            transports.append(pt)
+
         bridge = Bridge(nick, addr, port, fpr)
         bridge.flags.update("Running Stable")
         bridge.transports = transports
diff --git a/doc/HACKING.md b/doc/HACKING.md
index 22f9b2b..43e7fa1 100644
--- a/doc/HACKING.md
+++ b/doc/HACKING.md
@@ -13,7 +13,7 @@ with password ```writecode```.
 
 Developers wishing to test BridgeDB will need to generate mock bridge
 descriptors. This is accomplished through the [leekspin
-script](https://gitweb.torproject.org/user/isis/leekspin.git). To generate 20
+script](https://gitweb.torproject.org/user/phw/leekspin.git). To generate 20
 bridge descriptors, change to the bridgedb running directory and do:
 
     $ leekspin -n 20
diff --git a/scripts/setup-tests b/scripts/setup-tests
index 18298f4..ccfd6cd 100755
--- a/scripts/setup-tests
+++ b/scripts/setup-tests
@@ -29,7 +29,7 @@ sed -r -i -e "s/(SERVER_PUBLIC_FQDN = )(.*)/\1'127.0.0.1:6788'/" run/bridgedb.co
 sed -r -i -e "s/(MOAT_HTTP_IP = )(None)/\1'127.0.0.1'/" run/bridgedb.conf
 sed -r -i -e "s/(MOAT_HTTP_PORT = )(None)/\16790/" run/bridgedb.conf
 # Create descriptors
-leekspin -n 100
+leekspin -n 100 -xp 50
 cp -t run/from-authority networkstatus-bridges cached-extrainfo* bridge-descriptors
 cp -t run/from-bifroest networkstatus-bridges cached-extrainfo* bridge-descriptors
 # Create TLS certificates





More information about the tor-commits mailing list