Skip to content

Commit 4fa22f4

Browse files
GutoVeroneziDaanHoogland
authored andcommitted
Validate QCOW2 on upload and register
1 parent 123b426 commit 4fa22f4

File tree

4 files changed

+360
-11
lines changed

4 files changed

+360
-11
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package org.apache.cloudstack.storage.formatinspector;
19+
20+
public enum Qcow2HeaderField {
21+
MAGIC(0, 4),
22+
VERSION(4, 4),
23+
BACKING_FILE_OFFSET(8, 8),
24+
BACKING_FILE_NAME_LENGTH(16, 4),
25+
CLUSTER_BITS(20, 4),
26+
SIZE(24, 8),
27+
CRYPT_METHOD(32, 4),
28+
L1_SIZE(36, 4),
29+
LI_TABLE_OFFSET(40, 8),
30+
REFCOUNT_TABLE_OFFSET(48, 8),
31+
REFCOUNT_TABLE_CLUSTERS(56, 4),
32+
NB_SNAPSHOTS(60, 4),
33+
SNAPSHOTS_OFFSET(64, 8),
34+
INCOMPATIBLE_FEATURES(72, 8);
35+
36+
private final int offset;
37+
private final int length;
38+
39+
Qcow2HeaderField(int offset, int length) {
40+
this.offset = offset;
41+
this.length = length;
42+
}
43+
44+
public int getLength() {
45+
return length;
46+
}
47+
48+
public int getOffset() {
49+
return offset;
50+
}
51+
}
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package org.apache.cloudstack.storage.formatinspector;
19+
20+
import com.cloud.utils.NumbersUtil;
21+
import org.apache.commons.lang3.ArrayUtils;
22+
import org.apache.log4j.Logger;
23+
24+
import java.io.FileInputStream;
25+
import java.io.IOException;
26+
import java.io.InputStream;
27+
import java.util.Arrays;
28+
import java.util.HashMap;
29+
import java.util.Map;
30+
import java.util.Set;
31+
32+
/**
33+
* Class to inspect QCOW2 files/objects. In our context, a QCOW2 might be a threat to the environment if it meets one of the following criteria when coming from external sources
34+
* (like registering or uploading volumes and templates):
35+
* <ul>
36+
* <li>has a backing file reference;</li>
37+
* <li>has an external data file reference;</li>
38+
* <li>has unknown incompatible features.</li>
39+
* </ul>
40+
*
41+
* The implementation was done based on the <a href="https://gitlab.com/qemu-project/qemu/-/blob/master/docs/interop/qcow2.txt"> QEMU's official interoperability documentation</a>
42+
* and on the <a href="https://review.opendev.org/c/openstack/cinder/+/923247/2/cinder/image/format_inspector.py">OpenStack's Cinder implementation for Python</a>.
43+
*/
44+
public class Qcow2Inspector {
45+
protected static Logger LOGGER = Logger.getLogger(Qcow2Inspector.class);
46+
47+
private static final byte[] QCOW_MAGIC_STRING = ArrayUtils.add("QFI".getBytes(), (byte) 0xfb);
48+
private static final int INCOMPATIBLE_FEATURES_MAX_KNOWN_BIT = 4;
49+
private static final int INCOMPATIBLE_FEATURES_MAX_KNOWN_BYTE = 0;
50+
private static final int EXTERNAL_DATA_FILE_BYTE_POSITION = 7;
51+
private static final int EXTERNAL_DATA_FILE_BIT = 2;
52+
private static final byte EXTERNAL_DATA_FILE_BITMASK = (byte) (1 << EXTERNAL_DATA_FILE_BIT);
53+
54+
private static final Set<Qcow2HeaderField> SET_OF_HEADER_FIELDS_TO_READ = Set.of(Qcow2HeaderField.MAGIC,
55+
Qcow2HeaderField.VERSION,
56+
Qcow2HeaderField.SIZE,
57+
Qcow2HeaderField.BACKING_FILE_OFFSET,
58+
Qcow2HeaderField.INCOMPATIBLE_FEATURES);
59+
60+
/**
61+
* Validates if the file is a valid and allowed QCOW2 (i.e.: does not contain external references).
62+
* @param filePath Path of the file to be validated.
63+
* @throws RuntimeException If the QCOW2 file meets one of the following criteria:
64+
* <ul>
65+
* <li>has a backing file reference;</li>
66+
* <li>has an external data file reference;</li>
67+
* <li>has unknown incompatible features.</li>
68+
* </ul>
69+
*/
70+
public static void validateQcow2File(String filePath) throws RuntimeException {
71+
LOGGER.info(String.format("Verifying if [%s] is a valid and allowed QCOW2 file .", filePath));
72+
73+
Map<String, byte[]> headerFieldsAndValues;
74+
try (InputStream inputStream = new FileInputStream(filePath)) {
75+
headerFieldsAndValues = unravelQcow2Header(inputStream, filePath);
76+
} catch (IOException ex) {
77+
throw new RuntimeException(String.format("Unable to validate file [%s] due to: ", filePath), ex);
78+
}
79+
80+
validateQcow2HeaderFields(headerFieldsAndValues, filePath);
81+
82+
LOGGER.info(String.format("[%s] is a valid and allowed QCOW2 file.", filePath));
83+
}
84+
85+
/**
86+
* Unravels the QCOW2 header in a serial fashion, iterating through the {@link Qcow2HeaderField}, reading the fields specified in
87+
* {@link Qcow2Inspector#SET_OF_HEADER_FIELDS_TO_READ} and skipping the others.
88+
* @param qcow2InputStream InputStream of the QCOW2 being unraveled.
89+
* @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
90+
* @return A map of the header fields and their values according to the {@link Qcow2Inspector#SET_OF_HEADER_FIELDS_TO_READ}.
91+
* @throws IOException If the field cannot be read or skipped.
92+
*/
93+
public static Map<String, byte[]> unravelQcow2Header(InputStream qcow2InputStream, String qcow2LogReference) throws IOException {
94+
Map<String, byte[]> result = new HashMap<>();
95+
96+
LOGGER.debug(String.format("Unraveling QCOW2 [%s] headers.", qcow2LogReference));
97+
for (Qcow2HeaderField qcow2Header : Qcow2HeaderField.values()) {
98+
if (!SET_OF_HEADER_FIELDS_TO_READ.contains(qcow2Header)) {
99+
skipHeader(qcow2InputStream, qcow2Header, qcow2LogReference);
100+
continue;
101+
}
102+
103+
byte[] headerValue = readHeader(qcow2InputStream, qcow2Header, qcow2LogReference);
104+
result.put(qcow2Header.name(), headerValue);
105+
}
106+
107+
return result;
108+
}
109+
110+
/**
111+
* Skips the field's length in the InputStream.
112+
* @param qcow2InputStream InputStream of the QCOW2 being unraveled.
113+
* @param field Field being skipped (name and length).
114+
* @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
115+
* @throws IOException If the bytes skipped do not match the field length.
116+
*/
117+
protected static void skipHeader(InputStream qcow2InputStream, Qcow2HeaderField field, String qcow2LogReference) throws IOException {
118+
LOGGER.trace(String.format("Skipping field [%s] of QCOW2 [%s].", field, qcow2LogReference));
119+
120+
if (qcow2InputStream.skip(field.getLength()) != field.getLength()) {
121+
throw new IOException(String.format("Unable to skip field [%s] of QCOW2 [%s].", field, qcow2LogReference));
122+
}
123+
}
124+
125+
/**
126+
* Reads the field's length in the InputStream.
127+
* @param qcow2InputStream InputStream of the QCOW2 being unraveled.
128+
* @param field Field being read (name and length).
129+
* @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
130+
* @throws IOException If the bytes read do not match the field length.
131+
*/
132+
protected static byte[] readHeader(InputStream qcow2InputStream, Qcow2HeaderField field, String qcow2LogReference) throws IOException {
133+
byte[] readBytes = new byte[field.getLength()];
134+
135+
LOGGER.trace(String.format("Reading field [%s] of QCOW2 [%s].", field, qcow2LogReference));
136+
if (qcow2InputStream.read(readBytes) != field.getLength()) {
137+
throw new IOException(String.format("Unable to read field [%s] of QCOW2 [%s].", field, qcow2LogReference));
138+
}
139+
140+
LOGGER.trace(String.format("Read %s as field [%s] of QCOW2 [%s].", ArrayUtils.toString(readBytes), field, qcow2LogReference));
141+
return readBytes;
142+
}
143+
144+
/**
145+
* Validates the values of the header fields {@link Qcow2HeaderField#MAGIC}, {@link Qcow2HeaderField#BACKING_FILE_OFFSET}, and {@link Qcow2HeaderField#INCOMPATIBLE_FEATURES}.
146+
* @param headerFieldsAndValues A map of the header fields and their values.
147+
* @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
148+
* @throws SecurityException If the QCOW2 does not contain the QCOW magic string or contains a backing file reference or incompatible features.
149+
*/
150+
public static void validateQcow2HeaderFields(Map<String, byte[]> headerFieldsAndValues, String qcow2LogReference) throws SecurityException{
151+
byte[] fieldValue = headerFieldsAndValues.get(Qcow2HeaderField.MAGIC.name());
152+
validateQcowMagicString(fieldValue, qcow2LogReference);
153+
154+
fieldValue = headerFieldsAndValues.get(Qcow2HeaderField.BACKING_FILE_OFFSET.name());
155+
validateAbsenceOfBackingFileReference(NumbersUtil.bytesToLong(fieldValue), qcow2LogReference);
156+
157+
fieldValue = headerFieldsAndValues.get(Qcow2HeaderField.INCOMPATIBLE_FEATURES.name());
158+
validateAbsenceOfIncompatibleFeatures(fieldValue, qcow2LogReference);
159+
}
160+
161+
/**
162+
* Verifies if the first 4 bytes of the header are the QCOW magic string. Throws an exception if not.
163+
* @param headerMagicString The first 4 bytes of the header.
164+
* @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
165+
* @throws SecurityException If the header's magic string is not the QCOW magic string.
166+
*/
167+
private static void validateQcowMagicString(byte[] headerMagicString, String qcow2LogReference) throws SecurityException {
168+
LOGGER.debug(String.format("Verifying if [%s] has a valid QCOW magic string.", qcow2LogReference));
169+
170+
if (!Arrays.equals(QCOW_MAGIC_STRING, headerMagicString)) {
171+
throw new SecurityException(String.format("[%s] is not a valid QCOW2 because its first 4 bytes are not the QCOW magic string.", qcow2LogReference));
172+
}
173+
174+
LOGGER.debug(String.format("[%s] has a valid QCOW magic string.", qcow2LogReference));
175+
}
176+
177+
/**
178+
* Verifies if the QCOW2 has a backing file and throws an exception if so.
179+
* @param backingFileOffset The backing file offset value of the QCOW2 header.
180+
* @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
181+
* @throws SecurityException If the QCOW2 has a backing file reference.
182+
*/
183+
private static void validateAbsenceOfBackingFileReference(long backingFileOffset, String qcow2LogReference) throws SecurityException {
184+
LOGGER.debug(String.format("Verifying if [%s] has a backing file reference.", qcow2LogReference));
185+
186+
if (backingFileOffset != 0) {
187+
throw new SecurityException(String.format("[%s] has a backing file reference. This can be an attack to the infrastructure; therefore, we will not accept" +
188+
" this QCOW2.", qcow2LogReference));
189+
}
190+
191+
LOGGER.debug(String.format("[%s] does not have a backing file reference.", qcow2LogReference));
192+
}
193+
194+
/**
195+
* Verifies if the QCOW2 has incompatible features and throw an exception if it has an external data file reference or unknown incompatible features.
196+
* @param incompatibleFeatures The incompatible features bytes of the QCOW2 header.
197+
* @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
198+
* @throws SecurityException If the QCOW2 has an external data file reference or unknown incompatible features.
199+
*/
200+
private static void validateAbsenceOfIncompatibleFeatures(byte[] incompatibleFeatures, String qcow2LogReference) throws SecurityException {
201+
LOGGER.debug(String.format("Verifying if [%s] has incompatible features.", qcow2LogReference));
202+
203+
if (NumbersUtil.bytesToLong(incompatibleFeatures) == 0) {
204+
LOGGER.debug(String.format("[%s] does not have incompatible features.", qcow2LogReference));
205+
return;
206+
}
207+
208+
LOGGER.debug(String.format("[%s] has incompatible features.", qcow2LogReference));
209+
210+
validateAbsenceOfExternalDataFileReference(incompatibleFeatures, qcow2LogReference);
211+
validateAbsenceOfUnknownIncompatibleFeatures(incompatibleFeatures, qcow2LogReference);
212+
}
213+
214+
/**
215+
* Verifies if the QCOW2 has an external data file reference and throw an exception if so.
216+
* @param incompatibleFeatures The incompatible features bytes of the QCOW2 header.
217+
* @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
218+
* @throws SecurityException If the QCOW2 has an external data file reference.
219+
*/
220+
private static void validateAbsenceOfExternalDataFileReference(byte[] incompatibleFeatures, String qcow2LogReference) throws SecurityException {
221+
LOGGER.debug(String.format("Verifying if [%s] has an external data file reference.", qcow2LogReference));
222+
223+
if ((incompatibleFeatures[EXTERNAL_DATA_FILE_BYTE_POSITION] & EXTERNAL_DATA_FILE_BITMASK) != 0) {
224+
throw new SecurityException(String.format("[%s] has an external data file reference. This can be an attack to the infrastructure; therefore, we will discard" +
225+
" this file.", qcow2LogReference));
226+
}
227+
228+
LOGGER.info(String.format("[%s] does not have an external data file reference.", qcow2LogReference));
229+
}
230+
231+
/**
232+
* Verifies if the QCOW2 has unknown incompatible features and throw an exception if so.
233+
* <br/><br/>
234+
* Unknown incompatible features are those with bit greater than
235+
* {@link Qcow2Inspector#INCOMPATIBLE_FEATURES_MAX_KNOWN_BIT}, which will be the represented by bytes in positions greater than
236+
* {@link Qcow2Inspector#INCOMPATIBLE_FEATURES_MAX_KNOWN_BYTE} (in Big Endian order). Therefore, we expect that those bytes are always zero. If not, an exception is thrown.
237+
* @param incompatibleFeatures The incompatible features bytes of the QCOW2 header.
238+
* @param qcow2LogReference A reference (like the filename) of the QCOW2 being unraveled to print in the logs and exceptions.
239+
* @throws SecurityException If the QCOW2 has unknown incompatible features.
240+
*/
241+
private static void validateAbsenceOfUnknownIncompatibleFeatures(byte[] incompatibleFeatures, String qcow2LogReference) throws SecurityException {
242+
LOGGER.debug(String.format("Verifying if [%s] has unknown incompatible features [%s].", qcow2LogReference, ArrayUtils.toString(incompatibleFeatures)));
243+
244+
for (int byteNum = incompatibleFeatures.length - 1; byteNum >= 0; byteNum--) {
245+
int bytePosition = incompatibleFeatures.length - 1 - byteNum;
246+
LOGGER.trace(String.format("Looking for unknown incompatible feature bit in position [%s].", bytePosition));
247+
248+
byte bitmask = 0;
249+
if (byteNum == INCOMPATIBLE_FEATURES_MAX_KNOWN_BYTE) {
250+
bitmask = ((1 << INCOMPATIBLE_FEATURES_MAX_KNOWN_BIT) - 1);
251+
}
252+
253+
LOGGER.trace(String.format("Bitmask for byte in position [%s] is [%s].", bytePosition, Integer.toBinaryString(bitmask)));
254+
255+
int featureBit = incompatibleFeatures[bytePosition] & ~bitmask;
256+
if (featureBit != 0) {
257+
throw new SecurityException(String.format("Found unknown incompatible feature bit [%s] in byte [%s] of [%s]. This can be an attack to the infrastructure; " +
258+
"therefore, we will discard this QCOW2.", featureBit, bytePosition + Qcow2HeaderField.INCOMPATIBLE_FEATURES.getOffset(), qcow2LogReference));
259+
}
260+
261+
LOGGER.trace(String.format("Did not find unknown incompatible feature in position [%s].", bytePosition));
262+
}
263+
264+
LOGGER.info(String.format("[%s] does not have unknown incompatible features.", qcow2LogReference));
265+
}
266+
267+
}

services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/resource/NfsSecondaryStorageResource.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
import org.apache.cloudstack.storage.command.browser.ListDataStoreObjectsCommand;
7272
import org.apache.cloudstack.storage.configdrive.ConfigDrive;
7373
import org.apache.cloudstack.storage.configdrive.ConfigDriveBuilder;
74+
import org.apache.cloudstack.storage.formatinspector.Qcow2Inspector;
7475
import org.apache.cloudstack.storage.template.DownloadManager;
7576
import org.apache.cloudstack.storage.template.DownloadManagerImpl;
7677
import org.apache.cloudstack.storage.template.UploadEntity;
@@ -3482,8 +3483,19 @@ public String postUpload(String uuid, String filename, long processTimeout) {
34823483
return result;
34833484
}
34843485

3486+
String finalFilename = resourcePath + "/" + templateFilename;
3487+
3488+
if (ImageStoreUtil.isCorrectExtension(finalFilename, "qcow2")) {
3489+
try {
3490+
Qcow2Inspector.validateQcow2File(finalFilename);
3491+
} catch (RuntimeException e) {
3492+
s_logger.error(String.format("Uploaded file [%s] is not a valid QCOW2.", finalFilename), e);
3493+
return "The uploaded file is not a valid QCOW2. Ask the administrator to check the logs for more details.";
3494+
}
3495+
}
3496+
34853497
// Set permissions for the downloaded template
3486-
File downloadedTemplate = new File(resourcePath + "/" + templateFilename);
3498+
File downloadedTemplate = new File(finalFilename);
34873499
_storage.setWorldReadableAndWriteable(downloadedTemplate);
34883500

34893501
// Set permissions for template/volume.properties

0 commit comments

Comments
 (0)