Patch Reference
Complete reference for ipa-forge patch definition YAML: every operation type, field, version-matching rule, and the hooks block.
This is the complete reference for writing patch definition files for ipa-forge. A patch definition is a single YAML (or JSON) file that describes what to change in an app bundle, keyed by bundle id + version. ipa-forge never embeds app-specific logic — everything it does to an app comes from files like this.
Working examples live in fixtures/patches/:
example.yaml—binary_replace+resource_replaceexample_dylib_inject.yaml—dylib_injectexample_plist_edit.yaml—plist_edit
File structure
target: # which app this definition applies to (required)
bundle_id: "com.example.app"
version: { exact: "1.2.3" } # or { min: "1.0.0", max: "2.0.0" }
patches: # one or more operations (required, non-empty)
- id: "my-op-1" # arbitrary unique id, shown in output
type: binary_replace # one of the types below
# ... type-specific fields ...Definitions are loaded with yaml.safe_load (no arbitrary code execution) and
validated against a pydantic schema; a file that fails validation produces a
single-line actionable error, never a traceback. An empty patches: list is
rejected — a definition with no operations is always a mistake.
The target block — matching
The definition only applies when both conditions hold:
Prop
Type
Version matching
Two forms, mutually exclusive:
version: { exact: "1.2.3" } # string equality against the bundle's version
version: { min: "1.0.0", max: "2.0.0" } # numeric rangeexactcompares the raw strings:exact: "1.0.0"will not match a bundle whose version is1.0.0-beta.min/maxcompare numerically.maxis exclusive —max: "2.0.0"matches1.9.9and1.5.0-betabut not2.0.0.- Range matching strips non-numeric segments, so
1.0.0-betaand1.0.0are considered equal formin/maxpurposes. - Both
minandmaxare optional;{min: "1.0.0"}means "1.0.0 or later".
What happens when the target does not match
If the supplied definition's target does not match the IPA you patched, zero
operations resolve:
forge patch --dry-runwarns ("matches 0 patch operations ... nothing will be applied") and still succeeds — useful for confirming a version gate.- a real (non-dry-run)
forge patchfails with "refusing to produce an unpatched IPA". A typo'd bundle id can never silently produce an unpatched output.
Common fields
Prop
Type
Operation types
binary_replace — deterministic byte patching
Replaces an exact byte sequence inside a Mach-O executable with a fixed replacement.
- id: "zero-marker-bytes"
type: binary_replace
executable: "TestApp" # basename of the Mach-O file inside the bundle
arch: "arm64" # required for fat/universal binaries
pattern: "ca fe f0 0d de ad" # space-separated hex bytes
replacement: "00 00 00 00 00 00"
expected_matches: 1 # optional, default 1
note: "Zero the synthetic marker used to demonstrate byte patching"
symbol: "marker_bytes"Prop
Type
Semantics:
-
Replacement bytes must be explicit:
??is accepted only inpattern. A wildcard replacement, invalid hex, or unequal lengths fails lint and the dry-run gate before mutation. Match-count errors show at most ten hex offsets. -
The search is bounded to one architecture slice: for a fat/universal binary the pattern is only matched within the selected slice's byte range, so a patch can never silently land in the wrong architecture. For thin binaries the whole file is searched and
archis unnecessary. -
A universal binary without
archis an error ("an explicitarch:selector is required") — ipa-forge refuses to guess. -
Supported arch names:
arm64,armv7,x86_64,i386. -
If the pattern occurs more than once, every occurrence is replaced (and
expected_matchesmust match the actual count). -
The operation fails during dry run if: the executable isn't in the bundle, the pattern is malformed, the file isn't Mach-O, or the match count differs.
resource_replace — overwrite a bundle file
- id: "swap-asset"
type: resource_replace
path: "asset.txt" # bundle-relative destination
source: "assets/patched_asset.txt" # relative to the definition fileProp
Type
sourcemay also be an absolute path or../reference — the definition, the IPA, and the signing credentials are all supplied by the same trusted user, so sources are intentionally not sandboxed.- Fails in dry run if the source is missing or the destination doesn't exist
(use
resource_addto create new files instead).
resource_add — add a new bundle file
- id: "add-hook-lib"
type: resource_add
path: "Frameworks/libHook.dylib"
source: "assets/libHook.dylib"- Identical fields to
resource_replace. - Creates intermediate directories as needed.
- Fails in dry run if the destination already exists (use
resource_replaceto overwrite) or the source is missing.
resource_remove — delete a bundle file
- id: "remove-obsolete-asset"
type: resource_remove
path: "obsolete.txt"Prop
Type
- Fails in dry run if the destination doesn't exist or is a directory.
dylib_inject — add a dylib load command
Adds an LC_LOAD_DYLIB (or LC_LOAD_WEAK_DYLIB) entry to a Mach-O so the
runtime loads the named dylib.
- id: "inject-hook"
type: dylib_inject
executable: "TestApp"
arch: "arm64" # required for fat/universal binaries
install_name: "@rpath/libHook.dylib" # the load path, not a source file
load_command: "LC_LOAD_DYLIB" # optional; or LC_LOAD_WEAK_DYLIBProp
Type
This operation does not copy the dylib into the bundle
The dylib file must already be present (shipped with the app, or placed there
with a resource_add). The standard pattern is:
patches:
- id: "stage-dylib"
type: resource_add
path: "Frameworks/libHook.dylib"
source: "assets/libHook.dylib"
- id: "link-dylib"
type: dylib_inject
executable: "TestApp"
install_name: "@rpath/libHook.dylib"Outcomes (reported per-operation in the manifest):
Prop
Type
Dylib injection is deliberately the most fragile operation — it rewrites
Mach-O load commands and forces a full re-sign. Prefer binary_replace or
resource operations when they can express the change.
plist_edit — set/remove Info.plist keys
- id: "set-display-name"
type: plist_edit
action: "set" # or "remove"
key: "CFBundleDisplayName"
value: "Patched App" # required when action is set
path: "Info.plist" # optional, bundle-relative, defaults to Info.plistProp
Type
removefails in dry run if the key is not present.- Works on binary or XML plists transparently.
How operations run: the dry-run gate and ordering
The dry-run gate
forge patch never mutates anything until every operation has reported
dry_run_ok. If any operation would fail, the whole patch is rejected before
a single file changes.
When applying, operations run in a fixed order:
Resource operations and plist edits — resource_replace/resource_add/resource_remove
and plist_edit run first; they don't touch Mach-O layout.
Binary patches — binary_replace raw byte edits.
Dylib injection — dylib_inject runs last, because LIEF's load-command
rewrites can shift byte offsets that binary patches depend on.
Order within a group follows the order in the YAML file.
The hooks block — verify runtime hooks against the binary
Dylib-injection tweaks live or die on ObjC runtime hooks: the dylib swizzles
-[Class selector], and when a newer app version renames or removes that
class/selector the hook silently no-ops — the feature just stops working
with no error. The hooks: block declares every hook the patch set relies on
so forge can verify it against the actual binary before anything mutates:
hooks:
- class: "AppConfig"
selector: "isFeatureFlagEnabled"
kind: instance # instance (default) | class
- class: "PlaybackResponse"
selector: "extraDataArray"
added: true # the tweak adds this method itself, it doesn't exist in the app
- class: "SessionService"
selector: "urlFromURL:withAdditionalFragmentParameters:"
required: true # fail the run if this hook can't attachProp
Type
forge patch --dry-run verifies every declared hook against the app's main
binary, embedded frameworks, and standalone dylibs (class table + method lists
- selrefs, chained-fixup aware) and prints
a summary. The
forge hookscommands do the same without patching, and every one of them accepts--app-dir Payload/<App>.appin place of--ipato skip re-extraction when iterating on the same app.
Dry run OK -- 4 operation(s) would apply.
Hooks: 151/159 attach, 8 unverified (parser gap -- check on device), 0 failing
? SettingsController -[isHintsDisabled]: unverified -- class exists ... walk missed itHook statuses
Prop
Type
The three groups are attaching, unknown, and blocking. Only a required hook
in the blocking group fails a run; unverified never blocks, even with
required: true. Confirm parser gaps on device.
referenced-only is the dead-hook detector: the binary references the
selector (selrefs / NSSelectorFromString / optional protocol sends) but does
not declare it as a method anywhere (__objc_methname has no such name), so
there is no IMP to swizzle and class_getInstanceMethod returns NULL — the
hook cannot attach. This is the classic "the verify says it attaches but the
feature does nothing" trap (some selectors are referenced by the app but
implemented by nothing in a given build). Distinguish it from
unverified, which means the selector is declared as a method somewhere (a
protocol, a category, or a method list the walker did not decode) — the hook
may well attach.
unverified is usually a parser gap, not real drift
When a class or selector the walk missed is still present as a string in some
binary image (the old manual strings <binary> | grep cross-check), the
report says so — "class-name string present … the walk missed it (likely
attaches)" / "selector declared as a method somewhere … (likely attaches)".
The parser under-reports methods on protobuf-generated message classes this
way. Swift classes (_TtC…) are parsed like any ObjC class — the walker masks
the Swift flag bits in class_t.data as the ObjC runtime does — so a Swift
class absent from the table usually means a rename; it is still reported
unverified (never mislabeled as a system class) because generic Swift
classes never appear in __objc_classlist.
Porting to a new app version becomes: bump target.version, run
--dry-run, read the hook report, and fix exactly the hooks the report flags.
forge hooks manifest --dir <dylib-sources> regenerates the hooks: block
from the tweak sources (inline NSClassFromString calls, unambiguous
variable assignments, class names passed through a resolver helper — a
function in any scanned file whose body calls NSClassFromString, invoked as
resolver("X") at the hook call site or assigned to a variable — and <prefix>HookConfigBool config-flag getters); pass
--inplace patch.yaml to write the block straight into the patch definition.
forge hooks diff --old A.ipa --new B.ipa --patches patch.yaml shows which
hooks regressed between two versions.
forge hooks find <selector> --ipa App.ipa is the reverse lookup — which
classes implement a selector, plus the referenced-only warning when nothing
does. Check it first when a hook "attaches per verify" but does nothing on
device. forge analysis classdump --ipa App.ipa --class <name> prints the FULL
method list (no truncation) — pipe large config classes through grep.
A hook not declared in the hooks: block is invisible to --dry-run
--dry-run only checks declared hooks; a hook the tweak source calls but the
block omits produces no error, no warning. forge hooks audit --ipa App.ipa --dir <dylib-sources> --patches patch.yaml closes that gap: it scans the
sources the same way manifest does, then diffs what it found against
what's declared, printing every hook that's missing and exiting 1 if any
are. Run it as a standard part of the loop above (after manifest,
alongside --dry-run), not only when a feature seems broken — this is how
real, load-bearing hooks have been found missing from shipped patch sets
after the fact, with --dry-run green the whole time.
Operation IDs must be unique across patches:; the schema rejects duplicates.
forge lint patch.yaml also rejects repeated class/selector hook entries.
The manifest
Every successful run produces a structured manifest (--manifest PATH writes
JSON for dry-runs and real runs, --verbose prints it, and the GUI shows it
in the result card). File inventories use app-relative paths. Binary entries
include file, hex offsets, before, after, and optional note/symbol.
For several wildcard matches, before_by_offset records each actual window.
Dry-run entries carry byte evidence but do not declare applied file changes.
An applied manifest looks like:
{
"input_sha256": "...",
"bundle_id": "com.example.synthetic",
"version": "1.0.0",
"build": "1",
"patches_applied": [
{"id": "zero-marker-bytes", "status": "applied", "file": "TestApp",
"offsets": ["0x100"], "before": "ca fe", "after": "00 00",
"note": "Zero the marker", "symbol": "marker_bytes",
"message": "replaced 1 match(es) at [0x100]"}
],
"files_added": [], "files_modified": ["TestApp"], "files_removed": [],
"macho_modified": ["TestApp"],
"profile": {"uuid": "...", "team_id": "...", "expiration": "..."},
"output_sha256": "..."
}Use it to verify exactly what changed (and what didn't) before installing.