Skip to content

Commit fe88152

Browse files
kk7dsmarkgoddard
authored andcommitted
Additional qemu safety checking on base images
There is an additional way we can be fooled into using a qcow2 file with a data-file, which is uploading it as raw to glance and then booting an instance from it. Because when we go to create the ephemeral disk from a cached base image, we've lost the information about the original source's format, we probe the image's file type without a strict format specified. If a qcow2 file is listed in glance as a raw, we won't notice it until it is too late. This brings over another piece of code (proposed against) glance's format inspector which provides a safe format detection routine. This patch uses that to detect the format of and run a safety check on the base image each time we go to use it to create an ephemeral disk image from it. This also detects QED files and always marks them as unsafe as we do not support that format at all. Since we could be fooled into downloading one and passing it to qemu-img if we don't recognize it, we need to detect and reject it as unsafe. Change-Id: I4881c8cbceb30c1ff2d2b859c554e0d02043f1f5 (cherry picked from commit 5d85ffded64b194a447b63042f78960b82c544f7) (cherry picked from commit a343ed60a3d813b4c8da42cf70a7c1cfd92e6bec) (cherry picked from commit 5d18a6478dfebebeaaddd8ba54ae0e203948d9b4)
1 parent c5336be commit fe88152

File tree

5 files changed

+149
-16
lines changed

5 files changed

+149
-16
lines changed

nova/image/format_inspector.py

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,23 @@ def safety_check(self):
368368
not self.has_unknown_features)
369369

370370

371+
class QEDInspector(FileInspector):
372+
def __init__(self, tracing=False):
373+
super().__init__(tracing)
374+
self.new_region('header', CaptureRegion(0, 512))
375+
376+
@property
377+
def format_match(self):
378+
if not self.region('header').complete:
379+
return False
380+
return self.region('header').data.startswith(b'QED\x00')
381+
382+
def safety_check(self):
383+
# QED format is not supported by anyone, but we want to detect it
384+
# and mark it as just always unsafe.
385+
return False
386+
387+
371388
# The VHD (or VPC as QEMU calls it) format consists of a big-endian
372389
# 512-byte "footer" at the beginning of the file with various
373390
# information, most of which does not matter to us:
@@ -871,19 +888,52 @@ def close(self):
871888
self._source.close()
872889

873890

891+
ALL_FORMATS = {
892+
'raw': FileInspector,
893+
'qcow2': QcowInspector,
894+
'vhd': VHDInspector,
895+
'vhdx': VHDXInspector,
896+
'vmdk': VMDKInspector,
897+
'vdi': VDIInspector,
898+
'qed': QEDInspector,
899+
}
900+
901+
874902
def get_inspector(format_name):
875903
"""Returns a FormatInspector class based on the given name.
876904
877905
:param format_name: The name of the disk_format (raw, qcow2, etc).
878906
:returns: A FormatInspector or None if unsupported.
879907
"""
880-
formats = {
881-
'raw': FileInspector,
882-
'qcow2': QcowInspector,
883-
'vhd': VHDInspector,
884-
'vhdx': VHDXInspector,
885-
'vmdk': VMDKInspector,
886-
'vdi': VDIInspector,
887-
}
888-
889-
return formats.get(format_name)
908+
909+
return ALL_FORMATS.get(format_name)
910+
911+
912+
def detect_file_format(filename):
913+
"""Attempts to detect the format of a file.
914+
915+
This runs through a file one time, running all the known inspectors in
916+
parallel. It stops reading the file once one of them matches or all of
917+
them are sure they don't match.
918+
919+
Returns the FileInspector that matched, if any. None if 'raw'.
920+
"""
921+
inspectors = {k: v() for k, v in ALL_FORMATS.items()}
922+
with open(filename, 'rb') as f:
923+
for chunk in chunked_reader(f):
924+
for format, inspector in list(inspectors.items()):
925+
try:
926+
inspector.eat_chunk(chunk)
927+
except ImageFormatError:
928+
# No match, so stop considering this format
929+
inspectors.pop(format)
930+
continue
931+
if (inspector.format_match and inspector.complete and
932+
format != 'raw'):
933+
# First complete match (other than raw) wins
934+
return inspector
935+
if all(i.complete for i in inspectors.values()):
936+
# If all the inspectors are sure they are not a match, avoid
937+
# reading to the end of the file to settle on 'raw'.
938+
break
939+
return inspectors['raw']

nova/tests/unit/virt/libvirt/test_driver.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13793,10 +13793,11 @@ def test_create_images_and_backing_images_exist(
1379313793
'/fake/instance/dir', disk_info)
1379413794
self.assertFalse(mock_fetch_image.called)
1379513795

13796+
@mock.patch('nova.image.format_inspector.detect_file_format')
1379613797
@mock.patch('nova.privsep.path.utime')
1379713798
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
1379813799
def test_create_images_and_backing_ephemeral_gets_created(
13799-
self, mock_create_cow_image, mock_utime):
13800+
self, mock_create_cow_image, mock_utime, mock_detect):
1380013801
drvr = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False)
1380113802

1380213803
base_dir = os.path.join(CONF.instances_path,
@@ -15532,11 +15533,13 @@ def test_create_ephemeral_specified_fs(self, fake_mkfs):
1553215533
fake_mkfs.assert_has_calls([mock.call('ext4', '/dev/something',
1553315534
'myVol')])
1553415535

15536+
@mock.patch('nova.image.format_inspector.detect_file_format')
1553515537
@mock.patch('nova.privsep.path.utime')
1553615538
@mock.patch('nova.virt.libvirt.utils.fetch_image')
1553715539
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
1553815540
def test_create_ephemeral_specified_fs_not_valid(
15539-
self, mock_create_cow_image, mock_fetch_image, mock_utime):
15541+
self, mock_create_cow_image, mock_fetch_image, mock_utime,
15542+
mock_detect):
1554015543
CONF.set_override('default_ephemeral_format', 'ext4')
1554115544
ephemerals = [{'device_type': 'disk',
1554215545
'disk_bus': 'virtio',

nova/tests/unit/virt/libvirt/test_imagebackend.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -522,13 +522,15 @@ def test_cache_template_exists(self, mock_exists):
522522

523523
mock_exists.assert_has_calls(exist_calls)
524524

525+
@mock.patch('nova.image.format_inspector.detect_file_format')
525526
@mock.patch.object(imagebackend.utils, 'synchronized')
526527
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
527528
@mock.patch.object(os.path, 'exists', side_effect=[])
528529
@mock.patch.object(imagebackend.Image, 'verify_base_size')
529530
@mock.patch('nova.privsep.path.utime')
530531
def test_create_image(
531-
self, mock_utime, mock_verify, mock_exist, mock_create, mock_sync
532+
self, mock_utime, mock_verify, mock_exist, mock_create, mock_sync,
533+
mock_detect_format
532534
):
533535
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
534536
fn = mock.MagicMock()
@@ -549,15 +551,19 @@ def test_create_image(
549551
mock_exist.assert_has_calls(exist_calls)
550552
self.assertTrue(mock_sync.called)
551553
mock_utime.assert_called()
554+
mock_detect_format.assert_called_once()
555+
mock_detect_format.return_value.safety_check.assert_called_once_with()
552556

557+
@mock.patch('nova.image.format_inspector.detect_file_format')
553558
@mock.patch.object(imagebackend.utils, 'synchronized')
554559
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
555560
@mock.patch.object(imagebackend.disk, 'extend')
556561
@mock.patch.object(os.path, 'exists', side_effect=[])
557562
@mock.patch.object(imagebackend.Qcow2, 'get_disk_size')
558563
@mock.patch('nova.privsep.path.utime')
559564
def test_create_image_too_small(self, mock_utime, mock_get, mock_exist,
560-
mock_extend, mock_create, mock_sync):
565+
mock_extend, mock_create, mock_sync,
566+
mock_detect_format):
561567
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
562568
mock_get.return_value = self.SIZE
563569
fn = mock.MagicMock()
@@ -574,7 +580,9 @@ def test_create_image_too_small(self, mock_utime, mock_get, mock_exist,
574580
self.assertTrue(mock_sync.called)
575581
self.assertFalse(mock_create.called)
576582
self.assertFalse(mock_extend.called)
583+
mock_detect_format.assert_called_once()
577584

585+
@mock.patch('nova.image.format_inspector.detect_file_format')
578586
@mock.patch.object(imagebackend.utils, 'synchronized')
579587
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
580588
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
@@ -586,7 +594,8 @@ def test_create_image_too_small(self, mock_utime, mock_get, mock_exist,
586594
def test_generate_resized_backing_files(self, mock_utime, mock_copy,
587595
mock_verify, mock_exist,
588596
mock_extend, mock_get,
589-
mock_create, mock_sync):
597+
mock_create, mock_sync,
598+
mock_detect_format):
590599
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
591600
mock_get.return_value = self.QCOW2_BASE
592601
fn = mock.MagicMock()
@@ -613,7 +622,9 @@ def test_generate_resized_backing_files(self, mock_utime, mock_copy,
613622
self.assertTrue(mock_sync.called)
614623
self.assertFalse(mock_create.called)
615624
mock_utime.assert_called()
625+
mock_detect_format.assert_called_once()
616626

627+
@mock.patch('nova.image.format_inspector.detect_file_format')
617628
@mock.patch.object(imagebackend.utils, 'synchronized')
618629
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
619630
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
@@ -624,7 +635,8 @@ def test_generate_resized_backing_files(self, mock_utime, mock_copy,
624635
def test_qcow2_exists_and_has_no_backing_file(self, mock_utime,
625636
mock_verify, mock_exist,
626637
mock_extend, mock_get,
627-
mock_create, mock_sync):
638+
mock_create, mock_sync,
639+
mock_detect_format):
628640
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
629641
mock_get.return_value = None
630642
fn = mock.MagicMock()
@@ -645,6 +657,31 @@ def test_qcow2_exists_and_has_no_backing_file(self, mock_utime,
645657
self.assertTrue(mock_sync.called)
646658
self.assertFalse(mock_create.called)
647659
self.assertFalse(mock_extend.called)
660+
mock_detect_format.assert_called_once()
661+
662+
@mock.patch('nova.image.format_inspector.detect_file_format')
663+
@mock.patch.object(imagebackend.utils, 'synchronized')
664+
@mock.patch('nova.virt.libvirt.utils.create_image')
665+
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
666+
@mock.patch.object(imagebackend.disk, 'extend')
667+
@mock.patch.object(os.path, 'exists', side_effect=[])
668+
@mock.patch.object(imagebackend.Image, 'verify_base_size')
669+
def test_qcow2_exists_and_fails_safety_check(self,
670+
mock_verify, mock_exist,
671+
mock_extend, mock_get,
672+
mock_create, mock_sync,
673+
mock_detect_format):
674+
mock_detect_format.return_value.safety_check.return_value = False
675+
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
676+
mock_get.return_value = None
677+
fn = mock.MagicMock()
678+
mock_exist.side_effect = [False, True, False, True, True]
679+
image = self.image_class(self.INSTANCE, self.NAME)
680+
681+
self.assertRaises(exception.InvalidDiskInfo,
682+
image.create_image, fn, self.TEMPLATE_PATH,
683+
self.SIZE)
684+
mock_verify.assert_not_called()
648685

649686
def test_resolve_driver_format(self):
650687
image = self.image_class(self.INSTANCE, self.NAME)

nova/virt/libvirt/imagebackend.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import nova.conf
3535
from nova import exception
3636
from nova.i18n import _
37+
from nova.image import format_inspector
3738
from nova.image import glance
3839
import nova.privsep.libvirt
3940
import nova.privsep.path
@@ -637,6 +638,20 @@ def create_qcow2_image(base, target, size):
637638
if not os.path.exists(base):
638639
prepare_template(target=base, *args, **kwargs)
639640

641+
# NOTE(danms): We need to perform safety checks on the base image
642+
# before we inspect it for other attributes. We do this each time
643+
# because additional safety checks could have been added since we
644+
# downloaded the image.
645+
if not CONF.workarounds.disable_deep_image_inspection:
646+
inspector = format_inspector.detect_file_format(base)
647+
if not inspector.safety_check():
648+
LOG.warning('Base image %s failed safety check', base)
649+
# NOTE(danms): This is the same exception as would be raised
650+
# by qemu_img_info() if the disk format was unreadable or
651+
# otherwise unsuitable.
652+
raise exception.InvalidDiskInfo(
653+
reason=_('Base image failed safety check'))
654+
640655
# NOTE(ankit): Update the mtime of the base file so the image
641656
# cache manager knows it is in use.
642657
_update_utime_ignore_eacces(base)

nova/virt/libvirt/utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from nova import context as nova_context
3535
from nova import exception
3636
from nova.i18n import _
37+
from nova.image import format_inspector
3738
from nova import objects
3839
from nova.objects import fields as obj_fields
3940
import nova.privsep.fs
@@ -139,7 +140,34 @@ def create_cow_image(
139140
base_cmd = ['qemu-img', 'create', '-f', 'qcow2']
140141
cow_opts = []
141142
if backing_file:
143+
# NOTE(danms): We need to perform safety checks on the base image
144+
# before we inspect it for other attributes. We do this each time
145+
# because additional safety checks could have been added since we
146+
# downloaded the image.
147+
if not CONF.workarounds.disable_deep_image_inspection:
148+
inspector = format_inspector.detect_file_format(backing_file)
149+
if not inspector.safety_check():
150+
LOG.warning('Base image %s failed safety check', backing_file)
151+
# NOTE(danms): This is the same exception as would be raised
152+
# by qemu_img_info() if the disk format was unreadable or
153+
# otherwise unsuitable.
154+
raise exception.InvalidDiskInfo(
155+
reason=_('Base image failed safety check'))
156+
142157
base_details = images.qemu_img_info(backing_file)
158+
if base_details.backing_file is not None:
159+
LOG.warning('Base image %s failed safety check', backing_file)
160+
raise exception.InvalidDiskInfo(
161+
reason=_('Base image failed safety check'))
162+
try:
163+
data_file = base_details.format_specific['data']['data-file']
164+
except (KeyError, TypeError, AttributeError):
165+
data_file = None
166+
if data_file is not None:
167+
LOG.warning('Base image %s failed safety check', backing_file)
168+
raise exception.InvalidDiskInfo(
169+
reason=_('Base image failed safety check'))
170+
143171
cow_opts += ['backing_file=%s' % backing_file]
144172
cow_opts += ['backing_fmt=%s' % base_details.file_format]
145173
else:

0 commit comments

Comments
 (0)