CVE-2014-9720
Description
Tornado before 3.2.2 used a fixed CSRF token and HTTP compression, enabling BREACH side-channel attacks to recover the token via crafted requests.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Tornado before 3.2.2 used a fixed CSRF token and HTTP compression, enabling BREACH side-channel attacks to recover the token via crafted requests.
Vulnerability
Overview
Tornado versions prior to 3.2.2 are vulnerable to a BREACH side-channel attack due to the way the CSRF token was handled. The token was generated once and reused across requests, and responses containing the token could be compressed with HTTP compression. This combination allowed an attacker to exploit the BREACH attack, which leverages compression ratio differences to recover secret data from encrypted traffic [1].
Exploitation
Prerequisites
An attacker must be able to inject arbitrary data into requests that are reflected in compressed responses (e.g., via a cross-site scripting vulnerability or by controlling part of the request). By sending a series of crafted requests and measuring the size of the compressed responses, the attacker can incrementally determine the CSRF token value. No authentication is required if the token is present in unauthenticated responses [1][4].
Impact
Successful recovery of the CSRF token allows the attacker to forge requests on behalf of the victim, leading to cross-site request forgery (CSRF) attacks. This can result in unauthorized actions such as changing account settings, submitting forms, or performing transactions without the victim's consent [1][3].
Mitigation
The vulnerability is fixed in Tornado 3.2.2, which introduces a masked CSRF token format using a random salt, as shown in the commit [2]. Users should upgrade to the latest version. As a workaround, disabling HTTP compression can reduce the effectiveness of BREACH attacks, but upgrading is the recommended solution [3][4].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
tornadoPyPI | < 3.2.2 | 3.2.2 |
Affected products
10- Tornado/Tornadodescription
- ghsa-coords9 versionspkg:pypi/tornadopkg:rpm/suse/python-backports.ssl_match_hostname&distro=SUSE%20Linux%20Enterprise%20Desktop%2012pkg:rpm/suse/python-backports.ssl_match_hostname&distro=SUSE%20Linux%20Enterprise%20Desktop%2012%20SP1pkg:rpm/suse/python-backports.ssl_match_hostname&distro=SUSE%20Linux%20Enterprise%20Workstation%20Extension%2012pkg:rpm/suse/python-backports.ssl_match_hostname&distro=SUSE%20Linux%20Enterprise%20Workstation%20Extension%2012%20SP1pkg:rpm/suse/python-tornado&distro=SUSE%20Linux%20Enterprise%20Desktop%2012pkg:rpm/suse/python-tornado&distro=SUSE%20Linux%20Enterprise%20Desktop%2012%20SP1pkg:rpm/suse/python-tornado&distro=SUSE%20Linux%20Enterprise%20Workstation%20Extension%2012pkg:rpm/suse/python-tornado&distro=SUSE%20Linux%20Enterprise%20Workstation%20Extension%2012%20SP1
< 3.2.2+ 8 more
- (no CPE)range: < 3.2.2
- (no CPE)range: < 3.4.0.2-15.1
- (no CPE)range: < 3.4.0.2-15.1
- (no CPE)range: < 3.4.0.2-15.1
- (no CPE)range: < 3.4.0.2-15.1
- (no CPE)range: < 4.2.1-11.1
- (no CPE)range: < 4.2.1-11.1
- (no CPE)range: < 4.2.1-11.1
- (no CPE)range: < 4.2.1-11.1
Patches
11c36307463b1Change the xsrf cookie format to be masked with a random salt.
5 files changed · +101 −53
tornado/test/websocket_test.py+2 −1 modified@@ -11,6 +11,7 @@ try: import tornado.websocket + from tornado.util import _websocket_mask_python except ImportError: # The unittest module presents misleading errors on ImportError # (it acts as if websocket_test could not be found, hiding the underlying @@ -19,7 +20,7 @@ traceback.print_exc() raise -from tornado.websocket import WebSocketHandler, websocket_connect, WebSocketError, _websocket_mask_python +from tornado.websocket import WebSocketHandler, websocket_connect, WebSocketError try: from tornado import speedups
tornado/test/web_test.py+4 −2 modified@@ -2004,16 +2004,18 @@ def test_cross_user(self): def test_refresh_token(self): token = self.xsrf_token + tokens_seen = set([token]) # A user's token is stable over time. Refreshing the page in one tab # might update the cookie while an older tab still has the old cookie # in its DOM. Simulate this scenario by passing a constant token # in the body and re-querying for the token. for i in range(5): token = self.get_token(token) - # Implementation detail: the same token is returned each time - self.assertEqual(token, self.xsrf_token) + # Tokens are encoded uniquely each time + tokens_seen.add(token) response = self.fetch( "/", method="POST", body=urllib_parse.urlencode(dict(_xsrf=self.xsrf_token)), headers=self.cookie_headers(token)) self.assertEqual(response.code, 200) + self.assertEqual(len(tokens_seen), 6)
tornado/util.py+43 −0 modified@@ -12,11 +12,19 @@ from __future__ import absolute_import, division, print_function, with_statement +import array import inspect +import os import sys import zlib +try: + xrange # py2 +except NameError: + xrange = range # py3 + + class ObjectDict(dict): """Makes a dictionary behave like an object, with attribute-style access. """ @@ -265,6 +273,41 @@ def replace(self, new_value, args, kwargs): return old_value, args, kwargs +def _websocket_mask_python(mask, data): + """Websocket masking function. + + `mask` is a `bytes` object of length 4; `data` is a `bytes` object of any length. + Returns a `bytes` object of the same length as `data` with the mask applied + as specified in section 5.3 of RFC 6455. + + This pure-python implementation may be replaced by an optimized version when available. + """ + mask = array.array("B", mask) + unmasked = array.array("B", data) + for i in xrange(len(data)): + unmasked[i] = unmasked[i] ^ mask[i % 4] + if hasattr(unmasked, 'tobytes'): + # tostring was deprecated in py32. It hasn't been removed, + # but since we turn on deprecation warnings in our tests + # we need to use the right one. + return unmasked.tobytes() + else: + return unmasked.tostring() + +if (os.environ.get('TORNADO_NO_EXTENSION') or + os.environ.get('TORNADO_EXTENSION') == '0'): + # These environment variables exist to make it easier to do performance + # comparisons; they are not guaranteed to remain supported in the future. + _websocket_mask = _websocket_mask_python +else: + try: + from tornado.speedups import websocket_mask as _websocket_mask + except ImportError: + if os.environ.get('TORNADO_EXTENSION') == '1': + raise + _websocket_mask = _websocket_mask_python + + def doctests(): import doctest return doctest.DocTestSuite()
tornado/web.py+51 −8 modified@@ -72,7 +72,6 @@ def get(self): import tornado import traceback import types -import uuid from tornado.concurrent import Future from tornado import escape @@ -82,7 +81,7 @@ def get(self): from tornado import stack_context from tornado import template from tornado.escape import utf8, _unicode -from tornado.util import bytes_type, import_object, ObjectDict, raise_exc_info, unicode_type +from tornado.util import bytes_type, import_object, ObjectDict, raise_exc_info, unicode_type, _websocket_mask try: from io import BytesIO # python 3 @@ -1071,14 +1070,56 @@ def xsrf_token(self): See http://en.wikipedia.org/wiki/Cross-site_request_forgery """ if not hasattr(self, "_xsrf_token"): - token = self.get_cookie("_xsrf") - if not token: - token = binascii.b2a_hex(os.urandom(16)) + version, token, timestamp = self._get_raw_xsrf_token() + mask = os.urandom(4) + self._xsrf_token = b"|".join([ + b"2", + binascii.b2a_hex(mask), + binascii.b2a_hex(_websocket_mask(mask, token)), + utf8(str(int(timestamp)))]) + if version is None or version != 2: expires_days = 30 if self.current_user else None - self.set_cookie("_xsrf", token, expires_days=expires_days) - self._xsrf_token = token + self.set_cookie("_xsrf", self._xsrf_token, + expires_days=expires_days) return self._xsrf_token + def _get_raw_xsrf_token(self): + if not hasattr(self, '_raw_xsrf_token'): + cookie = self.get_cookie("_xsrf") + if cookie: + version, token, timestamp = self._decode_xsrf_token(cookie) + else: + version, token, timestamp = None, None, None + if token is None: + version = None + token = os.urandom(16) + timestamp = time.time() + self._raw_xsrf_token = (version, token, timestamp) + return self._raw_xsrf_token + + def _decode_xsrf_token(self, cookie): + m = _signed_value_version_re.match(utf8(cookie)) + if m: + version = int(m.group(1)) + if version == 2: + _, mask, masked_token, timestamp = cookie.split("|") + mask = binascii.a2b_hex(utf8(mask)) + token = _websocket_mask( + mask, binascii.a2b_hex(utf8(masked_token))) + timestamp = int(timestamp) + return version, token, timestamp + else: + # Treat unknown versions as not present instead of failing. + return None, None, None + elif len(cookie) == 32: + version = 1 + token = binascii.a2b_hex(cookie) + # We don't have a usable timestamp in older versions. + timestamp = int(time.time()) + return (version, token, timestamp) + else: + return None, None, None + def check_xsrf_cookie(self): """Verifies that the ``_xsrf`` cookie matches the ``_xsrf`` argument. @@ -1105,7 +1146,9 @@ def check_xsrf_cookie(self): self.request.headers.get("X-Csrftoken")) if not token: raise HTTPError(403, "'_xsrf' argument missing from POST") - if not _time_independent_equals(utf8(self.xsrf_token), utf8(token)): + _, token, _ = self._decode_xsrf_token(token) + _, expected_token, _ = self._get_raw_xsrf_token() + if not _time_independent_equals(utf8(token), utf8(expected_token)): raise HTTPError(403, "XSRF cookie does not match POST argument") def xsrf_form_html(self):
tornado/websocket.py+1 −42 modified@@ -20,7 +20,6 @@ from __future__ import absolute_import, division, print_function, with_statement # Author: Jacob Kristhammar, 2010 -import array import base64 import collections import functools @@ -39,12 +38,7 @@ from tornado.log import gen_log, app_log from tornado.netutil import Resolver from tornado import simple_httpclient -from tornado.util import bytes_type, unicode_type - -try: - xrange # py2 -except NameError: - xrange = range # py3 +from tornado.util import bytes_type, unicode_type, _websocket_mask class WebSocketError(Exception): @@ -890,38 +884,3 @@ def websocket_connect(url, io_loop=None, callback=None, connect_timeout=None): if callback is not None: io_loop.add_future(conn.connect_future, callback) return conn.connect_future - - -def _websocket_mask_python(mask, data): - """Websocket masking function. - - `mask` is a `bytes` object of length 4; `data` is a `bytes` object of any length. - Returns a `bytes` object of the same length as `data` with the mask applied - as specified in section 5.3 of RFC 6455. - - This pure-python implementation may be replaced by an optimized version when available. - """ - mask = array.array("B", mask) - unmasked = array.array("B", data) - for i in xrange(len(data)): - unmasked[i] = unmasked[i] ^ mask[i % 4] - if hasattr(unmasked, 'tobytes'): - # tostring was deprecated in py32. It hasn't been removed, - # but since we turn on deprecation warnings in our tests - # we need to use the right one. - return unmasked.tobytes() - else: - return unmasked.tostring() - -if (os.environ.get('TORNADO_NO_EXTENSION') or - os.environ.get('TORNADO_EXTENSION') == '0'): - # These environment variables exist to make it easier to do performance - # comparisons; they are not guaranteed to remain supported in the future. - _websocket_mask = _websocket_mask_python -else: - try: - from tornado.speedups import websocket_mask as _websocket_mask - except ImportError: - if os.environ.get('TORNADO_EXTENSION') == '1': - raise - _websocket_mask = _websocket_mask_python
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-8vpw-mgpf-mpvvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2014-9720ghsaADVISORY
- openwall.com/lists/oss-security/2015/05/19/4ghsax_refsource_MISCWEB
- www.tornadoweb.org/en/stable/releases/v3.2.2.htmlghsax_refsource_MISCWEB
- bugzilla.novell.com/show_bug.cgighsax_refsource_MISCWEB
- bugzilla.redhat.com/show_bug.cgighsax_refsource_MISCWEB
- github.com/pypa/advisory-database/tree/main/vulns/tornado/PYSEC-2020-213.yamlghsaWEB
- github.com/tornadoweb/tornado/commit/1c36307463b1e8affae100bf9386948e6c1b2308ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.