commit efff3eb11dc62044ab2d64cf1a078ec913f7ae7c Author: Philipp Winter phw@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@bad0bed11a9018f65555b3c699... +git+https://git.torproject.org/user/phw/leekspin.git@d34c804cd0f01af5206833e62c0... 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@bad0bed11a9018f65555b3c699... +git+https://git.torproject.org/user/phw/leekspin.git@d34c804cd0f01af5206833e62c0... 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
tor-commits@lists.torproject.org