From d6a9829bd109d8654fa8eb1a0576da1d9ba1cf6f Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Fri, 10 Jul 2026 17:25:01 +0100 Subject: [PATCH] Add pure-Ruby ad-hoc signing - remove the `/usr/bin/codesign` dependency from `MachO.codesign!` so Homebrew can repair bottle signatures on Apple Silicon using Ruby - parse and build XNU-compatible `SuperBlob` and `CodeDirectory` data so replacement signatures can retain security-relevant metadata - add or replace `LC_CODE_SIGNATURE`, resize `__LINKEDIT` and rebuild fat offsets so every page hash describes the final on-disk layout - use SHA-256 with legacy SHA-1 agility only for old deployment targets because XNU accepts the open `ld64` ad-hoc format without an identity - preserve non-linker requirements, entitlements, flags and runtime metadata while deliberately replacing linker-generated metadata - reject malformed `CodeDirectory` hash arrays before exposing offsets and validate before in-place writes to retain hard links and leave failed inputs unchanged - cover thin, fat, byte-order, metadata and macOS verification paths with regression tests, and document the compatibility boundaries --- README.md | 39 +++ lib/macho.rb | 23 +- lib/macho/code_signing.rb | 576 +++++++++++++++++++++++++++++++++++++ lib/macho/fat_file.rb | 38 +++ lib/macho/load_commands.rb | 20 ++ lib/macho/macho_file.rb | 10 + test/test_code_signing.rb | 293 +++++++++++++++++++ 7 files changed, 987 insertions(+), 12 deletions(-) create mode 100644 lib/macho/code_signing.rb create mode 100644 test/test_code_signing.rb diff --git a/README.md b/README.md index 3af9ff586..d82e1b5f6 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,49 @@ lc_vers = file[:LC_VERSION_MIN_MACOSX].first puts lc_vers.version_string # => "10.10.0" ``` +### Ad-hoc code signing + +Changing a Mach-O load command invalidates any existing code signature. This is +especially important when Homebrew pours bottles on Apple Silicon, where native +code must remain signed after its paths are rewritten. `MachO.codesign!` creates +the required ad-hoc signature in Ruby instead of invoking `/usr/bin/codesign`: + +```ruby +MachO.codesign!("/path/to/my/binary") +``` + +The implementation follows the public structures used by +[XNU](https://github.com/apple-oss-distributions/xnu/blob/main/osfmk/kern/cs_blobs.h) +and [ld64](https://github.com/apple-oss-distributions/ld64). For each thin +Mach-O slice it adds or replaces `LC_CODE_SIGNATURE`, resizes `__LINKEDIT` then +hashes the final pre-signature bytes in 4 KiB pages. It emits a SHA-256 +CodeDirectory, adding a SHA-1 alternate only when the declared deployment target +requires legacy hash agility. Fat binaries are signed one slice at a time then +laid out again with updated architecture offsets and sizes. + +When replacing a non-linker signature, the signer preserves its requirements, +entitlements, flags, runtime version and executable-segment flags. Linker +signatures use fresh ad-hoc metadata, matching Apple's replacement behaviour. +Code-signing blobs use their mandated big-endian representation independently +of the Mach-O byte order, while the load command retains the slice byte order. + +The complete signature is built and validated before the file is written. This +leaves the on-disk file unchanged on validation errors, while the final in-place +write preserves its inode, mode and hard links. Adding a missing load command +requires 16 bytes of existing header padding; ruby-macho raises +`MachO::CodeSigningError` rather than moving segments when that space is absent. +Only ad-hoc signing is provided: certificate identities, Developer ID signing, +notarisation and policy assessment remain outside ruby-macho's scope. See +[issue #262](https://github.com/Homebrew/ruby-macho/issues/262) for the original +design discussion. + ### What works? * Reading data from x86/x86_64/arm64/PPC Mach-O files (other architectures are unsupported, but may work) * Changing the IDs of Mach-O and Fat dylibs * Changing install names in Mach-O and Fat files * Adding, deleting, and modifying rpaths. +* Parsing embedded code signatures and applying ad-hoc signatures in pure Ruby. ### What needs to be done? @@ -73,6 +110,8 @@ overcommit --install * Constants were taken from Apple, Inc's [`loader.h` in `cctools/include/mach-o`](https://opensource.apple.com/source/cctools/cctools-973.0.1/include/mach-o/loader.h.auto.html). (Apple Public Source License 2.0). +* Code-signing constants and structures follow Apple, Inc's +[`cs_blobs.h` in XNU](https://github.com/apple-oss-distributions/xnu/blob/main/osfmk/kern/cs_blobs.h). * Binary files used for testing were taken from The LLVM Project. ([Apache License v2.0 with LLVM Exceptions](test/bin/llvm/LICENSE.txt)). ### License diff --git a/lib/macho.rb b/lib/macho.rb index 97da525bd..a3c4a6891 100644 --- a/lib/macho.rb +++ b/lib/macho.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true -require "open3" - require_relative "macho/utils" require_relative "macho/structure" require_relative "macho/view" require_relative "macho/headers" +require_relative "macho/code_signing" require_relative "macho/load_commands" require_relative "macho/sections" require_relative "macho/macho_file" @@ -42,20 +41,20 @@ def self.open(filename) file end - # Signs the dylib using an ad-hoc identity. - # Necessary after making any changes to a dylib, since otherwise - # changing a signed file invalidates its signature. + # Signs a thin or fat Mach-O using an ad-hoc identity. + # Necessary after changing signed Mach-O data because the signature covers + # the header, load commands and all bytes preceding the signature. # @param filename [String] the file being opened # @return [void] - # @raise [ModificationError] if the operation fails + # @raise [CodeSigningError] if the operation fails def self.codesign!(filename) - raise ArgumentError, "codesign binary is not available on Linux" if RUBY_PLATFORM !~ /darwin/ raise ArgumentError, "#{filename}: no such file" unless File.file?(filename) - _, _, status = Open3.capture3("codesign", "--sign", "-", "--force", - "--preserve-metadata=entitlements,requirements,flags,runtime", - filename) - - raise CodeSigningError, "#{filename}: signing failed!" unless status.success? + file = MachO.open(filename) + file.codesign! + file.write! + nil + rescue MachOError => e + raise CodeSigningError, "#{filename}: signing failed: #{e.message}" end end diff --git a/lib/macho/code_signing.rb b/lib/macho/code_signing.rb new file mode 100644 index 000000000..0455176ec --- /dev/null +++ b/lib/macho/code_signing.rb @@ -0,0 +1,576 @@ +# frozen_string_literal: true + +require "digest/sha1" +require "digest/sha2" + +module MachO + # Structures and helpers for embedded Mach-O code signatures. + module CodeSigning + CSMAGIC_REQUIREMENT = 0xfade0c00 + CSMAGIC_REQUIREMENTS = 0xfade0c01 + CSMAGIC_CODEDIRECTORY = 0xfade0c02 + CSMAGIC_BLOBWRAPPER = 0xfade0b01 + CSMAGIC_EMBEDDED_SIGNATURE = 0xfade0cc0 + CSMAGIC_DETACHED_SIGNATURE = 0xfade0cc1 + CSMAGIC_EMBEDDED_ENTITLEMENTS = 0xfade7171 + CSMAGIC_EMBEDDED_DER_ENTITLEMENTS = 0xfade7172 + CSMAGIC_EMBEDDED_LAUNCH_CONSTRAINT = 0xfade8181 + CSMAGIC_ENTITLEMENT = CSMAGIC_EMBEDDED_ENTITLEMENTS + CSMAGIC_ENTITLEMENTDER = CSMAGIC_EMBEDDED_DER_ENTITLEMENTS + + CS_MAGICS = { + CSMAGIC_REQUIREMENT => :CSMAGIC_REQUIREMENT, + CSMAGIC_REQUIREMENTS => :CSMAGIC_REQUIREMENTS, + CSMAGIC_CODEDIRECTORY => :CSMAGIC_CODEDIRECTORY, + CSMAGIC_BLOBWRAPPER => :CSMAGIC_BLOBWRAPPER, + CSMAGIC_EMBEDDED_SIGNATURE => :CSMAGIC_EMBEDDED_SIGNATURE, + CSMAGIC_DETACHED_SIGNATURE => :CSMAGIC_DETACHED_SIGNATURE, + CSMAGIC_EMBEDDED_ENTITLEMENTS => :CSMAGIC_EMBEDDED_ENTITLEMENTS, + CSMAGIC_EMBEDDED_DER_ENTITLEMENTS => :CSMAGIC_EMBEDDED_DER_ENTITLEMENTS, + CSMAGIC_EMBEDDED_LAUNCH_CONSTRAINT => :CSMAGIC_EMBEDDED_LAUNCH_CONSTRAINT, + }.freeze + + CSSLOT_CODEDIRECTORY = 0 + CSSLOT_INFOSLOT = 1 + CSSLOT_REQUIREMENTS = 2 + CSSLOT_ENTITLEMENTS = 5 + CSSLOT_DER_ENTITLEMENTS = 7 + CSSLOT_ALTERNATE_CODEDIRECTORIES = 0x1000 + CSSLOT_SIGNATURESLOT = 0x10000 + + CS_HASHTYPE_SHA1 = 1 + CS_HASHTYPE_SHA256 = 2 + CS_HASHTYPE_SHA256_TRUNCATED = 3 + CS_HASHTYPE_SHA384 = 4 + + CS_HASHTYPES = { + CS_HASHTYPE_SHA1 => :CS_HASHTYPE_SHA1, + CS_HASHTYPE_SHA256 => :CS_HASHTYPE_SHA256, + CS_HASHTYPE_SHA256_TRUNCATED => :CS_HASHTYPE_SHA256_TRUNCATED, + CS_HASHTYPE_SHA384 => :CS_HASHTYPE_SHA384, + }.freeze + + CS_ADHOC = 0x2 + CS_HARD = 0x100 + CS_RUNTIME = 0x10000 + CS_LINKER_SIGNED = 0x20000 + CS_EXECSEG_MAIN_BINARY = 0x1 + CS_EXECSEG_JIT = 0x40 + + CS_SUPPORTSCODELIMIT64 = 0x20300 + CS_SUPPORTSEXECSEG = 0x20400 + CS_SUPPORTSRUNTIME = 0x20500 + CS_SUPPORTSLINKAGE = 0x20600 + + PAGE_SIZE = 4096 + PRESERVED_COMPONENT_SLOTS = [ + CSSLOT_REQUIREMENTS, + CSSLOT_ENTITLEMENTS, + CSSLOT_DER_ENTITLEMENTS, + ].freeze + + HASHES = { + CS_HASHTYPE_SHA1 => [Digest::SHA1, 20], + CS_HASHTYPE_SHA256 => [Digest::SHA256, 32], + }.freeze + + # An entry in a code-signing SuperBlob index. + BlobIndex = Struct.new(:type, :offset) + + # A generic code-signing blob. + class Blob + # @return [Integer] the blob magic + attr_reader :magic + + # @return [Integer] the complete blob length + attr_reader :length + + # Parses the concrete blob type represented by `data`. + # @param data [String] raw blob data + # @return [Blob] + def self.parse(data) + raise CodeSigningError, "code-signing blob is truncated" if data.nil? || data.bytesize < 8 + + case data.unpack1("N") + when CSMAGIC_CODEDIRECTORY + CodeDirectory.new(data) + when CSMAGIC_EMBEDDED_SIGNATURE + SuperBlob.new(data) + else + new(data) + end + end + + # @param data [String] raw blob data + def initialize(data) + raise CodeSigningError, "code-signing blob is truncated" if data.nil? || data.bytesize < 8 + + @magic, @length = data.unpack("N2") + raise CodeSigningError, "invalid code-signing blob length: #{length}" if length < 8 || length > data.bytesize + + @raw_data = data.byteslice(0, length).freeze + end + + # @return [String] raw blob data + def serialize + @raw_data + end + + # @return [Symbol, nil] the symbolic blob magic + def magic_sym + CS_MAGICS[magic] + end + + # @return [Hash] a hash representation of this blob + def to_h + { + "magic" => magic, + "magic_sym" => magic_sym, + "length" => length, + } + end + end + + # An embedded-signature SuperBlob. + class SuperBlob < Blob + # @return [Integer] the number of indexed blobs + attr_reader :count + + # @return [Array] the blob index + attr_reader :indices + + # @return [Array] the indexed blobs + attr_reader :blobs + + # Builds a SuperBlob containing the given slot/blob pairs. + # @param entries [Hash] blobs keyed by slot + # @return [String] serialised SuperBlob data + def self.build(entries) + entries = entries.sort_by(&:first) + offset = 12 + (entries.size * 8) + index = +"".b + payload = +"".b + entries.each do |type, blob| + index << [type, offset].pack("N2") + payload << blob + offset += blob.bytesize + end + + [CSMAGIC_EMBEDDED_SIGNATURE, offset, entries.size].pack("N3") + index + payload + end + + # @param data [String] raw SuperBlob data + def initialize(data) + super + raise CodeSigningError, "invalid embedded-signature magic: 0x#{magic.to_s(16)}" unless magic == CSMAGIC_EMBEDDED_SIGNATURE + raise CodeSigningError, "code-signing SuperBlob is truncated" if length < 12 + + @count = serialize.unpack1("N", :offset => 8) + raise CodeSigningError, "code-signing SuperBlob index is truncated" if 12 + (count * 8) > length + + @indices = count.times.map do |index| + BlobIndex.new(*serialize.unpack("N2", :offset => 12 + (index * 8))) + end.freeze + @blobs = indices.map do |entry| + raise CodeSigningError, "code-signing blob offset is invalid: #{entry.offset}" if entry.offset < 12 + (count * 8) || entry.offset + 8 > length + + Blob.parse(serialize.byteslice(entry.offset, length - entry.offset)) + end.freeze + end + + # Returns the blob stored in `type`, if present. + # @param type [Integer] the slot type + # @return [Blob, nil] + def blob(type) + index = indices.index { |entry| entry.type == type } + blobs[index] if index + end + + # Yields every index entry. + # @yieldparam index [BlobIndex] + # @return [Enumerator, void] + def each_blob_index(&block) + return indices.each unless block + + indices.each(&block) + end + + # Yields every indexed blob. + # @yieldparam blob [Blob] + # @return [Enumerator, void] + def each_blob(&block) + return blobs.each unless block + + blobs.each(&block) + end + + # @return [Hash] a hash representation of this SuperBlob + def to_h + { + "count" => count, + "indices" => indices.map { |entry| { "type" => entry.type, "offset" => entry.offset } }, + "blobs" => blobs.map(&:to_h), + }.merge super + end + end + + # A CodeDirectory describing signed code pages and special components. + class CodeDirectory < Blob + attr_reader :version, :flags, :hash_offset, :ident_offset, + :n_special_slots, :n_code_slots, :hash_size, :hash_type, + :platform, :page_size, :scatter_offset, :team_offset, + :code_limit64, :exec_seg_base, :exec_seg_limit, + :exec_seg_flags, :runtime, :pre_encrypt_offset, + :linkage_hash_type, :linkage_application_type, + :linkage_application_subtype, :linkage_offset, :linkage_size + + # Builds a CodeDirectory for `source`. + # @param source [String] bytes before the embedded signature + # @param identifier [String] the signing identifier + # @param hash_type [Integer] the hash algorithm + # @param flags [Integer] CodeDirectory flags + # @param special_slots [Hash] special-slot contents + # @param exec_seg_base [Integer] executable segment offset + # @param exec_seg_limit [Integer] executable segment size + # @param exec_seg_flags [Integer] executable segment flags + # @param runtime [Integer] hardened runtime version + # @param hashes [Boolean] whether to calculate hashes + # @return [String] serialised CodeDirectory data + def self.build(source, identifier:, hash_type:, flags:, special_slots:, + exec_seg_base:, exec_seg_limit:, exec_seg_flags:, runtime:, hashes: true) + digest, hash_size = HASHES.fetch(hash_type) + version = runtime.zero? ? CS_SUPPORTSEXECSEG : CS_SUPPORTSRUNTIME + fixed_size = runtime.zero? ? 88 : 96 + identifier = "#{identifier.b.delete("\x00")}\x00".b + n_special_slots = special_slots.keys.max || 0 + n_code_slots = (source.bytesize + PAGE_SIZE - 1) / PAGE_SIZE + hash_offset = fixed_size + identifier.bytesize + (n_special_slots * hash_size) + length = hash_offset + (n_code_slots * hash_size) + code_limit = [source.bytesize, 0xffffffff].min + code_limit64 = source.bytesize > 0xffffffff ? source.bytesize : 0 + + data = [CSMAGIC_CODEDIRECTORY, length, version, flags, hash_offset, + fixed_size, n_special_slots, n_code_slots, code_limit].pack("N9") + data << [hash_size, hash_type, 0, 12].pack("C4") + data << [0, 0, 0, 0].pack("N4") + data << [code_limit64, exec_seg_base, exec_seg_limit, exec_seg_flags].pack("Q>4") + data << [runtime, 0].pack("N2") unless runtime.zero? + data << identifier + if hashes + n_special_slots.downto(1) do |slot| + data << if special_slots.key?(slot) + digest.digest(special_slots.fetch(slot)) + else + "\x00" * hash_size + end + end + 0.step(source.bytesize - 1, PAGE_SIZE) do |offset| + data << digest.digest(source.byteslice(offset, PAGE_SIZE)) + end + else + data << Utils.nullpad((n_special_slots + n_code_slots) * hash_size) + end + data + end + + # @param data [String] raw CodeDirectory data + def initialize(data) + super + raise CodeSigningError, "invalid CodeDirectory magic: 0x#{magic.to_s(16)}" unless magic == CSMAGIC_CODEDIRECTORY + raise CodeSigningError, "CodeDirectory is truncated" if length < 44 + + _, _, @version, @flags, @hash_offset, @ident_offset, + @n_special_slots, @n_code_slots, @code_limit = serialize.unpack("N9") + @hash_size, @hash_type, @platform, @page_size = serialize.unpack("C4", :offset => 36) + fixed_size = 44 + @scatter_offset = unpack_uint32(44) if version >= 0x20100 + fixed_size = 48 if version >= 0x20100 + @team_offset = unpack_uint32(48) if version >= 0x20200 + fixed_size = 52 if version >= 0x20200 + if version >= CS_SUPPORTSCODELIMIT64 + raise CodeSigningError, "CodeDirectory is truncated" if length < 64 + + @code_limit64 = serialize.unpack1("Q>", :offset => 56) + fixed_size = 64 + end + if version >= CS_SUPPORTSEXECSEG + raise CodeSigningError, "CodeDirectory is truncated" if length < 88 + + @exec_seg_base, @exec_seg_limit, @exec_seg_flags = serialize.unpack("Q>3", :offset => 64) + fixed_size = 88 + end + if version >= CS_SUPPORTSRUNTIME + raise CodeSigningError, "CodeDirectory is truncated" if length < 96 + + @runtime, @pre_encrypt_offset = serialize.unpack("N2", :offset => 88) + fixed_size = 96 + end + if version >= CS_SUPPORTSLINKAGE + raise CodeSigningError, "CodeDirectory is truncated" if length < 108 + + @linkage_hash_type, @linkage_application_type, + @linkage_application_subtype, @linkage_offset, + @linkage_size = serialize.unpack("CCnN2", :offset => 96) + fixed_size = 108 + end + raise CodeSigningError, "CodeDirectory identifier offset is invalid" if ident_offset < fixed_size || ident_offset >= length + + terminator = serialize.index("\x00", ident_offset) + raise CodeSigningError, "CodeDirectory identifier is unterminated" unless terminator && terminator < length + if hash_size.zero? || + hash_offset < fixed_size || + hash_offset - (n_special_slots * hash_size) < terminator + 1 || + hash_offset + (n_code_slots * hash_size) > length + raise CodeSigningError, "CodeDirectory hash range is invalid" + end + + @identifier = serialize.byteslice(ident_offset, terminator - ident_offset) + end + + # @return [String] the signing identifier + attr_reader :identifier + + # @return [Integer] the number of signed bytes + def code_limit + version >= CS_SUPPORTSCODELIMIT64 && code_limit64&.positive? ? code_limit64 : @code_limit + end + + # Returns a code page hash. + # @param slot [Integer] a zero-based page slot + # @return [String, nil] + def code_hash(slot) + return if slot.negative? || slot >= n_code_slots + + serialize.byteslice(hash_offset + (slot * hash_size), hash_size) + end + + # @return [Symbol, nil] the symbolic hash type + def hash_type_sym + CS_HASHTYPES[hash_type] + end + + # Returns a special-slot hash. + # @param slot [Integer] a positive special-slot number + # @return [String, nil] + def special_hash(slot) + return unless slot.positive? && slot <= n_special_slots + + serialize.byteslice(hash_offset - (slot * hash_size), hash_size) + end + + # @return [Hash] a hash representation of this CodeDirectory + def to_h + { + "version" => version, + "flags" => flags, + "identifier" => identifier, + "hash_offset" => hash_offset, + "n_special_slots" => n_special_slots, + "n_code_slots" => n_code_slots, + "code_limit" => code_limit, + "hash_size" => hash_size, + "hash_type" => hash_type, + "hash_type_sym" => hash_type_sym, + "page_size" => page_size, + }.merge super + end + + private + + def unpack_uint32(offset) + raise CodeSigningError, "CodeDirectory is truncated" if length < offset + 4 + + serialize.unpack1("N", :offset => offset) + end + end + + # Generates and embeds an ad-hoc signature in one Mach-O slice. + class AdhocSigner + # @param macho [MachOFile] the slice to sign + # @param identifier [String] the signing identifier + def initialize(macho, identifier) + @macho = macho + @identifier = identifier + end + + # Embeds a new signature. + # @return [void] + def sign! + signature_commands = @macho[:LC_CODE_SIGNATURE] + raise CodeSigningError, "Mach-O contains multiple LC_CODE_SIGNATURE commands" if signature_commands.size > 1 + + signature_command = signature_commands.first + metadata = metadata_from(signature_command) + remove_signature(signature_command) if signature_command&.datasize&.positive? + add_signature_command unless signature_command + + signature_command = @macho[:LC_CODE_SIGNATURE].first + linkedit_segments = @macho.segments.select { |segment| segment.segname == "__LINKEDIT" } + raise CodeSigningError, "Mach-O must contain exactly one __LINKEDIT segment" unless linkedit_segments.one? + + linkedit = linkedit_segments.first + + @macho.serialize << Utils.nullpad(Utils.padding_for(@macho.serialize.bytesize, 16)) + dataoff = @macho.serialize.bytesize + raise CodeSigningError, "code signature offset exceeds LC_CODE_SIGNATURE" if dataoff > 0xffffffff + + entries = signature_entries(metadata, :hashes => false) + datasize = Utils.round(SuperBlob.build(entries).bytesize, 16) + + update_linkedit_data_command(signature_command, dataoff, datasize) + update_linkedit_segment(linkedit, dataoff + datasize) + + entries = signature_entries(metadata) + signature = SuperBlob.build(entries) + @macho.serialize << signature << Utils.nullpad(datasize - signature.bytesize) + @macho.populate_fields + nil + end + + private + + def metadata_from(signature_command) + return default_metadata unless signature_command&.datasize&.positive? + + superblob = signature_command.superblob + code_directory = superblob.blobs.grep(CodeDirectory).find { |blob| blob.hash_type == CS_HASHTYPE_SHA256 } || + superblob.blobs.grep(CodeDirectory).first + return default_metadata unless code_directory + return default_metadata if code_directory.flags.anybits?(CS_LINKER_SIGNED) + + components = PRESERVED_COMPONENT_SLOTS.to_h do |slot| + [slot, superblob.blob(slot)&.serialize] + end.compact + { + :components => components, + :exec_seg_flags => code_directory.exec_seg_flags.to_i, + :flags => (code_directory.flags & ~CS_LINKER_SIGNED) | CS_ADHOC, + :runtime => code_directory.runtime.to_i, + } + end + + def default_metadata + { + :components => {}, + :exec_seg_flags => 0, + :flags => CS_ADHOC, + :runtime => 0, + } + end + + def remove_signature(signature_command) + end_offset = signature_command.dataoff + signature_command.datasize + trailing_data = @macho.serialize.byteslice(end_offset..).to_s + unless signature_command.dataoff <= @macho.serialize.bytesize && + end_offset <= @macho.serialize.bytesize && + trailing_data.bytesize <= 15 && trailing_data.bytes.all?(&:zero?) + raise CodeSigningError, "LC_CODE_SIGNATURE does not point to the end of the Mach-O" + end + + @macho.serialize.slice!(signature_command.dataoff..) + @macho.populate_fields + end + + def add_signature_command + @macho.add_command(LoadCommands::LoadCommand.create(:LC_CODE_SIGNATURE, 0, 0)) + rescue ModificationError => e + raise CodeSigningError, e.message + end + + def signature_entries(metadata, hashes: true) + components = metadata.fetch(:components).dup + components[CSSLOT_REQUIREMENTS] ||= [CSMAGIC_REQUIREMENTS, 12, 0].pack("N3") + special_slots = components.dup + if (info_plist = CodeSigning.info_plist(@macho)) + special_slots[CSSLOT_INFOSLOT] = info_plist + end + + text = @macho.segments.find { |segment| segment.segname == "__TEXT" } + exec_seg_flags = metadata.fetch(:exec_seg_flags) + if @macho.executable? + exec_seg_flags |= CS_EXECSEG_MAIN_BINARY + else + exec_seg_flags &= ~CS_EXECSEG_MAIN_BINARY + end + source = @macho.serialize + entries = {} + hash_types.each_with_index do |hash_type, index| + entries[index.zero? ? CSSLOT_CODEDIRECTORY : CSSLOT_ALTERNATE_CODEDIRECTORIES + index - 1] = + CodeDirectory.build(source, + :identifier => @identifier, + :hash_type => hash_type, + :flags => metadata.fetch(:flags), + :special_slots => special_slots, + :exec_seg_base => text&.fileoff.to_i, + :exec_seg_limit => text&.filesize.to_i, + :exec_seg_flags => exec_seg_flags, + :runtime => metadata.fetch(:runtime), + :hashes => hashes) + end + components.each { |slot, blob| entries[slot] = blob } + entries[CSSLOT_SIGNATURESLOT] = [CSMAGIC_BLOBWRAPPER, 8].pack("N2") + entries + end + + def hash_types + version = @macho[:LC_VERSION_MIN_MACOSX].first&.version + build_version = @macho[:LC_BUILD_VERSION].first + version ||= build_version.minos if build_version&.platform == 1 + version && version < 0x000a0b04 ? [CS_HASHTYPE_SHA1, CS_HASHTYPE_SHA256] : [CS_HASHTYPE_SHA256] + end + + def update_linkedit_data_command(command, dataoff, datasize) + format = Utils.specialize_format("L=", @macho.endianness) + @macho.serialize[command.view.offset + 8, 8] = [dataoff, datasize].pack(format * 2) + end + + def update_linkedit_segment(segment, final_size) + filesize = final_size - segment.fileoff + raise CodeSigningError, "__LINKEDIT extends past the end of the Mach-O" if filesize.negative? + raise CodeSigningError, "__LINKEDIT exceeds its 32-bit filesize" if @macho.magic32? && filesize > 0xffffffff + + if @macho.magic64? + format = Utils.specialize_format("Q=", @macho.endianness) + @macho.serialize[segment.view.offset + 48, 8] = [filesize].pack(format) + @macho.serialize[segment.view.offset + 32, 8] = [Utils.round(filesize, 2**@macho.segment_alignment)].pack(format) if filesize > segment.vmsize + else + format = Utils.specialize_format("L=", @macho.endianness) + @macho.serialize[segment.view.offset + 36, 4] = [filesize].pack(format) + @macho.serialize[segment.view.offset + 28, 4] = [Utils.round(filesize, 2**@macho.segment_alignment)].pack(format) if filesize > segment.vmsize + end + end + end + + # Derives the identifier used by Apple's ad-hoc signer for a bare Mach-O. + # @param macho [MachOFile] a Mach-O slice + # @param filename [String, nil] its filename + # @return [String] + def self.identifier(macho, filename) + if (match = info_plist(macho)&.match(%r{\s*CFBundleIdentifier\s*\s*\s*([^<]+?)\s*}m)) + return match[1] + end + + filename = File.basename(filename || "adhoc") + filename = File.basename(filename, File.extname(filename)) + return filename if filename.include?(".") + + uuid = macho[:LC_UUID].first + identity = if uuid + ("UUID".b + uuid.uuid.pack("C*")).unpack1("H*") + else + Digest::SHA1.hexdigest(macho.serialize.byteslice(0, macho.header.class.bytesize + macho.sizeofcmds)) + end + "#{filename}-#{identity}" + end + + # Returns an embedded Info.plist, if present. + # @param macho [MachOFile] a Mach-O slice + # @return [String, nil] + def self.info_plist(macho) + section = macho.segments.flat_map(&:sections).find do |candidate| + candidate.segname == "__TEXT" && candidate.sectname == "__info_plist" + end + macho.serialize.byteslice(section.offset, section.size) if section + end + end +end diff --git a/lib/macho/fat_file.rb b/lib/macho/fat_file.rb index 0439ab5f7..e6a295fe4 100644 --- a/lib/macho/fat_file.rb +++ b/lib/macho/fat_file.rb @@ -283,6 +283,16 @@ def delete_rpath(path, options = {}) repopulate_raw_machos end + # Replaces every embedded signature with a pure-Ruby ad-hoc signature. + # @param identifier [String, nil] the signing identifier + # @return [void] + def codesign!(identifier: nil) + identifier ||= CodeSigning.identifier(canonical_macho, filename) + machos.each { |macho| macho.codesign!(:identifier => identifier) } + repopulate_resized_raw_machos + nil + end + # Extract a Mach-O with the given CPU type from the file. # @example # file.extract(:i386) # => MachO::MachOFile @@ -402,6 +412,34 @@ def repopulate_raw_machos end end + # Rebuild the fat file after internal Mach-Os change size. + # @return [void] + # @api private + def repopulate_resized_raw_machos + fat_arch_class = Utils.fat_magic32?(header.magic) ? Headers::FatArch : Headers::FatArch64 + header_size = Headers::FatHeader.bytesize + (fat_archs.size * fat_arch_class.bytesize) + header_data = @raw_data.byteslice(0, header_size) + slices = +"".b + offset = header_size + + fat_archs.zip(machos).each_with_index do |(arch, macho), index| + macho_offset = Utils.round(offset, 2**arch.align) + slices << Utils.nullpad(macho_offset - offset) << macho.serialize + arch_offset = Headers::FatHeader.bytesize + (index * fat_arch_class.bytesize) + if fat_arch_class == Headers::FatArch + raise FatArchOffsetOverflowError, macho_offset if macho_offset > 0xffffffff + + header_data[arch_offset + 8, 8] = [macho_offset, macho.serialize.bytesize].pack("N2") + else + header_data[arch_offset + 8, 16] = [macho_offset, macho.serialize.bytesize].pack("Q>2") + end + offset = macho_offset + macho.serialize.bytesize + end + + @raw_data = header_data + slices + populate_fields + end + # Yield each Mach-O object in the file, rescuing and accumulating errors. # @param options [Hash] # @option options [Boolean] :strict (true) whether or not to fail loudly diff --git a/lib/macho/load_commands.rb b/lib/macho/load_commands.rb index 8d3dbae3a..653b009b1 100644 --- a/lib/macho/load_commands.rb +++ b/lib/macho/load_commands.rb @@ -92,6 +92,7 @@ module LoadCommands LC_ID_DYLIB LC_RPATH LC_LOAD_DYLINKER + LC_CODE_SIGNATURE ].freeze # association of load command symbols to string representations of classes @@ -1088,6 +1089,25 @@ class LinkeditDataCommand < LoadCommand # @return [Integer] size of the data in the __LINKEDIT segment field :datasize, :uint32 + # @param context [SerializationContext] the context + # @return [String] the serialized fields of the load command + # @api private + def serialize(context) + raise LoadCommandNotSerializableError, type unless serializable? + + format = Utils.specialize_format(self.class.format, context.endianness) + [cmd, self.class.bytesize, dataoff, datasize].pack(format) + end + + # The embedded signature referenced by this command. + # @return [CodeSigning::SuperBlob] + # @raise [CodeSigningError] if this is not an LC_CODE_SIGNATURE command + def superblob + raise CodeSigningError, "#{type} does not contain a code signature" unless type == :LC_CODE_SIGNATURE + + CodeSigning::SuperBlob.new(view.raw_data.byteslice(dataoff, datasize)) + end + # @return [Hash] a hash representation of this {LinkeditDataCommand} def to_h { diff --git a/lib/macho/macho_file.rb b/lib/macho/macho_file.rb index 6b50f7c02..f542733c0 100644 --- a/lib/macho/macho_file.rb +++ b/lib/macho/macho_file.rb @@ -433,6 +433,13 @@ def delete_rpath(path, options = {}) rpath_cmds.reverse_each { |cmd| delete_command(cmd) } end + # Replaces the embedded signature with a pure-Ruby ad-hoc signature. + # @param identifier [String, nil] the signing identifier + # @return [void] + def codesign!(identifier: nil) + CodeSigning::AdhocSigner.new(self, identifier || CodeSigning.identifier(self, filename)).sign! + end + # Write all Mach-O data to the given filename. # @param filename [String] the file to write to # @return [void] @@ -646,6 +653,9 @@ def low_fileoff offset = @raw_data.size segments.each do |seg| + offset = seg.fileoff if seg.nsects.zero? && seg.fileoff.positive? && + seg.filesize.positive? && seg.fileoff < offset + seg.sections.each do |sect| next if sect.empty? next if sect.type?(:S_ZEROFILL) diff --git a/test/test_code_signing.rb b/test/test_code_signing.rb new file mode 100644 index 000000000..62d09e8c7 --- /dev/null +++ b/test/test_code_signing.rb @@ -0,0 +1,293 @@ +# frozen_string_literal: true + +require_relative "helpers" + +class MachOCodeSigningTest < Minitest::Test + include Helpers + + def test_signs_an_unsigned_macho + SINGLE_ARCHES.each do |arch| + tempfile_with_data("hello", File.binread(fixture(arch, "hello.bin"))) do |file| + old_path = ENV.fetch("PATH", nil) + begin + ENV["PATH"] = "" + assert_nil MachO.codesign!(file.path) + ensure + ENV["PATH"] = old_path + end + + assert_valid_ad_hoc_signature(MachO.open(file.path)) + end + end + end + + def test_replaces_an_existing_signature + tempfile_with_data("hello", File.binread(fixture(:x86_64, "hello.bin"))) do |file| + MachO.codesign!(file.path) + first_signature = File.binread(file.path) + + MachO.codesign!(file.path) + + assert_equal first_signature, File.binread(file.path) + assert_valid_ad_hoc_signature(MachO.open(file.path)) + end + end + + def test_parses_and_enumerates_embedded_signature_data + tempfile_with_data("hello", File.binread(fixture(:x86_64, "hello.bin"))) do |file| + MachO.codesign!(file.path) + parsed = MachO::CodeSigning::Blob.parse( + MachO.open(file.path)[:LC_CODE_SIGNATURE].first.superblob.serialize + ) + yielded_indices = [] + yielded_blobs = [] + + parsed.each_blob_index { |index| yielded_indices << index } + parsed.each_blob { |blob| yielded_blobs << blob } + + assert_instance_of MachO::CodeSigning::SuperBlob, parsed + assert_equal parsed.indices, parsed.each_blob_index.to_a + assert_equal parsed.indices, yielded_indices + assert_equal parsed.blobs, parsed.each_blob.to_a + assert_equal parsed.blobs, yielded_blobs + assert_equal parsed.indices.map { |index| { "type" => index.type, "offset" => index.offset } }, + parsed.to_h["indices"] + assert_equal parsed.blobs.map(&:to_h), parsed.to_h["blobs"] + end + end + + def test_rejects_invalid_code_directory_hash_ranges + options = { + :identifier => "test", + :hash_type => MachO::CodeSigning::CS_HASHTYPE_SHA256, + :flags => MachO::CodeSigning::CS_ADHOC, + :special_slots => {}, + :exec_seg_base => 0, + :exec_seg_limit => 0, + :exec_seg_flags => 0, + :runtime => 0, + } + code_directory = MachO::CodeSigning::CodeDirectory.build("code", **options) + zero_hash_size = code_directory.dup + zero_hash_size.setbyte(36, 0) + hash_before_header = code_directory.dup + hash_before_header[16, 4] = [hash_before_header.unpack1("N", :offset => 20) - 1].pack("N") + hashes_over_identifier = MachO::CodeSigning::CodeDirectory.build( + "code", **options, :special_slots => { MachO::CodeSigning::CSSLOT_REQUIREMENTS => "requirements" } + ) + hashes_over_identifier[16, 4] = [ + hashes_over_identifier.unpack1("N", :offset => 20) + + (hashes_over_identifier.unpack1("N", :offset => 24) * hashes_over_identifier.getbyte(36)), + ].pack("N") + + [zero_hash_size, hash_before_header, hashes_over_identifier].each do |malformed| + error = assert_raises(MachO::CodeSigningError) do + MachO::CodeSigning::CodeDirectory.new(malformed) + end + assert_equal "CodeDirectory hash range is invalid", error.message + end + end + + def test_fills_an_empty_signature_command + tempfile_with_data("hello", File.binread(fixture(:x86_64, "hello.bin"))) do |file| + macho = MachO.open(file.path) + macho.add_command(MachO::LoadCommands::LoadCommand.create(:LC_CODE_SIGNATURE, 0, 0)) + macho.write! + + MachO.codesign!(file.path) + + assert_equal 1, MachO.open(file.path)[:LC_CODE_SIGNATURE].size + assert_valid_ad_hoc_signature(MachO.open(file.path)) + end + end + + def test_preserves_the_inode_and_mode + tempfile_with_data("hello", File.binread(fixture(:x86_64, "hello.bin"))) do |file| + link = "#{file.path}.link" + File.link(file.path, link) + stat = File.stat(file.path) + + MachO.codesign!(file.path) + + assert_equal stat.ino, File.stat(file.path).ino + assert_equal stat.mode, File.stat(file.path).mode + assert_equal File.binread(file.path), File.binread(link) + ensure + FileUtils.rm_f(link) + end + end + + def test_macos_accepts_the_signature + skip unless RUBY_PLATFORM.include?("darwin") + + tempfile_with_data("hello", File.binread(fixture(:x86_64, "hello.bin"))) do |file| + MachO.codesign!(file.path) + + assert system("/usr/bin/codesign", "--verify", "--strict", file.path, + :out => File::NULL, :err => File::NULL) + end + end + + def test_signs_every_slice_in_a_fat_macho + tempfile_with_data("hello", File.binread(fixture(%i[i386 x86_64], "hello.bin"))) do |file| + MachO.codesign!(file.path) + + fat = MachO.open(file.path) + fat.machos.each { |macho| assert_valid_ad_hoc_signature(macho) } + fat.fat_archs.zip(fat.machos).each do |arch, macho| + assert_equal macho.serialize.bytesize, arch.size + assert_equal 0, arch.offset % (2**arch.align) + end + + first_signature = fat.serialize + MachO.codesign!(file.path) + + assert_equal first_signature, File.binread(file.path) + end + end + + def test_preserves_codesign_metadata + tempfile_with_data("hello", File.binread(fixture(:x86_64, "hello.bin"))) do |file| + MachO.codesign!(file.path) + macho = MachO.open(file.path) + command = macho[:LC_CODE_SIGNATURE].first + entitlements = entitlement_blob + requirements = [MachO::CodeSigning::CSMAGIC_REQUIREMENTS, 16, 0, "test"].pack("N3a4") + code_directory = MachO::CodeSigning::CodeDirectory.build( + macho.serialize.byteslice(0, command.dataoff), + :identifier => "old-identifier", + :hash_type => MachO::CodeSigning::CS_HASHTYPE_SHA256, + :flags => MachO::CodeSigning::CS_ADHOC | MachO::CodeSigning::CS_HARD | MachO::CodeSigning::CS_RUNTIME, + :special_slots => { + MachO::CodeSigning::CSSLOT_REQUIREMENTS => requirements, + MachO::CodeSigning::CSSLOT_ENTITLEMENTS => entitlements, + }, + :exec_seg_base => 0, + :exec_seg_limit => 4096, + :exec_seg_flags => MachO::CodeSigning::CS_EXECSEG_MAIN_BINARY | MachO::CodeSigning::CS_EXECSEG_JIT, + :runtime => 0x000d0000 + ) + superblob = MachO::CodeSigning::SuperBlob.build( + MachO::CodeSigning::CSSLOT_CODEDIRECTORY => code_directory, + MachO::CodeSigning::CSSLOT_REQUIREMENTS => requirements, + MachO::CodeSigning::CSSLOT_ENTITLEMENTS => entitlements + ) + assert_operator superblob.bytesize, :<=, command.datasize + macho.serialize[command.dataoff, command.datasize] = superblob.ljust(command.datasize, "\x00") + macho.write! + + MachO.codesign!(file.path) + + command = MachO.open(file.path)[:LC_CODE_SIGNATURE].first + superblob = command.superblob + code_directory = superblob.blobs.grep(MachO::CodeSigning::CodeDirectory).last + assert_equal MachO::CodeSigning::CS_ADHOC | MachO::CodeSigning::CS_HARD | MachO::CodeSigning::CS_RUNTIME, + code_directory.flags + assert_equal 0x000d0000, code_directory.runtime + assert_equal MachO::CodeSigning::CS_EXECSEG_MAIN_BINARY | MachO::CodeSigning::CS_EXECSEG_JIT, + code_directory.exec_seg_flags + assert_equal requirements, superblob.blob(MachO::CodeSigning::CSSLOT_REQUIREMENTS).serialize + assert_equal entitlements, superblob.blob(MachO::CodeSigning::CSSLOT_ENTITLEMENTS).serialize + assert_equal Digest::SHA256.digest(requirements), + code_directory.special_hash(MachO::CodeSigning::CSSLOT_REQUIREMENTS) + assert_equal Digest::SHA256.digest(entitlements), + code_directory.special_hash(MachO::CodeSigning::CSSLOT_ENTITLEMENTS) + end + end + + def test_does_not_modify_a_file_when_signing_fails + tempfile_with_data("hello.o", File.binread(fixture(:x86_64, "hello.o"))) do |file| + original = File.binread(file.path) + + assert_raises MachO::CodeSigningError do + MachO.codesign!(file.path) + end + + assert_equal original, File.binread(file.path) + end + end + + def test_rejects_data_after_an_existing_signature + tempfile_with_data("hello", File.binread(fixture(:x86_64, "hello.bin"))) do |file| + MachO.codesign!(file.path) + File.open(file.path, "ab") { |output| output.write("not part of the signature") } + original = File.binread(file.path) + + assert_raises MachO::CodeSigningError do + MachO.codesign!(file.path) + end + + assert_equal original, File.binread(file.path) + end + end + + def test_rejects_duplicate_signature_commands + tempfile_with_data("hello", File.binread(fixture(:x86_64, "hello.bin"))) do |file| + macho = MachO.open(file.path) + 2.times do + macho.add_command(MachO::LoadCommands::LoadCommand.create(:LC_CODE_SIGNATURE, 0, 0)) + end + macho.write! + original = File.binread(file.path) + + assert_raises MachO::CodeSigningError do + MachO.codesign!(file.path) + end + + assert_equal original, File.binread(file.path) + end + end + + private + + def entitlement_blob + payload = "" + [MachO::CodeSigning::CSMAGIC_EMBEDDED_ENTITLEMENTS, payload.bytesize + 8].pack("N2") + payload + end + + def assert_valid_ad_hoc_signature(macho) + command = macho[:LC_CODE_SIGNATURE].first + refute_nil command + assert_equal 0, command.dataoff % 16 + assert_equal macho.serialize.bytesize, command.dataoff + command.datasize + + linkedit = macho.segments.find { |segment| segment.segname == "__LINKEDIT" } + assert_equal macho.serialize.bytesize - linkedit.fileoff, linkedit.filesize + assert_operator linkedit.vmsize, :>=, linkedit.filesize + + superblob = command.superblob + assert_equal MachO::CodeSigning::CSMAGIC_EMBEDDED_SIGNATURE, superblob.magic + assert_equal :CSMAGIC_EMBEDDED_SIGNATURE, superblob.magic_sym + assert_equal superblob.indices.size, superblob.count + assert_equal superblob.blobs.size, superblob.count + + code_directories = superblob.blobs.grep(MachO::CodeSigning::CodeDirectory) + assert_includes [ + [MachO::CodeSigning::CS_HASHTYPE_SHA256], + [MachO::CodeSigning::CS_HASHTYPE_SHA1, MachO::CodeSigning::CS_HASHTYPE_SHA256], + ], code_directories.map(&:hash_type) + + requirements = superblob.blob(MachO::CodeSigning::CSSLOT_REQUIREMENTS) + assert_equal MachO::CodeSigning::CSMAGIC_REQUIREMENTS, requirements.magic + + code_directories.each do |code_directory| + refute_nil code_directory.hash_type_sym + assert_equal MachO::CodeSigning::CS_ADHOC, code_directory.flags + assert_equal command.dataoff, code_directory.code_limit + assert_equal 12, code_directory.page_size + assert_equal((command.dataoff + 4095) / 4096, code_directory.n_code_slots) + + digest = if code_directory.hash_type == MachO::CodeSigning::CS_HASHTYPE_SHA1 + Digest::SHA1 + else + Digest::SHA256 + end + 0.step(command.dataoff - 1, 4096).with_index do |offset, slot| + assert_equal digest.digest(macho.serialize.byteslice(offset, [4096, command.dataoff - offset].min)), + code_directory.code_hash(slot) + end + assert_equal digest.digest(requirements.serialize), + code_directory.special_hash(MachO::CodeSigning::CSSLOT_REQUIREMENTS) + end + end +end