Skip to content

Commit 9e10ac2

Browse files
kk7dsgibizer
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 b1b88bf) (cherry picked from commit 8a0d5f2) (cherry picked from commit 0269234)
1 parent 67e5376 commit 9e10ac2

File tree

6 files changed

+186
-19
lines changed

6 files changed

+186
-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
@@ -14497,10 +14497,11 @@ def test_create_images_and_backing_images_exist(
1449714497
'/fake/instance/dir', disk_info)
1449814498
self.assertFalse(mock_fetch_image.called)
1449914499

14500+
@mock.patch('nova.image.format_inspector.detect_file_format')
1450014501
@mock.patch('nova.privsep.path.utime')
1450114502
@mock.patch('nova.virt.libvirt.utils.create_image')
1450214503
def test_create_images_and_backing_ephemeral_gets_created(
14503-
self, mock_create_cow_image, mock_utime):
14504+
self, mock_create_cow_image, mock_utime, mock_detect):
1450414505
drvr = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False)
1450514506

1450614507
base_dir = os.path.join(CONF.instances_path,
@@ -16240,11 +16241,13 @@ def test_create_ephemeral_specified_fs(self, fake_mkfs):
1624016241
fake_mkfs.assert_has_calls([mock.call('ext4', '/dev/something',
1624116242
'myVol')])
1624216243

16244+
@mock.patch('nova.image.format_inspector.detect_file_format')
1624316245
@mock.patch('nova.privsep.path.utime')
1624416246
@mock.patch('nova.virt.libvirt.utils.fetch_image')
1624516247
@mock.patch('nova.virt.libvirt.utils.create_image')
1624616248
def test_create_ephemeral_specified_fs_not_valid(
16247-
self, mock_create_cow_image, mock_fetch_image, mock_utime):
16249+
self, mock_create_cow_image, mock_fetch_image, mock_utime,
16250+
mock_detect):
1624816251
CONF.set_override('default_ephemeral_format', 'ext4')
1624916252
ephemerals = [{'device_type': 'disk',
1625016253
'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: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,29 @@ def test_valid_hostname_bad(self):
107107
@mock.patch('tempfile.NamedTemporaryFile')
108108
@mock.patch('oslo_concurrency.processutils.execute')
109109
@mock.patch('nova.virt.images.qemu_img_info')
110+
@mock.patch('nova.image.format_inspector.detect_file_format')
110111
def _test_create_image(
111-
self, path, disk_format, disk_size, mock_info, mock_execute,
112-
mock_ntf, backing_file=None, encryption=None
112+
self, path, disk_format, disk_size, mock_detect, mock_info,
113+
mock_execute, mock_ntf, backing_file=None, encryption=None,
114+
safety_check=True
113115
):
116+
if isinstance(backing_file, dict):
117+
backing_info = backing_file
118+
backing_file = backing_info.pop('file', None)
119+
else:
120+
backing_info = {}
121+
backing_backing_file = backing_info.pop('backing_file', None)
122+
114123
mock_info.return_value = mock.Mock(
115124
file_format=mock.sentinel.backing_fmt,
116125
cluster_size=mock.sentinel.cluster_size,
126+
backing_file=backing_backing_file,
127+
format_specific=backing_info,
117128
)
118129
fh = mock_ntf.return_value.__enter__.return_value
119130

131+
mock_detect.return_value.safety_check.return_value = safety_check
132+
120133
libvirt_utils.create_image(
121134
path, disk_format, disk_size, backing_file=backing_file,
122135
encryption=encryption,
@@ -130,7 +143,7 @@ def _test_create_image(
130143
mock_info.assert_called_once_with(backing_file)
131144
cow_opts = [
132145
'-o',
133-
f'backing_file={mock.sentinel.backing_file},'
146+
f'backing_file={backing_file},'
134147
f'backing_fmt={mock.sentinel.backing_fmt},'
135148
f'cluster_size={mock.sentinel.cluster_size}',
136149
]
@@ -166,6 +179,8 @@ def _test_create_image(
166179
expected_args += (disk_size,)
167180

168181
self.assertEqual([(expected_args,)], mock_execute.call_args_list)
182+
if backing_file:
183+
mock_detect.return_value.safety_check.assert_called_once_with()
169184

170185
def test_create_image_raw(self):
171186
self._test_create_image('/some/path', 'raw', '10G')
@@ -181,6 +196,25 @@ def test_create_image_backing_file(self):
181196
backing_file=mock.sentinel.backing_file,
182197
)
183198

199+
def test_create_image_base_has_backing_file(self):
200+
self.assertRaises(
201+
exception.InvalidDiskInfo,
202+
self._test_create_image,
203+
'/some/stuff', 'qcow2', '1234567891234',
204+
backing_file={'file': mock.sentinel.backing_file,
205+
'backing_file': mock.sentinel.backing_backing_file},
206+
)
207+
208+
def test_create_image_base_has_data_file(self):
209+
self.assertRaises(
210+
exception.InvalidDiskInfo,
211+
self._test_create_image,
212+
'/some/stuff', 'qcow2', '1234567891234',
213+
backing_file={'file': mock.sentinel.backing_file,
214+
'backing_file': mock.sentinel.backing_backing_file,
215+
'data': {'data-file': mock.sentinel.data_file}},
216+
)
217+
184218
def test_create_image_size_none(self):
185219
self._test_create_image(
186220
'/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
@@ -661,6 +662,20 @@ def create_qcow2_image(base, target, size):
661662
if not os.path.exists(base):
662663
prepare_template(target=base, *args, **kwargs)
663664

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

nova/virt/libvirt/utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from nova import context as nova_context
3636
from nova import exception
3737
from nova.i18n import _
38+
from nova.image import format_inspector
3839
from nova import objects
3940
from nova.objects import fields as obj_fields
4041
import nova.privsep.fs
@@ -147,7 +148,34 @@ def create_image(
147148
]
148149

149150
if backing_file:
151+
# NOTE(danms): We need to perform safety checks on the base image
152+
# before we inspect it for other attributes. We do this each time
153+
# because additional safety checks could have been added since we
154+
# downloaded the image.
155+
if not CONF.workarounds.disable_deep_image_inspection:
156+
inspector = format_inspector.detect_file_format(backing_file)
157+
if not inspector.safety_check():
158+
LOG.warning('Base image %s failed safety check', backing_file)
159+
# NOTE(danms): This is the same exception as would be raised
160+
# by qemu_img_info() if the disk format was unreadable or
161+
# otherwise unsuitable.
162+
raise exception.InvalidDiskInfo(
163+
reason=_('Base image failed safety check'))
164+
150165
base_details = images.qemu_img_info(backing_file)
166+
if base_details.backing_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+
try:
171+
data_file = base_details.format_specific['data']['data-file']
172+
except (KeyError, TypeError, AttributeError):
173+
data_file = None
174+
if data_file is not None:
175+
LOG.warning('Base image %s failed safety check', backing_file)
176+
raise exception.InvalidDiskInfo(
177+
reason=_('Base image failed safety check'))
178+
151179
cow_opts = [
152180
f'backing_file={backing_file}',
153181
f'backing_fmt={base_details.file_format}'

0 commit comments

Comments
 (0)