swiftdc
Health Warn
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 8 GitHub stars
Code Pass
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
A Swift-aware Mach-O decompiler for Apple Silicon: reconstructs Swift and Objective-C declarations, annotated ARM64, and structured function bodies from runtime metadata.
swiftdc
swiftdc is a decompiler for Swift and Objective-C Mach-O binaries on Apple
Silicon. It reads executables, frameworks, dylibs, .ipas and images in the
dyld shared cache.
From runtime metadata it rebuilds Swift and Objective-C declarations. From the
code it produces annotated ARM64 disassembly and structured function bodies:if/else/while with conditions written in terms of the source values,
message sends in bracket syntax, self.field reads and writes, and recovered
call arguments. Most of this survives stripping, because the __swift5_* and__objc_* metadata it depends on survives too.
The design rule is that swiftdc declines rather than guesses. A value the
analysis can't prove prints as ?. There's no confidence score to
second-guess: if a name or argument appears, metadata or data flow justified
it. Returns follow the same rule. return ? means the function returns
something swiftdc couldn't recover. A bare return means it returns nothing,
or that its signature wasn't recoverable enough to tell.
Quickstart
You need an Apple Silicon Mac with Xcode 26 (requirements).
Clone and build a release binary, then put it on your PATH for this shell:
git clone https://github.com/CamdenBopp/swiftdc.git && cd swiftdc
swift build -c release --scratch-path .build-release
export PATH="$PWD/.build-release/release:$PATH"
The first build fetches and compiles every dependency, so it takes a while.
Then point it at a binary, a .app, a .framework or an .ipa:
swiftdc analyze /path/to/Binary # declarations and disassembly, grouped
swiftdc interface MyApp.app # .swiftinterface-style Swift source
swiftdc objc MyApp.app --methods # ObjC headers and recovered bodies
swiftdc <path> with no subcommand runs analyze.
The examples below run on the test fixtures in Fixtures/Sample/, which
aren't checked in. Build them with Tools/fixtures.sh to run the examples
yourself.
What the output looks like
Swift declarations, rebuilt from metadata alone:
$ swiftdc dump Fixtures/Sample/sample.release --sections types --demangle simplified
struct Point {
var x: Double
var y: Double
/* Allocator */
Point.init(x:y:)
/* Variable */
Point.x.getter
Point.x.setter
Point.y.getter
Point.y.setter
/* Function */
Point.distance(to:)
/* Static Function */
static Point.__derived_struct_equals(_:_:)
}
An Objective-C method body, with its control flow, ivars and arguments named:
$ swiftdc objc Fixtures/Sample/libSample.dylib --function incrementIfEnabled --structured
-[SDObjCCounter incrementIfEnabled:] {
// - (long long)incrementIfEnabled:(long long)arg0;
if (self->_enabled) {
self->_count += arg0
}
return self->_count
}
Stored-property layout. This is the index that turns ldr x8, [x0, #0x10]
into self.name, and stripping doesn't touch it:
$ swiftdc layout Fixtures/Sample/sample.release
Dog // instance size 48 bytes
+0x20 16B breed: SS
Point // instance size 16 bytes
+0x0 8B x: Sd
+0x8 8B y: Sd
Commands
Every subcommand takes a Mach-O, a fat binary, a .app or .framework
bundle, or an .ipa (swiftdc unzips it), and reads the bundle's main
executable unless you name another. devices, apps and research take no
binary path. score takes a binary, or a probe trace with --trace, plus an
independent oracle; INSTRUMENTATION.md covers
fixture scoring, manifest defaults and the Swift API.
| Subcommand | What it does |
|---|---|
analyze (default) |
Declarations and disassembly, grouped by owning type |
dump |
Swift declarations from __swift5_* metadata |
interface |
A full .swiftinterface-style view, more faithful than dump |
objc |
Objective-C headers, and optionally method bodies |
disasm |
Annotated ARM64, with --cfg, --pseudo and --structured views |
xrefs |
Callers and callees of a function, from the recovered call graph |
layout |
Byte offset to Swift stored property, per type |
swift-version |
The Swift version that built the binary, when the binary carries evidence |
devices |
Attached iPhones and iPads |
apps |
Installed apps and their FairPlay encryption status |
score |
Scores integer return claims against independently known values, reporting agreement and coverage separately |
research |
Compiles and scores a matrix of native fixtures across optimization levels, display modes and analysis modes, keeping findings and provenance |
Shared options
analyze, dump, interface, objc and disasm take all of these.xrefs and layout take most of them (see below the table).
| Option | Meaning |
|---|---|
-a, --arch <arch> |
Pick a slice of a fat binary |
--image <name> |
Read this image from the dyld shared cache, by name |
--image-path <path> |
The same, by its full path in the cache |
--cache <path> |
Use an extracted cache file instead of the running system's |
--binary <name> |
In a bundle or .ipa, analyze this embedded framework |
--list-binaries |
List the main executable and embedded frameworks, then exit |
-o, --output <file> |
Write to a file |
--json |
Structured JSON, for jq or for diffing two builds |
xrefs takes everything except --list-binaries. layout takes everything
except --image-path, --list-binaries and --demangle. devices takes
only --json, not -o.
Per-subcommand options
# dump: Swift declarations
swiftdc dump Binary --sections types protocols # space-separated, not commas
swiftdc dump Binary --demangle simplified # default | simplified | interface
swiftdc dump --list-images # every image path in the cache
# interface: a full Swift interface, with optional layout annotations
swiftdc interface Binary --field-offsets --enum-layout --type-layout
swiftdc interface Binary --member-addresses --vtable-offsets --sort-by-offset
swiftdc interface Binary --show-c-imported-types
swiftdc interface Binary --opaque-return-types # experimental
# objc: headers, bodies, structured bodies
swiftdc objc Binary # headers only
swiftdc objc Binary --metadata-only # headers without reading code (below)
swiftdc objc Binary --methods # plus every metadata-backed IMP
swiftdc objc Binary --function incrementBy --pseudo
swiftdc objc Binary --function incrementIfEnabled --structured
# disasm: four views of the same code
swiftdc disasm Binary --function distance # annotated ARM64
swiftdc disasm Binary --function sum --cfg # basic blocks and successor edges
swiftdc disasm Binary --function speak --pseudo # recovered calls, ARC noise hidden
swiftdc disasm Binary --function sum --structured # if/else/while
# layout: stored-property offsets, and the self-type index behind field naming
swiftdc layout Binary # every type
swiftdc layout Binary --type Dog # substring filter
swiftdc layout Binary --at 0x10 --bytes 8 # what lives at this offset?
swiftdc layout Binary --self-index # address to the type of its `self`
# xrefs: call graph queries. It disassembles the whole image, so it's slower
# than `disasm --function`, which decodes only what matched.
swiftdc xrefs Binary --function "Circle.describe"
swiftdc xrefs App.app --function 'objc_msgSend$standardUserDefaults'
swiftdc xrefs Binary --unreferenced # nothing calls these statically
-f is short for --function, -s for --sections and -t for --type.
objc reads each property accessor's body to decide whether it's synthesized
or custom. A synthesized accessor is implied by its @property, so it's
dropped; a custom one stays. --metadata-only reads no code, which suits an
image too large to analyze or a quick look. Without the bodies it can't tell
the two kinds apart, so it keeps every accessor and says so:
@property(copy, nonatomic) NSString *name; // @synthesize name=_name
- (NSString *)name; // unclassified accessor: body not analyzed
- (void)setName:(NSString *)arg0; // unclassified accessor: body not analyzed
Swift version
swift-version names the Swift toolchain that built a binary, but only when
the binary proves it. The version comes from the stable-ABI bits in__objc_imageinfo or from a swift-tool record in LC_BUILD_VERSION. With
neither, it reports the ABI generation (from __swift5_* metadata) or
declines. It shows the SDK as context and never passes it off as the compiler
version.
swiftdc swift-version MyApp.app # "Swift 6.4", with the evidence
swiftdc swift-version Binary --json # confidence, version, evidence[]
swiftdc swift-version --image Foundation
Swift 6.4
confidence: exact
evidence:
- objc image-info stable-ABI version bits → 6.4
- __swift5 reflection metadata present (11 sections): __swift5_assocty, …
build environment (not proof of the Swift version): macOS, min OS 27.0.0, SDK 27.0.0
The reflection sections and image info aren't part of the symbol table, so a
stripped binary still names its Swift version.
Devices
devices and apps talk to a USB-attached device directly. <udid> is a
device identifier as devices prints it.
swiftdc devices # attached devices and their UDIDs
swiftdc apps # user apps (default)
swiftdc apps --type system # user | system | internal | any
swiftdc apps --encrypted-only
swiftdc apps --udid <udid> # needed only with more than one device attached
Example output, with made-up bundle IDs:
iPhone (iPhone15,3, iOS 27.0) — 00008120-001122AABBCCDDEE
ENC com.example.PhotoVault 3.2.1 PhotoVault
· com.example.DevSandbox 1.0 DevSandbox
2 of 2 apps — 1 FairPlay-encrypted, 1 plaintext
swiftdc speaks usbmux, lockdown and installation_proxy itself. It needs no
root, no libimobiledevice and no Python.
dyld shared cache
On modern macOS and iOS, system frameworks have no standalone binary on disk.swiftdc dump|objc|disasm|analyze|xrefs|layout --image Foundation reads them
straight out of the cache. docs/dyld-shared-cache.md
explains how that works and what behaves differently there.
What it recovers
From metadata, which survives stripping
- Swift declarations:
struct,enum,classandprotocol, with stored
properties, methods, enum cases (includingindirect), generics and
inheritance. - Objective-C headers:
@interface,@protocoland category declarations
with properties and method signatures. That covers the Swift and ObjC mix in
real apps, including Swift classes exposed to the ObjC runtime. - Objective-C IMPs: class and category method records give real function
boundaries and conventional names (-[Class selector:]), even after
stripping. Type encodings become signatures, and runtime ivar offsets turn
direct loads and stores intoself->_countandself->_count = arg1. - Function boundaries on stripped binaries:
LC_FUNCTION_STARTSplus the
ObjC IMPs split a stripped binary into separate functions instead of one blob. - Stored-property layout (
swiftdc layout): byte offset to field, computed
offline from__swift5_fieldmd, so it matches the runtime exactly.
From code analysis
- Annotated ARM64: branch and call targets demangled, string literals shown,
andadrp/addoperands resolved to the name of what they point at
(→ Rectangle.origin.getter,→ type descriptor for Stack).objdump
leaves all of that bare. - Control-flow graph: Capstone decodes
__textin process, and swiftdc
splits the instructions into basic blocks with successor edges. - Call arguments: an abstract interpreter carries constants and
adrp/addaddresses through registers, as a forward data-flow fixpoint
over the whole CFG, then reads the argument registers at each call. Calls
print asswift_allocObject(type descriptor for Dog, 48, 7). Return values
flow into later arguments, so nested expressions come out nested. Swift_SmallStringliterals packed into register pairs decode back to text:Dog("Rex", "Lab"). - Objective-C message sends:
[[NSUserDefaults standardUserDefaults] setBool:0 forKey:@"…"]. swiftdc handles both dispatch shapes, the
per-selector stub in__objc_stubs(Xcode 14 and later) and the older form
that loads the selector into x1. Receivers resolve through GOT binds, and__cfstringoperands print as@"literal". Without this, a real app's
pseudocode would be little more than ARC bookkeeping. - Structured control flow: recovered calls, stores and returns fold into
if/else/while, using post-dominators over the CFG. Conditions are
rewritten in terms of the block's inputs. Reducible loops becomewhile (true) { … break/continue }, with induction variables, exit-test
rotation and loop-carried accumulators (total += i) rebuilt. Anything
irreducible falls back to a labeledgoto, so the braces always balance and
the structure is never wrong. - Explicit integer semantics:
>>ˢis an arithmetic (sign-filling) right
shift and>>ᵁa logical (zero-filling) one, whatever the source parameter
or result type was. These operators work on register bit patterns, and
32-bit normalization stays an explicit mask. In the same way,mulhˢandmulhᵁare the high 64 bits of signed and unsigned 128-bit products. - Cross-references: a call graph over resolved branch targets. It includes
indirectblrcalls whose register provably holds a pointer loaded from a
specific witness table, vtable or GOT slot.
Swift field names
ldr x0, [x20, #0x10] prints as self.age, and add x0, x20, #0x20 as&self.breed, which then shows up in recovered call arguments
(swift_beginAccess(&self.age, …)).
Ghidra can't do this, because it doesn't know what a Swift field descriptor
or a class vtable slot means. swiftdc needs two facts, both from metadata and
both safe from stripping. swiftdc layout says what lives at +0x10 of aDog, and SelfTypeIndex says the pointer in x20 is a Dog. Under the Swift
calling convention self arrives in x20, and swiftdc seeds it only when it
knows the receiver's type.
On the sample fixture at 9f92995, 49 field sites are named unstripped and 33
after strip -x, so 67% survive. To reproduce:
for f in Fixtures/Sample/sample.release Fixtures/Sample/sample.stripped; do
echo "$f: $(swiftdc disasm $f | grep -oE 'self\.[A-Za-z_][A-Za-z0-9_]*' | wc -l)"
done
The third that's lost needed the symbol to name it. The rest comes from class
vtable slots, which are metadata.
Two types can share a simple name, which happens often across modules.
Left alone, one type's layout would then name the other's fields. So field
maps are indexed by qualified name as well as simple name. A simple name that
collides is dropped, and a receiver whose qualified name is known still
resolves through the qualified key. A colliding type loses its field names
instead of getting wrong ones.
What it can't recover
- Original source. You don't get local names, macros, comments, exact
source types, arbitrary pointer aliasing, or every optimized expression.
The annotated assembly stays the authority to fall back on. - Other architectures. x86_64 slices load, and their metadata,
declarations and interfaces work, but disassembly and annotation are tuned
for ARM64. - All names on stripped binaries. Function boundaries always recover.
Names come from ObjC method records and from Swift metadata for class
vtable methods (Type.method) and protocol-conformance witnesses
(Type: Protocol.kind). Free functions, closures, thunks, and struct or
enum methods that aren't protocol requirements print assub_<addr>: they
dispatch statically, so no metadata records them. A class vtable name
sometimes falls back tosub_<addr>even unstripped (a SwiftDump resolution
gap); the address is still right and still disassembles. - FairPlay-encrypted
.ipas. App Store.ipas ship their main binary
encrypted (cryptid != 0). swiftdc refuses such an image instead of
decoding ciphertext, so you need a decrypted dump (frida-ios-dump on a
jailbroken device, for example) or an unencrypted build. Enterprise and
development builds and plain.apps are fine. - Binaries from a device. The device commands list what's installed but
don't pull binaries off. A stock iOS device doesn't hand out other apps'
bundles over any lockdown service:house_arrestreaches an app's data
container, not its.app. - Proof of dead code.
--unreferencedleaves generic witness tables,
vtables of unknown receivers and block pointers unresolved, andxrefs
reports how many. - Every
self. swiftdc reads x20 asselfonly for a Swift-mangled
callee, and only when x20 was written since the previous call. A Swift
method whoseselfisn't a pointer (Double.write(to:), withselfin d0)
shows noselfat all rather than a stale x20. That's a deliberate choice
of a missing answer over a wrong one. - Aliased stack slots. Stack tracking keys slots by
spandx29offsets
and doesn't model aliasing. A callee handed&localcan write through it
unseen, so a slot's value can go stale across that call. - Field names without both facts. A field is named only when the
receiver's type is known and the offset falls inside the part of that type's
layout swiftdc trusts. Swift lays fields out in declaration order, so the
first field it can't resolve (a resilient type from another module, an
existential, an unsubstituted generic) makes every later offset unknown, not
just unnamed. Static methods and allocating initializers hold a metatype in
x20 rather than an instance, and the self-index'sisInstanceflag refuses
them. cryptidfrom a device.appsinfers encryption from the FairPlayApplicationSINFblob, which is what installation_proxy exposes. Readingcryptidwould need the binary, which the device won't give up. The two
signals agree by construction, and on 573 apps on an iOS 27 device every
App Store app had a SINF and every system or development-signed app had
none. When swiftdc does have the binary, the preflight reads the realcryptid.
docs/dyld-shared-cache.md lists the known rough
edges with cache images.
If an unfiltered run recovers far fewer functions than the binary's ownLC_FUNCTION_STARTS declares, swiftdc prints a coverage warning to stderr.
Treat that listing as a sample, not an inventory, and please report it.
Requirements and build
- macOS on Apple Silicon.
- Xcode 26 with Swift 6.3, which supplies
swiftc,llvm-objdumpandswift-demangle. - Capstone 6, which SwiftPM builds from source. It's the same
capstone
package MachOSwiftSection links, and there's nothing to install. It's the
decoder behind CFG recovery. - OpenSSL (
brew install openssl@3), only fordevicesandapps.
swift build # debug: .build/debug/swiftdc
swift build -c release --scratch-path .build-release # release: .build-release/release/swiftdc
swift test # tests that need fixtures skip when they aren't built
If you build both debug and release, give release its own scratch path. In a
shared .build, switching configurations rebuilds every dependency, which
takes about 5 minutes instead of about 1.
Fixtures/Sample/sample.swift and sample_objc.m make up a program dense with
metadata: Swift structs, enums, classes, protocols and generics, and ObjC
methods, properties, ivars and categories. Tools/fixtures.sh builds its
debug, release and stripped variants, along with the other fixtures, and
caches them. CONTRIBUTING.md covers running the tests
properly.
Architecture
swiftdc (CLI, swift-argument-parser)
│
▼
SwiftDecompilerCore (library)
├── BinaryLoader load Mach-O / select fat slice (MachOKit)
├── SwiftDeclarationDumper declarations from Swift metadata (MachOSwiftSection)
├── ObjCDumper ObjC headers (MachOObjCSection / ObjCDump)
├── ObjCMetadataIndex IMP → class/category/selector/signature; ivar layouts
├── Disassembler ARM64 + demangled annotation (llvm-objdump + Demangling)
├── CapstoneEngine structured decode: control flow, targets (Capstone)
├── CFG basic-block / control-flow-graph recovery
├── ValueTracer abstract interpreter → args, expressions, stores, returns
├── TypeInference per-value type lattice (width, signedness, enum/Optional facets)
├── Structurer CFG → if/else/while/goto via post-dominators
├── ObjCSelectors __objc_stubs/__objc_selrefs → selector names
├── FieldMap byte offset → Swift stored property (`swiftdc layout`)
├── SelfTypeIndex impl address → the type of its `self` (strip-proof)
├── CacheSymbolResolver dyld-cache stub islands → cross-image symbols
├── CallGraph caller/callee edges (backs `xrefs`)
└── AnalysisReport combined, grouped report
MobileDevice (library) talk to physical iOS devices
├── DeviceSocket AF_UNIX socket + mid-stream TLS upgrade (OpenSSL)
├── UsbmuxClient ListDevices / ReadPairRecord / Connect
├── LockdownClient StartSession → TLS → StartService
└── InstallationProxy Browse → installed apps + FairPlay status
MobileDevice is its own target so the decompiler doesn't depend on OpenSSL,
and swiftdc analyze works with no device attached.
Parsing builds on MachOKit for the
Mach-O container,MachOSwiftSection
for turning __swift5_* metadata into typed declarations, andMachOObjCSection
with ObjCDump for ObjC headers.
Demangling uses the in-process Demangling library. Instruction text comes
from the llvm-objdump that ships with Xcode, andCapstone decodes the same__text bytes in process to add each instruction's control-flow class and
branch target.
MachOKit is pinned to a commit on the
CamdenBopp/MachOKit fork. The fork
adds one change to upstream 0.55.100: it remembers that a standalone binary
isn't a dyld cache instead of re-checking on every access, which took one
measured layout run from 69.9 s to 4.4 s. Package.swift records why each
dependency is pinned where it is.
Design notes and research
docs/PRODUCTION-READINESS.md is the running
list of what stands between swiftdc and a decompiler you can trust without
checking by hand. It covers soundness, completeness, robustness, determinism,
usability, reconstruction quality and validation, each with evidence. Read it
to learn what to believe, or to find something to work on.
CLAUDE.md is the working agreement, including the empty-result
rule for verifying a "nothing found" answer.
Each file in docs/research/ records one probe: the hypothesis and what the
evidence showed.
| Document | Subject |
|---|---|
| decompiler-comparison.md | Ghidra, Malimite and LittleSwift compared: where swiftdc wins and loses, and the list of architectural debt |
| phase1-type-lattice.md | Per-value type lattice (width and signedness), which fixed the signed/unsigned comparison defect |
| phase2-flowing-types.md | Propagating types through the type state inside a body |
| phase3-edge-indexed-phi.md | Edge-indexed phi nodes at CFG merges |
| phase4-unify-value-and-structure.md | Unifying the value and structure layers |
| phase4b-loop-induction-variables.md | Induction variables, exit-test rotation, loop-carried accumulators |
| computational-bodies.md | Which function bodies can be rebuilt from computation alone, and which are blocked on dependencies |
| field-map-name-collision.md | The one confirmed class of fabricated names, and the qualified-key fix |
| value-unknown-causes.md | Why the tracer leaves values unknown |
| goto-structuring.md | goto in irreducible control flow |
| findings-scratch.md | Raw probe evidence (historical; see its status header) |
License
MIT. See LICENSE.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found