Skip to content

Commit 05fa039

Browse files
Add ad-hoc code signing for Mach-O images
Apple Silicon refuses to execute an unsigned image, and any edit invalidates an existing signature, so an arm64 binary could be modified by this library only to become unrunnable. Adds ad-hoc signing: a code directory of SHA-256 page digests plus an empty requirement set, appended to __LINKEDIT with the load command added when the image was previously unsigned. The signature covers exactly the bytes preceding it, so its size is derived from the identifier and the signed length before any digest is taken, and the image is laid out once and then hashed as it will finally be written. Signing appends, leaving every segment and section where it was. - src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs: builds the superblob, which is big-endian throughout unlike the rest of the format - src/LibObjectFile/MachO/MachOFile.Sign.cs: places the signature and grows __LINKEDIT to cover it - src/LibObjectFile.Tests/MachO/MachOSigningTests.cs: recomputes every page digest from the written image, which is the property the kernel checks
1 parent aa17d9f commit 05fa039

4 files changed

Lines changed: 626 additions & 0 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright (c) Alexandre Mutel. All rights reserved.
2+
// This file is licensed under the BSD-Clause 2 license.
3+
// See the license.txt file in the project root for more information.
4+
5+
using System;
6+
using System.Buffers.Binary;
7+
using System.IO;
8+
using System.Linq;
9+
using System.Security.Cryptography;
10+
using System.Text;
11+
using LibObjectFile.MachO;
12+
using LibObjectFile.MachO.CodeSign;
13+
14+
namespace LibObjectFile.Tests.MachO;
15+
16+
using static MachOCodeSignatureConstants;
17+
18+
[TestClass]
19+
public class MachOSigningTests
20+
{
21+
private static string GetFile(string name) => Path.Combine(AppContext.BaseDirectory, "MachO", name);
22+
23+
private static MachOFile Load(string name) => MachOFile.Read(new MemoryStream(File.ReadAllBytes(GetFile(name))));
24+
25+
private static byte[] SignAndWrite(string fixture, string identifier)
26+
{
27+
var file = Load(fixture);
28+
file.AdHocSign(identifier);
29+
var stream = new MemoryStream();
30+
file.Write(stream);
31+
return stream.ToArray();
32+
}
33+
34+
/// <summary>
35+
/// Recomputes every page digest from the written image and compares it with what the code
36+
/// directory claims. This is the property the kernel checks, so getting it wrong produces a
37+
/// file that looks signed and is refused at execution.
38+
/// </summary>
39+
[TestMethod]
40+
[DataRow("unixthread_i386")]
41+
[DataRow("helloworld_x86_64")]
42+
[DataRow("helloworld_arm64")]
43+
[DataRow("libhelloworld_x86_64.dylib")]
44+
public void EveryPageDigestMatchesTheImage(string fixture)
45+
{
46+
var image = SignAndWrite(fixture, fixture);
47+
var file = MachOFile.Read(new MemoryStream(image));
48+
49+
var command = file.CodeSignature;
50+
Assert.IsNotNull(command);
51+
52+
var codeDirectory = FindCodeDirectory(image, command.DataOffset);
53+
var hashOffset = BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(16));
54+
var codeSlots = BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(28));
55+
var codeLimit = BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(32));
56+
57+
Assert.AreEqual(command.DataOffset, codeLimit, "the signature must cover exactly the bytes before it");
58+
Assert.AreEqual((codeLimit + PageSize - 1) / PageSize, codeSlots);
59+
60+
for (var slot = 0; slot < codeSlots; slot++)
61+
{
62+
var start = slot * PageSize;
63+
var length = Math.Min(PageSize, (int)codeLimit - start);
64+
var expected = SHA256.HashData(image.AsSpan(start, length));
65+
var stored = codeDirectory.Slice((int)hashOffset + slot * Sha256Size, Sha256Size);
66+
CollectionAssert.AreEqual(expected, stored.ToArray(), $"page {slot} digest does not match");
67+
}
68+
}
69+
70+
[TestMethod]
71+
public void SignatureUsesTheAdHocCodeDirectoryFormat()
72+
{
73+
var image = SignAndWrite("unixthread_i386", "swkotor");
74+
var file = MachOFile.Read(new MemoryStream(image));
75+
var command = file.CodeSignature!;
76+
77+
var superBlob = image.AsSpan((int)command.DataOffset);
78+
Assert.AreEqual(EmbeddedSignatureMagic, BinaryPrimitives.ReadUInt32BigEndian(superBlob));
79+
Assert.AreEqual(3u, BinaryPrimitives.ReadUInt32BigEndian(superBlob.Slice(8)), "code directory, requirements and CMS slots");
80+
81+
var codeDirectory = FindCodeDirectory(image, command.DataOffset);
82+
Assert.AreEqual(CodeDirectoryMagic, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory));
83+
Assert.AreEqual(CodeDirectoryVersionExecSeg, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(8)));
84+
Assert.AreEqual(AdHocFlag, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(12)));
85+
Assert.AreEqual((uint)CodeDirectoryHeaderSize, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(20)), "identifier follows the fixed header");
86+
Assert.AreEqual(2u, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(24)), "Info and requirements special slots");
87+
Assert.AreEqual(Sha256Size, codeDirectory[36]);
88+
Assert.AreEqual(HashTypeSha256, codeDirectory[37]);
89+
Assert.AreEqual(PageSizeLog2, codeDirectory[39]);
90+
91+
var identifier = codeDirectory.Slice(CodeDirectoryHeaderSize);
92+
Assert.AreEqual("swkotor", Encoding.UTF8.GetString(identifier.Slice(0, identifier.IndexOf((byte)0))));
93+
94+
// The executable range comes from __TEXT, and an executable is flagged as a main binary.
95+
var text = file.FindSegment("__TEXT")!;
96+
Assert.AreEqual(text.FileOffset, BinaryPrimitives.ReadUInt64BigEndian(codeDirectory.Slice(64)));
97+
Assert.AreEqual(text.FileSize, BinaryPrimitives.ReadUInt64BigEndian(codeDirectory.Slice(72)));
98+
Assert.AreEqual(ExecSegMainBinary, BinaryPrimitives.ReadUInt64BigEndian(codeDirectory.Slice(80)));
99+
}
100+
101+
/// <summary>
102+
/// The Info.plist slot has nothing to hash for a bare executable, but it is still described,
103+
/// because a slot recorded as absent and a slot recorded as empty are different claims.
104+
/// </summary>
105+
[TestMethod]
106+
public void SpecialSlotsRecordAnEmptyInfoAndTheRequirements()
107+
{
108+
var image = SignAndWrite("unixthread_i386", "swkotor");
109+
var file = MachOFile.Read(new MemoryStream(image));
110+
var codeDirectory = FindCodeDirectory(image, file.CodeSignature!.DataOffset);
111+
var hashOffset = (int)BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(16));
112+
113+
var info = codeDirectory.Slice(hashOffset - Sha256Size, Sha256Size);
114+
Assert.IsTrue(info.ToArray().All(b => b == 0), "the Info.plist slot should be zero");
115+
116+
var requirements = FindBlob(image, file.CodeSignature.DataOffset, RequirementsSlot);
117+
Assert.AreEqual(RequirementsMagic, BinaryPrimitives.ReadUInt32BigEndian(requirements));
118+
Assert.AreEqual(0u, BinaryPrimitives.ReadUInt32BigEndian(requirements.Slice(8)), "an ad-hoc signature states no requirements");
119+
120+
var stored = codeDirectory.Slice(hashOffset - 2 * Sha256Size, Sha256Size);
121+
CollectionAssert.AreEqual(SHA256.HashData(requirements.ToArray()), stored.ToArray());
122+
}
123+
124+
[TestMethod]
125+
public void SigningAnUnsignedImageAddsTheLoadCommand()
126+
{
127+
var file = Load("unixthread_i386");
128+
Assert.IsNull(file.CodeSignature);
129+
var before = file.LoadCommands.Count;
130+
var space = file.AvailableLoadCommandSpace;
131+
132+
file.AdHocSign("swkotor");
133+
134+
Assert.AreEqual(before + 1, file.LoadCommands.Count);
135+
Assert.AreEqual(space - MachOLinkEditDataCommand.CommandSize, file.AvailableLoadCommandSpace);
136+
Assert.AreEqual(0u, file.CodeSignature!.DataOffset % SignatureAlignment, "the signature must be 16-byte aligned");
137+
}
138+
139+
/// <summary>
140+
/// Re-signing has to replace the previous signature rather than stack a new one behind it,
141+
/// otherwise repeated edits would grow the file without bound.
142+
/// </summary>
143+
[TestMethod]
144+
public void ReSigningReplacesTheExistingSignature()
145+
{
146+
var file = Load("helloworld_arm64");
147+
Assert.IsNotNull(file.CodeSignature);
148+
var commands = file.LoadCommands.Count;
149+
150+
file.AdHocSign("helloworld");
151+
var first = new MemoryStream();
152+
file.Write(first);
153+
154+
var again = MachOFile.Read(new MemoryStream(first.ToArray()));
155+
again.AdHocSign("helloworld");
156+
var second = new MemoryStream();
157+
again.Write(second);
158+
159+
Assert.AreEqual(commands, again.LoadCommands.Count, "re-signing added a load command");
160+
Assert.AreEqual(first.Length, second.Length, "re-signing grew the file");
161+
ByteArrayAssert.AreEqual(first.ToArray(), second.ToArray(), "re-signing is not idempotent");
162+
}
163+
164+
[TestMethod]
165+
public void SignedImagesRoundTrip()
166+
{
167+
var image = SignAndWrite("unixthread_i386", "swkotor");
168+
169+
var reread = MachOFile.Read(new MemoryStream(image));
170+
var again = new MemoryStream();
171+
reread.Write(again);
172+
173+
ByteArrayAssert.AreEqual(image, again.ToArray(), "a signed image does not round-trip");
174+
}
175+
176+
/// <summary>
177+
/// Signing appends to __LINKEDIT, so it must not disturb anything mapped before it.
178+
/// </summary>
179+
[TestMethod]
180+
public void SigningLeavesEverySectionWhereItWas()
181+
{
182+
var before = Load("unixthread_i386");
183+
var originalOffsets = before.Segments.SelectMany(s => s.Sections).Select(s => (s.Name, s.FileOffset)).ToArray();
184+
185+
var image = SignAndWrite("unixthread_i386", "swkotor");
186+
var after = MachOFile.Read(new MemoryStream(image));
187+
188+
CollectionAssert.AreEqual(originalOffsets, after.Segments.SelectMany(s => s.Sections).Select(s => (s.Name, s.FileOffset)).ToArray());
189+
190+
var linkEdit = after.FindSegment("__LINKEDIT")!;
191+
Assert.AreEqual(before.FindSegment("__LINKEDIT")!.FileOffset, linkEdit.FileOffset, "__LINKEDIT moved");
192+
Assert.IsTrue(linkEdit.FileSize > before.FindSegment("__LINKEDIT")!.FileSize, "__LINKEDIT did not grow to cover the signature");
193+
Assert.AreEqual(linkEdit.FileEndOffset, after.CodeSignature!.DataOffset + after.CodeSignature.DataSize);
194+
}
195+
196+
[TestMethod]
197+
public void RejectsWhatCannotBeSigned()
198+
{
199+
// An object file has no __LINKEDIT, so there is nowhere for a signature to live.
200+
Assert.ThrowsExactly<InvalidOperationException>(() => Load("helloworld_x86_64.o").AdHocSign("helloworld"));
201+
Assert.ThrowsExactly<ArgumentException>(() => Load("unixthread_i386").AdHocSign(string.Empty));
202+
}
203+
204+
private static Span<byte> FindCodeDirectory(byte[] image, uint signatureOffset)
205+
=> FindBlob(image, signatureOffset, CodeDirectorySlot);
206+
207+
private static Span<byte> FindBlob(byte[] image, uint signatureOffset, uint slot)
208+
{
209+
var superBlob = image.AsSpan((int)signatureOffset);
210+
var count = BinaryPrimitives.ReadUInt32BigEndian(superBlob.Slice(8));
211+
for (var i = 0; i < count; i++)
212+
{
213+
var entry = superBlob.Slice(12 + i * 8);
214+
if (BinaryPrimitives.ReadUInt32BigEndian(entry) != slot) continue;
215+
216+
var offset = (int)BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(4));
217+
var length = (int)BinaryPrimitives.ReadUInt32BigEndian(superBlob.Slice(offset + 4));
218+
return superBlob.Slice(offset, length);
219+
}
220+
221+
throw new InvalidOperationException($"The signature has no blob in slot {slot}");
222+
}
223+
}

0 commit comments

Comments
 (0)