A read through DrWhax/unfairplay: publishing secrets with os.Link instead of os.Rename, rejecting existing symlinks in directory paths, and validating binary input before allocating.
· 8 min read

unfairplay in Go: Private File Writes, Symlink Checks, and Binary Parsing


unfairplay publishes its secret files with os.Link instead of os.Rename. That one call tells you most of what you need to know about the codebase: it handles key material, part of it runs under sudo, and it reads archives it did not produce. The Go in it is mostly defensive plumbing. The plumbing is the interesting part.

The tool is a macOS CLI, described in its root command as “Recover native Mac FairPlay enrollment and decrypt personalized IPAs” (FairPlay is Apple’s DRM). Below are the patterns I’d lift out of the source and reuse: safe secret-file publishing, directory creation that survives running as root, and fixed-layout binary parsing that refuses to trust a length field.

writePrivateFile in cmd/unfairplay/main.go is the “write a key to disk” helper. The whole function:

func writePrivateFile(path string, value []byte) (returnErr error) {
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return err
	}
	temporary, err := os.CreateTemp(filepath.Dir(path), ".unfairplay-*")
	if err != nil {
		return err
	}
	temporaryPath := temporary.Name()
	published := false
	defer func() {
		_ = temporary.Close()
		if returnErr != nil {
			_ = os.Remove(temporaryPath)
			if published {
				_ = os.Remove(path)
			}
		}
	}()
	if err := temporary.Chmod(0o600); err != nil {
		return err
	}
	if _, err := temporary.Write(value); err != nil {
		return err
	}
	if err := temporary.Sync(); err != nil {
		return err
	}
	if err := temporary.Close(); err != nil {
		return err
	}
	if err := os.Link(temporaryPath, path); err != nil {
		return err
	}
	published = true
	if err := os.Chmod(path, 0o600); err != nil {
		return err
	}
	return os.Remove(temporaryPath)
}

The temp file lands in the same directory as the destination. Hard links cannot cross filesystems, and creating the destination link is atomic. A temp file under /tmp could be on another filesystem, where both os.Link and os.Rename fail rather than silently copying the file.

os.Link fails when the destination already exists. os.Rename would overwrite it without a word. For enrollment state you do not want to clobber, failing loudly is the better default, and the cost is real: this function cannot update a file in place. If you want overwrite semantics, you have to remove the destination yourself first, which is an explicit decision rather than an accident.

The named return returnErr exists so the deferred closure can see whether the function succeeded. If something fails after the link lands, published is true and the cleanup removes the published path as well as the temp file. This is the one use of named returns I find genuinely hard to replace with anything cleaner. How to defer in Golang like a pro covers the mechanics if the ordering rules are fuzzy for you.

Close happens twice on purpose: once explicitly before the link, once in the defer. The deferred one throws its error away with _ =, because closing an already-closed file just tells you it’s already closed.

The two Chmod(0o600) calls, one on the handle and one on the destination path, are belt and braces. os.CreateTemp already creates at 0600. The explicit calls make the permission a property of this code instead of a fact you have to remember about the standard library.

createPrivateModelDirectory in cmd/unfairplay/setup.go does not reach straight for os.MkdirAll. It walks up the path first:

for current := absolute; ; current = filepath.Dir(current) {
	info, statErr := os.Lstat(current)
	if statErr == nil {
		if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
			return nil, fmt.Errorf("model directory path is not a private directory: %s", current)
		}
		break
	}
	if !errors.Is(statErr, os.ErrNotExist) {
		return nil, statErr
	}
	created = append(created, current)
	parent := filepath.Dir(current)
	if parent == current {
		return nil, fmt.Errorf("model directory has no existing ancestor")
	}
}

os.Lstat is the key check. os.Stat follows symlinks and would report a link pointing at a directory as a directory. Lstat describes the link itself, and the info.Mode()&os.ModeSymlink test rejects it.

This is a preflight check, not a race-free filesystem primitive. Another process can replace an ancestor after Lstat and before MkdirAll or Chmod, and those path-based calls will follow the replacement. Code defending against an actively hostile writer needs descriptor-relative operations such as openat/mkdirat with no-follow checks on each component. Here the function protects against an already-present or accidental symlink.

The loop also records every ancestor that does not exist yet in created. Afterwards the function calls os.MkdirAll(absolute, 0o700) and then os.Chmod(absolute, 0o700), because MkdirAll runs your mode through the process umask. When the checked path remains stable, the final Chmod sets the leaf directory to the exact mode.

That created slice is not bookkeeping for its own sake. It gives the caller the exact set of directories this run brought into existence, and restoreInvokingUserOwnership in the same file takes paths ...string and chowns them.

Chowning is necessary because the tool can run under sudo. Both restoreInvokingUserOwnership and restoreInvokingUserTree return nil immediately unless os.Geteuid() == 0 and SUDO_UID/SUDO_GID are both set. They parse those with strconv.Atoi and reject negative values. restoreInvokingUserTree walks with filepath.WalkDir and returns an error the moment it hits a symlink rather than chowning through it, which is the same instinct as the Lstat loop applied to a whole tree.

defaultModelDirectory carries the same sudo awareness. It checks UNFAIRPLAY_MODEL_DIR first. If that’s empty and the process is root with a SUDO_USER, it resolves the account with user.Lookup, fails if the home directory is empty, and builds the base path from that home, switching on runtime.GOOS between Library/Application Support and .config. Otherwise it falls back to os.UserConfigDir(). Root-owned junk left in a user’s config directory is one of the oldest CLI papercuts there is, and this is about 20 lines of fix. If you’re weighing config precedence in your own tool, this comparison of Go env var libraries pairs well with it.

Parsing fixed-layout binary blobs

internal/fairplay/current_sinf.go parses a binary model format, and it opens by stating the exact byte size of each version as a sum of field sizes:

currentSINFModelV1Bytes  = 8 + 4 + 4 + 32 + 16 + 16 + 16*256
currentSINFModelV2Bytes  = 8 + 4 + 4 + 32 + 4 + 4 + 16 + 16 + 16*256
currentSINFV3SourceBytes = 27 * aes.BlockSize

Writing 8 + 4 + 4 + 32 + ... rather than the precomputed total turns the constant into documentation of the layout. Change a field, edit one term. And 27 * aes.BlockSize pins the size to crypto/aes instead of leaving a bare 432 in the file for someone to misread later.

The envelope check runs before anything is sliced:

if len(pack) < 16 || binary.LittleEndian.Uint32(pack[12:16]) != uint32(len(pack)) {
	return model, fmt.Errorf("invalid current SINF model envelope")
}

The declared length has to equal the buffer’s real length, so a lying length field never gets to drive an offset. Each version case then demands an exact len(pack), an 8-byte magic string ("FPSENR3\x00" and relatives), and a matching version number, all three at once. Only then does it set payloadOffset and start copying. Version 3 tightens further: the three reserved bytes must be zero, sourceOffset must be non-negative, and sourceBytes must be exactly currentSINFV3SourceBytes. After the copies, the byte-permutation tables get validated too.

One structural tradeoff is worth noticing. currentSINFModel holds several [16 * 256]byte arrays plus a [432]byte as values, so the struct runs past 12KB, and parseCurrentSINFModel returns it by value. The payoff is that the parsed model never aliases the caller’s buffer, and the later error paths return currentSINFModel{} explicitly, so a caller that ignores the error gets a zeroed struct instead of a half-filled one. The price is a double-digit-kilobyte copy on every return. For a parse that happens once per run, I’d make the same call. In a hot loop I’d pass a pointer and eat the aliasing question instead.

The file also declares a sentinel:

var errCurrentSINFProfileMismatch = errors.New("SINF does not match the selected FairPlay model")

Sentinel for the one condition a caller might reasonably want to match with errors.Is; fmt.Errorf for the malformed-input cases where there’s nothing to do but give up. That split is the right habit, and it’s the one most codebases get backwards by making everything a sentinel.

Reading untrusted zip members

An IPA is a zip file, and internal/fairplay/decrypt.go treats it as hostile. readMachOProbe pulls a 32-byte header with io.ReadAll(io.LimitReader(reader, 32)), then decides how much more to read from the Mach-O load-command size in bytes 20 to 24:

commandsBytes := int(binary.LittleEndian.Uint32(header[20:24]))
required := headerBytes + commandsBytes
if required < headerBytes || required > 1024*1024 || uint64(required) > file.UncompressedSize64 {
	return header, fmt.Errorf("Mach-O load-command extent is invalid")
}
probe := make([]byte, required)

An overflow check, a hard 1MB cap, and a cross-check against the zip entry’s declared uncompressed size, all before a single allocation. make([]byte, n) where n came out of a file is the shortest path from a malformed archive to an OOM kill, and three cheap comparisons close it.

A smaller detail in the same file trips people up constantly. Zip member names are slash-separated on every platform, so the code uses path, not path/filepath:

func adjacentSINF(member string) string {
	return path.Join(path.Dir(member), "SC_Info", path.Base(member)+".sinf")
}

decrypt.go imports both and keeps them in their lanes: path for archive members, filepath for real filesystem paths. On Windows, filepath.Join would hand you backslashes and your map lookups would quietly return nothing.

When no adjacent .sinf exists, sinfName climbs toward the root, collects candidates sitting directly under a containing SC_Info directory, sorts them, and accepts exactly one. Two or more is an error that names every candidate. Zero keeps it climbing. For a tool whose output is a decrypted binary, refusing to guess is the only defensible answer.

The CLI shape

The root command in main.go is Cobra with SilenceUsage and SilenceErrors both true, so a runtime failure prints error: ... from main and exits 1 rather than burying the message under a usage dump. Version is wired to a package-level var version = "dev" that a build overrides with -ldflags. The subcommands are setup, collect, recover, decrypt, convert-ipa-macos, verify, doctor, and profile. If that structure is new to you, generating a CLI application with Cobra in Golang covers the setup.

Flags bind straight into an options struct via flags.StringVar (fairplay.EnrollmentCollectOptions for collect), with command.MarkFlagRequired on --app and --output. Output goes through a four-line printJSON using json.NewEncoder(os.Stdout) with SetIndent("", " ").

That JSON-first stance carries into internal/fairplay/diagnostics.go, where FairPlayDoctorReport has a schema field, a created_at timestamp, separate ready and decrypt_ready booleans, and sub-reports tagged omitempty. Versioning a diagnostic schema on day one costs one string field and means doctor output can be parsed instead of grepped.

If you take two things out of this repo, take the non-overwriting writePrivateFile publication pattern and the Lstat ancestor preflight—along with its race limitation. Neither depends on FairPlay, and both make the intended filesystem behavior explicit.