Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOReader.cs
master 00d2c99af9 feat: add Attestation Chain and Triage Evidence API clients and models
- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains.
- Created models for Attestation Chain, including DSSE envelope structures and verification results.
- Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component.
- Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence.
- Introduced mock implementations for both API clients to facilitate testing and development.
2025-12-18 13:15:13 +02:00

641 lines
21 KiB
C#

using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Result from parsing a Mach-O file.
/// </summary>
/// <param name="Path">File path.</param>
/// <param name="LayerDigest">Container layer digest if applicable.</param>
/// <param name="Identities">List of identities (one per slice in fat binary).</param>
public sealed record MachOParseResult(
string Path,
string? LayerDigest,
IReadOnlyList<MachOIdentity> Identities);
/// <summary>
/// Full Mach-O file reader with identity extraction.
/// Handles both single-arch and fat (universal) binaries.
/// </summary>
public static class MachOReader
{
// Mach-O magic numbers
private const uint MH_MAGIC = 0xFEEDFACE; // 32-bit, native endian
private const uint MH_CIGAM = 0xCEFAEDFE; // 32-bit, reversed endian
private const uint MH_MAGIC_64 = 0xFEEDFACF; // 64-bit, native endian
private const uint MH_CIGAM_64 = 0xCFFAEDFE; // 64-bit, reversed endian
// Fat binary magic numbers
private const uint FAT_MAGIC = 0xCAFEBABE; // Big-endian
private const uint FAT_CIGAM = 0xBEBAFECA; // Little-endian
// Load command types
private const uint LC_UUID = 0x1B;
private const uint LC_CODE_SIGNATURE = 0x1D;
private const uint LC_VERSION_MIN_MACOSX = 0x24;
private const uint LC_VERSION_MIN_IPHONEOS = 0x25;
private const uint LC_VERSION_MIN_WATCHOS = 0x30;
private const uint LC_VERSION_MIN_TVOS = 0x2F;
private const uint LC_BUILD_VERSION = 0x32;
private const uint LC_DYLD_INFO = 0x22;
private const uint LC_DYLD_INFO_ONLY = 0x80000022;
private const uint LC_DYLD_EXPORTS_TRIE = 0x80000033;
// Code signature blob types
private const uint CSMAGIC_CODEDIRECTORY = 0xFADE0C02;
private const uint CSMAGIC_EMBEDDED_SIGNATURE = 0xFADE0CC0;
private const uint CSMAGIC_EMBEDDED_ENTITLEMENTS = 0xFADE7171;
// CPU types
private const int CPU_TYPE_X86 = 7;
private const int CPU_TYPE_X86_64 = CPU_TYPE_X86 | 0x01000000;
private const int CPU_TYPE_ARM = 12;
private const int CPU_TYPE_ARM64 = CPU_TYPE_ARM | 0x01000000;
/// <summary>
/// Parse a Mach-O file and extract full identity information.
/// For fat binaries, returns identities for all slices.
/// </summary>
public static MachOParseResult? Parse(Stream stream, string path, string? layerDigest = null)
{
if (!TryReadBytes(stream, 4, out var magicBytes))
{
return null;
}
stream.Position = 0;
var magic = BinaryPrimitives.ReadUInt32BigEndian(magicBytes);
// Check for fat binary
if (magic is FAT_MAGIC or FAT_CIGAM)
{
var identities = ParseFatBinary(stream);
return identities.Count > 0
? new MachOParseResult(path, layerDigest, identities)
: null;
}
// Single architecture binary
var identity = ParseSingleMachO(stream);
return identity is not null
? new MachOParseResult(path, layerDigest, [identity])
: null;
}
/// <summary>
/// Try to extract just the identity without full parsing.
/// </summary>
public static bool TryExtractIdentity(Stream stream, out MachOIdentity? identity)
{
identity = null;
if (!TryReadBytes(stream, 4, out var magicBytes))
{
return false;
}
stream.Position = 0;
var magic = BinaryPrimitives.ReadUInt32BigEndian(magicBytes);
// Skip fat binary quick extraction for now
if (magic is FAT_MAGIC or FAT_CIGAM)
{
var identities = ParseFatBinary(stream);
identity = identities.Count > 0 ? identities[0] : null;
return identity is not null;
}
identity = ParseSingleMachO(stream);
return identity is not null;
}
/// <summary>
/// Parse a fat binary and return all slice identities.
/// </summary>
public static IReadOnlyList<MachOIdentity> ParseFatBinary(Stream stream)
{
var identities = new List<MachOIdentity>();
if (!TryReadBytes(stream, 8, out var headerBytes))
{
return identities;
}
var magic = BinaryPrimitives.ReadUInt32BigEndian(headerBytes);
var swapBytes = magic == FAT_CIGAM;
var nfatArch = swapBytes
? BinaryPrimitives.ReadUInt32LittleEndian(headerBytes.AsSpan(4))
: BinaryPrimitives.ReadUInt32BigEndian(headerBytes.AsSpan(4));
if (nfatArch > 100)
{
// Sanity check
return identities;
}
for (var i = 0; i < nfatArch; i++)
{
if (!TryReadBytes(stream, 20, out var archBytes))
{
break;
}
// Fat arch structure is always big-endian (unless FAT_CIGAM)
uint offset, size;
if (swapBytes)
{
// cputype(4), cpusubtype(4), offset(4), size(4), align(4)
offset = BinaryPrimitives.ReadUInt32LittleEndian(archBytes.AsSpan(8));
size = BinaryPrimitives.ReadUInt32LittleEndian(archBytes.AsSpan(12));
}
else
{
offset = BinaryPrimitives.ReadUInt32BigEndian(archBytes.AsSpan(8));
size = BinaryPrimitives.ReadUInt32BigEndian(archBytes.AsSpan(12));
}
// Save position and parse the embedded Mach-O
var currentPos = stream.Position;
stream.Position = offset;
var sliceIdentity = ParseSingleMachO(stream, isFatSlice: true);
if (sliceIdentity is not null)
{
identities.Add(sliceIdentity);
}
stream.Position = currentPos;
}
return identities;
}
/// <summary>
/// Parse a single Mach-O binary (not fat).
/// </summary>
private static MachOIdentity? ParseSingleMachO(Stream stream, bool isFatSlice = false)
{
var startOffset = stream.Position;
if (!TryReadBytes(stream, 4, out var magicBytes))
{
return null;
}
var magic = BinaryPrimitives.ReadUInt32LittleEndian(magicBytes);
bool is64Bit;
bool swapBytes;
switch (magic)
{
case MH_MAGIC:
is64Bit = false;
swapBytes = false;
break;
case MH_CIGAM:
is64Bit = false;
swapBytes = true;
break;
case MH_MAGIC_64:
is64Bit = true;
swapBytes = false;
break;
case MH_CIGAM_64:
is64Bit = true;
swapBytes = true;
break;
default:
return null;
}
// Read rest of Mach header
var headerSize = is64Bit ? 32 : 28;
stream.Position = startOffset;
if (!TryReadBytes(stream, headerSize, out var headerBytes))
{
return null;
}
// Parse header
var cpuType = ReadInt32(headerBytes, 4, swapBytes);
var cpuSubtype = ReadUInt32(headerBytes, 8, swapBytes);
var ncmds = ReadUInt32(headerBytes, 16, swapBytes);
var sizeofcmds = ReadUInt32(headerBytes, 20, swapBytes);
var cpuTypeName = GetCpuTypeName(cpuType);
// Initialize identity fields
string? uuid = null;
var platform = MachOPlatform.Unknown;
string? minOsVersion = null;
string? sdkVersion = null;
MachOCodeSignature? codeSignature = null;
var exports = new List<string>();
// Read load commands
var loadCommandsStart = stream.Position;
var loadCommandsEnd = loadCommandsStart + sizeofcmds;
for (uint cmd = 0; cmd < ncmds && stream.Position < loadCommandsEnd; cmd++)
{
if (!TryReadBytes(stream, 8, out var cmdHeader))
{
break;
}
var cmdType = ReadUInt32(cmdHeader, 0, swapBytes);
var cmdSize = ReadUInt32(cmdHeader, 4, swapBytes);
if (cmdSize < 8)
{
break;
}
var cmdDataSize = (int)cmdSize - 8;
switch (cmdType)
{
case LC_UUID when cmdDataSize >= 16:
if (TryReadBytes(stream, 16, out var uuidBytes))
{
uuid = Convert.ToHexStringLower(uuidBytes);
}
stream.Position = loadCommandsStart + GetNextCmdOffset(cmd, ncmds, stream.Position - loadCommandsStart, cmdSize);
continue;
case LC_BUILD_VERSION when cmdDataSize >= 16:
if (TryReadBytes(stream, cmdDataSize, out var buildVersionBytes))
{
var platformValue = ReadUInt32(buildVersionBytes, 0, swapBytes);
platform = (MachOPlatform)platformValue;
var minos = ReadUInt32(buildVersionBytes, 4, swapBytes);
minOsVersion = FormatVersion(minos);
var sdk = ReadUInt32(buildVersionBytes, 8, swapBytes);
sdkVersion = FormatVersion(sdk);
}
continue;
case LC_VERSION_MIN_MACOSX:
case LC_VERSION_MIN_IPHONEOS:
case LC_VERSION_MIN_WATCHOS:
case LC_VERSION_MIN_TVOS:
if (TryReadBytes(stream, cmdDataSize, out var versionMinBytes))
{
if (platform == MachOPlatform.Unknown)
{
platform = cmdType switch
{
LC_VERSION_MIN_MACOSX => MachOPlatform.MacOS,
LC_VERSION_MIN_IPHONEOS => MachOPlatform.iOS,
LC_VERSION_MIN_WATCHOS => MachOPlatform.WatchOS,
LC_VERSION_MIN_TVOS => MachOPlatform.TvOS,
_ => MachOPlatform.Unknown
};
}
if (versionMinBytes.Length >= 8)
{
var version = ReadUInt32(versionMinBytes, 0, swapBytes);
if (minOsVersion is null)
{
minOsVersion = FormatVersion(version);
}
var sdk = ReadUInt32(versionMinBytes, 4, swapBytes);
if (sdkVersion is null)
{
sdkVersion = FormatVersion(sdk);
}
}
}
continue;
case LC_CODE_SIGNATURE:
if (TryReadBytes(stream, cmdDataSize, out var codeSignBytes) && codeSignBytes.Length >= 8)
{
var dataOff = ReadUInt32(codeSignBytes, 0, swapBytes);
var dataSize = ReadUInt32(codeSignBytes, 4, swapBytes);
// Parse code signature at offset
var currentPos = stream.Position;
stream.Position = startOffset + dataOff;
codeSignature = ParseCodeSignature(stream, (int)dataSize);
stream.Position = currentPos;
}
continue;
}
// Skip remaining bytes of command
var remaining = cmdDataSize - (stream.Position - loadCommandsStart - 8);
if (remaining > 0)
{
stream.Position += remaining;
}
}
return new MachOIdentity(
cpuTypeName,
cpuSubtype,
uuid,
isFatSlice,
platform,
minOsVersion,
sdkVersion,
codeSignature,
exports);
}
/// <summary>
/// Parse the code signature blob.
/// </summary>
private static MachOCodeSignature? ParseCodeSignature(Stream stream, int size)
{
if (!TryReadBytes(stream, 8, out var superBlobHeader))
{
return null;
}
var magic = BinaryPrimitives.ReadUInt32BigEndian(superBlobHeader);
if (magic != CSMAGIC_EMBEDDED_SIGNATURE)
{
return null;
}
var length = BinaryPrimitives.ReadUInt32BigEndian(superBlobHeader.AsSpan(4));
if (length > size || length < 12)
{
return null;
}
if (!TryReadBytes(stream, 4, out var countBytes))
{
return null;
}
var count = BinaryPrimitives.ReadUInt32BigEndian(countBytes);
if (count > 100)
{
return null;
}
var blobStart = stream.Position - 12;
// Read blob index entries
var blobs = new List<(uint type, uint offset)>();
for (uint i = 0; i < count; i++)
{
if (!TryReadBytes(stream, 8, out var indexEntry))
{
break;
}
var blobType = BinaryPrimitives.ReadUInt32BigEndian(indexEntry);
var blobOffset = BinaryPrimitives.ReadUInt32BigEndian(indexEntry.AsSpan(4));
blobs.Add((blobType, blobOffset));
}
string? teamId = null;
string? signingId = null;
string? cdHash = null;
var hasHardenedRuntime = false;
var entitlements = new List<string>();
foreach (var (blobType, blobOffset) in blobs)
{
stream.Position = blobStart + blobOffset;
if (!TryReadBytes(stream, 8, out var blobHeader))
{
continue;
}
var blobMagic = BinaryPrimitives.ReadUInt32BigEndian(blobHeader);
var blobLength = BinaryPrimitives.ReadUInt32BigEndian(blobHeader.AsSpan(4));
switch (blobMagic)
{
case CSMAGIC_CODEDIRECTORY:
(teamId, signingId, cdHash, hasHardenedRuntime) = ParseCodeDirectory(stream, blobStart + blobOffset, (int)blobLength);
break;
case CSMAGIC_EMBEDDED_ENTITLEMENTS:
entitlements = ParseEntitlements(stream, (int)blobLength - 8);
break;
}
}
if (teamId is null && signingId is null && cdHash is null)
{
return null;
}
return new MachOCodeSignature(teamId, signingId, cdHash, hasHardenedRuntime, entitlements);
}
/// <summary>
/// Parse CodeDirectory blob.
/// </summary>
private static (string? TeamId, string? SigningId, string? CdHash, bool HasHardenedRuntime) ParseCodeDirectory(
Stream stream, long blobStart, int length)
{
// CodeDirectory has a complex structure, we'll extract key fields
stream.Position = blobStart;
if (!TryReadBytes(stream, Math.Min(length, 52), out var cdBytes))
{
return (null, null, null, false);
}
// Offsets in CodeDirectory (all big-endian)
// +8: version
// +12: flags
// +16: hashOffset
// +20: identOffset
// +28: nCodeSlots
// +32: codeLimit
// +36: hashSize
// +37: hashType
// +38: platform
// +39: pageSize
// +44: spare2
// +48: scatterOffset (v2+)
// +52: teamOffset (v2+)
var version = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(8));
var flags = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(12));
var identOffset = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(20));
// Check for hardened runtime (flag 0x10000)
var hasHardenedRuntime = (flags & 0x10000) != 0;
// Read signing identifier
string? signingId = null;
if (identOffset > 0 && identOffset < length)
{
stream.Position = blobStart + identOffset;
signingId = ReadNullTerminatedString(stream, 256);
}
// Read team ID (version 0x20200 and later)
string? teamId = null;
if (version >= 0x20200 && cdBytes.Length >= 56)
{
var teamOffset = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(52));
if (teamOffset > 0 && teamOffset < length)
{
stream.Position = blobStart + teamOffset;
teamId = ReadNullTerminatedString(stream, 20);
}
}
// Compute CDHash (SHA-256 of the entire CodeDirectory blob)
stream.Position = blobStart;
if (TryReadBytes(stream, length, out var fullCdBytes))
{
var hash = SHA256.HashData(fullCdBytes);
var cdHash = Convert.ToHexStringLower(hash);
return (teamId, signingId, cdHash, hasHardenedRuntime);
}
return (teamId, signingId, null, hasHardenedRuntime);
}
/// <summary>
/// Parse entitlements plist and extract keys.
/// </summary>
private static List<string> ParseEntitlements(Stream stream, int length)
{
var keys = new List<string>();
if (!TryReadBytes(stream, length, out var plistBytes))
{
return keys;
}
// Simple plist key extraction (looks for <key>...</key> patterns)
var plist = Encoding.UTF8.GetString(plistBytes);
var keyStart = 0;
while ((keyStart = plist.IndexOf("<key>", keyStart, StringComparison.Ordinal)) >= 0)
{
keyStart += 5;
var keyEnd = plist.IndexOf("</key>", keyStart, StringComparison.Ordinal);
if (keyEnd > keyStart)
{
var key = plist[keyStart..keyEnd];
if (!string.IsNullOrWhiteSpace(key))
{
keys.Add(key);
}
keyStart = keyEnd + 6;
}
else
{
break;
}
}
return keys;
}
/// <summary>
/// Get CPU type name from CPU type value.
/// </summary>
private static string? GetCpuTypeName(int cpuType) => cpuType switch
{
CPU_TYPE_X86 => "i386",
CPU_TYPE_X86_64 => "x86_64",
CPU_TYPE_ARM => "arm",
CPU_TYPE_ARM64 => "arm64",
_ => $"cpu_{cpuType}"
};
/// <summary>
/// Format version number (major.minor.patch from packed uint32).
/// </summary>
private static string FormatVersion(uint version)
{
var major = (version >> 16) & 0xFFFF;
var minor = (version >> 8) & 0xFF;
var patch = version & 0xFF;
return patch == 0 ? $"{major}.{minor}" : $"{major}.{minor}.{patch}";
}
/// <summary>
/// Read a null-terminated string from stream.
/// </summary>
private static string? ReadNullTerminatedString(Stream stream, int maxLength)
{
var bytes = new byte[maxLength];
var count = 0;
while (count < maxLength)
{
var b = stream.ReadByte();
if (b <= 0)
{
break;
}
bytes[count++] = (byte)b;
}
return count > 0 ? Encoding.UTF8.GetString(bytes, 0, count) : null;
}
/// <summary>
/// Try to read exactly the specified number of bytes.
/// </summary>
private static bool TryReadBytes(Stream stream, int count, out byte[] bytes)
{
bytes = new byte[count];
var totalRead = 0;
while (totalRead < count)
{
var read = stream.Read(bytes, totalRead, count - totalRead);
if (read == 0)
{
return false;
}
totalRead += read;
}
return true;
}
/// <summary>
/// Read int32 with optional byte swapping.
/// </summary>
private static int ReadInt32(byte[] data, int offset, bool swap) =>
swap
? BinaryPrimitives.ReadInt32BigEndian(data.AsSpan(offset))
: BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset));
/// <summary>
/// Read uint32 with optional byte swapping.
/// </summary>
private static uint ReadUInt32(byte[] data, int offset, bool swap) =>
swap
? BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset))
: BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(offset));
/// <summary>
/// Calculate the offset for the next load command.
/// </summary>
private static long GetNextCmdOffset(uint currentCmd, uint totalCmds, long currentOffset, uint cmdSize) =>
currentOffset + cmdSize - 8;
}