What does a code signature actually protect, what does it not say about the signer, and what happens when a signed binary is modified?

14.7.3 · L3 · Interview

Predict first

What happens to a signed binary when one byte changes?

import Foundation

func sh(_ args: [String]) -> (Int32, String) {
    let p = Process(); p.executableURL = URL(fileURLWithPath: "/usr/bin/env"); p.arguments = args
    let out = Pipe(); p.standardOutput = out; p.standardError = out
    try! p.run()
    let data = out.fileHandleForReading.readDataToEndOfFile()
    p.waitUntilExit()
    return (p.terminationStatus, String(decoding: data, as: UTF8.self))
}
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cs\(getpid())")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let src = dir.appendingPathComponent("t.swift"), bin = dir.appendingPathComponent("t"), ent = dir.appendingPathComponent("e.plist")
try "import Foundation\nprint(\"ran\", ProcessInfo.processInfo.processIdentifier > 0)".write(to: src, atomically: true, encoding: .utf8)
try """
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>com.apple.security.get-task-allow</key><true/></dict></plist>
""".write(to: ent, atomically: true, encoding: .utf8)
_ = sh(["swiftc", src.path, "-o", bin.path])
_ = sh(["codesign", "-f", "-s", "-", "--entitlements", ent.path, bin.path])
let info = sh(["codesign", "-dvv", bin.path]).1
print("ad hoc:", info.contains("Signature=adhoc"), "| no team:", info.contains("TeamIdentifier=not set"))
print("entitlement embedded:", sh(["codesign", "-d", "--entitlements", ":-", bin.path]).1.contains("get-task-allow"))
print("verify before:", sh(["codesign", "-v", bin.path]).0)
let h = try FileHandle(forUpdating: bin)
let mid = UInt64((try FileManager.default.attributesOfItem(atPath: bin.path)[.size] as! NSNumber).intValue / 3)
try h.seek(toOffset: mid)
let old = try h.read(upToCount: 1)!
try h.seek(toOffset: mid)
try h.write(contentsOf: Data([old[0] ^ 0xff]))
try h.close()
print("verify after one changed byte:", sh(["codesign", "-v", bin.path]).0)
let p = Process(); p.executableURL = bin
p.standardOutput = Pipe(); p.standardError = Pipe()
try p.run(); p.waitUntilExit()
print("tampered binary ends by signal:", p.terminationReason == .uncaughtSignal, p.terminationStatus)
try? FileManager.default.removeItem(at: dir)
  • ad hoc: true | no team: true / entitlement embedded: true / verify before: 0 / verify after one changed byte: 1 / tampered binary ends by signal: true 9
  • verify after one changed byte: 0 / tampered binary ends by signal: false 0
  • ad hoc: false | no team: false

Mental model. A signature is a wax seal pressed over a list of fingerprints of every page: break any page and the seal no longer matches, but the seal alone does not tell you whose ring made it.

A code signature stores, in the binary’s CodeDirectory, a hash for each page of the code and for the resources and entitlements, and the signature covers that directory. The kernel and the loader check it at launch and again as pages are faulted in, so changing even one byte makes the binary invalid: the check shows codesign -v succeeding (exit 0), then failing (exit 1) after a single byte was altered, and running the altered file is killed by a signal (status 9) on Apple silicon. Entitlements are part of what is signed, so they cannot be added afterwards without re-signing, and reading them back (codesign -d --entitlements :-) shows exactly what the system will honour (Q479).

What a signature does not say is who you can trust. An ad hoc signature (codesign -s -) has no certificate and no team identifier (the check shows both), which is enough to run locally and in the simulator but proves only integrity. Trust comes from the certificate chain behind a real signature, the team identifier it carries, and for distribution from the App Store’s re-signing or notarisation on macOS (Q478). For your own design this has two consequences: a re-signed or modified app is a different app that the system will run if the new signature is valid, so integrity checks that live inside your own code can be removed (hence the weak value of client-side tamper detection, Q350 and App Attest, Q344); and any resource outside the signed bundle (downloaded content, preferences) is not covered at all and needs its own signature or MAC. Not run: certificate-based signing (needs an identity) and the iOS device launch path.

The page hashes no longer match, so verification fails and the kernel kills the process.

Common traps

  • Assuming “validly signed” means “trusted”. An ad hoc signature is valid and says nothing about the author.
  • Putting data you must trust outside the bundle. Only the signed bundle contents are integrity-checked.
  • Relying on the app checking its own signature. The check can be patched out by whoever can re-sign.

They might follow up with

  • Where do I read the signing details? codesign -dvv and codesign -d --entitlements :-.
  • Does a Debug build’s get-task-allow entitlement matter? It lets a debugger attach, and release builds must not have it.

Related questions

  • 14.7.4 How does a Secure Enclave key behave in code: what is stored, what can be restored, and what can you not do…
  • 25.1.1 How does iOS code signing work: certificates, App IDs, provisioning profiles, signing identities, export…
  • 25.1.2 What is the difference between a capability, an entitlement and an Info.plist key, and what breaks when they…
  • 14.2.2 What do App Attest and DeviceCheck prove, when do they help, and what are their limits?

← What exactly does App Transport Security block, which Info.plist keys…   How does a Secure Enclave key behave in code: what is stored, what… →