Back

IDA 9.4: Improved analysis of compiled Swift binaries

IDA 9.4: Improved analysis of compiled Swift binaries

IDA 9.4 constitutes the first step towards better handling of Swift binaries. Perhaps unsurprisingly, our focus is on Swift for ARM64 Mach-O files, but we generally aim to improve Swift support also in the more unusual settings across different architectures and file formats like x86-64 and ELF. For 9.4, we want to highlight two different improvements: proper modelling of the Swift ABI, and proper typing of Swift runtime functions.

Proper Modelling of the Swift ABI

With IDA 9.4, we introduce three new keywords for function prototypes: __swiftself, __swiftthrows, and __swiftasync.

Those three keywords all are preconditioned on the function being declared as __swiftcall. __swiftcall has existed for many IDA releases already, but it now acts as a strong discriminator for any Swift-related logic inside IDA. For Objective-C/Swift mixed binaries, __swiftcall is your way to tell IDA that a function should be handled by the Swift machinery.

Let's go over the three new keywords one by one: since each of them is strongly linked to a Swift-specific ABI register, they are best presented by understanding the corresponding part of the Swift ABI.

The Self Register

Probably the most relevant one, because it made Swift reversing terribly annoying. The following very small snippet of ARM64 assembly code highlights the problem rather clearly:

Disassembly
__text:000000010000530C         ADD     X20, SP, #0x70+stack_str ; self
__text:0000000100005310         MOV     X0, X27 ; other
__text:0000000100005314         MOV     X1, X28 ; other
__text:0000000100005318         BL      _$sSS6appendyySSF ; String.append(_:)

The code just concatenates two Swift strings: the existing string is a temporary placed on the stack (#0x70+stack_str), and the string that is to be appended, the compiler stored in register pair X27 and X28. We'll get to the peculiarities of Swift string internals at a later point in time; the annoyance we are trying to solve here is how the compiler passes both the existing and the to-be-appended strings to the String.append function. We see that the to-be-appended string is passed by value in registers X0 and X1, but the existing string is passed via a pointer in X20. X0 and X1 are standard argument registers under the regular ARM64 calling convention, but X20 is callee-saved under standard AAPCS64. Pre-9.4 IDA knew that a Swift::String occupied two 8-byte registers, but it had no concept of the Swift ABI specialties, so the decompiler would (wrongly) render this as follows:

Pseudocode
Swift::String v14; // x0
// ---------------------------------------------------
v14._countAndFlagsBits = v11;
v14._object = v13;
String.append(v14);

The attentive reverser will immediately spot that String.append wrongly receives just one argument: the string that is to be appended. Crucially, we have no clue about the target of the string append operation. This might be recoverable/guessable from context in this isolated example, but since the Swift compiler actually relies a lot on passing pointers to objects in X20, the overall decompiler output becomes almost unintelligible, because the relationship between entities is difficult to track.

For this purpose, IDA 9.4 introduces the __swiftself annotation for arguments in function prototypes. With this new keyword, the String.append function looks as follows:

Pseudocode
void __swiftcall String_append(Swift::String *__swiftself self, Swift::String other);
                                              ^~~~~~~~~~~ new keyword

With the updated function signature, the call site in the decompiler renders exactly as we would expect, with an explicit pointer to the object being manipulated passed as the first argument.

Pseudocode
Swift::String v14; // x0
Swift::String stack_str; // [xsp+8h] [xbp-68h] BYREF
// ---------------------------------------------------
v14._countAndFlagsBits = v11;
v14._object = v13;
String.append(self: &stack_str, other: v14);

Only functions carrying the __swiftcall keyword can contain exactly one __swiftself-tagged argument. On x86-64 Swift binaries, the same logic is applied to r13.

The Async Context Register

A similar problem arises if we study the following snippet:

Disassembly
__text:0000000100003EA4 ; void __swiftcall specialized thunk for @escaping @convention(thin) @async () -> ()()
__text:0000000100003EA4 _$sIetH_yts5Error_pIegHrzo_TR10async_MainTf3npf_n
__text:0000000100003EA4
__text:0000000100003EA4 var_20  = -0x20
__text:0000000100003EA4 var_18  = -0x18
__text:0000000100003EA4 var_10  = -0x10
__text:0000000100003EA4
__text:0000000100003EA4         PACIBSP
__text:0000000100003EA8         ORR         X29, X29, #0x1000000000000000
__text:0000000100003EAC         SUB         SP, SP, #0x20
__text:0000000100003EB0         STP         X29, X30, [SP,#0x20+var_10]
__text:0000000100003EB4         ADD         X16, SP, #0x20+var_18
__text:0000000100003EB8         MOVK        X16, #0xC31A,LSL#48
__text:0000000100003EBC         MOV         X17, X22
__text:0000000100003EC0         PACDB       X17, X16
__text:0000000100003EC4         STR         X17, [SP,#0x20+var_18]
__text:0000000100003EC8         ADD         X29, SP, #0x20+var_10
__text:0000000100003ECC         MOV         X0, #0  ; request
__text:0000000100003ED0         BL          _$sScMMa ; type metadata accessor for MainActor
__text:0000000100003ED4         STR         X0, [X22,#0x10] ; (A)
__text:0000000100003ED8         MOV         X20, X0
__text:0000000100003EDC         BL          _$sScM6sharedScMvgZ ; static MainActor.shared.getter
__text:0000000100003EE0         STR         X0, [X22,#0x18] ; (B)
__text:0000000100003EE4         ADRL        X8, _$s14sampler_arm64e3AppV4mainyyYaFZTf4d_nTu ; async function pointer to specialized static App.main()
__text:0000000100003EEC         LDP         W9, W0, [X8] ; size
; ---------------------------- snip ---------------------------------

Despite the PAC-aware prologue, what draws our attention here are the X22-based memory accesses without an obvious preceding sequence that would make X22 point to anything sensible. Again, that would be fair confusion on compiler-generated AAPCS64 code, but the Swift compiler reserves X22 (and its x86-64 counterpart r14) as the async context register.

The async context stores state needed to suspend and resume an async function, such as continuation/resume information, but most notably async frame data, which can often span entire Swift objects. Keeping it in a dedicated callee-saved register lets Swift async code access that state cheaply across calls without repeatedly passing it as a normal argument.

This deviation from the regular ABI used to confuse pre-9.4 IDAs once again. Here's the corresponding pseudocode in IDA 9.3 (ignore the terrible whitespacing induced by the Swift demangler for a second):

void __swiftcall specialized thunk for @escaping @convention(thin) @async () -> ()()
{
  _QWORD *v0; // x22
  _QWORD *v1; // x0
  __int64 v2; // [xsp+18h] [xbp-8h]

  v0[2] = type metadata accessor for MainActor(0).value;
  v0[3] = static MainActor.shared.getter();
  v1 = swift_task_alloc(0x160u);
  v0[4] = v1;
  *v1 = v0;
  v1[1] = specialized thunk for @escaping @convention(thin) @async () -> ();
  if ( ((v2 ^ (2 * v2)) & 0x4000000000000000LL) != 0 )
    __break(0xC471u);
  specialized static App.main()();
}
// 100003ED4: variable 'v0' is possibly undefined
// 100003F4C: variable 'v2' is possibly undefined
// 100006964: using guessed type __int64 __swiftcall static MainActor.shared.getter();

The key is the line saying // 100003ED4: variable 'v0' is possibly undefined: in GUI IDA, v0 would render in red, indicating that the underlying register was read from before it was written to. (Such red markers serve as a great indicator that something is wrong with the current pseudocode.)

In IDA 9.4, we can mark this function as __swiftasync to tell the decompiler that X22 carries a pointer to a meaningful async context:

pseudocode
void __swiftcall __swiftasync specialized thunk for @escaping @convention(thin) @async () -> ()()
{
  _QWORD *async_context; // x22
  _QWORD *v1; // x0
  __int64 v2; // [xsp+18h] [xbp-8h]

  async_context = __swift_get_async_context();
  async_context[2] = type metadata accessor for MainActor(request: 0).value;
  async_context[3] = static MainActor.shared.getter();
  v1 = swift_task_alloc(size: 0x160u);
  async_context[4] = v1;
  *v1 = async_context;
  v1[1] = specialized thunk for @escaping @convention(thin) @async () -> ();
  if ( ((v2 ^ (2 * v2)) & 0x4000000000000000LL) != 0 )
    __break(0xC471u);
  specialized static App.main()();
}

As you'll notice, we decided against modelling the async context as an explicit parameter, and rather fall back to a custom intrinsic, __swift_get_async_context. This intrinsic is internally marked as non-propagating, so that it always results in an explicit local variable that can be explicitly typed by the user. This way, we can define a new struct type my_async_ctxt, then incrementally build up information about the currently executing async context and edit the underlying type as we go.

The Error Return Register

Which brings us to our last contender for the top spot in the bizarre ABI zoo: the error return register (ARM64 X21 / x86-64 r12). Here is the third snippet for you to study:

disassembly
__text:0000000100000EC0 ; Swift::Int __swiftcall callTyped(_:)(Swift::Int)
__text:0000000100000EC0 _$s10swift_errs9callTypedyS2iF          ; CODE XREF: _main+100↑p
__text:0000000100000EC0
__text:0000000100000EC0 var_10          = -0x10
__text:0000000100000EC0 var_s0          =  0
__text:0000000100000EC0
__text:0000000100000EC0                 SUB             SP, SP, #0x30
__text:0000000100000EC4                 STP             X22, X21, [SP,#0x20+var_10]
__text:0000000100000EC8                 STP             X29, X30, [SP,#0x20+var_s0]
__text:0000000100000ECC                 ADD             X29, SP, #0x20
__text:0000000100000ED0                 MOV             X21, #0
__text:0000000100000ED4                 BL              _$s10swift_errs11typedThrowsyS2iAA7MyErrorOYKF
__text:0000000100000ED8                 CMP             X21, #0
__text:0000000100000EDC                 MOV             X8, #0xFFFFFFFFFFFFFFFD
__text:0000000100000EE0                 CSEL            X0, X0, X8, EQ
__text:0000000100000EE4                 LDP             X29, X30, [SP,#0x20+var_s0]
__text:0000000100000EE8                 LDP             X22, X21, [SP,#0x20+var_10]
__text:0000000100000EEC                 ADD             SP, SP, #0x30 ; '0'
__text:0000000100000EF0                 RET
__text:0000000100000EF0 ; End of function callTyped(_:)

The most interesting part is how this function treats X21: the MOV at 0x100000ED0 initializes it to zero, followed by a call at 0x100000ED4, which is in turn followed by a check for zero at 0x100000ED8.

Maybe a bit surprisingly, pre-9.4 IDAs would decompile this function as follows:

Pseudocode
Swift::Int __swiftcall callTyped(_:)(Swift::Int a1)
{
  return _s10swift_errs11typedThrowsyS2iAA7MyErrorOYKF(a1);
}

Why? Again because of Swift-specific ABI trickery: in vanilla ARM64, X21 is callee-saved. That means the decompiler sees the sequence "initialize X21; call something [that will preserve X21, because it's callee-saved]; check if X21 is still zero", and consequently concludes that the CMP at 0x100000ED8 will always set the EQ flag and hence the CSEL will always take the X0 input reg (which still holds the return value of the callee in standard AAPCS64) — it hence completely optimizes away the check, leading to the flawed pseudocode output.

In IDA 9.4, however, the __swiftthrows function prototype annotation is here to save the day: unlike the other two new keywords presented in this blog post, __swiftthrows alters the behaviour of all callers, rather than the function that carries the annotation. In our example that means the sole callee of this function is typed

pseudocode
__int64 __swiftcall __swiftthrows _s10swift_errs11typedThrowsyS2iAA7MyErrorOYKF(__int64 result);
                    ^~~~~~~~~~~~~ new keyword

and hence signals to the caller that X21 is precisely not callee-saved, but rather carries the "exception state" (~zero = no exception, ~non-zero = exception; we'll spare you an explanation of Swift's typed exceptions today). As a consequence, the decompiler surfaces any subsequent X21 accesses in terms of the new __swift_get_error() custom intrinsic, and we get the desired result:

pseudocode
Swift::Int __swiftcall callTyped(_:)(Swift::Int a1)
{
  Swift::Int result; // x0
  void *error; // x21

  result = _s10swift_errs11typedThrowsyS2iAA7MyErrorOYKF(result: a1);
  error = __swift_get_error();
  if ( error != nullptr )
    return -3;
  return result;
}

This is tremendously helpful for analyzing complex control flow. The __swiftthrows keyword is either deduced from the demangled function prototype (if available) or inferred from the callee's structure for unsymbolicated but known-Swift callees. We had to make this auto-detection check rather strict to prevent false positives because, sure enough, in other parts of the code — in non-excepting contexts — the Swift compiler uses X21 as a perfectly fine callee-saved local scratch register. This new keyword and mechanic in the caller supersedes the old way of handling the Swift error return register, where IDA would work with spoils lists when it detected a thrower via the prototype (resulting in incantations like Swift::Int __swiftcall __spoils<CF,ZF,NF,VF,X0,X1,...,X17,X21,Q0,...,Q31> Thrower(Swift::Int x, Swift::Int y, Swift::Int z);).

Runtime Symbol Typing

Modelling the ABI fixes how Swift functions talk to each other. The other half of the readability problem is the functions a Swift binary talks to: the runtime and standard library. A typical app calls hundreds of swift_* entry points, and historically IDA had nothing to say about any of them. They are not described by the __swift5 metadata IDA walks (that metadata describes the types the binary defines, not the runtime it links against), so the best IDA could do was the usual __int64 __fastcall foo(_QWORD, _QWORD, ...) guess. Multiply that by every call site, and a Swift pseudocode listing turns into a sea of untyped quadwords.

IDA 9.4 ships a curated set of prototypes for the runtime surface and applies them by name, so these functions arrive fully typed: with __swiftcall, the right argument types, and meaningful enums. Take swift_beginAccess, the exclusivity-checking primitive the compiler emits around inout and stored-property access. Before:

pseudocode
// __int64 __fastcall swift_beginAccess(_QWORD, _QWORD, _QWORD, _QWORD); weak
swift_beginAccess(v1 + 16, a1, 33, 0);
swift_beginAccess(v0 + 16, v3, 1, 0);

and after:

pseudocode
// void __swiftcall swift_beginAccess(void *pointer, void *buffer, Swift::ExclusivityFlags flags, void *pc);
swift_beginAccess((void *)(self + 16), buffer, Tracking|Modify, nullptr);
swift_beginAccess((void *)(v0  + 16), buffer, Modify,          nullptr);

The prototype is now the actual one — __swiftcall, with typed arguments rather than a guess. Notice how the flags argument is typed as Swift::ExclusivityFlags, so 33 prints as Tracking|Modify (0x20|0x1) and 1 as Modify.

The Future

Those of you closely following IDA’s progress over the last releases already know that what was presented above is just the beginning. Upcoming IDA versions will address many more shortcomings (Strings, Metadata, Typing, Mangling, …) of current analysis and continue closing the gap between Swift binaries and the analysis they deserve.