Skip to content
CVE-2026-121691 ·
Medium · November 10, 2026

Unbounded decompression in the whatstk ZIP loader exhausts memory and disk

S0
Secur0 CNA
CVE-2026-121691

Description

Improper Handling of Highly Compressed Data (CWE-409) in the ZIP loader of whatstk, in _str_from_file() (whatstk/whatsapp/parser.py), in whatstk from 0.7.0 before 0.9.0. A party who supplies a chat export can exhaust the memory and the disk of the process that parses it with an archive of a few tens of kilobytes.

The loader calls zip_ref.extractall() with no bound on the size of the members and no check of the compression ratio, then reads the extracted file in full with f.read(). The only guard on the archive counts the extracted files and raises when there is not exactly one, but it runs after extractall() has already written them to disk, so it does not prevent the cost it was meant to bound.

The decompressed text is then multiplied. _clean_text() passes it through unicodedata.normalize("NFKD", text), which holds a second copy, and the parse builds a list of regular expression matches and a DataFrame over the result. Peak memory is therefore a multiple of the decompressed text rather than of the archive, and a well formed chat is worse than a block of filler because it runs the whole pipeline instead of failing header detection early.

A ZIP is a documented input of WhatsAppChat.from_source() and df_from_whatsapp(), and is the form in which WhatsApp exports a chat on iOS, so the attacker supplies the whole file and nothing has to be malformed.

Vulnerability type (CWE)

  • CWE-409: Improper Handling of Highly Compressed Data (Data Amplification)

Affected versions

whatstk from 0.7.0 before 0.9.0. ZIP support arrives in 0.7.0 and releases before that do not read archives at all. Default status: unaffected.

Measured on 0.8.1 from PyPI with a 69.7 KB archive holding 20 MB of well formed chat: peak resident memory 405 MB and 5.9 seconds, against a 183 MB baseline for importing the library alone, so roughly 220 MB is attributable to the parse and the overall amplification from the archive is about three orders of magnitude. The same archive on 0.9.0 still costs the same, because 20 MB is under the new default bound; an archive holding 60 MB is refused in under 0.01 seconds with nothing materialised.

Score (CVSS 4.0)

Medium (6.7)

CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

The attack vector is Local because the library reads an export file on the machine that runs it, which is its documented use; a service that accepts uploaded exports and parses them is the case where the vector would be Network instead. Privileges required are None, since the party who supplies the archive needs no access to that machine. Attack requirements are None because the attacker writes the whole archive and nothing has to coincide. User interaction is Active because the parse has to be invoked on the file. Confidentiality and integrity are None: nothing is read or altered. Availability on the vulnerable system is High because the memory and disk consumed are bounded only by the size the attacker chooses to decompress to, and the process is killed where that exceeds what is available. The subsequent system metrics are None, since what stops is the program doing the parsing.

Solution

Upgrade to whatstk 0.9.0 or later, which rejects a source larger than 50 MB by default.

Pass a lower max_size where the parsing happens on behalf of other people, because the default bound still permits a single source of 50 MB and the parse costs a multiple of that.

Patch

Released in 0.9.0, through pull request #182 and the release commit 01d66d8. The member count is checked before anything is read, so a malformed archive costs nothing. The member is then read straight out of the archive through zip_ref.open() and a bounded reader, so nothing is written to disk and an archive cannot fill the filesystem. A new max_size argument, 50 MB by default, applies to local files, URLs and Google Drive sources as well as to ZIP members. The declared ZipInfo.file_size is used only as a first cheap rejection and the fix does not rely on it, which is the right call: the field comes from the archive and is attacker controlled.

The bound limits what can be fed in, not what parsing costs per byte. The residual cost is recorded by the maintainer in issue #186.

Credits

  • Jorge Rey Cotallo - finder
  • Xoán M. Otero Jorge - analyst
  • Darío Rivas - analyst
  • Secur0 CNA - coordinator
  • Lucas Rodés-Guirao - remediation developer

Discovery source: External

Official record: CVE-2026-121691

Related records

  • CVE-2026-121577 - unescaped header separators building a regular expression that backtracks exponentially in the same library