diff options
| author | jvoisin | 2019-04-28 19:25:45 +0200 |
|---|---|---|
| committer | jvoisin | 2019-05-01 17:55:35 +0200 |
| commit | 95169906931e60ace651e8f0aefe0fbe375b2f40 (patch) | |
| tree | 0a9ed871dca3104f769419bbeff91b9c2d9c56dd | |
| parent | a7ebb587e19ce1177a7ef067e2da74e4964ff19e (diff) | |
Add some verification for "dangerous" tarfiles
| -rw-r--r-- | libmat2/archive.py | 53 | ||||
| -rw-r--r-- | tests/test_corrupted_files.py | 120 |
2 files changed, 172 insertions, 1 deletions
diff --git a/libmat2/archive.py b/libmat2/archive.py index 969bbd8..e6c11c1 100644 --- a/libmat2/archive.py +++ b/libmat2/archive.py | |||
| @@ -15,7 +15,7 @@ from . import abstract, UnknownMemberPolicy, parser_factory | |||
| 15 | assert Set | 15 | assert Set |
| 16 | assert Pattern | 16 | assert Pattern |
| 17 | 17 | ||
| 18 | # pylint: disable=not-callable,assignment-from-no-return | 18 | # pylint: disable=not-callable,assignment-from-no-return,too-many-branches |
| 19 | 19 | ||
| 20 | # An ArchiveClass is a class representing an archive, | 20 | # An ArchiveClass is a class representing an archive, |
| 21 | # while an ArchiveMember is a class representing an element | 21 | # while an ArchiveMember is a class representing an element |
| @@ -238,6 +238,57 @@ class TarParser(ArchiveBasedAbstractParser): | |||
| 238 | def is_archive_valid(self): | 238 | def is_archive_valid(self): |
| 239 | if tarfile.is_tarfile(self.filename) is False: | 239 | if tarfile.is_tarfile(self.filename) is False: |
| 240 | raise ValueError | 240 | raise ValueError |
| 241 | self.__check_tarfile_safety() | ||
| 242 | |||
| 243 | def __check_tarfile_safety(self): | ||
| 244 | """Checks if the tarfile doesn't have any "suspicious" members. | ||
| 245 | |||
| 246 | This is a rewrite of this patch: https://bugs.python.org/file47826/safetarfile-4.diff | ||
| 247 | inspired by this bug from 2014: https://bugs.python.org/issue21109 | ||
| 248 | because Python's stdlib doesn't provide a way to "safely" extract | ||
| 249 | things from a tar file. | ||
| 250 | """ | ||
| 251 | names = set() | ||
| 252 | with tarfile.open(self.filename) as f: | ||
| 253 | members = f.getmembers() | ||
| 254 | for member in members: | ||
| 255 | name = member.name | ||
| 256 | if os.path.isabs(name): | ||
| 257 | raise ValueError("The archive %s contains a file with an " \ | ||
| 258 | "absolute path: %s" % (self.filename, name)) | ||
| 259 | elif os.path.normpath(name).startswith('../') or '/../' in name: | ||
| 260 | raise ValueError("The archive %s contains a file with an " \ | ||
| 261 | "path traversal attack: %s" % (self.filename, name)) | ||
| 262 | |||
| 263 | if name in names: | ||
| 264 | raise ValueError("The archive %s contains two times the same " \ | ||
| 265 | "file: %s" % (self.filename, name)) | ||
| 266 | else: | ||
| 267 | names.add(name) | ||
| 268 | |||
| 269 | if member.isfile(): | ||
| 270 | if member.mode & stat.S_ISUID: | ||
| 271 | raise ValueError("The archive %s contains a setuid file: %s" % \ | ||
| 272 | (self.filename, name)) | ||
| 273 | elif member.mode & stat.S_ISGID: | ||
| 274 | raise ValueError("The archive %s contains a setgid file: %s" % \ | ||
| 275 | (self.filename, name)) | ||
| 276 | elif member.issym(): | ||
| 277 | linkname = member.linkname | ||
| 278 | if os.path.normpath(linkname).startswith('..'): | ||
| 279 | raise ValueError('The archive %s contains a symlink pointing' \ | ||
| 280 | 'outside of the archive via a path traversal: %s -> %s' % \ | ||
| 281 | (self.filename, name, linkname)) | ||
| 282 | if os.path.isabs(linkname): | ||
| 283 | raise ValueError('The archive %s contains a symlink pointing' \ | ||
| 284 | 'outside of the archive: %s -> %s' % \ | ||
| 285 | (self.filename, name, linkname)) | ||
| 286 | elif member.isdev(): | ||
| 287 | raise ValueError("The archive %s contains a non-regular " \ | ||
| 288 | "file: %s" % (self.filename, name)) | ||
| 289 | elif member.islnk(): | ||
| 290 | raise ValueError("The archive %s contains a hardlink: %s" \ | ||
| 291 | % (self.filename, name)) | ||
| 241 | 292 | ||
| 242 | @staticmethod | 293 | @staticmethod |
| 243 | def _clean_member(member: ArchiveMember) -> ArchiveMember: | 294 | def _clean_member(member: ArchiveMember) -> ArchiveMember: |
diff --git a/tests/test_corrupted_files.py b/tests/test_corrupted_files.py index b7240fe..1d66556 100644 --- a/tests/test_corrupted_files.py +++ b/tests/test_corrupted_files.py | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | import unittest | 3 | import unittest |
| 4 | import stat | ||
| 4 | import time | 5 | import time |
| 5 | import shutil | 6 | import shutil |
| 6 | import os | 7 | import os |
| @@ -325,6 +326,7 @@ class TestReadOnlyArchiveMembers(unittest.TestCase): | |||
| 325 | tarinfo = tarfile.TarInfo('./tests/data/dirty.jpg') | 326 | tarinfo = tarfile.TarInfo('./tests/data/dirty.jpg') |
| 326 | tarinfo.mtime = time.time() | 327 | tarinfo.mtime = time.time() |
| 327 | tarinfo.uid = 1337 | 328 | tarinfo.uid = 1337 |
| 329 | tarinfo.gid = 0 | ||
| 328 | tarinfo.mode = 0o000 | 330 | tarinfo.mode = 0o000 |
| 329 | tarinfo.size = os.stat('./tests/data/dirty.jpg').st_size | 331 | tarinfo.size = os.stat('./tests/data/dirty.jpg').st_size |
| 330 | with open('./tests/data/dirty.jpg', 'rb') as f: | 332 | with open('./tests/data/dirty.jpg', 'rb') as f: |
| @@ -340,3 +342,121 @@ class TestReadOnlyArchiveMembers(unittest.TestCase): | |||
| 340 | os.remove('./tests/data/clean.tar') | 342 | os.remove('./tests/data/clean.tar') |
| 341 | os.remove('./tests/data/clean.cleaned.tar') | 343 | os.remove('./tests/data/clean.cleaned.tar') |
| 342 | 344 | ||
| 345 | |||
| 346 | class TestPathTraversalArchiveMembers(unittest.TestCase): | ||
| 347 | def test_tar_traversal(self): | ||
| 348 | with tarfile.open('./tests/data/clean.tar', 'w') as zout: | ||
| 349 | zout.add('./tests/data/dirty.png') | ||
| 350 | tarinfo = tarfile.TarInfo('./tests/data/dirty.jpg') | ||
| 351 | tarinfo.name = '../../../../../../../../../../tmp/mat2_test.png' | ||
| 352 | with open('./tests/data/dirty.jpg', 'rb') as f: | ||
| 353 | zout.addfile(tarinfo=tarinfo, fileobj=f) | ||
| 354 | with self.assertRaises(ValueError): | ||
| 355 | archive.TarParser('./tests/data/clean.tar') | ||
| 356 | os.remove('./tests/data/clean.tar') | ||
| 357 | |||
| 358 | def test_tar_absolute_path(self): | ||
| 359 | with tarfile.open('./tests/data/clean.tar', 'w') as zout: | ||
| 360 | zout.add('./tests/data/dirty.png') | ||
| 361 | tarinfo = tarfile.TarInfo('./tests/data/dirty.jpg') | ||
| 362 | tarinfo.name = '/etc/passwd' | ||
| 363 | with open('./tests/data/dirty.jpg', 'rb') as f: | ||
| 364 | zout.addfile(tarinfo=tarinfo, fileobj=f) | ||
| 365 | with self.assertRaises(ValueError): | ||
| 366 | archive.TarParser('./tests/data/clean.tar') | ||
| 367 | os.remove('./tests/data/clean.tar') | ||
| 368 | |||
| 369 | def test_tar_duplicate_file(self): | ||
| 370 | with tarfile.open('./tests/data/clean.tar', 'w') as zout: | ||
| 371 | for _ in range(3): | ||
| 372 | zout.add('./tests/data/dirty.png') | ||
| 373 | tarinfo = tarfile.TarInfo('./tests/data/dirty.jpg') | ||
| 374 | with open('./tests/data/dirty.jpg', 'rb') as f: | ||
| 375 | zout.addfile(tarinfo=tarinfo, fileobj=f) | ||
| 376 | with self.assertRaises(ValueError): | ||
| 377 | archive.TarParser('./tests/data/clean.tar') | ||
| 378 | os.remove('./tests/data/clean.tar') | ||
| 379 | |||
| 380 | def test_tar_setuid(self): | ||
| 381 | with tarfile.open('./tests/data/clean.tar', 'w') as zout: | ||
| 382 | zout.add('./tests/data/dirty.png') | ||
| 383 | tarinfo = tarfile.TarInfo('./tests/data/dirty.jpg') | ||
| 384 | tarinfo.mode |= stat.S_ISUID | ||
| 385 | with open('./tests/data/dirty.jpg', 'rb') as f: | ||
| 386 | zout.addfile(tarinfo=tarinfo, fileobj=f) | ||
| 387 | with self.assertRaises(ValueError): | ||
| 388 | archive.TarParser('./tests/data/clean.tar') | ||
| 389 | os.remove('./tests/data/clean.tar') | ||
| 390 | |||
| 391 | def test_tar_setgid(self): | ||
| 392 | with tarfile.open('./tests/data/clean.tar', 'w') as zout: | ||
| 393 | zout.add('./tests/data/dirty.png') | ||
| 394 | tarinfo = tarfile.TarInfo('./tests/data/dirty.jpg') | ||
| 395 | tarinfo.mode |= stat.S_ISGID | ||
| 396 | with open('./tests/data/dirty.jpg', 'rb') as f: | ||
| 397 | zout.addfile(tarinfo=tarinfo, fileobj=f) | ||
| 398 | with self.assertRaises(ValueError): | ||
| 399 | archive.TarParser('./tests/data/clean.tar') | ||
| 400 | os.remove('./tests/data/clean.tar') | ||
| 401 | |||
| 402 | def test_tar_symlink_absolute(self): | ||
| 403 | os.symlink('/etc/passwd', './tests/data/symlink') | ||
| 404 | with tarfile.open('./tests/data/clean.tar', 'w') as zout: | ||
| 405 | zout.add('./tests/data/symlink') | ||
| 406 | tarinfo = tarfile.TarInfo('./tests/data/symlink') | ||
| 407 | tarinfo.linkname = '/etc/passwd' | ||
| 408 | tarinfo.type = tarfile.SYMTYPE | ||
| 409 | with open('./tests/data/dirty.jpg', 'rb') as f: | ||
| 410 | zout.addfile(tarinfo=tarinfo, fileobj=f) | ||
| 411 | with self.assertRaises(ValueError): | ||
| 412 | archive.TarParser('./tests/data/clean.tar') | ||
| 413 | os.remove('./tests/data/clean.tar') | ||
| 414 | os.remove('./tests/data/symlink') | ||
| 415 | |||
| 416 | def test_tar_symlink_ok(self): | ||
| 417 | shutil.copy('./tests/data/dirty.png', './tests/data/clean.png') | ||
| 418 | with tarfile.open('./tests/data/clean.tar', 'w') as zout: | ||
| 419 | zout.add('./tests/data/dirty.png') | ||
| 420 | t = tarfile.TarInfo('mydir') | ||
| 421 | t.type = tarfile.DIRTYPE | ||
| 422 | zout.addfile(t) | ||
| 423 | zout.add('./tests/data/clean.png') | ||
| 424 | t = tarfile.TarInfo('mylink') | ||
| 425 | t.type = tarfile.SYMTYPE | ||
| 426 | t.linkname = './tests/data/clean.png' | ||
| 427 | zout.addfile(t) | ||
| 428 | zout.add('./tests/data/dirty.jpg') | ||
| 429 | archive.TarParser('./tests/data/clean.tar') | ||
| 430 | os.remove('./tests/data/clean.tar') | ||
| 431 | os.remove('./tests/data/clean.png') | ||
| 432 | |||
| 433 | def test_tar_symlink_relative(self): | ||
| 434 | os.symlink('../../../etc/passwd', './tests/data/symlink') | ||
| 435 | with tarfile.open('./tests/data/clean.tar', 'w') as zout: | ||
| 436 | zout.add('./tests/data/symlink') | ||
| 437 | tarinfo = tarfile.TarInfo('./tests/data/symlink') | ||
| 438 | with open('./tests/data/dirty.jpg', 'rb') as f: | ||
| 439 | zout.addfile(tarinfo=tarinfo, fileobj=f) | ||
| 440 | with self.assertRaises(ValueError): | ||
| 441 | archive.TarParser('./tests/data/clean.tar') | ||
| 442 | os.remove('./tests/data/clean.tar') | ||
| 443 | os.remove('./tests/data/symlink') | ||
| 444 | |||
| 445 | def test_tar_device_file(self): | ||
| 446 | with tarfile.open('./tests/data/clean.tar', 'w') as zout: | ||
| 447 | zout.add('/dev/null') | ||
| 448 | with self.assertRaises(ValueError): | ||
| 449 | archive.TarParser('./tests/data/clean.tar') | ||
| 450 | os.remove('./tests/data/clean.tar') | ||
| 451 | |||
| 452 | def test_tar_hardlink(self): | ||
| 453 | shutil.copy('./tests/data/dirty.png', './tests/data/clean.png') | ||
| 454 | os.link('./tests/data/clean.png', './tests/data/hardlink.png') | ||
| 455 | with tarfile.open('./tests/data/cleaner.tar', 'w') as zout: | ||
| 456 | zout.add('tests/data/clean.png') | ||
| 457 | zout.add('tests/data/hardlink.png') | ||
| 458 | with self.assertRaises(ValueError): | ||
| 459 | archive.TarParser('./tests/data/cleaner.tar') | ||
| 460 | os.remove('./tests/data/cleaner.tar') | ||
| 461 | os.remove('./tests/data/clean.png') | ||
| 462 | os.remove('./tests/data/hardlink.png') | ||
