aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/package_windows_conpty.py142
-rw-r--r--scripts/test_package_windows_conpty.py186
2 files changed, 327 insertions, 1 deletions
diff --git a/scripts/package_windows_conpty.py b/scripts/package_windows_conpty.py
index 3c63312e..1877d6b0 100644
--- a/scripts/package_windows_conpty.py
+++ b/scripts/package_windows_conpty.py
@@ -49,6 +49,142 @@ def pe_machine(data: bytes) -> int:
return struct.unpack_from("<H", data, pe_offset + 4)[0]
+# Imported DLL name prefixes that identify the dynamic Microsoft C/C++
+# runtime. Matching by prefix covers release and debug variants
+# (msvcp140d.dll, vcruntime140_1.dll, ucrtbased.dll, ...), older versioned
+# runtimes (msvcr120.dll, msvcp120.dll), and the api-ms-win-crt-* Universal
+# CRT API sets. Shipping any of them would make the archive depend on a Visual
+# C++ Redistributable or on the Universal CRT that is not part of the
+# documented install contents.
+DYNAMIC_MSVC_RUNTIME_PREFIXES = (
+ "vcruntime140",
+ "msvcp",
+ "msvcr",
+ "concrt140",
+ "ucrtbase",
+ "api-ms-win-crt-",
+)
+
+# msvcrt.dll is the legacy CRT that ships with Windows and is always present,
+# so depending on it does not require the Visual C++ Redistributable.
+SYSTEM_CRT_DLLS = frozenset({"msvcrt.dll"})
+
+
+def is_dynamic_msvc_runtime(dll: str) -> bool:
+ """Return True when a PE import names a dynamic MSVC/UCRT runtime DLL."""
+ lowered = dll.lower()
+ if lowered in SYSTEM_CRT_DLLS:
+ return False
+ return lowered.startswith(DYNAMIC_MSVC_RUNTIME_PREFIXES)
+
+
+def _pe_sections(data: bytes) -> tuple[list[tuple[int, int, int]], int, int]:
+ """Return PE sections, the data-directory offset, and the image base."""
+ pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
+ number_of_sections = struct.unpack_from("<H", data, pe_offset + 6)[0]
+ optional_size = struct.unpack_from("<H", data, pe_offset + 20)[0]
+ optional_offset = pe_offset + 24
+ if optional_offset + optional_size > len(data):
+ raise ValueError("file has a truncated PE optional header")
+ magic = struct.unpack_from("<H", data, optional_offset)[0]
+ if magic == 0x20B:
+ data_directory_offset = optional_offset + 112
+ image_base = struct.unpack_from("<Q", data, optional_offset + 24)[0]
+ elif magic == 0x10B:
+ data_directory_offset = optional_offset + 96
+ image_base = struct.unpack_from("<I", data, optional_offset + 28)[0]
+ else:
+ raise ValueError(f"unsupported PE optional header magic: 0x{magic:04x}")
+
+ sections: list[tuple[int, int, int]] = []
+ section_offset = optional_offset + optional_size
+ for index in range(number_of_sections):
+ header = section_offset + index * 40
+ if header + 40 > len(data):
+ raise ValueError("file has truncated PE section headers")
+ virtual_size, virtual_address, raw_size, raw_pointer = struct.unpack_from(
+ "<IIII", data, header + 8
+ )
+ sections.append((virtual_address, max(virtual_size, raw_size), raw_pointer))
+ return sections, data_directory_offset, image_base
+
+
+def pe_imported_dlls(data: bytes) -> list[str]:
+ """Return the DLL names from the PE import and delay-import tables."""
+ if len(data) < 0x40 or data[:2] != b"MZ":
+ raise ValueError("file is not a PE image")
+ pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
+ if pe_offset + 24 > len(data) or data[pe_offset : pe_offset + 4] != b"PE\0\0":
+ raise ValueError("file has an invalid PE header")
+
+ sections, data_directory_offset, image_base = _pe_sections(data)
+
+ def rva_to_offset(rva: int) -> int | None:
+ for virtual_address, virtual_size, raw_pointer in sections:
+ if virtual_address <= rva < virtual_address + virtual_size:
+ return raw_pointer + (rva - virtual_address)
+ return None
+
+ def read_c_string(offset: int | None) -> str:
+ if offset is None or offset < 0 or offset >= len(data):
+ raise ValueError("PE import name points outside the file")
+ end = data.find(b"\0", offset)
+ if end == -1:
+ raise ValueError("PE import name is not NUL-terminated")
+ return data[offset:end].decode("ascii", errors="replace")
+
+ # Directory index 1 is the import table, index 13 is the delay-import
+ # table. Delay-import descriptors store addresses as VAs unless their
+ # attributes set the RVA flag (bit 1), so normalize those to RVAs.
+ names: list[str] = []
+ for directory_index, descriptor_size, name_field, has_attributes in (
+ (1, 20, 12, False),
+ (13, 32, 4, True),
+ ):
+ entry_offset = data_directory_offset + directory_index * 8
+ if entry_offset + 8 > len(data):
+ continue
+ directory_rva, directory_size = struct.unpack_from("<II", data, entry_offset)
+ if directory_rva == 0:
+ continue
+ directory_offset = rva_to_offset(directory_rva)
+ if directory_offset is None:
+ continue
+ # Bound each walk by the directory's declared size so bytes past the
+ # terminator are not parsed as descriptors. Fall back to the file end
+ # if a linker omits the size.
+ directory_end = (
+ min(directory_offset + directory_size, len(data))
+ if directory_size
+ else len(data)
+ )
+ position = directory_offset
+ while position + descriptor_size <= directory_end:
+ if data[position : position + descriptor_size] == b"\0" * descriptor_size:
+ break
+ name_value = struct.unpack_from("<I", data, position + name_field)[0]
+ if name_value == 0:
+ break
+ if has_attributes and not struct.unpack_from("<I", data, position)[0] & 1:
+ name_value -= image_base
+ names.append(read_c_string(rva_to_offset(name_value)))
+ position += descriptor_size
+ return names
+
+
+def validate_static_msvc_runtime(data: bytes, label: str) -> None:
+ """Reject an executable that depends on the dynamic Microsoft C runtime."""
+ offending = sorted(
+ {dll for dll in pe_imported_dlls(data) if is_dynamic_msvc_runtime(dll)}
+ )
+ if offending:
+ raise ValueError(
+ f"{label} depends on the dynamic Microsoft C/C++ runtime "
+ f"({', '.join(offending)}); the Windows archive must link the CRT "
+ "statically so it runs on a clean Windows install"
+ )
+
+
def validate_nuspec(archive: zipfile.ZipFile, package: dict[str, Any]) -> None:
nuspec_name = f"{package['id']}.nuspec"
try:
@@ -124,6 +260,8 @@ def stage_bundle(
if not herdr_exe.is_file():
raise ValueError(f"Herdr executable does not exist: {herdr_exe}")
+ validate_static_msvc_runtime(herdr_exe.read_bytes(), herdr_exe.name)
+
acquire_package(metadata["package"], package_path)
bundle = metadata["bundles"][architecture]
metadata_root = metadata_path.resolve().parent
@@ -196,6 +334,10 @@ def validate_stage(metadata_path: Path, architecture: str, stage_dir: Path) -> N
)
if (stage_dir / MARKER_PATH).read_bytes() != marker_data(metadata, architecture):
raise ValueError("bundle marker does not match pinned ConPTY metadata")
+ # The executable is not hash-pinned (it changes every build), so re-check
+ # it here to cover a direct archive of an existing stage or a swap after
+ # staging.
+ validate_static_msvc_runtime((stage_dir / "herdr.exe").read_bytes(), "herdr.exe")
for item in metadata["bundles"][architecture]["files"]:
path = stage_dir / PurePosixPath(item["destination"])
actual_hash = sha256_file(path)
diff --git a/scripts/test_package_windows_conpty.py b/scripts/test_package_windows_conpty.py
index b92edaf3..61aec9d0 100644
--- a/scripts/test_package_windows_conpty.py
+++ b/scripts/test_package_windows_conpty.py
@@ -129,11 +129,20 @@ class WindowsConptyPackageTests(unittest.TestCase):
encoding="utf-8",
)
herdr = root / "input-herdr.exe"
- herdr.write_bytes(b"herdr")
+ herdr.write_bytes(self._pe_with_imports(0x8664, ["KERNEL32.dll"]))
stage = root / "stage"
package.stage_bundle(metadata_path, "x86_64", nupkg, herdr, stage)
package.validate_stage(metadata_path, "x86_64", stage)
+ (stage / "herdr.exe").write_bytes(
+ self._pe_with_imports(0x8664, ["MSVCP140D.dll"])
+ )
+ with self.assertRaisesRegex(ValueError, "dynamic Microsoft"):
+ package.validate_stage(metadata_path, "x86_64", stage)
+ (stage / "herdr.exe").write_bytes(
+ self._pe_with_imports(0x8664, ["KERNEL32.dll"])
+ )
+
output = root / "herdr.zip"
package.archive_bundle(metadata_path, "x86_64", stage, output)
with zipfile.ZipFile(output) as archive:
@@ -148,6 +157,107 @@ class WindowsConptyPackageTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "staged file hash mismatch"):
package.validate_stage(metadata_path, "x86_64", stage)
+ def test_pe_imported_dlls_reads_import_and_delay_import_tables(self) -> None:
+ data = self._pe_with_imports(
+ 0x8664,
+ ["KERNEL32.dll", "api-ms-win-core-synch-l1-2-0.dll"],
+ ["USER32.dll"],
+ )
+ self.assertEqual(
+ set(package.pe_imported_dlls(data)),
+ {"KERNEL32.dll", "api-ms-win-core-synch-l1-2-0.dll", "USER32.dll"},
+ )
+ pe32 = self._pe_with_imports(0x8664, ["VCRUNTIME140.dll"], magic=0x10B)
+ self.assertEqual(package.pe_imported_dlls(pe32), ["VCRUNTIME140.dll"])
+
+ def test_pe_imported_dlls_handles_va_based_delay_imports(self) -> None:
+ # VA-based delay-import descriptors predate the RVA flag and are only
+ # representable with a 32-bit image base.
+ data = self._pe_with_imports(
+ 0x8664,
+ ["KERNEL32.dll"],
+ ["MSVCP140.dll"],
+ magic=0x10B,
+ delay_imports_use_va=True,
+ )
+ self.assertEqual(
+ set(package.pe_imported_dlls(data)),
+ {"KERNEL32.dll", "MSVCP140.dll"},
+ )
+ with self.assertRaisesRegex(ValueError, "dynamic Microsoft"):
+ package.validate_static_msvc_runtime(data, "herdr.exe")
+
+ def test_pe_imported_dlls_respects_declared_directory_size(self) -> None:
+ # The second descriptor is outside the declared import-directory size,
+ # so it must not be treated as an import even though it names a
+ # dynamic runtime.
+ outside = self._pe_with_imports(
+ 0x8664,
+ ["KERNEL32.dll", "VCRUNTIME140.dll"],
+ import_directory_size=20,
+ )
+ self.assertEqual(package.pe_imported_dlls(outside), ["KERNEL32.dll"])
+ package.validate_static_msvc_runtime(outside, "herdr.exe")
+
+ inside = self._pe_with_imports(0x8664, ["KERNEL32.dll", "VCRUNTIME140.dll"])
+ with self.assertRaisesRegex(ValueError, "dynamic Microsoft"):
+ package.validate_static_msvc_runtime(inside, "herdr.exe")
+
+ def test_dynamic_msvc_runtime_imports_are_rejected(self) -> None:
+ for dll in (
+ "VCRUNTIME140.dll",
+ "vcruntime140_1.dll",
+ "vcruntime140d.dll",
+ "MSVCP140.dll",
+ "MSVCP140D.dll",
+ "MSVCR120.dll",
+ "MSVCP120.dll",
+ "concrt140d.dll",
+ "ucrtbase.dll",
+ "ucrtbased.dll",
+ "api-ms-win-crt-runtime-l1-1-0.dll",
+ ):
+ with self.subTest(dll=dll):
+ with self.assertRaisesRegex(ValueError, "dynamic Microsoft"):
+ package.validate_static_msvc_runtime(
+ self._pe_with_imports(0x8664, [dll]), "herdr.exe"
+ )
+
+ def test_static_crt_imports_are_accepted(self) -> None:
+ data = self._pe_with_imports(
+ 0x8664,
+ [
+ "KERNEL32.dll",
+ "ntdll.dll",
+ "msvcrt.dll",
+ "api-ms-win-core-synch-l1-2-0.dll",
+ ],
+ ["USER32.dll"],
+ )
+ package.validate_static_msvc_runtime(data, "herdr.exe")
+ self.assertFalse(package.is_dynamic_msvc_runtime("msvcrt.dll"))
+
+ def test_stage_rejects_dynamic_crt_executable(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ metadata_path = root / "conpty.json"
+ metadata_path.write_text(
+ json.dumps(
+ {"schema_version": 1, "bundles": {"x86_64": {}}, "notices": []}
+ ),
+ encoding="utf-8",
+ )
+ herdr = root / "herdr.exe"
+ herdr.write_bytes(self._pe_with_imports(0x8664, ["VCRUNTIME140.dll"]))
+ with self.assertRaisesRegex(ValueError, "dynamic Microsoft"):
+ package.stage_bundle(
+ metadata_path,
+ "x86_64",
+ root / "missing.nupkg",
+ herdr,
+ root / "stage",
+ )
+
@staticmethod
def _pe(machine: int, payload: bytes) -> bytes:
data = bytearray(0x80)
@@ -158,6 +268,80 @@ class WindowsConptyPackageTests(unittest.TestCase):
return bytes(data) + payload
@staticmethod
+ def _pe_with_imports(
+ machine: int,
+ imports: list[str],
+ delay_imports: list[str] | None = None,
+ magic: int = 0x20B,
+ delay_imports_use_va: bool = False,
+ import_directory_size: int | None = None,
+ ) -> bytes:
+ delay_imports = delay_imports or []
+ pe_offset = 0x40
+ optional_size = 0xF0 if magic == 0x20B else 0xE0
+ data_directory_offset = 112 if magic == 0x20B else 96
+ image_base = 0x140000000 if magic == 0x20B else 0x400000
+ section_rva = 0x1000
+ section_raw = 0x400
+
+ import_size = 20 * (len(imports) + 1)
+ delay_size = 32 * (len(delay_imports) + 1) if delay_imports else 0
+ section = bytearray(import_size + delay_size)
+ name_rvas: dict[str, int] = {}
+ for name in [*imports, *delay_imports]:
+ if name not in name_rvas:
+ name_rvas[name] = section_rva + len(section)
+ section += name.encode("ascii") + b"\0"
+ for index, name in enumerate(imports):
+ struct.pack_into("<I", section, index * 20 + 12, name_rvas[name])
+ for index, name in enumerate(delay_imports):
+ offset = import_size + index * 32
+ if delay_imports_use_va:
+ # Attributes leave the RVA flag unset, so the name is a VA.
+ struct.pack_into("<I", section, offset + 4, image_base + name_rvas[name])
+ else:
+ struct.pack_into("<I", section, offset, 1)
+ struct.pack_into("<I", section, offset + 4, name_rvas[name])
+
+ data = bytearray(section_raw + len(section))
+ data[:2] = b"MZ"
+ struct.pack_into("<I", data, 0x3C, pe_offset)
+ data[pe_offset : pe_offset + 4] = b"PE\0\0"
+ struct.pack_into("<H", data, pe_offset + 4, machine)
+ struct.pack_into("<H", data, pe_offset + 6, 1)
+ struct.pack_into("<H", data, pe_offset + 20, optional_size)
+ optional_offset = pe_offset + 24
+ struct.pack_into("<H", data, optional_offset, magic)
+ if magic == 0x20B:
+ struct.pack_into("<Q", data, optional_offset + 24, image_base)
+ else:
+ struct.pack_into("<I", data, optional_offset + 28, image_base)
+ struct.pack_into("<I", data, optional_offset + data_directory_offset - 4, 16)
+ struct.pack_into(
+ "<II",
+ data,
+ optional_offset + data_directory_offset + 8,
+ section_rva,
+ import_size if import_directory_size is None else import_directory_size,
+ )
+ if delay_imports:
+ struct.pack_into(
+ "<II",
+ data,
+ optional_offset + data_directory_offset + 13 * 8,
+ section_rva + import_size,
+ delay_size,
+ )
+ section_header_offset = pe_offset + 24 + optional_size
+ data[section_header_offset : section_header_offset + 8] = b".rdata\0\0"
+ struct.pack_into("<I", data, section_header_offset + 8, len(section))
+ struct.pack_into("<I", data, section_header_offset + 12, section_rva)
+ struct.pack_into("<I", data, section_header_offset + 16, len(section))
+ struct.pack_into("<I", data, section_header_offset + 20, section_raw)
+ data[section_raw : section_raw + len(section)] = section
+ return bytes(data)
+
+ @staticmethod
def _file(source: str, destination: str, data: bytes, machine: str) -> dict[str, str]:
return {
"source": source,