Flask is vulnerable to Denial of Service via incorrect encoding of JSON data
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
Details
The Pallets Project flask version Before 0.12.3 contains a CWE-20: Improper Input Validation vulnerability in flask that can result in Large amount of memory usage possibly leading to denial of service. This attack appear to be exploitable via Attacker provides JSON data in incorrect encoding. This vulnerability appears to have been fixed in 0.12.3.
The fix
detect UTF encodings when loading json
CHANGES.rst+4 −0
@@ -147,6 +147,9 @@ unreleasedwhen it is registered with the app. (`#2629`_)- :meth:`Request.get_json() <flask.Request.get_json>` doesn't cache theresult if parsing fails when ``silent`` is true. (`#2651`_)+- :func:`request.get_json <flask.Request.get_json>` no longer accepts+arbitrary encodings. Incoming JSON should be encoded using UTF-8 per+:rfc:`8259`, but Flask will autodetect UTF-8, -16, or -32. (`#2691`_).. _pallets/meta#24: https://github.com/pallets/meta/issues/24.. _#1421: https://github.com/pallets/flask/issues/1421@@ -192,6 +195,7 @@ unreleased.. _#2635: https://github.com/pallets/flask/pull/2635.. _#2629: https://github.com/pallets/flask/pull/2629.. _#2651: https://github.com/pallets/flask/issues/2651+.. _#2691: https://github.com/pallets/flask/pull/2691Version 0.12.2
flask/json/__init__.py+48 −2
@@ -6,7 +6,7 @@:copyright: © 2010 by the Pallets team.:license: BSD, see LICENSE for more details."""-+import codecsimport ioimport uuidfrom datetime import date, datetime@@ -121,6 +121,49 @@ def _load_arg_defaults(kwargs):kwargs.setdefault('cls', JSONDecoder)+def detect_encoding(data):+"""Detect which UTF codec was used to encode the given bytes.++The latest JSON standard (:rfc:`8259`) suggests that only UTF-8 is+accepted. Older documents allowed 8, 16, or 32. 16 and 32 can be big+or little endian. Some editors or libraries may prepend a BOM.++:param data: Bytes in unknown UTF encoding.+:return: UTF encoding name+"""+head = data[:4]++if head[:3] == codecs.BOM_UTF8:+return 'utf-8-sig'++if b'\x00' not in head:+return 'utf-8'++if head in (codecs.BOM_UTF32_BE, codecs.BOM_UTF32_LE):+return 'utf-32'++if head[:2] in (codecs.BOM_UTF16_BE, codecs.BOM_UTF16_LE):+return 'utf-16'++if len(head) == 4:+if head[:3] == b'\x00\x00\x00':+return 'utf-32-be'++if head[::2] == b'\x00\x00':+return 'utf-16-be'++if head[1:] == b'\x00\x00\x00':+return 'utf-32-le'++if head[1::2] == b'\x00\x00':+return 'utf-16-le'++if len(head) == 2:+return 'utf-16-be' if head.startswith(b'\x00') else 'utf-16-le'++return 'utf-8'++def dumps(obj, **kwargs):"""Serialize ``obj`` to a JSON formatted ``str`` by using the application'sconfigured encoder (:attr:`~flask.Flask.json_encoder`) if there is an@@ -155,7 +198,10 @@ def loads(s, **kwargs):"""_load_arg_defaults(kwargs)if isinstance(s, bytes):-s = s.decode(kwargs.pop('encoding', None) or 'utf-8')+encoding = kwargs.pop('encoding', None)+if encoding is None:+encoding = detect_encoding(s)+s = s.decode(encoding)return _json.loads(s, **kwargs)
flask/wrappers.py+11 −13
@@ -50,15 +50,17 @@ def _get_data_for_json(self, cache):return self.get_data(cache=cache)def get_json(self, force=False, silent=False, cache=True):-"""Parse and return the data as JSON. If the mimetype does not indicate-JSON (:mimetype:`application/json`, see :meth:`is_json`), this returns-``None`` unless ``force`` is true. If parsing fails,-:meth:`on_json_loading_failed` is called and its return value is used-as the return value.+"""Parse and return the data as JSON. If the mimetype does not+indicate JSON (:mimetype:`application/json`, see+:meth:`is_json`), this returns ``None`` unless ``force`` is+true. If parsing fails, :meth:`on_json_loading_failed` is called+and its return value is used as the return value.:param force: Ignore the mimetype and always try to parse JSON.-:param silent: Silence parsing errors and return ``None`` instead.-:param cache: Store the parsed JSON to return for subsequent calls.+:param silent: Silence parsing errors and return ``None``+instead.+:param cache: Store the parsed JSON to return for subsequent+calls."""if cache and self._cached_json[silent] is not Ellipsis:return self._cached_json[silent]@@ -66,14 +68,10 @@ def get_json(self, force=False, silent=False, cache=True):if not (force or self.is_json):return None-# We accept MIME charset against the specification as certain clients-# have used this in the past. For responses, we assume that if the-# charset is set then the data has been encoded correctly as well.-charset = self.mimetype_params.get('charset')+data = self._get_data_for_json(cache=cache)try:-data = self._get_data_for_json(cache=cache)-rv = json.loads(data, encoding=charset)+rv = json.loads(data)except ValueError as e:if silent:rv = None
tests/test_helpers.py+19 −11
@@ -16,10 +16,13 @@import pytestfrom werkzeug.datastructures import Rangefrom werkzeug.exceptions import BadRequest, NotFound-from werkzeug.http import http_date, parse_cache_control_header, \+from werkzeug.http import (+http_date, parse_cache_control_header,parse_options_header+)import flask+from flask import jsonfrom flask._compat import StringIO, text_typefrom flask.helpers import get_debug_flag, get_env@@ -55,6 +58,21 @@ def dst(self, dt):class TestJSON(object):+@pytest.mark.parametrize('value', (+1, 't', True, False, None,+[], [1, 2, 3],+{}, {'foo': u'🐍'},+))+@pytest.mark.parametrize('encoding', (+'utf-8', 'utf-8-sig',+'utf-16-le', 'utf-16-be', 'utf-16',+'utf-32-le', 'utf-32-be', 'utf-32',+))+def test_detect_encoding(self, value, encoding):+data = json.dumps(value).encode(encoding)+assert json.detect_encoding(data) == encoding+assert json.loads(data) == value+def test_ignore_cached_json(self, app):with app.test_request_context('/', method='POST', data='malformed',content_type='application/json'):@@ -121,16 +139,6 @@ def return_json():rv = client.post('/json', data='"foo"', content_type='application/x+json')assert rv.data == b'foo'-def test_json_body_encoding(self, app, client):--@app.route('/')-def index():-return flask.request.get_json()--resp = client.get('/', data=u'"Hällo Wörld"'.encode('iso-8859-15'),-content_type='application/json; charset=iso-8859-15')-assert resp.data == u'Hällo Wörld'.encode('utf-8')-@pytest.mark.parametrize('test_value,expected', [(True, '"\\u2603"'), (False, u'"\u2603"')])def test_json_as_unicode(self, test_value, expected, app, app_ctx):
detect UTF encodings when loading json
flask/json.py+48 −1
@@ -8,6 +8,7 @@:copyright: (c) 2015 by Armin Ronacher.:license: BSD, see LICENSE for more details."""+import codecsimport ioimport uuidfrom datetime import date@@ -108,6 +109,49 @@ def _load_arg_defaults(kwargs):kwargs.setdefault('cls', JSONDecoder)+def detect_encoding(data):+"""Detect which UTF codec was used to encode the given bytes.++The latest JSON standard (:rfc:`8259`) suggests that only UTF-8 is+accepted. Older documents allowed 8, 16, or 32. 16 and 32 can be big+or little endian. Some editors or libraries may prepend a BOM.++:param data: Bytes in unknown UTF encoding.+:return: UTF encoding name+"""+head = data[:4]++if head[:3] == codecs.BOM_UTF8:+return 'utf-8-sig'++if b'\x00' not in head:+return 'utf-8'++if head in (codecs.BOM_UTF32_BE, codecs.BOM_UTF32_LE):+return 'utf-32'++if head[:2] in (codecs.BOM_UTF16_BE, codecs.BOM_UTF16_LE):+return 'utf-16'++if len(head) == 4:+if head[:3] == b'\x00\x00\x00':+return 'utf-32-be'++if head[::2] == b'\x00\x00':+return 'utf-16-be'++if head[1:] == b'\x00\x00\x00':+return 'utf-32-le'++if head[1::2] == b'\x00\x00':+return 'utf-16-le'++if len(head) == 2:+return 'utf-16-be' if head.startswith(b'\x00') else 'utf-16-le'++return 'utf-8'++def dumps(obj, **kwargs):"""Serialize ``obj`` to a JSON formatted ``str`` by using the application'sconfigured encoder (:attr:`~flask.Flask.json_encoder`) if there is an@@ -142,7 +186,10 @@ def loads(s, **kwargs):"""_load_arg_defaults(kwargs)if isinstance(s, bytes):-s = s.decode(kwargs.pop('encoding', None) or 'utf-8')+encoding = kwargs.pop('encoding', None)+if encoding is None:+encoding = detect_encoding(s)+s = s.decode(encoding)return _json.loads(s, **kwargs)
flask/wrappers.py+3 −10
@@ -144,17 +144,10 @@ def get_json(self, force=False, silent=False, cache=True):if not (force or self.is_json):return None-# We accept a request charset against the specification as-# certain clients have been using this in the past. This-# fits our general approach of being nice in what we accept-# and strict in what we send out.-request_charset = self.mimetype_params.get('charset')+data = _get_data(self, cache)+try:-data = _get_data(self, cache)-if request_charset is not None:-rv = json.loads(data, encoding=request_charset)-else:-rv = json.loads(data)+rv = json.loads(data)except ValueError as e:if silent:rv = None
tests/test_helpers.py+16 −12
@@ -21,6 +21,8 @@from werkzeug.exceptions import BadRequest, NotFoundfrom werkzeug.http import parse_cache_control_header, parse_options_headerfrom werkzeug.http import http_date++from flask import jsonfrom flask._compat import StringIO, text_type@@ -34,6 +36,20 @@ def has_encoding(name):class TestJSON(object):+@pytest.mark.parametrize('value', (+1, 't', True, False, None,+[], [1, 2, 3],+{}, {'foo': u'🐍'},+))+@pytest.mark.parametrize('encoding', (+'utf-8', 'utf-8-sig',+'utf-16-le', 'utf-16-be', 'utf-16',+'utf-32-le', 'utf-32-be', 'utf-32',+))+def test_detect_encoding(self, value, encoding):+data = json.dumps(value).encode(encoding)+assert json.detect_encoding(data) == encoding+assert json.loads(data) == valuedef test_ignore_cached_json(self):app = flask.Flask(__name__)@@ -85,18 +101,6 @@ def return_json():rv = c.post('/json', data='"foo"', content_type='application/x+json')assert rv.data == b'foo'-def test_json_body_encoding(self):-app = flask.Flask(__name__)-app.testing = True-@app.route('/')-def index():-return flask.request.get_json()--c = app.test_client()-resp = c.get('/', data=u'"Hällo Wörld"'.encode('iso-8859-15'),-content_type='application/json; charset=iso-8859-15')-assert resp.data == u'Hällo Wörld'.encode('utf-8')-def test_json_as_unicode(self):app = flask.Flask(__name__)
References
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2018-1000656
- WEBhttps://github.com/pallets/flask/pull/2691
- WEBhttps://github.com/pallets/flask/commit/b178e89e4456e777b1a7ac6d7199052d0dfdbbbe
- ADVISORYhttps://github.com/advisories/GHSA-562c-5r94-xh97
- PACKAGEhttps://github.com/pallets/flask
- WEBhttps://github.com/pallets/flask/releases/tag/0.12.3
- WEBhttps://github.com/pypa/advisory-database/tree/main/vulns/flask/PYSEC-2018-66.yaml
- WEBhttps://lists.debian.org/debian-lts-announce/2019/08/msg00025.html
- WEBhttps://security.netapp.com/advisory/ntap-20190221-0001
- WEBhttps://usn.ubuntu.com/4378-1