Skip to content

Commit 303c2c9

Browse files
kk7dsElod Illes
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. Conflicts: nova/tests/unit/virt/libvirt/test_utils.py nova/virt/libvirt/utils.py NOTE(elod.illes): conflicts are due to encryption support adding patch I5d6d2a7b03b5ace0826af80c4004de852579ff12 was introduced in zed. Change-Id: I4881c8cbceb30c1ff2d2b859c554e0d02043f1f5 (cherry picked from commit b1b88bf) (cherry picked from commit 8a0d5f2) (cherry picked from commit 0269234) (cherry picked from commit 9e10ac2)
1 parent da352ed commit 303c2c9

File tree

6 files changed

+185
-19
lines changed

6 files changed

+185
-19
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
@@ -14275,10 +14275,11 @@ def test_create_images_and_backing_images_exist(
1427514275
'/fake/instance/dir', disk_info)
1427614276
self.assertFalse(mock_fetch_image.called)
1427714277

14278+
@mock.patch('nova.image.format_inspector.detect_file_format')
1427814279
@mock.patch('nova.privsep.path.utime')
1427914280
@mock.patch('nova.virt.libvirt.utils.create_image')
1428014281
def test_create_images_and_backing_ephemeral_gets_created(
14281-
self, mock_create_cow_image, mock_utime):
14282+
self, mock_create_cow_image, mock_utime, mock_detect):
1428214283
drvr = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False)
1428314284

1428414285
base_dir = os.path.join(CONF.instances_path,
@@ -16018,11 +16019,13 @@ def test_create_ephemeral_specified_fs(self, fake_mkfs):
1601816019
fake_mkfs.assert_has_calls([mock.call('ext4', '/dev/something',
1601916020
'myVol')])
1602016021

16022+
@mock.patch('nova.image.format_inspector.detect_file_format')
1602116023
@mock.patch('nova.privsep.path.utime')
1602216024
@mock.patch('nova.virt.libvirt.utils.fetch_image')
1602316025
@mock.patch('nova.virt.libvirt.utils.create_image')
1602416026
def test_create_ephemeral_specified_fs_not_valid(
16025-
self, mock_create_cow_image, mock_fetch_image, mock_utime):
16027+
self, mock_create_cow_image, mock_fetch_image, mock_utime,
16028+
mock_detect):
1602616029
CONF.set_override('default_ephemeral_format', 'ext4')
1602716030
ephemerals = [{'device_type': 'disk',
1602816031
'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
@@ -524,13 +524,15 @@ def test_cache_template_exists(self, mock_exists):
524524

525525
mock_exists.assert_has_calls(exist_calls)
526526

527+
@mock.patch('nova.image.format_inspector.detect_file_format')
527528
@mock.patch.object(imagebackend.utils, 'synchronized')
528529
@mock.patch('nova.virt.libvirt.utils.create_image')
529530
@mock.patch.object(os.path, 'exists', side_effect=[])
530531
@mock.patch.object(imagebackend.Image, 'verify_base_size')
531532
@mock.patch('nova.privsep.path.utime')
532533
def test_create_image(
533-
self, mock_utime, mock_verify, mock_exist, mock_create, mock_sync
534+
self, mock_utime, mock_verify, mock_exist, mock_create, mock_sync,
535+
mock_detect_format
534536
):
535537
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
536538
fn = mock.MagicMock()
@@ -551,15 +553,19 @@ def test_create_image(
551553
mock_exist.assert_has_calls(exist_calls)
552554
self.assertTrue(mock_sync.called)
553555
mock_utime.assert_called()
556+
mock_detect_format.assert_called_once()
557+
mock_detect_format.return_value.safety_check.assert_called_once_with()
554558

559+
@mock.patch('nova.image.format_inspector.detect_file_format')
555560
@mock.patch.object(imagebackend.utils, 'synchronized')
556561
@mock.patch('nova.virt.libvirt.utils.create_image')
557562
@mock.patch.object(imagebackend.disk, 'extend')
558563
@mock.patch.object(os.path, 'exists', side_effect=[])
559564
@mock.patch.object(imagebackend.Qcow2, 'get_disk_size')
560565
@mock.patch('nova.privsep.path.utime')
561566
def test_create_image_too_small(self, mock_utime, mock_get, mock_exist,
562-
mock_extend, mock_create, mock_sync):
567+
mock_extend, mock_create, mock_sync,
568+
mock_detect_format):
563569
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
564570
mock_get.return_value = self.SIZE
565571
fn = mock.MagicMock()
@@ -576,7 +582,9 @@ def test_create_image_too_small(self, mock_utime, mock_get, mock_exist,
576582
self.assertTrue(mock_sync.called)
577583
self.assertFalse(mock_create.called)
578584
self.assertFalse(mock_extend.called)
585+
mock_detect_format.assert_called_once()
579586

587+
@mock.patch('nova.image.format_inspector.detect_file_format')
580588
@mock.patch.object(imagebackend.utils, 'synchronized')
581589
@mock.patch('nova.virt.libvirt.utils.create_image')
582590
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
@@ -588,7 +596,8 @@ def test_create_image_too_small(self, mock_utime, mock_get, mock_exist,
588596
def test_generate_resized_backing_files(self, mock_utime, mock_copy,
589597
mock_verify, mock_exist,
590598
mock_extend, mock_get,
591-
mock_create, mock_sync):
599+
mock_create, mock_sync,
600+
mock_detect_format):
592601
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
593602
mock_get.return_value = self.QCOW2_BASE
594603
fn = mock.MagicMock()
@@ -615,7 +624,9 @@ def test_generate_resized_backing_files(self, mock_utime, mock_copy,
615624
self.assertTrue(mock_sync.called)
616625
self.assertFalse(mock_create.called)
617626
mock_utime.assert_called()
627+
mock_detect_format.assert_called_once()
618628

629+
@mock.patch('nova.image.format_inspector.detect_file_format')
619630
@mock.patch.object(imagebackend.utils, 'synchronized')
620631
@mock.patch('nova.virt.libvirt.utils.create_image')
621632
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
@@ -626,7 +637,8 @@ def test_generate_resized_backing_files(self, mock_utime, mock_copy,
626637
def test_qcow2_exists_and_has_no_backing_file(self, mock_utime,
627638
mock_verify, mock_exist,
628639
mock_extend, mock_get,
629-
mock_create, mock_sync):
640+
mock_create, mock_sync,
641+
mock_detect_format):
630642
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
631643
mock_get.return_value = None
632644
fn = mock.MagicMock()
@@ -647,6 +659,31 @@ def test_qcow2_exists_and_has_no_backing_file(self, mock_utime,
647659
self.assertTrue(mock_sync.called)
648660
self.assertFalse(mock_create.called)
649661
self.assertFalse(mock_extend.called)
662+
mock_detect_format.assert_called_once()
663+
664+
@mock.patch('nova.image.format_inspector.detect_file_format')
665+
@mock.patch.object(imagebackend.utils, 'synchronized')
666+
@mock.patch('nova.virt.libvirt.utils.create_image')
667+
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
668+
@mock.patch.object(imagebackend.disk, 'extend')
669+
@mock.patch.object(os.path, 'exists', side_effect=[])
670+
@mock.patch.object(imagebackend.Image, 'verify_base_size')
671+
def test_qcow2_exists_and_fails_safety_check(self,
672+
mock_verify, mock_exist,
673+
mock_extend, mock_get,
674+
mock_create, mock_sync,
675+
mock_detect_format):
676+
mock_detect_format.return_value.safety_check.return_value = False
677+
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
678+
mock_get.return_value = None
679+
fn = mock.MagicMock()
680+
mock_exist.side_effect = [False, True, False, True, True]
681+
image = self.image_class(self.INSTANCE, self.NAME)
682+
683+
self.assertRaises(exception.InvalidDiskInfo,
684+
image.create_image, fn, self.TEMPLATE_PATH,
685+
self.SIZE)
686+
mock_verify.assert_not_called()
650687

651688
def test_resolve_driver_format(self):
652689
image = self.image_class(self.INSTANCE, self.NAME)

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,27 @@ def test_valid_hostname_bad(self):
106106

107107
@mock.patch('oslo_concurrency.processutils.execute')
108108
@mock.patch('nova.virt.images.qemu_img_info')
109+
@mock.patch('nova.image.format_inspector.detect_file_format')
109110
def _test_create_image(
110-
self, path, disk_format, disk_size, mock_info, mock_execute,
111-
backing_file=None
111+
self, path, disk_format, disk_size, mock_detect, mock_info,
112+
mock_execute, backing_file=None, safety_check=True
112113
):
114+
if isinstance(backing_file, dict):
115+
backing_info = backing_file
116+
backing_file = backing_info.pop('file', None)
117+
else:
118+
backing_info = {}
119+
backing_backing_file = backing_info.pop('backing_file', None)
120+
113121
mock_info.return_value = mock.Mock(
114122
file_format=mock.sentinel.backing_fmt,
115123
cluster_size=mock.sentinel.cluster_size,
124+
backing_file=backing_backing_file,
125+
format_specific=backing_info,
116126
)
117127

128+
mock_detect.return_value.safety_check.return_value = safety_check
129+
118130
libvirt_utils.create_image(
119131
path, disk_format, disk_size, backing_file=backing_file)
120132

@@ -126,7 +138,7 @@ def _test_create_image(
126138
mock_info.assert_called_once_with(backing_file)
127139
cow_opts = [
128140
'-o',
129-
f'backing_file={mock.sentinel.backing_file},'
141+
f'backing_file={backing_file},'
130142
f'backing_fmt={mock.sentinel.backing_fmt},'
131143
f'cluster_size={mock.sentinel.cluster_size}',
132144
]
@@ -139,6 +151,8 @@ def _test_create_image(
139151
expected_args += (disk_size,)
140152

141153
self.assertEqual([(expected_args,)], mock_execute.call_args_list)
154+
if backing_file:
155+
mock_detect.return_value.safety_check.assert_called_once_with()
142156

143157
def test_create_image_raw(self):
144158
self._test_create_image('/some/path', 'raw', '10G')
@@ -154,6 +168,25 @@ def test_create_image_backing_file(self):
154168
backing_file=mock.sentinel.backing_file,
155169
)
156170

171+
def test_create_image_base_has_backing_file(self):
172+
self.assertRaises(
173+
exception.InvalidDiskInfo,
174+
self._test_create_image,
175+
'/some/stuff', 'qcow2', '1234567891234',
176+
backing_file={'file': mock.sentinel.backing_file,
177+
'backing_file': mock.sentinel.backing_backing_file},
178+
)
179+
180+
def test_create_image_base_has_data_file(self):
181+
self.assertRaises(
182+
exception.InvalidDiskInfo,
183+
self._test_create_image,
184+
'/some/stuff', 'qcow2', '1234567891234',
185+
backing_file={'file': mock.sentinel.backing_file,
186+
'backing_file': mock.sentinel.backing_backing_file,
187+
'data': {'data-file': mock.sentinel.data_file}},
188+
)
189+
157190
def test_create_image_size_none(self):
158191
self._test_create_image(
159192
'/some/stuff', 'qcow2', None,

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
@@ -660,6 +661,20 @@ def create_qcow2_image(base, target, size):
660661
if not os.path.exists(base):
661662
prepare_template(target=base, *args, **kwargs)
662663

664+
# NOTE(danms): We need to perform safety checks on the base image
665+
# before we inspect it for other attributes. We do this each time
666+
# because additional safety checks could have been added since we
667+
# downloaded the image.
668+
if not CONF.workarounds.disable_deep_image_inspection:
669+
inspector = format_inspector.detect_file_format(base)
670+
if not inspector.safety_check():
671+
LOG.warning('Base image %s failed safety check', base)
672+
# NOTE(danms): This is the same exception as would be raised
673+
# by qemu_img_info() if the disk format was unreadable or
674+
# otherwise unsuitable.
675+
raise exception.InvalidDiskInfo(
676+
reason=_('Base image failed safety check'))
677+
663678
# NOTE(ankit): Update the mtime of the base file so the image
664679
# cache manager knows it is in use.
665680
_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
@@ -132,7 +133,34 @@ def create_image(
132133
cow_opts = []
133134

134135
if backing_file:
136+
# NOTE(danms): We need to perform safety checks on the base image
137+
# before we inspect it for other attributes. We do this each time
138+
# because additional safety checks could have been added since we
139+
# downloaded the image.
140+
if not CONF.workarounds.disable_deep_image_inspection:
141+
inspector = format_inspector.detect_file_format(backing_file)
142+
if not inspector.safety_check():
143+
LOG.warning('Base image %s failed safety check', backing_file)
144+
# NOTE(danms): This is the same exception as would be raised
145+
# by qemu_img_info() if the disk format was unreadable or
146+
# otherwise unsuitable.
147+
raise exception.InvalidDiskInfo(
148+
reason=_('Base image failed safety check'))
149+
135150
base_details = images.qemu_img_info(backing_file)
151+
if base_details.backing_file is not None:
152+
LOG.warning('Base image %s failed safety check', backing_file)
153+
raise exception.InvalidDiskInfo(
154+
reason=_('Base image failed safety check'))
155+
try:
156+
data_file = base_details.format_specific['data']['data-file']
157+
except (KeyError, TypeError, AttributeError):
158+
data_file = None
159+
if data_file is not None:
160+
LOG.warning('Base image %s failed safety check', backing_file)
161+
raise exception.InvalidDiskInfo(
162+
reason=_('Base image failed safety check'))
163+
136164
cow_opts += [
137165
f'backing_file={backing_file}',
138166
f'backing_fmt={base_details.file_format}'

0 commit comments

Comments
 (0)